Migrate
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
# API Aggregation & BFF - Reference Examples
|
||||
|
||||
## Complete Implementation Examples
|
||||
|
||||
### 1. YARP Advanced Configuration
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Advanced YARP configuration with transforms and load balancing.
|
||||
/// VI: Cấu hình YARP nâng cao với transforms và load balancing.
|
||||
/// </summary>
|
||||
|
||||
// Program.cs
|
||||
builder.Services.AddReverseProxy()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||
.AddTransforms(transforms =>
|
||||
{
|
||||
// EN: Add correlation ID to all requests
|
||||
transforms.AddRequestTransform(ctx =>
|
||||
{
|
||||
var correlationId = ctx.HttpContext.Request.Headers["X-Correlation-Id"]
|
||||
.FirstOrDefault() ?? Guid.NewGuid().ToString();
|
||||
ctx.ProxyRequest.Headers.TryAddWithoutValidation(
|
||||
"X-Correlation-Id", correlationId);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
|
||||
// EN: Add timing header to responses
|
||||
transforms.AddResponseTransform(ctx =>
|
||||
{
|
||||
ctx.HttpContext.Response.Headers["X-Processed-At"] =
|
||||
DateTime.UtcNow.ToString("O");
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
});
|
||||
|
||||
// EN: Add health checks for backends
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddUrlGroup(new Uri("http://user-service:5001/health"), name: "user-service")
|
||||
.AddUrlGroup(new Uri("http://order-service:5002/health"), name: "order-service");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// EN: Add authentication before proxy
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapReverseProxy(proxyPipeline =>
|
||||
{
|
||||
proxyPipeline.UseSessionAffinity();
|
||||
proxyPipeline.UseLoadBalancing();
|
||||
proxyPipeline.UsePassiveHealthChecks();
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Complete Service Clients with Resilience
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: HTTP client configuration with resilience.
|
||||
/// VI: Cấu hình HTTP client với resilience.
|
||||
/// </summary>
|
||||
public static class HttpClientExtensions
|
||||
{
|
||||
public static IServiceCollection AddServiceClients(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// EN: Product Service Client
|
||||
services.AddHttpClient<IProductServiceClient, ProductServiceClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(configuration["Services:Products:BaseUrl"]!);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
})
|
||||
.AddStandardResilienceHandler();
|
||||
|
||||
// EN: Review Service Client
|
||||
services.AddHttpClient<IReviewServiceClient, ReviewServiceClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(configuration["Services:Reviews:BaseUrl"]!);
|
||||
})
|
||||
.AddStandardResilienceHandler();
|
||||
|
||||
// EN: Inventory Service Client
|
||||
services.AddHttpClient<IInventoryServiceClient, InventoryServiceClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(configuration["Services:Inventory:BaseUrl"]!);
|
||||
})
|
||||
.AddStandardResilienceHandler();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Product service client implementation.
|
||||
/// VI: Triển khai client cho product service.
|
||||
/// </summary>
|
||||
public interface IProductServiceClient
|
||||
{
|
||||
Task<ProductDto?> GetProductAsync(Guid productId, CancellationToken ct = default);
|
||||
Task<PagedResult<ProductDto>> GetProductsAsync(int page, int pageSize, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class ProductServiceClient : IProductServiceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<ProductServiceClient> _logger;
|
||||
|
||||
public ProductServiceClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<ProductServiceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ProductDto?> GetProductAsync(Guid productId, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"/api/products/{productId}", ct);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ProductDto>(ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get product {ProductId}", productId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PagedResult<ProductDto>> GetProductsAsync(
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var response = await _httpClient.GetAsync(
|
||||
$"/api/products?page={page}&pageSize={pageSize}", ct);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PagedResult<ProductDto>>(ct)
|
||||
?? new PagedResult<ProductDto>();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Aggregation with Timeout and Fallback
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Aggregator with timeout and fallback handling.
|
||||
/// VI: Aggregator với xử lý timeout và fallback.
|
||||
/// </summary>
|
||||
public class ResilientAggregatorService
|
||||
{
|
||||
private readonly IProductServiceClient _productClient;
|
||||
private readonly IReviewServiceClient _reviewClient;
|
||||
private readonly IInventoryServiceClient _inventoryClient;
|
||||
private readonly IDistributedCache _cache;
|
||||
private readonly ILogger<ResilientAggregatorService> _logger;
|
||||
|
||||
private static readonly TimeSpan AggregationTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public ResilientAggregatorService(
|
||||
IProductServiceClient productClient,
|
||||
IReviewServiceClient reviewClient,
|
||||
IInventoryServiceClient inventoryClient,
|
||||
IDistributedCache cache,
|
||||
ILogger<ResilientAggregatorService> logger)
|
||||
{
|
||||
_productClient = productClient;
|
||||
_reviewClient = reviewClient;
|
||||
_inventoryClient = inventoryClient;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ProductDetailsDto?> GetProductDetailsAsync(
|
||||
Guid productId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// EN: Try cache first
|
||||
var cacheKey = $"product-details:{productId}";
|
||||
var cached = await _cache.GetStringAsync(cacheKey, ct);
|
||||
if (!string.IsNullOrEmpty(cached))
|
||||
{
|
||||
return JsonSerializer.Deserialize<ProductDetailsDto>(cached);
|
||||
}
|
||||
|
||||
// EN: Create timeout cancellation token
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(AggregationTimeout);
|
||||
|
||||
// EN: Fetch product (required)
|
||||
var product = await _productClient.GetProductAsync(productId, timeoutCts.Token);
|
||||
if (product == null)
|
||||
return null;
|
||||
|
||||
// EN: Fetch optional data with fallbacks
|
||||
var reviewsTask = GetReviewsSafeAsync(productId, timeoutCts.Token);
|
||||
var stockTask = GetStockSafeAsync(productId, timeoutCts.Token);
|
||||
|
||||
await Task.WhenAll(reviewsTask, stockTask);
|
||||
|
||||
var reviews = await reviewsTask;
|
||||
var stock = await stockTask;
|
||||
|
||||
var result = new ProductDetailsDto
|
||||
{
|
||||
Id = product.Id,
|
||||
Name = product.Name,
|
||||
Description = product.Description,
|
||||
Price = product.Price,
|
||||
ImageUrl = product.ImageUrl,
|
||||
|
||||
// EN: Reviews with fallback
|
||||
AverageRating = reviews?.AverageRating ?? 0,
|
||||
ReviewCount = reviews?.TotalCount ?? 0,
|
||||
TopReviews = reviews?.Items.Take(3).ToList() ?? new List<ReviewDto>(),
|
||||
ReviewsAvailable = reviews != null,
|
||||
|
||||
// EN: Stock with fallback
|
||||
InStock = stock?.AvailableQuantity > 0 ?? true, // Assume in stock if unknown
|
||||
AvailableQuantity = stock?.AvailableQuantity,
|
||||
StockAvailable = stock != null
|
||||
};
|
||||
|
||||
// EN: Cache the result
|
||||
await _cache.SetStringAsync(
|
||||
cacheKey,
|
||||
JsonSerializer.Serialize(result),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = CacheDuration
|
||||
},
|
||||
ct);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<ReviewSummaryDto?> GetReviewsSafeAsync(
|
||||
Guid productId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _reviewClient.GetReviewSummaryAsync(productId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to get reviews for product {ProductId}", productId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<StockDto?> GetStockSafeAsync(
|
||||
Guid productId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _inventoryClient.GetStockAsync(productId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to get stock for product {ProductId}", productId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Complete BFF Project Structure
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Mobile BFF project structure and implementation.
|
||||
/// VI: Cấu trúc và triển khai Mobile BFF project.
|
||||
/// </summary>
|
||||
|
||||
// MobileBff/Program.cs
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// EN: Add services
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
|
||||
builder.Services.AddServiceClients(builder.Configuration);
|
||||
builder.Services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = builder.Configuration.GetConnectionString("Redis");
|
||||
});
|
||||
|
||||
// EN: Add authentication
|
||||
builder.Services.AddJwtAuthentication(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
// MobileBff/Features/Home/GetHomeScreenQuery.cs
|
||||
public record GetHomeScreenQuery : IRequest<HomeScreenDto>
|
||||
{
|
||||
public string UserId { get; init; } = default!;
|
||||
}
|
||||
|
||||
public record HomeScreenDto
|
||||
{
|
||||
public UserProfileSummaryDto? Profile { get; init; }
|
||||
public List<FeaturedProductDto> FeaturedProducts { get; init; } = new();
|
||||
public List<CategoryDto> Categories { get; init; } = new();
|
||||
public List<PromotionDto> ActivePromotions { get; init; } = new();
|
||||
public int CartItemCount { get; init; }
|
||||
public int UnreadNotifications { get; init; }
|
||||
}
|
||||
|
||||
public class GetHomeScreenQueryHandler : IRequestHandler<GetHomeScreenQuery, HomeScreenDto>
|
||||
{
|
||||
private readonly IUserServiceClient _userClient;
|
||||
private readonly IProductServiceClient _productClient;
|
||||
private readonly ICategoryServiceClient _categoryClient;
|
||||
private readonly IPromotionServiceClient _promotionClient;
|
||||
private readonly ICartServiceClient _cartClient;
|
||||
private readonly INotificationServiceClient _notificationClient;
|
||||
private readonly ILogger<GetHomeScreenQueryHandler> _logger;
|
||||
|
||||
public GetHomeScreenQueryHandler(
|
||||
IUserServiceClient userClient,
|
||||
IProductServiceClient productClient,
|
||||
ICategoryServiceClient categoryClient,
|
||||
IPromotionServiceClient promotionClient,
|
||||
ICartServiceClient cartClient,
|
||||
INotificationServiceClient notificationClient,
|
||||
ILogger<GetHomeScreenQueryHandler> logger)
|
||||
{
|
||||
_userClient = userClient;
|
||||
_productClient = productClient;
|
||||
_categoryClient = categoryClient;
|
||||
_promotionClient = promotionClient;
|
||||
_cartClient = cartClient;
|
||||
_notificationClient = notificationClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HomeScreenDto> Handle(
|
||||
GetHomeScreenQuery request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// EN: All calls in parallel with safe wrappers
|
||||
var profileTask = SafeCallAsync(() =>
|
||||
_userClient.GetProfileSummaryAsync(request.UserId, ct));
|
||||
var featuredTask = SafeCallAsync(() =>
|
||||
_productClient.GetFeaturedAsync(6, ct));
|
||||
var categoriesTask = SafeCallAsync(() =>
|
||||
_categoryClient.GetTopCategoriesAsync(8, ct));
|
||||
var promotionsTask = SafeCallAsync(() =>
|
||||
_promotionClient.GetActiveAsync(ct));
|
||||
var cartCountTask = SafeCallAsync(() =>
|
||||
_cartClient.GetItemCountAsync(request.UserId, ct));
|
||||
var notificationCountTask = SafeCallAsync(() =>
|
||||
_notificationClient.GetUnreadCountAsync(request.UserId, ct));
|
||||
|
||||
await Task.WhenAll(
|
||||
profileTask, featuredTask, categoriesTask,
|
||||
promotionsTask, cartCountTask, notificationCountTask);
|
||||
|
||||
return new HomeScreenDto
|
||||
{
|
||||
Profile = await profileTask,
|
||||
FeaturedProducts = (await featuredTask)?.ToList() ?? new(),
|
||||
Categories = (await categoriesTask)?.ToList() ?? new(),
|
||||
ActivePromotions = (await promotionsTask)?.ToList() ?? new(),
|
||||
CartItemCount = await cartCountTask ?? 0,
|
||||
UnreadNotifications = await notificationCountTask ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<T?> SafeCallAsync<T>(Func<Task<T>> call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await call();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Service call failed in home screen aggregation");
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. GraphQL BFF with DataLoaders
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: GraphQL BFF with efficient data loading.
|
||||
/// VI: GraphQL BFF với data loading hiệu quả.
|
||||
/// </summary>
|
||||
|
||||
// GraphQL/Query.cs
|
||||
public class Query
|
||||
{
|
||||
[UseProjection]
|
||||
[UseFiltering]
|
||||
[UseSorting]
|
||||
public async Task<IQueryable<Product>> GetProducts(
|
||||
[Service] IProductRepository repository) =>
|
||||
repository.GetAll();
|
||||
|
||||
public async Task<Product?> GetProduct(
|
||||
[ID] Guid id,
|
||||
ProductByIdDataLoader loader) =>
|
||||
await loader.LoadAsync(id);
|
||||
}
|
||||
|
||||
// GraphQL/DataLoaders/ProductByIdDataLoader.cs
|
||||
public class ProductByIdDataLoader : BatchDataLoader<Guid, Product>
|
||||
{
|
||||
private readonly IProductServiceClient _client;
|
||||
|
||||
public ProductByIdDataLoader(
|
||||
IProductServiceClient client,
|
||||
IBatchScheduler batchScheduler,
|
||||
DataLoaderOptions? options = null)
|
||||
: base(batchScheduler, options)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
protected override async Task<IReadOnlyDictionary<Guid, Product>> LoadBatchAsync(
|
||||
IReadOnlyList<Guid> keys,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// EN: Batch load all requested products
|
||||
var products = await _client.GetProductsByIdsAsync(keys, ct);
|
||||
return products.ToDictionary(p => p.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// GraphQL/Types/ProductType.cs
|
||||
public class ProductType : ObjectType<Product>
|
||||
{
|
||||
protected override void Configure(IObjectTypeDescriptor<Product> descriptor)
|
||||
{
|
||||
descriptor.Field(p => p.Id).Type<NonNullType<IdType>>();
|
||||
descriptor.Field(p => p.Name).Type<NonNullType<StringType>>();
|
||||
descriptor.Field(p => p.Price).Type<NonNullType<DecimalType>>();
|
||||
|
||||
// EN: Reviews field with data loader
|
||||
descriptor
|
||||
.Field("reviews")
|
||||
.Type<NonNullType<ListType<NonNullType<ReviewType>>>>()
|
||||
.Resolve(async ctx =>
|
||||
{
|
||||
var loader = ctx.DataLoader<ReviewsByProductIdDataLoader>();
|
||||
return await loader.LoadAsync(ctx.Parent<Product>().Id);
|
||||
});
|
||||
|
||||
// EN: Stock field with data loader
|
||||
descriptor
|
||||
.Field("stock")
|
||||
.Type<StockType>()
|
||||
.Resolve(async ctx =>
|
||||
{
|
||||
var loader = ctx.DataLoader<StockByProductIdDataLoader>();
|
||||
return await loader.LoadAsync(ctx.Parent<Product>().Id);
|
||||
});
|
||||
|
||||
// EN: Related products
|
||||
descriptor
|
||||
.Field("relatedProducts")
|
||||
.Type<ListType<ProductType>>()
|
||||
.Argument("limit", a => a.Type<IntType>().DefaultValue(4))
|
||||
.Resolve(async ctx =>
|
||||
{
|
||||
var product = ctx.Parent<Product>();
|
||||
var limit = ctx.ArgumentValue<int>("limit");
|
||||
var loader = ctx.DataLoader<RelatedProductsDataLoader>();
|
||||
var related = await loader.LoadAsync(product.CategoryId);
|
||||
return related.Where(p => p.Id != product.Id).Take(limit);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docker Compose for API Gateway
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
api-gateway:
|
||||
build:
|
||||
context: ./src/ApiGateway
|
||||
ports:
|
||||
- "5000:80"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ReverseProxy__Clusters__users-cluster__Destinations__user-service__Address=http://user-service:80
|
||||
- ReverseProxy__Clusters__orders-cluster__Destinations__order-service__Address=http://order-service:80
|
||||
depends_on:
|
||||
- user-service
|
||||
- order-service
|
||||
|
||||
mobile-bff:
|
||||
build:
|
||||
context: ./src/MobileBff
|
||||
ports:
|
||||
- "5001:80"
|
||||
environment:
|
||||
- Services__Products__BaseUrl=http://product-service:80
|
||||
- Services__Reviews__BaseUrl=http://review-service:80
|
||||
- ConnectionStrings__Redis=redis:6379
|
||||
|
||||
web-bff:
|
||||
build:
|
||||
context: ./src/WebBff
|
||||
ports:
|
||||
- "5002:80"
|
||||
|
||||
user-service:
|
||||
build:
|
||||
context: ./src/UserService
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
order-service:
|
||||
build:
|
||||
context: ./src/OrderService
|
||||
expose:
|
||||
- "80"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
```
|
||||
Reference in New Issue
Block a user