552 lines
17 KiB
Markdown
552 lines
17 KiB
Markdown
# 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"
|
|
```
|