chore: Update compatibility for skills to .NET 10+
- Revised compatibility specifications in multiple skill documentation files to reflect support for .NET 10+, ensuring alignment with the latest development standards. - Updated files include API design, error handling patterns, project rules, repository pattern, security, and testing patterns. - This change enhances the relevance and usability of the skills for current and future development projects.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: api-design
|
||||
description: RESTful API design standards for GoodGo microservices. Use for new API endpoints, DTOs, controllers, OpenAPI documentation, or standardized responses.
|
||||
compatibility: ".NET 8+, ASP.NET Core, MediatR, Swashbuckle, Asp.Versioning"
|
||||
compatibility: ".NET 10+, ASP.NET Core, MediatR, Swashbuckle, Asp.Versioning"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "2.0"
|
||||
|
||||
454
.agent/skills/cqrs-mediatr/SKILL.md
Normal file
454
.agent/skills/cqrs-mediatr/SKILL.md
Normal file
@@ -0,0 +1,454 @@
|
||||
---
|
||||
name: cqrs-mediatr
|
||||
description: CQRS pattern với MediatR. Use for Commands, Queries, Handlers, Pipeline Behaviors, và Idempotency.
|
||||
compatibility: ".NET 10+, MediatR 12+, FluentValidation"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# CQRS & MediatR Patterns / Mẫu CQRS & MediatR
|
||||
|
||||
CQRS pattern với MediatR cho GoodGo microservices.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Separating read/write operations / Tách biệt operations đọc/ghi
|
||||
- Creating Commands and Queries / Tạo Commands và Queries
|
||||
- Implementing MediatR handlers / Triển khai MediatR handlers
|
||||
- Adding cross-cutting concerns via Behaviors / Thêm behaviors xuyên suốt
|
||||
- Ensuring idempotency / Đảm bảo tính idempotent
|
||||
|
||||
## Core Concepts / Khái Niệm Cốt Lõi
|
||||
|
||||
### CQRS Overview / Tổng Quan CQRS
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ API Controller │
|
||||
└─────────────────────┬───────────────────────────┬───────────┘
|
||||
│ │
|
||||
┌───────▼───────┐ ┌───────▼───────┐
|
||||
│ Commands │ │ Queries │
|
||||
│ (Write/Ghi) │ │ (Read/Đọc) │
|
||||
└───────┬───────┘ └───────┬───────┘
|
||||
│ │
|
||||
┌───────▼───────┐ ┌───────▼───────┐
|
||||
│ MediatR │ │ MediatR │
|
||||
│ Handler │ │ Handler │
|
||||
└───────┬───────┘ └───────┬───────┘
|
||||
│ │
|
||||
┌───────▼───────┐ ┌───────▼───────┐
|
||||
│ Domain Model │ │ Dapper │
|
||||
│ EF Core │ │ Raw SQL │
|
||||
└───────┬───────┘ └───────┬───────┘
|
||||
│ │
|
||||
└───────────────┬───────────────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Database │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### Command vs Query / Lệnh vs Truy Vấn
|
||||
|
||||
| Aspect | Command | Query |
|
||||
|--------|---------|-------|
|
||||
| **Purpose** | Modify state | Read data |
|
||||
| **Returns** | Result/void | Data/DTO |
|
||||
| **Model** | Full Domain Model | Lightweight DTO |
|
||||
| **ORM** | EF Core | Dapper/Raw SQL |
|
||||
| **Validation** | Full business rules | Minimal |
|
||||
|
||||
### MediatR Flow / Luồng MediatR
|
||||
|
||||
```
|
||||
Request → Pipeline Behaviors → Handler → Response
|
||||
├── LoggingBehavior
|
||||
├── ValidationBehavior
|
||||
└── TransactionBehavior
|
||||
```
|
||||
|
||||
## Key Patterns / Mẫu Chính
|
||||
|
||||
### Command Definition / Định Nghĩa Command
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Command to create a new order.
|
||||
/// VI: Command tạo order mới.
|
||||
/// </summary>
|
||||
public record CreateOrderCommand(
|
||||
string UserId,
|
||||
Address ShippingAddress,
|
||||
List<OrderItemDto> Items) : IRequest<OrderResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command result.
|
||||
/// VI: Kết quả command.
|
||||
/// </summary>
|
||||
public record OrderResult(Guid OrderId);
|
||||
```
|
||||
|
||||
### Query Definition / Định Nghĩa Query
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Query to get user orders.
|
||||
/// VI: Query lấy orders của user.
|
||||
/// </summary>
|
||||
public record GetUserOrdersQuery(
|
||||
string UserId,
|
||||
int Skip = 0,
|
||||
int Take = 20) : IRequest<PagedResult<OrderSummaryDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Lightweight DTO for query results.
|
||||
/// VI: DTO nhẹ cho kết quả query.
|
||||
/// </summary>
|
||||
public record OrderSummaryDto(
|
||||
Guid Id,
|
||||
string Status,
|
||||
decimal TotalAmount,
|
||||
DateTime CreatedAt,
|
||||
int ItemCount);
|
||||
```
|
||||
|
||||
### Command Handler / Handler Command
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateOrderCommand.
|
||||
/// VI: Handler cho CreateOrderCommand.
|
||||
/// </summary>
|
||||
public class CreateOrderCommandHandler
|
||||
: IRequestHandler<CreateOrderCommand, OrderResult>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly ILogger<CreateOrderCommandHandler> _logger;
|
||||
|
||||
public CreateOrderCommandHandler(
|
||||
IOrderRepository orderRepository,
|
||||
ILogger<CreateOrderCommandHandler> logger)
|
||||
{
|
||||
_orderRepository = orderRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<OrderResult> Handle(
|
||||
CreateOrderCommand request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// EN: Create order through domain model
|
||||
// VI: Tạo order qua domain model
|
||||
var order = new Order(request.UserId, request.ShippingAddress);
|
||||
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
|
||||
}
|
||||
|
||||
await _orderRepository.AddAsync(order, ct);
|
||||
await _orderRepository.UnitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"EN: Order created / VI: Order đã tạo: {OrderId}",
|
||||
order.Id);
|
||||
|
||||
return new OrderResult(order.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Query Handler with Dapper / Handler Query với Dapper
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Query handler using Dapper for optimized reads.
|
||||
/// VI: Query handler dùng Dapper cho đọc tối ưu.
|
||||
/// </summary>
|
||||
public class GetUserOrdersQueryHandler
|
||||
: IRequestHandler<GetUserOrdersQuery, PagedResult<OrderSummaryDto>>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public GetUserOrdersQueryHandler(IDbConnection connection)
|
||||
{
|
||||
_connection = connection;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<OrderSummaryDto>> Handle(
|
||||
GetUserOrdersQuery request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string countSql = "SELECT COUNT(*) FROM Orders WHERE UserId = @UserId";
|
||||
var total = await _connection.ExecuteScalarAsync<int>(countSql, new { request.UserId });
|
||||
|
||||
const string sql = @"
|
||||
SELECT o.Id, o.Status, o.TotalAmount, o.CreatedAt,
|
||||
COUNT(oi.Id) as ItemCount
|
||||
FROM Orders o
|
||||
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
|
||||
WHERE o.UserId = @UserId
|
||||
GROUP BY o.Id, o.Status, o.TotalAmount, o.CreatedAt
|
||||
ORDER BY o.CreatedAt DESC
|
||||
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY";
|
||||
|
||||
var orders = await _connection.QueryAsync<OrderSummaryDto>(sql, new
|
||||
{
|
||||
request.UserId,
|
||||
request.Skip,
|
||||
request.Take
|
||||
});
|
||||
|
||||
return new PagedResult<OrderSummaryDto>(orders.ToList(), total);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Behavior / Behavior Validation
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Pipeline behavior for FluentValidation.
|
||||
/// VI: Pipeline behavior cho FluentValidation.
|
||||
/// </summary>
|
||||
public class ValidationBehavior<TRequest, TResponse>
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
|
||||
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||
{
|
||||
_validators = validators;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_validators.Any())
|
||||
return await next();
|
||||
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
var failures = (await Task.WhenAll(
|
||||
_validators.Select(v => v.ValidateAsync(context, ct))))
|
||||
.SelectMany(r => r.Errors)
|
||||
.Where(f => f != null)
|
||||
.ToList();
|
||||
|
||||
if (failures.Count != 0)
|
||||
throw new ValidationException(failures);
|
||||
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Logging Behavior / Behavior Logging
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Pipeline behavior for logging.
|
||||
/// VI: Pipeline behavior cho logging.
|
||||
/// </summary>
|
||||
public class LoggingBehavior<TRequest, TResponse>
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
_logger.LogInformation(
|
||||
"EN: Handling {RequestName} / VI: Xử lý {RequestName}",
|
||||
requestName);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var response = await next();
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"EN: Handled {RequestName} in {ElapsedMs}ms / VI: Đã xử lý {RequestName} trong {ElapsedMs}ms",
|
||||
requestName, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Controller with MediatR / Controller với MediatR
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Slim controller using MediatR.
|
||||
/// VI: Controller gọn nhẹ với MediatR.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/orders")]
|
||||
public class OrdersController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public OrdersController(IMediator mediator)
|
||||
{
|
||||
_mediator = mediator;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ApiResponse<OrderResult>>> CreateOrder(
|
||||
CreateOrderRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var command = new CreateOrderCommand(
|
||||
GetUserId(),
|
||||
request.ShippingAddress,
|
||||
request.Items);
|
||||
|
||||
var result = await _mediator.Send(command, ct);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetOrder),
|
||||
new { orderId = result.OrderId },
|
||||
ApiResponse<OrderResult>.Ok(result));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ApiResponse<PagedResult<OrderSummaryDto>>>> GetOrders(
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = new GetUserOrdersQuery(GetUserId(), skip, take);
|
||||
var result = await _mediator.Send(query, ct);
|
||||
return Ok(ApiResponse<PagedResult<OrderSummaryDto>>.Ok(result));
|
||||
}
|
||||
|
||||
private string GetUserId() =>
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
### 1. Using Domain Model for Queries
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Using EF Core for simple reads
|
||||
public async Task<IEnumerable<Order>> Handle(GetOrdersQuery query, CancellationToken ct)
|
||||
{
|
||||
return await _context.Orders
|
||||
.Include(o => o.OrderItems)
|
||||
.Where(o => o.UserId == query.UserId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
// ✅ GOOD: Using Dapper for optimized reads
|
||||
public async Task<IEnumerable<OrderDto>> Handle(GetOrdersQuery query, CancellationToken ct)
|
||||
{
|
||||
const string sql = "SELECT Id, Status, TotalAmount FROM Orders WHERE UserId = @UserId";
|
||||
return await _connection.QueryAsync<OrderDto>(sql, new { query.UserId });
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Missing Pipeline Behaviors
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Validation in handler
|
||||
public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.UserId))
|
||||
throw new ValidationException("UserId required");
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ GOOD: Validation via Behavior
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Fat Controllers
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Logic in controller
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
|
||||
{
|
||||
var order = new Order(request.UserId, request.Address);
|
||||
foreach (var item in request.Items)
|
||||
order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
|
||||
await _repository.AddAsync(order);
|
||||
await _repository.UnitOfWork.SaveChangesAsync();
|
||||
return Ok(order.Id);
|
||||
}
|
||||
|
||||
// ✅ GOOD: Delegate to MediatR
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
|
||||
{
|
||||
var command = request.ToCommand(GetUserId());
|
||||
var result = await _mediator.Send(command);
|
||||
return CreatedAtAction(nameof(GetOrder), new { orderId = result.OrderId }, result);
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
### MediatR Request Types
|
||||
|
||||
| Interface | Purpose | Example |
|
||||
|-----------|---------|---------|
|
||||
| `IRequest<T>` | Request with response | Commands, Queries |
|
||||
| `IRequest` | Request without response | Fire-and-forget |
|
||||
| `INotification` | Event notification | Domain events |
|
||||
|
||||
### Pipeline Behavior Order
|
||||
|
||||
```csharp
|
||||
// EN: Registration order = execution order
|
||||
// VI: Thứ tự đăng ký = thứ tự thực thi
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); // 1st
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); // 2nd
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>)); // 3rd
|
||||
```
|
||||
|
||||
### DI Registration
|
||||
|
||||
```csharp
|
||||
// EN: Register MediatR with behaviors
|
||||
// VI: Đăng ký MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||
});
|
||||
|
||||
// EN: Register FluentValidation
|
||||
// VI: Đăng ký FluentValidation
|
||||
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
|
||||
```
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Detailed Examples](./references/REFERENCE.md) - Full code examples
|
||||
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access
|
||||
- [Error Handling](../error-handling-patterns/SKILL.md) - Validation errors
|
||||
- [Testing Patterns](../testing-patterns/SKILL.md) - Handler testing
|
||||
- [API Design](../api-design/SKILL.md) - Controller patterns
|
||||
764
.agent/skills/cqrs-mediatr/references/REFERENCE.md
Normal file
764
.agent/skills/cqrs-mediatr/references/REFERENCE.md
Normal file
@@ -0,0 +1,764 @@
|
||||
# CQRS-MediatR - Detailed Reference
|
||||
|
||||
Detailed code examples for CQRS pattern với MediatR trong ASP.NET Core.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Project Setup](#project-setup)
|
||||
2. [Command Patterns](#command-patterns)
|
||||
3. [Query Patterns](#query-patterns)
|
||||
4. [Pipeline Behaviors](#pipeline-behaviors)
|
||||
5. [Idempotency](#idempotency)
|
||||
6. [Domain Events](#domain-events)
|
||||
7. [Transaction Management](#transaction-management)
|
||||
|
||||
---
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Package Dependencies
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.9.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
|
||||
<PackageReference Include="Dapper" Version="2.1.24" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
### DI Registration
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure MediatR with all behaviors.
|
||||
/// VI: Cấu hình MediatR với tất cả behaviors.
|
||||
/// </summary>
|
||||
|
||||
// EN: Register MediatR / VI: Đăng ký MediatR
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
|
||||
|
||||
// EN: Pipeline behaviors (order matters!)
|
||||
// VI: Pipeline behaviors (thứ tự quan trọng!)
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
||||
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
|
||||
});
|
||||
|
||||
// EN: Register FluentValidation validators
|
||||
// VI: Đăng ký FluentValidation validators
|
||||
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
|
||||
|
||||
// EN: Register Dapper connection for queries
|
||||
// VI: Đăng ký Dapper connection cho queries
|
||||
builder.Services.AddScoped<IDbConnection>(_ =>
|
||||
new NpgsqlConnection(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Command Patterns
|
||||
|
||||
### Immutable Command Records
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Command to create an order. Commands should be immutable.
|
||||
/// VI: Command tạo order. Commands nên là immutable.
|
||||
/// </summary>
|
||||
public record CreateOrderCommand : IRequest<OrderResult>
|
||||
{
|
||||
public string UserId { get; init; }
|
||||
public Address ShippingAddress { get; init; }
|
||||
public IReadOnlyList<OrderItemDto> Items { get; init; }
|
||||
|
||||
public CreateOrderCommand(
|
||||
string userId,
|
||||
Address shippingAddress,
|
||||
IReadOnlyList<OrderItemDto> items)
|
||||
{
|
||||
UserId = userId;
|
||||
ShippingAddress = shippingAddress;
|
||||
Items = items;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update order status.
|
||||
/// VI: Command cập nhật trạng thái order.
|
||||
/// </summary>
|
||||
public record UpdateOrderStatusCommand(
|
||||
Guid OrderId,
|
||||
string UserId,
|
||||
OrderStatus NewStatus) : IRequest<OrderResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to cancel order.
|
||||
/// VI: Command hủy order.
|
||||
/// </summary>
|
||||
public record CancelOrderCommand(
|
||||
Guid OrderId,
|
||||
string UserId,
|
||||
string Reason) : IRequest<OrderResult>;
|
||||
```
|
||||
|
||||
### Command Handler with Full Domain Logic
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateOrderCommand with domain validation.
|
||||
/// VI: Handler cho CreateOrderCommand với domain validation.
|
||||
/// </summary>
|
||||
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, OrderResult>
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IProductService _productService;
|
||||
private readonly ILogger<CreateOrderCommandHandler> _logger;
|
||||
|
||||
public CreateOrderCommandHandler(
|
||||
IOrderRepository orderRepository,
|
||||
IProductService productService,
|
||||
ILogger<CreateOrderCommandHandler> logger)
|
||||
{
|
||||
_orderRepository = orderRepository;
|
||||
_productService = productService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<OrderResult> Handle(
|
||||
CreateOrderCommand request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// EN: Validate products exist and get prices
|
||||
// VI: Xác thực products tồn tại và lấy giá
|
||||
var productIds = request.Items.Select(i => i.ProductId).ToList();
|
||||
var products = await _productService.GetByIdsAsync(productIds, ct);
|
||||
|
||||
if (products.Count != productIds.Count)
|
||||
{
|
||||
var missingIds = productIds.Except(products.Select(p => p.Id));
|
||||
throw new NotFoundException("Products", string.Join(", ", missingIds));
|
||||
}
|
||||
|
||||
// EN: Create order aggregate
|
||||
// VI: Tạo order aggregate
|
||||
var order = new Order(request.UserId, request.ShippingAddress);
|
||||
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
var product = products.First(p => p.Id == item.ProductId);
|
||||
order.AddItem(product.Id, item.Quantity, product.Price);
|
||||
}
|
||||
|
||||
// EN: Persist via repository
|
||||
// VI: Lưu qua repository
|
||||
await _orderRepository.AddAsync(order, ct);
|
||||
await _orderRepository.UnitOfWork.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"EN: Order {OrderId} created for user {UserId} / " +
|
||||
"VI: Order {OrderId} đã tạo cho user {UserId}",
|
||||
order.Id, request.UserId);
|
||||
|
||||
return new OrderResult(order.Id, order.Status.ToString(), order.TotalAmount);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Command Validator
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: FluentValidation validator for CreateOrderCommand.
|
||||
/// VI: FluentValidation validator cho CreateOrderCommand.
|
||||
/// </summary>
|
||||
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
|
||||
{
|
||||
public CreateOrderCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId)
|
||||
.NotEmpty().WithMessage("User ID is required");
|
||||
|
||||
RuleFor(x => x.ShippingAddress)
|
||||
.NotNull().WithMessage("Shipping address is required")
|
||||
.SetValidator(new AddressValidator()!);
|
||||
|
||||
RuleFor(x => x.Items)
|
||||
.NotEmpty().WithMessage("At least one item is required")
|
||||
.Must(items => items.Count <= 100)
|
||||
.WithMessage("Maximum 100 items per order");
|
||||
|
||||
RuleForEach(x => x.Items)
|
||||
.SetValidator(new OrderItemDtoValidator());
|
||||
}
|
||||
}
|
||||
|
||||
public class AddressValidator : AbstractValidator<Address>
|
||||
{
|
||||
public AddressValidator()
|
||||
{
|
||||
RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.City).NotEmpty().MaximumLength(100);
|
||||
RuleFor(x => x.PostalCode).NotEmpty().Matches(@"^\d{5}(-\d{4})?$");
|
||||
RuleFor(x => x.Country).NotEmpty().MaximumLength(100);
|
||||
}
|
||||
}
|
||||
|
||||
public class OrderItemDtoValidator : AbstractValidator<OrderItemDto>
|
||||
{
|
||||
public OrderItemDtoValidator()
|
||||
{
|
||||
RuleFor(x => x.ProductId).NotEmpty();
|
||||
RuleFor(x => x.Quantity).GreaterThan(0).LessThanOrEqualTo(1000);
|
||||
RuleFor(x => x.UnitPrice).GreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Optimized Query with Dapper
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Query for paginated user orders.
|
||||
/// VI: Query cho orders phân trang của user.
|
||||
/// </summary>
|
||||
public record GetUserOrdersQuery(
|
||||
string UserId,
|
||||
int Skip = 0,
|
||||
int Take = 20,
|
||||
OrderStatus? StatusFilter = null) : IRequest<PagedResult<OrderSummaryDto>>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler using Dapper for optimized reads.
|
||||
/// VI: Handler dùng Dapper cho đọc tối ưu.
|
||||
/// </summary>
|
||||
public class GetUserOrdersQueryHandler
|
||||
: IRequestHandler<GetUserOrdersQuery, PagedResult<OrderSummaryDto>>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public GetUserOrdersQueryHandler(IDbConnection connection)
|
||||
{
|
||||
_connection = connection;
|
||||
}
|
||||
|
||||
public async Task<PagedResult<OrderSummaryDto>> Handle(
|
||||
GetUserOrdersQuery request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var whereClause = "WHERE o.UserId = @UserId";
|
||||
var parameters = new DynamicParameters();
|
||||
parameters.Add("UserId", request.UserId);
|
||||
parameters.Add("Skip", request.Skip);
|
||||
parameters.Add("Take", request.Take);
|
||||
|
||||
if (request.StatusFilter.HasValue)
|
||||
{
|
||||
whereClause += " AND o.Status = @Status";
|
||||
parameters.Add("Status", request.StatusFilter.Value.ToString());
|
||||
}
|
||||
|
||||
// EN: Count total
|
||||
// VI: Đếm tổng
|
||||
var countSql = $"SELECT COUNT(*) FROM Orders o {whereClause}";
|
||||
var total = await _connection.ExecuteScalarAsync<int>(countSql, parameters);
|
||||
|
||||
// EN: Get data with pagination
|
||||
// VI: Lấy dữ liệu với phân trang
|
||||
var dataSql = $@"
|
||||
SELECT
|
||||
o.Id,
|
||||
o.Status,
|
||||
o.TotalAmount,
|
||||
o.CreatedAt,
|
||||
(SELECT COUNT(*) FROM OrderItems WHERE OrderId = o.Id) as ItemCount
|
||||
FROM Orders o
|
||||
{whereClause}
|
||||
ORDER BY o.CreatedAt DESC
|
||||
OFFSET @Skip ROWS FETCH NEXT @Take ROWS ONLY";
|
||||
|
||||
var orders = await _connection.QueryAsync<OrderSummaryDto>(dataSql, parameters);
|
||||
|
||||
return new PagedResult<OrderSummaryDto>(
|
||||
orders.ToList(),
|
||||
total,
|
||||
request.Skip / request.Take + 1,
|
||||
request.Take);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Query with Multi-Mapping
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Query for order detail with items.
|
||||
/// VI: Query cho chi tiết order với items.
|
||||
/// </summary>
|
||||
public record GetOrderDetailQuery(
|
||||
Guid OrderId,
|
||||
string UserId) : IRequest<OrderDetailDto?>;
|
||||
|
||||
public class GetOrderDetailQueryHandler
|
||||
: IRequestHandler<GetOrderDetailQuery, OrderDetailDto?>
|
||||
{
|
||||
private readonly IDbConnection _connection;
|
||||
|
||||
public async Task<OrderDetailDto?> Handle(
|
||||
GetOrderDetailQuery request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const string sql = @"
|
||||
SELECT
|
||||
o.Id, o.UserId, o.Status, o.TotalAmount, o.CreatedAt,
|
||||
o.ShippingAddress_Street as Street,
|
||||
o.ShippingAddress_City as City,
|
||||
o.ShippingAddress_PostalCode as PostalCode,
|
||||
o.ShippingAddress_Country as Country,
|
||||
oi.Id as ItemId, oi.ProductId, oi.Quantity, oi.UnitPrice,
|
||||
p.Name as ProductName
|
||||
FROM Orders o
|
||||
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
|
||||
LEFT JOIN Products p ON oi.ProductId = p.Id
|
||||
WHERE o.Id = @OrderId AND o.UserId = @UserId";
|
||||
|
||||
var orderDict = new Dictionary<Guid, OrderDetailDto>();
|
||||
|
||||
await _connection.QueryAsync<OrderDetailDto, OrderItemDetailDto?, OrderDetailDto>(
|
||||
sql,
|
||||
(order, item) =>
|
||||
{
|
||||
if (!orderDict.TryGetValue(order.Id, out var existingOrder))
|
||||
{
|
||||
existingOrder = order;
|
||||
existingOrder.Items = new List<OrderItemDetailDto>();
|
||||
orderDict.Add(order.Id, existingOrder);
|
||||
}
|
||||
|
||||
if (item != null)
|
||||
existingOrder.Items.Add(item);
|
||||
|
||||
return existingOrder;
|
||||
},
|
||||
new { request.OrderId, request.UserId },
|
||||
splitOn: "ItemId");
|
||||
|
||||
return orderDict.Values.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pipeline Behaviors
|
||||
|
||||
### Complete Validation Behavior
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Validation behavior with detailed error mapping.
|
||||
/// VI: Validation behavior với ánh xạ lỗi chi tiết.
|
||||
/// </summary>
|
||||
public class ValidationBehavior<TRequest, TResponse>
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
private readonly ILogger<ValidationBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public ValidationBehavior(
|
||||
IEnumerable<IValidator<TRequest>> validators,
|
||||
ILogger<ValidationBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_validators = validators;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var typeName = typeof(TRequest).Name;
|
||||
|
||||
if (!_validators.Any())
|
||||
{
|
||||
_logger.LogDebug("No validators for {RequestType}", typeName);
|
||||
return await next();
|
||||
}
|
||||
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
|
||||
var validationResults = await Task.WhenAll(
|
||||
_validators.Select(v => v.ValidateAsync(context, ct)));
|
||||
|
||||
var failures = validationResults
|
||||
.SelectMany(r => r.Errors)
|
||||
.Where(f => f != null)
|
||||
.ToList();
|
||||
|
||||
if (failures.Count != 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Validation failed for {RequestType}: {Errors}",
|
||||
typeName,
|
||||
string.Join(", ", failures.Select(f => f.ErrorMessage)));
|
||||
|
||||
throw new ValidationException(failures);
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Logging Behavior with Performance Tracking
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Logging behavior with performance alerts.
|
||||
/// VI: Logging behavior với cảnh báo hiệu năng.
|
||||
/// </summary>
|
||||
public class LoggingBehavior<TRequest, TResponse>
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
|
||||
private readonly TimeSpan _slowThreshold = TimeSpan.FromSeconds(3);
|
||||
|
||||
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
var requestId = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
_logger.LogInformation(
|
||||
"[{RequestId}] Handling {RequestName}",
|
||||
requestId, requestName);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await next();
|
||||
stopwatch.Stop();
|
||||
|
||||
if (stopwatch.Elapsed > _slowThreshold)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[{RequestId}] SLOW: {RequestName} took {ElapsedMs}ms",
|
||||
requestId, requestName, stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[{RequestId}] Completed {RequestName} in {ElapsedMs}ms",
|
||||
requestId, requestName, stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogError(ex,
|
||||
"[{RequestId}] FAILED: {RequestName} after {ElapsedMs}ms",
|
||||
requestId, requestName, stopwatch.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Transaction Behavior
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Transaction behavior for commands.
|
||||
/// VI: Transaction behavior cho commands.
|
||||
/// </summary>
|
||||
public class TransactionBehavior<TRequest, TResponse>
|
||||
: IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public TransactionBehavior(
|
||||
ApplicationDbContext dbContext,
|
||||
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// EN: Only wrap commands in transactions
|
||||
// VI: Chỉ wrap commands trong transactions
|
||||
if (!IsCommand())
|
||||
return await next();
|
||||
|
||||
var typeName = typeof(TRequest).Name;
|
||||
|
||||
await using var transaction = await _dbContext.Database
|
||||
.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Begin transaction for {RequestType}", typeName);
|
||||
|
||||
var response = await next();
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
_logger.LogDebug("Committed transaction for {RequestType}", typeName);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
_logger.LogError(ex, "Rolled back transaction for {RequestType}", typeName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCommand()
|
||||
{
|
||||
return typeof(TRequest).Name.EndsWith("Command");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Idempotency
|
||||
|
||||
### Identified Command Pattern
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Wrapper for idempotent commands.
|
||||
/// VI: Wrapper cho commands idempotent.
|
||||
/// </summary>
|
||||
public record IdentifiedCommand<TCommand, TResult>(
|
||||
Guid RequestId,
|
||||
TCommand Command) : IRequest<TResult>
|
||||
where TCommand : IRequest<TResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for identified commands.
|
||||
/// VI: Handler cho identified commands.
|
||||
/// </summary>
|
||||
public class IdentifiedCommandHandler<TCommand, TResult>
|
||||
: IRequestHandler<IdentifiedCommand<TCommand, TResult>, TResult>
|
||||
where TCommand : IRequest<TResult>
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IRequestManager _requestManager;
|
||||
private readonly ILogger<IdentifiedCommandHandler<TCommand, TResult>> _logger;
|
||||
|
||||
public IdentifiedCommandHandler(
|
||||
IMediator mediator,
|
||||
IRequestManager requestManager,
|
||||
ILogger<IdentifiedCommandHandler<TCommand, TResult>> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_requestManager = requestManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<TResult> Handle(
|
||||
IdentifiedCommand<TCommand, TResult> request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var commandName = typeof(TCommand).Name;
|
||||
|
||||
// EN: Check if already processed
|
||||
// VI: Kiểm tra đã xử lý chưa
|
||||
if (await _requestManager.ExistsAsync(request.RequestId, ct))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Duplicate request {RequestId} for {CommandName}",
|
||||
request.RequestId, commandName);
|
||||
|
||||
return default!; // EN: Return cached result if needed
|
||||
}
|
||||
|
||||
// EN: Mark as processing
|
||||
// VI: Đánh dấu đang xử lý
|
||||
await _requestManager.CreateAsync(request.RequestId, commandName, ct);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _mediator.Send(request.Command, ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processed {CommandName} with RequestId {RequestId}",
|
||||
commandName, request.RequestId);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _requestManager.DeleteAsync(request.RequestId, ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Request Manager
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Interface for request deduplication.
|
||||
/// VI: Interface cho request deduplication.
|
||||
/// </summary>
|
||||
public interface IRequestManager
|
||||
{
|
||||
Task<bool> ExistsAsync(Guid requestId, CancellationToken ct);
|
||||
Task CreateAsync(Guid requestId, string commandName, CancellationToken ct);
|
||||
Task DeleteAsync(Guid requestId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Database-backed request manager.
|
||||
/// VI: Request manager với database.
|
||||
/// </summary>
|
||||
public class RequestManager : IRequestManager
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
|
||||
public RequestManager(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(Guid requestId, CancellationToken ct)
|
||||
{
|
||||
return await _context.ClientRequests
|
||||
.AnyAsync(r => r.Id == requestId, ct);
|
||||
}
|
||||
|
||||
public async Task CreateAsync(Guid requestId, string commandName, CancellationToken ct)
|
||||
{
|
||||
var request = new ClientRequest
|
||||
{
|
||||
Id = requestId,
|
||||
Name = commandName,
|
||||
Time = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.ClientRequests.Add(request);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid requestId, CancellationToken ct)
|
||||
{
|
||||
var request = await _context.ClientRequests.FindAsync(new object[] { requestId }, ct);
|
||||
if (request != null)
|
||||
{
|
||||
_context.ClientRequests.Remove(request);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity for tracking processed requests.
|
||||
/// VI: Entity theo dõi requests đã xử lý.
|
||||
/// </summary>
|
||||
public class ClientRequest
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime Time { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Events
|
||||
|
||||
### Publishing Domain Events
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Domain event interface.
|
||||
/// VI: Interface domain event.
|
||||
/// </summary>
|
||||
public interface IDomainEvent : INotification
|
||||
{
|
||||
DateTime OccurredOn { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Order created domain event.
|
||||
/// VI: Domain event order được tạo.
|
||||
/// </summary>
|
||||
public record OrderCreatedEvent(
|
||||
Guid OrderId,
|
||||
string UserId,
|
||||
decimal TotalAmount) : IDomainEvent
|
||||
{
|
||||
public DateTime OccurredOn { get; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for OrderCreatedEvent.
|
||||
/// VI: Handler cho OrderCreatedEvent.
|
||||
/// </summary>
|
||||
public class OrderCreatedEventHandler : INotificationHandler<OrderCreatedEvent>
|
||||
{
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly ILogger<OrderCreatedEventHandler> _logger;
|
||||
|
||||
public OrderCreatedEventHandler(
|
||||
IEmailService emailService,
|
||||
ILogger<OrderCreatedEventHandler> logger)
|
||||
{
|
||||
_emailService = emailService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Sending order confirmation email for Order {OrderId}",
|
||||
notification.OrderId);
|
||||
|
||||
await _emailService.SendOrderConfirmationAsync(
|
||||
notification.UserId,
|
||||
notification.OrderId,
|
||||
notification.TotalAmount,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [MediatR Documentation](https://github.com/jbogard/MediatR)
|
||||
- [FluentValidation](https://docs.fluentvalidation.net/)
|
||||
- [CQRS Pattern - Microsoft](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs)
|
||||
- [eShopOnContainers - CQRS](https://github.com/dotnet-architecture/eShopOnContainers)
|
||||
401
.agent/skills/docker-traefik/SKILL.md
Normal file
401
.agent/skills/docker-traefik/SKILL.md
Normal file
@@ -0,0 +1,401 @@
|
||||
---
|
||||
name: docker-traefik
|
||||
description: Docker containerization và Traefik reverse proxy. Use for Dockerfile, docker-compose, routing rules, SSL termination, và load balancing.
|
||||
compatibility: "Docker 24+, Docker Compose v2+, Traefik v3+"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Docker & Traefik Patterns / Mẫu Docker & Traefik
|
||||
|
||||
Docker containerization và Traefik reverse proxy cho GoodGo microservices.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Creating Dockerfiles for .NET services / Tạo Dockerfiles cho services .NET
|
||||
- Configuring docker-compose for local dev / Cấu hình docker-compose cho dev local
|
||||
- Setting up Traefik routing / Cài đặt Traefik routing
|
||||
- Configuring SSL/TLS termination / Cấu hình SSL/TLS termination
|
||||
- Implementing load balancing / Triển khai load balancing
|
||||
- Managing service discovery / Quản lý service discovery
|
||||
|
||||
## Core Concepts / Khái Niệm Cốt Lõi
|
||||
|
||||
### Architecture Overview / Tổng Quan Kiến Trúc
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Traefik (Gateway) │
|
||||
│ - SSL Termination - Rate Limiting │
|
||||
│ - Load Balancing - Path Routing │
|
||||
└───────┬────────────────────────────────┬────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐
|
||||
│ iam-service │ │ storage-svc │
|
||||
│ :8080 │ │ :8080 │
|
||||
└───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
### Traefik Concepts / Các Khái Niệm Traefik
|
||||
|
||||
| Concept | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| **EntryPoints** | Network ports | `web:80`, `websecure:443` |
|
||||
| **Routers** | Match requests to services | `PathPrefix(\`/api/v1/iam\`)` |
|
||||
| **Services** | Backend targets | `loadbalancer.server.port=8080` |
|
||||
| **Middlewares** | Request/Response modifiers | `stripprefix`, `ratelimit` |
|
||||
|
||||
### Docker Best Practices / Best Practices Docker
|
||||
|
||||
1. **Multi-stage builds** - Separate build and runtime
|
||||
2. **Non-root user** - Security best practice
|
||||
3. **Alpine images** - Smaller image size
|
||||
4. **.dockerignore** - Exclude unnecessary files
|
||||
5. **Layer caching** - Optimize build time
|
||||
|
||||
## Key Patterns / Mẫu Chính
|
||||
|
||||
### Dockerfile for .NET Service
|
||||
|
||||
```dockerfile
|
||||
# ===================================
|
||||
# EN: Build stage / VI: Stage build
|
||||
# ===================================
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# EN: Copy solution and restore dependencies
|
||||
# VI: Copy solution và restore dependencies
|
||||
COPY *.slnx ./
|
||||
COPY src/MyService.API/*.csproj src/MyService.API/
|
||||
COPY src/MyService.Domain/*.csproj src/MyService.Domain/
|
||||
COPY src/MyService.Infrastructure/*.csproj src/MyService.Infrastructure/
|
||||
RUN dotnet restore
|
||||
|
||||
# EN: Copy source code and build
|
||||
# VI: Copy source code và build
|
||||
COPY src/ src/
|
||||
RUN dotnet publish src/MyService.API -c Release -o /app --no-restore
|
||||
|
||||
# ===================================
|
||||
# EN: Runtime stage / VI: Stage runtime
|
||||
# ===================================
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# EN: Install additional packages if needed
|
||||
# VI: Cài đặt packages bổ sung nếu cần
|
||||
RUN apk add --no-cache icu-libs
|
||||
|
||||
# EN: Create non-root user for security
|
||||
# VI: Tạo user không phải root để bảo mật
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
USER appuser
|
||||
|
||||
# EN: Copy published files
|
||||
# VI: Copy files đã publish
|
||||
COPY --from=build --chown=appuser:appgroup /app .
|
||||
|
||||
# EN: Configure environment
|
||||
# VI: Cấu hình môi trường
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV DOTNET_RUNNING_IN_CONTAINER=true
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "MyService.API.dll"]
|
||||
```
|
||||
|
||||
### Docker Compose với Traefik
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# ===================================
|
||||
# Traefik - API Gateway
|
||||
# ===================================
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
container_name: traefik
|
||||
command:
|
||||
# EN: Enable API and Dashboard
|
||||
# VI: Bật API và Dashboard
|
||||
- "--api.dashboard=true"
|
||||
- "--api.insecure=true"
|
||||
|
||||
# EN: Docker provider configuration
|
||||
# VI: Cấu hình Docker provider
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=goodgo-network"
|
||||
|
||||
# EN: Entrypoints
|
||||
# VI: Các điểm vào
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
|
||||
# EN: Access logs
|
||||
# VI: Logs truy cập
|
||||
- "--accesslog=true"
|
||||
- "--accesslog.format=json"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:8080" # Dashboard
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
networks:
|
||||
- goodgo-network
|
||||
labels:
|
||||
# EN: Enable Traefik dashboard
|
||||
# VI: Bật Traefik dashboard
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.dashboard.rule=Host(`traefik.localhost`)"
|
||||
- "traefik.http.routers.dashboard.service=api@internal"
|
||||
|
||||
# ===================================
|
||||
# IAM Service
|
||||
# ===================================
|
||||
iam-service-net:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: services/iam-service-net/Dockerfile
|
||||
container_name: iam-service-net
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ConnectionStrings__DefaultConnection=${IAM_DATABASE_URL}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# EN: Router configuration / VI: Cấu hình router
|
||||
- "traefik.http.routers.iam-service-net.rule=PathPrefix(`/api/v1/iam`)"
|
||||
- "traefik.http.routers.iam-service-net.entrypoints=web"
|
||||
# EN: Service configuration / VI: Cấu hình service
|
||||
- "traefik.http.services.iam-service-net.loadbalancer.server.port=8080"
|
||||
# EN: Health check / VI: Kiểm tra sức khỏe
|
||||
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.path=/health/live"
|
||||
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.interval=10s"
|
||||
networks:
|
||||
- goodgo-network
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
# ===================================
|
||||
# Storage Service
|
||||
# ===================================
|
||||
storage-service-net:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: services/storage-service-net/Dockerfile
|
||||
container_name: storage-service-net
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ConnectionStrings__DefaultConnection=${STORAGE_DATABASE_URL}
|
||||
- MinIO__Endpoint=${MINIO_ENDPOINT}
|
||||
- MinIO__AccessKey=${MINIO_ACCESS_KEY}
|
||||
- MinIO__SecretKey=${MINIO_SECRET_KEY}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.storage-service-net.rule=PathPrefix(`/api/v1/storage`)"
|
||||
- "traefik.http.routers.storage-service-net.entrypoints=web"
|
||||
- "traefik.http.services.storage-service-net.loadbalancer.server.port=8080"
|
||||
networks:
|
||||
- goodgo-network
|
||||
depends_on:
|
||||
- postgres
|
||||
- minio
|
||||
|
||||
# ===================================
|
||||
# PostgreSQL
|
||||
# ===================================
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: postgres
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- goodgo-network
|
||||
|
||||
# ===================================
|
||||
# MinIO (S3-compatible storage)
|
||||
# ===================================
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
- MINIO_ROOT_USER=minioadmin
|
||||
- MINIO_ROOT_PASSWORD=minioadmin
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
networks:
|
||||
- goodgo-network
|
||||
|
||||
networks:
|
||||
goodgo-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
```
|
||||
|
||||
### Traefik Middlewares
|
||||
|
||||
```yaml
|
||||
# EN: Strip path prefix middleware
|
||||
# VI: Middleware bỏ prefix path
|
||||
services:
|
||||
iam-service-net:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.iam-service-net.rule=PathPrefix(`/api/v1/iam`)"
|
||||
- "traefik.http.routers.iam-service-net.entrypoints=web"
|
||||
# EN: Apply middleware to strip prefix
|
||||
# VI: Áp dụng middleware để bỏ prefix
|
||||
- "traefik.http.routers.iam-service-net.middlewares=iam-stripprefix"
|
||||
- "traefik.http.middlewares.iam-stripprefix.stripprefix.prefixes=/api/v1/iam"
|
||||
- "traefik.http.services.iam-service-net.loadbalancer.server.port=8080"
|
||||
```
|
||||
|
||||
### Rate Limiting Middleware
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api-service:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.api.rule=PathPrefix(`/api`)"
|
||||
# EN: Rate limiting middleware
|
||||
# VI: Middleware giới hạn rate
|
||||
- "traefik.http.routers.api.middlewares=api-ratelimit"
|
||||
- "traefik.http.middlewares.api-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.api-ratelimit.ratelimit.burst=50"
|
||||
- "traefik.http.middlewares.api-ratelimit.ratelimit.period=1m"
|
||||
```
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
### 1. Running as Root
|
||||
|
||||
```dockerfile
|
||||
# ❌ BAD: Running as root
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
COPY --from=build /app .
|
||||
ENTRYPOINT ["dotnet", "MyService.dll"]
|
||||
|
||||
# ✅ GOOD: Non-root user
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
|
||||
RUN adduser -D appuser
|
||||
USER appuser
|
||||
COPY --from=build --chown=appuser /app .
|
||||
ENTRYPOINT ["dotnet", "MyService.dll"]
|
||||
```
|
||||
|
||||
### 2. No Health Checks
|
||||
|
||||
```yaml
|
||||
# ❌ BAD: No health check
|
||||
services:
|
||||
my-service:
|
||||
build: .
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
|
||||
# ✅ GOOD: With health check
|
||||
services:
|
||||
my-service:
|
||||
build: .
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.my-service.loadbalancer.healthcheck.path=/health/live"
|
||||
- "traefik.http.services.my-service.loadbalancer.healthcheck.interval=10s"
|
||||
```
|
||||
|
||||
### 3. Exposing Internal Ports
|
||||
|
||||
```yaml
|
||||
# ❌ BAD: Exposing all ports
|
||||
services:
|
||||
my-service:
|
||||
ports:
|
||||
- "8080:8080" # Direct access bypasses Traefik
|
||||
|
||||
# ✅ GOOD: Only expose through Traefik
|
||||
services:
|
||||
my-service:
|
||||
# No ports exposed directly
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.my-service.loadbalancer.server.port=8080"
|
||||
```
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
### Docker Commands
|
||||
|
||||
```bash
|
||||
# EN: Build and start / VI: Build và start
|
||||
docker-compose -f deployments/local/docker-compose.yml up -d --build
|
||||
|
||||
# EN: View logs / VI: Xem logs
|
||||
docker-compose logs -f iam-service-net
|
||||
|
||||
# EN: Restart service / VI: Restart service
|
||||
docker-compose restart iam-service-net
|
||||
|
||||
# EN: Stop all / VI: Dừng tất cả
|
||||
docker-compose down
|
||||
|
||||
# EN: Clean rebuild / VI: Rebuild sạch
|
||||
docker-compose down -v && docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Traefik Router Rules
|
||||
|
||||
| Rule | Example | Description |
|
||||
|------|---------|-------------|
|
||||
| `Host` | ``Host(`api.example.com`)`` | Match by hostname |
|
||||
| `PathPrefix` | ``PathPrefix(`/api`)`` | Match by path prefix |
|
||||
| `Path` | ``Path(`/api/health`)`` | Match exact path |
|
||||
| `Method` | ``Method(`GET`, `POST`)`` | Match by HTTP method |
|
||||
| `Headers` | ``Headers(`X-Custom`, `value`)`` | Match by header |
|
||||
|
||||
### Common Labels
|
||||
|
||||
```yaml
|
||||
# EN: Basic routing / VI: Routing cơ bản
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.{name}.rule=PathPrefix(`/path`)"
|
||||
- "traefik.http.routers.{name}.entrypoints=web"
|
||||
- "traefik.http.services.{name}.loadbalancer.server.port=8080"
|
||||
|
||||
# EN: With HTTPS / VI: Với HTTPS
|
||||
- "traefik.http.routers.{name}-secure.rule=PathPrefix(`/path`)"
|
||||
- "traefik.http.routers.{name}-secure.entrypoints=websecure"
|
||||
- "traefik.http.routers.{name}-secure.tls=true"
|
||||
```
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Detailed Examples](./references/REFERENCE.md) - Full configurations
|
||||
- [Project Rules](../project-rules/SKILL.md) - Docker naming conventions
|
||||
- [Observability](../observability/SKILL.md) - Container monitoring
|
||||
- [Error Handling](../error-handling-patterns/SKILL.md) - Health checks
|
||||
560
.agent/skills/docker-traefik/references/REFERENCE.md
Normal file
560
.agent/skills/docker-traefik/references/REFERENCE.md
Normal file
@@ -0,0 +1,560 @@
|
||||
# Docker & Traefik - Detailed Reference
|
||||
|
||||
Detailed configurations và examples cho Docker và Traefik trong GoodGo.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Dockerfile Patterns](#dockerfile-patterns)
|
||||
2. [Docker Compose Configurations](#docker-compose-configurations)
|
||||
3. [Traefik Configuration](#traefik-configuration)
|
||||
4. [SSL/TLS Setup](#ssltls-setup)
|
||||
5. [Load Balancing](#load-balancing)
|
||||
6. [Middleware Examples](#middleware-examples)
|
||||
7. [Development Environment](#development-environment)
|
||||
|
||||
---
|
||||
|
||||
## Dockerfile Patterns
|
||||
|
||||
### Optimized .NET Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# ===================================
|
||||
# Stage 1: Build
|
||||
# ===================================
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# EN: Copy only project files first for better caching
|
||||
# VI: Copy chỉ project files trước để cache tốt hơn
|
||||
COPY services/iam-service-net/*.slnx ./
|
||||
COPY services/iam-service-net/src/IamService.API/*.csproj src/IamService.API/
|
||||
COPY services/iam-service-net/src/IamService.Domain/*.csproj src/IamService.Domain/
|
||||
COPY services/iam-service-net/src/IamService.Infrastructure/*.csproj src/IamService.Infrastructure/
|
||||
|
||||
# EN: Restore dependencies
|
||||
# VI: Restore dependencies
|
||||
RUN dotnet restore src/IamService.API
|
||||
|
||||
# EN: Copy remaining source files
|
||||
# VI: Copy các source files còn lại
|
||||
COPY services/iam-service-net/src/ src/
|
||||
|
||||
# EN: Build and publish
|
||||
# VI: Build và publish
|
||||
RUN dotnet publish src/IamService.API \
|
||||
-c Release \
|
||||
-o /app \
|
||||
--no-restore \
|
||||
/p:PublishTrimmed=false \
|
||||
/p:PublishSingleFile=false
|
||||
|
||||
# ===================================
|
||||
# Stage 2: Runtime
|
||||
# ===================================
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
|
||||
|
||||
# EN: Install required packages
|
||||
# VI: Cài đặt packages cần thiết
|
||||
RUN apk add --no-cache \
|
||||
icu-libs \
|
||||
tzdata \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# EN: Set timezone
|
||||
# VI: Đặt timezone
|
||||
ENV TZ=Asia/Ho_Chi_Minh
|
||||
|
||||
# EN: Create non-root user
|
||||
# VI: Tạo user không phải root
|
||||
RUN addgroup -g 1000 -S appgroup && \
|
||||
adduser -u 1000 -S appuser -G appgroup
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# EN: Copy published files with correct ownership
|
||||
# VI: Copy files đã publish với ownership đúng
|
||||
COPY --from=build --chown=appuser:appgroup /app .
|
||||
|
||||
# EN: Switch to non-root user
|
||||
# VI: Chuyển sang user không phải root
|
||||
USER appuser
|
||||
|
||||
# EN: Environment configuration
|
||||
# VI: Cấu hình môi trường
|
||||
ENV ASPNETCORE_URLS=http://+:8080 \
|
||||
ASPNETCORE_ENVIRONMENT=Production \
|
||||
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# EN: Health check
|
||||
# VI: Kiểm tra sức khỏe
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "IamService.API.dll"]
|
||||
```
|
||||
|
||||
### .dockerignore
|
||||
|
||||
```
|
||||
# EN: Git / VI: Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# EN: Build outputs / VI: Build outputs
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/out/
|
||||
|
||||
# EN: IDE / VI: IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.user
|
||||
|
||||
# EN: Test results / VI: Kết quả test
|
||||
**/TestResults/
|
||||
**/coverage/
|
||||
|
||||
# EN: Docker / VI: Docker
|
||||
**/Dockerfile*
|
||||
**/docker-compose*
|
||||
**/.dockerignore
|
||||
|
||||
# EN: Documentation / VI: Tài liệu
|
||||
**/README.md
|
||||
**/docs/
|
||||
|
||||
# EN: Secrets / VI: Secrets
|
||||
**/*.env
|
||||
**/appsettings.Development.json
|
||||
**/appsettings.Local.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose Configurations
|
||||
|
||||
### Complete Development Stack
|
||||
|
||||
```yaml
|
||||
# deployments/local/docker-compose.yml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# ===================================
|
||||
# TRAEFIK - API Gateway
|
||||
# ===================================
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
# EN: API and Dashboard
|
||||
- "--api.dashboard=true"
|
||||
- "--api.insecure=true"
|
||||
|
||||
# EN: Docker provider
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=goodgo-network"
|
||||
|
||||
# EN: Entrypoints
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
|
||||
# EN: Logging
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog=true"
|
||||
- "--accesslog.format=json"
|
||||
|
||||
# EN: Metrics
|
||||
- "--metrics.prometheus=true"
|
||||
- "--metrics.prometheus.buckets=0.1,0.3,1.2,5.0"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./traefik/certs:/certs:ro
|
||||
networks:
|
||||
- goodgo-network
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.traefik.rule=Host(`traefik.localhost`)"
|
||||
- "traefik.http.routers.traefik.service=api@internal"
|
||||
|
||||
# ===================================
|
||||
# IAM SERVICE
|
||||
# ===================================
|
||||
iam-service-net:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: services/iam-service-net/Dockerfile
|
||||
container_name: iam-service-net
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ConnectionStrings__DefaultConnection=${IAM_DATABASE_URL}
|
||||
- Jwt__Authority=http://iam-service-net:8080
|
||||
- Jwt__Audience=goodgo-api
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# EN: HTTP Router
|
||||
- "traefik.http.routers.iam-service-net.rule=PathPrefix(`/api/v1/iam`)"
|
||||
- "traefik.http.routers.iam-service-net.entrypoints=web"
|
||||
- "traefik.http.routers.iam-service-net.middlewares=iam-headers"
|
||||
# EN: Service
|
||||
- "traefik.http.services.iam-service-net.loadbalancer.server.port=8080"
|
||||
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.path=/health/live"
|
||||
- "traefik.http.services.iam-service-net.loadbalancer.healthcheck.interval=10s"
|
||||
# EN: Response headers middleware
|
||||
- "traefik.http.middlewares.iam-headers.headers.customresponseheaders.X-Service-Name=iam-service"
|
||||
networks:
|
||||
- goodgo-network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
# ===================================
|
||||
# STORAGE SERVICE
|
||||
# ===================================
|
||||
storage-service-net:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: services/storage-service-net/Dockerfile
|
||||
container_name: storage-service-net
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- ConnectionStrings__DefaultConnection=${STORAGE_DATABASE_URL}
|
||||
- MinIO__Endpoint=minio:9000
|
||||
- MinIO__AccessKey=${MINIO_ACCESS_KEY}
|
||||
- MinIO__SecretKey=${MINIO_SECRET_KEY}
|
||||
- MinIO__UseSSL=false
|
||||
- IamService__BaseUrl=http://iam-service-net:8080
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.storage-service-net.rule=PathPrefix(`/api/v1/storage`)"
|
||||
- "traefik.http.routers.storage-service-net.entrypoints=web"
|
||||
- "traefik.http.services.storage-service-net.loadbalancer.server.port=8080"
|
||||
- "traefik.http.services.storage-service-net.loadbalancer.healthcheck.path=/health/live"
|
||||
networks:
|
||||
- goodgo-network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_started
|
||||
|
||||
# ===================================
|
||||
# POSTGRESQL
|
||||
# ===================================
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_MULTIPLE_DATABASES=iam_db,storage_db,wallet_db
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./scripts/create-multiple-dbs.sh:/docker-entrypoint-initdb.d/create-multiple-dbs.sh
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- goodgo-network
|
||||
|
||||
# ===================================
|
||||
# REDIS
|
||||
# ===================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- goodgo-network
|
||||
|
||||
# ===================================
|
||||
# MINIO (S3-compatible)
|
||||
# ===================================
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
- MINIO_ROOT_USER=minioadmin
|
||||
- MINIO_ROOT_PASSWORD=minioadmin
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- goodgo-network
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.minio-console.rule=Host(`minio.localhost`)"
|
||||
- "traefik.http.routers.minio-console.service=minio-console"
|
||||
- "traefik.http.services.minio-console.loadbalancer.server.port=9001"
|
||||
|
||||
networks:
|
||||
goodgo-network:
|
||||
driver: bridge
|
||||
name: goodgo-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
```
|
||||
|
||||
### Multi-Database Init Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/create-multiple-dbs.sh
|
||||
|
||||
set -e
|
||||
set -u
|
||||
|
||||
function create_user_and_database() {
|
||||
local database=$1
|
||||
echo " Creating database '$database'"
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
|
||||
CREATE DATABASE $database;
|
||||
GRANT ALL PRIVILEGES ON DATABASE $database TO $POSTGRES_USER;
|
||||
EOSQL
|
||||
}
|
||||
|
||||
if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then
|
||||
echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES"
|
||||
for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do
|
||||
create_user_and_database $db
|
||||
done
|
||||
echo "Multiple databases created"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Traefik Configuration
|
||||
|
||||
### Static Configuration (traefik.yml)
|
||||
|
||||
```yaml
|
||||
# infra/traefik/traefik.yml
|
||||
|
||||
# EN: API and Dashboard
|
||||
# VI: API và Dashboard
|
||||
api:
|
||||
dashboard: true
|
||||
insecure: false
|
||||
|
||||
# EN: Entrypoints
|
||||
# VI: Các điểm vào
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: websecure
|
||||
scheme: https
|
||||
websecure:
|
||||
address: ":443"
|
||||
http:
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# EN: Certificate resolvers
|
||||
# VI: Bộ giải quyết chứng chỉ
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
email: admin@goodgo.vn
|
||||
storage: /certs/acme.json
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
|
||||
# EN: Providers
|
||||
# VI: Providers
|
||||
providers:
|
||||
docker:
|
||||
exposedByDefault: false
|
||||
network: goodgo-network
|
||||
file:
|
||||
directory: /etc/traefik/dynamic
|
||||
watch: true
|
||||
|
||||
# EN: Logging
|
||||
# VI: Logging
|
||||
log:
|
||||
level: INFO
|
||||
format: json
|
||||
|
||||
accessLog:
|
||||
format: json
|
||||
filters:
|
||||
statusCodes:
|
||||
- "400-599"
|
||||
|
||||
# EN: Metrics
|
||||
# VI: Metrics
|
||||
metrics:
|
||||
prometheus:
|
||||
buckets:
|
||||
- 0.1
|
||||
- 0.3
|
||||
- 1.2
|
||||
- 5.0
|
||||
```
|
||||
|
||||
### Dynamic Configuration
|
||||
|
||||
```yaml
|
||||
# infra/traefik/dynamic/middlewares.yml
|
||||
|
||||
http:
|
||||
middlewares:
|
||||
# EN: Security headers
|
||||
# VI: Headers bảo mật
|
||||
secure-headers:
|
||||
headers:
|
||||
frameDeny: true
|
||||
sslRedirect: true
|
||||
browserXssFilter: true
|
||||
contentTypeNosniff: true
|
||||
forceSTSHeader: true
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
stsSeconds: 31536000
|
||||
customResponseHeaders:
|
||||
X-Robots-Tag: "noindex,nofollow"
|
||||
|
||||
# EN: Rate limiting
|
||||
# VI: Giới hạn rate
|
||||
rate-limit:
|
||||
rateLimit:
|
||||
average: 100
|
||||
burst: 50
|
||||
period: 1m
|
||||
|
||||
# EN: Compression
|
||||
# VI: Nén
|
||||
compress:
|
||||
compress: {}
|
||||
|
||||
# EN: CORS
|
||||
# VI: CORS
|
||||
cors:
|
||||
headers:
|
||||
accessControlAllowMethods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
accessControlAllowHeaders:
|
||||
- "*"
|
||||
accessControlAllowOriginList:
|
||||
- "https://app.goodgo.vn"
|
||||
- "http://localhost:3000"
|
||||
accessControlMaxAge: 100
|
||||
addVaryHeader: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSL/TLS Setup
|
||||
|
||||
### Local Development with mkcert
|
||||
|
||||
```bash
|
||||
# EN: Install mkcert / VI: Cài đặt mkcert
|
||||
brew install mkcert
|
||||
mkcert -install
|
||||
|
||||
# EN: Generate certificates / VI: Tạo certificates
|
||||
cd infra/traefik/certs
|
||||
mkcert "*.localhost" localhost 127.0.0.1 ::1
|
||||
```
|
||||
|
||||
### Traefik with Local Certs
|
||||
|
||||
```yaml
|
||||
# docker-compose.override.yml for local HTTPS
|
||||
services:
|
||||
traefik:
|
||||
volumes:
|
||||
- ./infra/traefik/certs:/certs:ro
|
||||
labels:
|
||||
- "traefik.http.routers.traefik.tls=true"
|
||||
|
||||
# Dynamic config: infra/traefik/dynamic/tls.yml
|
||||
tls:
|
||||
certificates:
|
||||
- certFile: /certs/_wildcard.localhost.pem
|
||||
keyFile: /certs/_wildcard.localhost-key.pem
|
||||
stores:
|
||||
default:
|
||||
defaultCertificate:
|
||||
certFile: /certs/_wildcard.localhost.pem
|
||||
keyFile: /certs/_wildcard.localhost-key.pem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Load Balancing
|
||||
|
||||
### Multiple Instances
|
||||
|
||||
```yaml
|
||||
services:
|
||||
iam-service-net:
|
||||
deploy:
|
||||
replicas: 3
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.iam.rule=PathPrefix(`/api/v1/iam`)"
|
||||
- "traefik.http.services.iam.loadbalancer.server.port=8080"
|
||||
# EN: Sticky sessions (optional)
|
||||
# VI: Sticky sessions (tùy chọn)
|
||||
- "traefik.http.services.iam.loadbalancer.sticky.cookie.name=iam_sticky"
|
||||
- "traefik.http.services.iam.loadbalancer.sticky.cookie.secure=true"
|
||||
# EN: Health check
|
||||
- "traefik.http.services.iam.loadbalancer.healthcheck.path=/health/live"
|
||||
- "traefik.http.services.iam.loadbalancer.healthcheck.interval=5s"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Traefik Documentation](https://doc.traefik.io/traefik/)
|
||||
- [Docker Best Practices](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/)
|
||||
- [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet)
|
||||
- [Docker Compose Specification](https://docs.docker.com/compose/compose-file/)
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: error-handling-patterns
|
||||
description: Global error handling, domain exceptions, và Result pattern. Use for exception middleware, validation errors, Polly resiliency, và health checks.
|
||||
compatibility: ".NET 8+, Polly, FluentValidation, Microsoft.Extensions.Diagnostics.HealthChecks"
|
||||
compatibility: ".NET 10+, Polly, FluentValidation, Microsoft.Extensions.Diagnostics.HealthChecks"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
|
||||
451
.agent/skills/observability/SKILL.md
Normal file
451
.agent/skills/observability/SKILL.md
Normal file
@@ -0,0 +1,451 @@
|
||||
---
|
||||
name: observability
|
||||
description: Monitoring, logging, và tracing patterns. Use for Prometheus metrics, Grafana dashboards, Loki logging, và distributed tracing.
|
||||
compatibility: ".NET 10+, Serilog, OpenTelemetry, Prometheus, Grafana"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Observability Patterns / Mẫu Quan Sát
|
||||
|
||||
Monitoring, logging, và tracing cho GoodGo microservices.
|
||||
|
||||
## When to Use This Skill / Khi Nào Sử Dụng
|
||||
|
||||
Use this skill when:
|
||||
- Implementing health checks / Triển khai health checks
|
||||
- Configuring structured logging / Cấu hình structured logging
|
||||
- Setting up Prometheus metrics / Cài đặt Prometheus metrics
|
||||
- Implementing distributed tracing / Triển khai distributed tracing
|
||||
- Creating Grafana dashboards / Tạo Grafana dashboards
|
||||
|
||||
## Core Concepts / Khái Niệm Cốt Lõi
|
||||
|
||||
### Three Pillars of Observability / Ba Trụ Cột của Observability
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ OBSERVABILITY │
|
||||
├───────────────────┬───────────────────┬───────────────────┤
|
||||
│ METRICS │ LOGS │ TRACES │
|
||||
│ (Prometheus) │ (Loki/ELK) │ (OpenTelemetry) │
|
||||
├───────────────────┼───────────────────┼───────────────────┤
|
||||
│ What's happening │ What happened │ How it happened │
|
||||
│ (Số liệu) │ (Nhật ký) │ (Theo dõi) │
|
||||
└───────────────────┴───────────────────┴───────────────────┘
|
||||
```
|
||||
|
||||
### Health Check Types / Các Loại Health Check
|
||||
|
||||
| Type | Purpose | Endpoint |
|
||||
|------|---------|----------|
|
||||
| **Liveness** | Is the app running? | `/health/live` |
|
||||
| **Readiness** | Can the app accept traffic? | `/health/ready` |
|
||||
| **Startup** | Has the app initialized? | `/health/startup` |
|
||||
|
||||
### Logging Levels / Các Mức Log
|
||||
|
||||
| Level | Purpose | Example |
|
||||
|-------|---------|---------|
|
||||
| **Trace** | Detailed debugging | Variable values |
|
||||
| **Debug** | Development info | Method entry/exit |
|
||||
| **Information** | Normal operations | Request processed |
|
||||
| **Warning** | Potential problems | Slow query |
|
||||
| **Error** | Errors handled | Exception caught |
|
||||
| **Critical** | System failures | Database down |
|
||||
|
||||
## Key Patterns / Mẫu Chính
|
||||
|
||||
### Health Checks Configuration
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure comprehensive health checks.
|
||||
/// VI: Cấu hình health checks toàn diện.
|
||||
/// </summary>
|
||||
|
||||
// Program.cs
|
||||
builder.Services.AddHealthChecks()
|
||||
// EN: Database health / VI: Health database
|
||||
.AddNpgSql(
|
||||
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
|
||||
name: "postgresql",
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: new[] { "db", "ready" })
|
||||
|
||||
// EN: Redis cache / VI: Redis cache
|
||||
.AddRedis(
|
||||
redisConnectionString: builder.Configuration["Redis:ConnectionString"]!,
|
||||
name: "redis",
|
||||
failureStatus: HealthStatus.Degraded,
|
||||
tags: new[] { "cache", "ready" })
|
||||
|
||||
// EN: Custom health check / VI: Health check tùy chỉnh
|
||||
.AddCheck<ExternalServiceHealthCheck>(
|
||||
"external-api",
|
||||
failureStatus: HealthStatus.Degraded,
|
||||
tags: new[] { "external", "ready" });
|
||||
|
||||
// EN: Map health endpoints / VI: Map health endpoints
|
||||
app.MapHealthChecks("/health/live", new HealthCheckOptions
|
||||
{
|
||||
Predicate = _ => false, // EN: Just check app is running
|
||||
ResponseWriter = WriteMinimalResponse
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = check => check.Tags.Contains("ready"),
|
||||
ResponseWriter = WriteDetailedResponse
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
ResponseWriter = WriteDetailedResponse
|
||||
});
|
||||
```
|
||||
|
||||
### Structured Logging with Serilog
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure Serilog with structured logging.
|
||||
/// VI: Cấu hình Serilog với structured logging.
|
||||
/// </summary>
|
||||
|
||||
// Program.cs
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithMachineName()
|
||||
.Enrich.WithEnvironmentName()
|
||||
.Enrich.WithProperty("Application", "IamService")
|
||||
.WriteTo.Console(new JsonFormatter())
|
||||
.WriteTo.Seq(builder.Configuration["Seq:ServerUrl"]!)
|
||||
.CreateLogger();
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// EN: Usage in code / VI: Sử dụng trong code
|
||||
public class OrderService
|
||||
{
|
||||
private readonly ILogger<OrderService> _logger;
|
||||
|
||||
public async Task<Order> CreateOrderAsync(CreateOrderCommand cmd)
|
||||
{
|
||||
// EN: Structured logging with properties
|
||||
// VI: Structured logging với properties
|
||||
_logger.LogInformation(
|
||||
"Creating order for user {UserId} with {ItemCount} items",
|
||||
cmd.UserId,
|
||||
cmd.Items.Count);
|
||||
|
||||
try
|
||||
{
|
||||
var order = await ProcessOrderAsync(cmd);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Order {OrderId} created successfully. Total: {TotalAmount}",
|
||||
order.Id,
|
||||
order.TotalAmount);
|
||||
|
||||
return order;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to create order for user {UserId}",
|
||||
cmd.UserId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure Prometheus metrics.
|
||||
/// VI: Cấu hình Prometheus metrics.
|
||||
/// </summary>
|
||||
|
||||
// Program.cs
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddPrometheusExporter();
|
||||
});
|
||||
|
||||
// EN: Map Prometheus endpoint / VI: Map Prometheus endpoint
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Custom metrics for business logic.
|
||||
/// VI: Metrics tùy chỉnh cho business logic.
|
||||
/// </summary>
|
||||
public class OrderMetrics
|
||||
{
|
||||
private readonly Counter<int> _ordersCreated;
|
||||
private readonly Histogram<double> _orderProcessingDuration;
|
||||
private readonly UpDownCounter<int> _activeOrders;
|
||||
|
||||
public OrderMetrics(IMeterFactory meterFactory)
|
||||
{
|
||||
var meter = meterFactory.Create("GoodGo.Orders");
|
||||
|
||||
_ordersCreated = meter.CreateCounter<int>(
|
||||
"orders_created_total",
|
||||
description: "Total number of orders created");
|
||||
|
||||
_orderProcessingDuration = meter.CreateHistogram<double>(
|
||||
"order_processing_duration_seconds",
|
||||
description: "Duration of order processing in seconds");
|
||||
|
||||
_activeOrders = meter.CreateUpDownCounter<int>(
|
||||
"active_orders",
|
||||
description: "Number of orders currently being processed");
|
||||
}
|
||||
|
||||
public void OrderCreated(string status)
|
||||
{
|
||||
_ordersCreated.Add(1, new KeyValuePair<string, object?>("status", status));
|
||||
}
|
||||
|
||||
public void RecordProcessingDuration(double seconds)
|
||||
{
|
||||
_orderProcessingDuration.Record(seconds);
|
||||
}
|
||||
|
||||
public void IncrementActiveOrders() => _activeOrders.Add(1);
|
||||
public void DecrementActiveOrders() => _activeOrders.Add(-1);
|
||||
}
|
||||
```
|
||||
|
||||
### Distributed Tracing with OpenTelemetry
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure OpenTelemetry distributed tracing.
|
||||
/// VI: Cấu hình OpenTelemetry distributed tracing.
|
||||
/// </summary>
|
||||
|
||||
// Program.cs
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
.SetResourceBuilder(ResourceBuilder.CreateDefault()
|
||||
.AddService("iam-service")
|
||||
.AddAttributes(new[]
|
||||
{
|
||||
new KeyValuePair<string, object>("deployment.environment",
|
||||
builder.Environment.EnvironmentName)
|
||||
}))
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
{
|
||||
options.RecordException = true;
|
||||
options.Filter = ctx =>
|
||||
!ctx.Request.Path.StartsWithSegments("/health");
|
||||
})
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddEntityFrameworkCoreInstrumentation()
|
||||
.AddSource("GoodGo.Orders")
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(builder.Configuration["Otlp:Endpoint"]!);
|
||||
});
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// EN: Custom activity for business operations.
|
||||
/// VI: Activity tùy chỉnh cho business operations.
|
||||
/// </summary>
|
||||
public class OrderService
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("GoodGo.Orders");
|
||||
|
||||
public async Task<Order> ProcessOrderAsync(CreateOrderCommand cmd)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("ProcessOrder");
|
||||
activity?.SetTag("user.id", cmd.UserId);
|
||||
activity?.SetTag("order.items_count", cmd.Items.Count);
|
||||
|
||||
try
|
||||
{
|
||||
// EN: Create child span for validation
|
||||
// VI: Tạo child span cho validation
|
||||
using var validationActivity = ActivitySource.StartActivity("ValidateOrder");
|
||||
await ValidateOrderAsync(cmd);
|
||||
validationActivity?.SetStatus(ActivityStatusCode.Ok);
|
||||
|
||||
// EN: Create child span for persistence
|
||||
// VI: Tạo child span cho persistence
|
||||
using var persistActivity = ActivitySource.StartActivity("PersistOrder");
|
||||
var order = await SaveOrderAsync(cmd);
|
||||
persistActivity?.SetTag("order.id", order.Id.ToString());
|
||||
|
||||
activity?.SetTag("order.id", order.Id.ToString());
|
||||
activity?.SetStatus(ActivityStatusCode.Ok);
|
||||
|
||||
return order;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
activity?.RecordException(ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Request Logging Middleware
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Middleware for request/response logging.
|
||||
/// VI: Middleware cho logging request/response.
|
||||
/// </summary>
|
||||
public class RequestLoggingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<RequestLoggingMiddleware> _logger;
|
||||
|
||||
public RequestLoggingMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<RequestLoggingMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var requestId = context.TraceIdentifier;
|
||||
|
||||
// EN: Log request / VI: Log request
|
||||
_logger.LogInformation(
|
||||
"Request {RequestId} started: {Method} {Path}",
|
||||
requestId,
|
||||
context.Request.Method,
|
||||
context.Request.Path);
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
// EN: Log response / VI: Log response
|
||||
_logger.LogInformation(
|
||||
"Request {RequestId} completed: {StatusCode} in {ElapsedMs}ms",
|
||||
requestId,
|
||||
context.Response.StatusCode,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes / Lỗi Thường Gặp
|
||||
|
||||
### 1. Logging Sensitive Data
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Logging password
|
||||
_logger.LogInformation("Login: {Email} {Password}", email, password);
|
||||
|
||||
// ✅ GOOD: Redact sensitive data
|
||||
_logger.LogInformation("Login attempt for {Email}", email);
|
||||
```
|
||||
|
||||
### 2. Missing Correlation ID
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: No correlation between logs
|
||||
_logger.LogInformation("Processing order");
|
||||
_logger.LogInformation("Order completed");
|
||||
|
||||
// ✅ GOOD: Include correlation ID
|
||||
using (_logger.BeginScope(new Dictionary<string, object>
|
||||
{
|
||||
["OrderId"] = orderId,
|
||||
["UserId"] = userId
|
||||
}))
|
||||
{
|
||||
_logger.LogInformation("Processing order");
|
||||
_logger.LogInformation("Order completed");
|
||||
}
|
||||
```
|
||||
|
||||
### 3. No Health Check for Dependencies
|
||||
|
||||
```csharp
|
||||
// ❌ BAD: Only check if app runs
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
// ✅ GOOD: Check all dependencies
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(connectionString, name: "database")
|
||||
.AddRedis(redisConnection, name: "cache")
|
||||
.AddUrlGroup(new Uri(externalApi), name: "external-api");
|
||||
```
|
||||
|
||||
## Quick Reference / Tham Chiếu Nhanh
|
||||
|
||||
### Serilog Configuration (appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{ "Name": "Console", "Args": { "formatter": "Serilog.Formatting.Json.JsonFormatter" } },
|
||||
{ "Name": "Seq", "Args": { "serverUrl": "http://seq:5341" } }
|
||||
],
|
||||
"Enrich": ["FromLogContext", "WithMachineName", "WithEnvironmentName"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Health Check Endpoints
|
||||
|
||||
| Endpoint | Purpose | K8s Probe |
|
||||
|----------|---------|-----------|
|
||||
| `/health/live` | App is running | livenessProbe |
|
||||
| `/health/ready` | App can accept traffic | readinessProbe |
|
||||
| `/health/startup` | App has initialized | startupProbe |
|
||||
|
||||
### Common Metrics
|
||||
|
||||
| Metric | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `http_requests_total` | Counter | Total HTTP requests |
|
||||
| `http_request_duration_seconds` | Histogram | Request duration |
|
||||
| `dotnet_gc_collections_total` | Counter | GC collections |
|
||||
| `process_cpu_seconds_total` | Counter | CPU usage |
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [Detailed Examples](./references/REFERENCE.md) - Full configurations
|
||||
- [Error Handling](../error-handling-patterns/SKILL.md) - Health checks
|
||||
- [Docker Traefik](../docker-traefik/SKILL.md) - Container monitoring
|
||||
- [Project Rules](../project-rules/SKILL.md) - Logging standards
|
||||
570
.agent/skills/observability/references/REFERENCE.md
Normal file
570
.agent/skills/observability/references/REFERENCE.md
Normal file
@@ -0,0 +1,570 @@
|
||||
# Observability - Detailed Reference
|
||||
|
||||
Detailed configurations và examples cho Observability stack trong GoodGo.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Serilog Configuration](#serilog-configuration)
|
||||
2. [OpenTelemetry Setup](#opentelemetry-setup)
|
||||
3. [Prometheus & Grafana](#prometheus--grafana)
|
||||
4. [Health Checks](#health-checks)
|
||||
5. [Loki Logging](#loki-logging)
|
||||
6. [Alerting](#alerting)
|
||||
|
||||
---
|
||||
|
||||
## Serilog Configuration
|
||||
|
||||
### Complete Program.cs Setup
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Complete Serilog configuration for microservice.
|
||||
/// VI: Serilog configuration đầy đủ cho microservice.
|
||||
/// </summary>
|
||||
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Json;
|
||||
using Serilog.Sinks.Grafana.Loki;
|
||||
|
||||
// EN: Configure Serilog bootstrap logger for startup errors
|
||||
// VI: Cấu hình Serilog bootstrap logger cho lỗi startup
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// EN: Configure Serilog from configuration
|
||||
// VI: Cấu hình Serilog từ configuration
|
||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("Application", "IamService")
|
||||
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName)
|
||||
.Enrich.WithMachineName()
|
||||
.Enrich.WithThreadId()
|
||||
.WriteTo.Console(new JsonFormatter())
|
||||
.WriteTo.GrafanaLoki(
|
||||
context.Configuration["Loki:Endpoint"]!,
|
||||
labels: new[]
|
||||
{
|
||||
new LokiLabel { Key = "app", Value = "iam-service" },
|
||||
new LokiLabel { Key = "env", Value = context.HostingEnvironment.EnvironmentName }
|
||||
}));
|
||||
|
||||
// ... rest of configuration
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// EN: Add Serilog request logging middleware
|
||||
// VI: Thêm Serilog request logging middleware
|
||||
app.UseSerilogRequestLogging(options =>
|
||||
{
|
||||
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
||||
{
|
||||
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
|
||||
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
|
||||
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
|
||||
|
||||
if (httpContext.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
diagnosticContext.Set("UserId", httpContext.User.FindFirst("sub")?.Value);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
```
|
||||
|
||||
### appsettings.json for Serilog
|
||||
|
||||
```json
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": ["Serilog.Sinks.Console", "Serilog.Sinks.Seq"],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning",
|
||||
"Grpc": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Seq",
|
||||
"Args": {
|
||||
"serverUrl": "http://seq:5341",
|
||||
"apiKey": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithThreadId",
|
||||
"WithEnvironmentName"
|
||||
],
|
||||
"Properties": {
|
||||
"Application": "IamService"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenTelemetry Setup
|
||||
|
||||
### Complete OpenTelemetry Configuration
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure OpenTelemetry for tracing and metrics.
|
||||
/// VI: Cấu hình OpenTelemetry cho tracing và metrics.
|
||||
/// </summary>
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource
|
||||
.AddService(
|
||||
serviceName: "iam-service",
|
||||
serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
serviceInstanceId: Environment.MachineName)
|
||||
.AddAttributes(new[]
|
||||
{
|
||||
new KeyValuePair<string, object>("deployment.environment",
|
||||
builder.Environment.EnvironmentName),
|
||||
new KeyValuePair<string, object>("host.name", Environment.MachineName)
|
||||
}))
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
// EN: ASP.NET Core instrumentation
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
{
|
||||
options.RecordException = true;
|
||||
options.Filter = ctx =>
|
||||
!ctx.Request.Path.StartsWithSegments("/health") &&
|
||||
!ctx.Request.Path.StartsWithSegments("/metrics");
|
||||
})
|
||||
// EN: HTTP client instrumentation
|
||||
.AddHttpClientInstrumentation(options =>
|
||||
{
|
||||
options.RecordException = true;
|
||||
options.FilterHttpRequestMessage = req =>
|
||||
!req.RequestUri?.Host.Contains("health") ?? true;
|
||||
})
|
||||
// EN: Entity Framework instrumentation
|
||||
.AddEntityFrameworkCoreInstrumentation(options =>
|
||||
{
|
||||
options.SetDbStatementForText = true;
|
||||
options.SetDbStatementForStoredProcedure = true;
|
||||
})
|
||||
// EN: Custom activity sources
|
||||
.AddSource("GoodGo.Iam")
|
||||
.AddSource("GoodGo.Orders")
|
||||
// EN: Export to OTLP (Jaeger/Tempo)
|
||||
.AddOtlpExporter(options =>
|
||||
{
|
||||
options.Endpoint = new Uri(builder.Configuration["Otlp:Endpoint"]!);
|
||||
options.Protocol = OtlpExportProtocol.Grpc;
|
||||
});
|
||||
})
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
// EN: Custom meters
|
||||
.AddMeter("GoodGo.Iam")
|
||||
.AddMeter("GoodGo.Orders")
|
||||
// EN: Prometheus exporter
|
||||
.AddPrometheusExporter();
|
||||
});
|
||||
|
||||
// EN: Map Prometheus scraping endpoint
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
```
|
||||
|
||||
### Custom Activity Source
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Service with custom tracing.
|
||||
/// VI: Service với tracing tùy chỉnh.
|
||||
/// </summary>
|
||||
public class OrderService
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("GoodGo.Orders");
|
||||
private readonly ILogger<OrderService> _logger;
|
||||
|
||||
public async Task<Order> ProcessOrderAsync(CreateOrderCommand cmd, CancellationToken ct)
|
||||
{
|
||||
// EN: Create root span for order processing
|
||||
// VI: Tạo root span cho xử lý order
|
||||
using var activity = ActivitySource.StartActivity(
|
||||
"ProcessOrder",
|
||||
ActivityKind.Internal);
|
||||
|
||||
activity?.SetTag("user.id", cmd.UserId);
|
||||
activity?.SetTag("order.items_count", cmd.Items.Count);
|
||||
|
||||
try
|
||||
{
|
||||
// EN: Child span: Validate
|
||||
// VI: Child span: Xác thực
|
||||
using (var validateActivity = ActivitySource.StartActivity("ValidateOrder"))
|
||||
{
|
||||
await ValidateOrderAsync(cmd, ct);
|
||||
validateActivity?.SetTag("validation.result", "success");
|
||||
}
|
||||
|
||||
// EN: Child span: Check inventory
|
||||
// VI: Child span: Kiểm tra tồn kho
|
||||
using (var inventoryActivity = ActivitySource.StartActivity("CheckInventory"))
|
||||
{
|
||||
await CheckInventoryAsync(cmd.Items, ct);
|
||||
}
|
||||
|
||||
// EN: Child span: Persist
|
||||
// VI: Child span: Lưu trữ
|
||||
Order order;
|
||||
using (var persistActivity = ActivitySource.StartActivity("PersistOrder"))
|
||||
{
|
||||
order = await SaveOrderAsync(cmd, ct);
|
||||
persistActivity?.SetTag("order.id", order.Id.ToString());
|
||||
}
|
||||
|
||||
activity?.SetTag("order.id", order.Id.ToString());
|
||||
activity?.SetTag("order.total", order.TotalAmount);
|
||||
activity?.SetStatus(ActivityStatusCode.Ok);
|
||||
|
||||
return order;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
activity?.RecordException(ex);
|
||||
|
||||
_logger.LogError(ex, "Failed to process order for user {UserId}", cmd.UserId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prometheus & Grafana
|
||||
|
||||
### Docker Compose for Observability Stack
|
||||
|
||||
```yaml
|
||||
# infra/observability/docker-compose.yml
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# ===================================
|
||||
# PROMETHEUS
|
||||
# ===================================
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.47.0
|
||||
container_name: prometheus
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--web.enable-lifecycle"
|
||||
volumes:
|
||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- ./prometheus/alerts:/etc/prometheus/alerts
|
||||
- prometheus_data:/prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
networks:
|
||||
- goodgo-network
|
||||
|
||||
# ===================================
|
||||
# GRAFANA
|
||||
# ===================================
|
||||
grafana:
|
||||
image: grafana/grafana:10.1.0
|
||||
container_name: grafana
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
volumes:
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
- ./grafana/dashboards:/var/lib/grafana/dashboards
|
||||
- grafana_data:/var/lib/grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- goodgo-network
|
||||
depends_on:
|
||||
- prometheus
|
||||
- loki
|
||||
|
||||
# ===================================
|
||||
# LOKI (Log aggregation)
|
||||
# ===================================
|
||||
loki:
|
||||
image: grafana/loki:2.9.0
|
||||
container_name: loki
|
||||
command: -config.file=/etc/loki/loki-config.yml
|
||||
volumes:
|
||||
- ./loki/loki-config.yml:/etc/loki/loki-config.yml
|
||||
- loki_data:/loki
|
||||
ports:
|
||||
- "3100:3100"
|
||||
networks:
|
||||
- goodgo-network
|
||||
|
||||
# ===================================
|
||||
# TEMPO (Distributed tracing)
|
||||
# ===================================
|
||||
tempo:
|
||||
image: grafana/tempo:2.2.0
|
||||
container_name: tempo
|
||||
command: -config.file=/etc/tempo/tempo-config.yml
|
||||
volumes:
|
||||
- ./tempo/tempo-config.yml:/etc/tempo/tempo-config.yml
|
||||
- tempo_data:/var/tempo
|
||||
ports:
|
||||
- "4317:4317" # OTLP gRPC
|
||||
- "4318:4318" # OTLP HTTP
|
||||
networks:
|
||||
- goodgo-network
|
||||
|
||||
volumes:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
loki_data:
|
||||
tempo_data:
|
||||
|
||||
networks:
|
||||
goodgo-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
### Prometheus Configuration
|
||||
|
||||
```yaml
|
||||
# infra/observability/prometheus/prometheus.yml
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: []
|
||||
|
||||
rule_files:
|
||||
- /etc/prometheus/alerts/*.yml
|
||||
|
||||
scrape_configs:
|
||||
# EN: Prometheus self-monitoring
|
||||
- job_name: "prometheus"
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]
|
||||
|
||||
# EN: GoodGo Services via Traefik
|
||||
- job_name: "goodgo-services"
|
||||
docker_sd_configs:
|
||||
- host: unix:///var/run/docker.sock
|
||||
filters:
|
||||
- name: network
|
||||
values: ["goodgo-network"]
|
||||
relabel_configs:
|
||||
- source_labels: [__meta_docker_container_name]
|
||||
regex: /(.*)
|
||||
target_label: container
|
||||
- source_labels: [__meta_docker_container_label_com_docker_compose_service]
|
||||
target_label: service
|
||||
- source_labels: [__address__]
|
||||
regex: (.+):.*
|
||||
replacement: ${1}:8080
|
||||
target_label: __address__
|
||||
- source_labels: [__meta_docker_container_label_traefik_enable]
|
||||
regex: "true"
|
||||
action: keep
|
||||
|
||||
# EN: Traefik metrics
|
||||
- job_name: "traefik"
|
||||
static_configs:
|
||||
- targets: ["traefik:8080"]
|
||||
```
|
||||
|
||||
### Grafana Dashboard (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "GoodGo Services Overview",
|
||||
"panels": [
|
||||
{
|
||||
"title": "Request Rate",
|
||||
"type": "graph",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(http_server_request_duration_seconds_count[5m])",
|
||||
"legendFormat": "{{service}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Error Rate",
|
||||
"type": "graph",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(http_server_request_duration_seconds_count{http_response_status_code=~\"5..\"}[5m])",
|
||||
"legendFormat": "{{service}} - 5xx"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Request Duration P99",
|
||||
"type": "graph",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.99, rate(http_server_request_duration_seconds_bucket[5m]))",
|
||||
"legendFormat": "{{service}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health Checks
|
||||
|
||||
### Comprehensive Health Check Configuration
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// EN: Configure all health checks.
|
||||
/// VI: Cấu hình tất cả health checks.
|
||||
/// </summary>
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
// EN: Database
|
||||
.AddNpgSql(
|
||||
connectionString: builder.Configuration.GetConnectionString("DefaultConnection")!,
|
||||
name: "postgresql",
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: new[] { "db", "ready", "critical" })
|
||||
|
||||
// EN: Redis
|
||||
.AddRedis(
|
||||
redisConnectionString: builder.Configuration["Redis:ConnectionString"]!,
|
||||
name: "redis",
|
||||
failureStatus: HealthStatus.Degraded,
|
||||
tags: new[] { "cache", "ready" })
|
||||
|
||||
// EN: External HTTP dependency
|
||||
.AddUrlGroup(
|
||||
new Uri(builder.Configuration["Services:Payment:HealthUrl"]!),
|
||||
name: "payment-service",
|
||||
failureStatus: HealthStatus.Degraded,
|
||||
tags: new[] { "external", "ready" })
|
||||
|
||||
// EN: Disk space
|
||||
.AddDiskStorageHealthCheck(
|
||||
setup: options => options.AddDrive("/", 1024),
|
||||
name: "disk-space",
|
||||
failureStatus: HealthStatus.Degraded,
|
||||
tags: new[] { "infrastructure" })
|
||||
|
||||
// EN: Memory
|
||||
.AddProcessAllocatedMemoryHealthCheck(
|
||||
maximumMegabytesAllocated: 500,
|
||||
name: "memory",
|
||||
tags: new[] { "infrastructure" });
|
||||
|
||||
// EN: Map endpoints
|
||||
app.MapHealthChecks("/health/live", new HealthCheckOptions
|
||||
{
|
||||
Predicate = _ => false,
|
||||
ResponseWriter = WriteMinimalResponse
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = hc => hc.Tags.Contains("ready"),
|
||||
ResponseWriter = WriteDetailedResponse
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
ResponseWriter = WriteDetailedResponse
|
||||
});
|
||||
|
||||
// EN: Health check response writers
|
||||
static Task WriteMinimalResponse(HttpContext context, HealthReport report)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
return context.Response.WriteAsync(
|
||||
JsonSerializer.Serialize(new { status = report.Status.ToString() }));
|
||||
}
|
||||
|
||||
static Task WriteDetailedResponse(HttpContext context, HealthReport report)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var result = new
|
||||
{
|
||||
status = report.Status.ToString(),
|
||||
totalDuration = report.TotalDuration.TotalMilliseconds,
|
||||
entries = report.Entries.Select(e => new
|
||||
{
|
||||
name = e.Key,
|
||||
status = e.Value.Status.ToString(),
|
||||
duration = e.Value.Duration.TotalMilliseconds,
|
||||
description = e.Value.Description,
|
||||
tags = e.Value.Tags,
|
||||
data = e.Value.Data
|
||||
})
|
||||
};
|
||||
|
||||
return context.Response.WriteAsJsonAsync(result);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resources / Tài Nguyên
|
||||
|
||||
- [OpenTelemetry .NET](https://opentelemetry.io/docs/instrumentation/net/)
|
||||
- [Serilog Documentation](https://serilog.net/)
|
||||
- [Prometheus Documentation](https://prometheus.io/docs/)
|
||||
- [Grafana Dashboards](https://grafana.com/grafana/dashboards/)
|
||||
- [Loki Documentation](https://grafana.com/docs/loki/)
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: project-rules
|
||||
description: GoodGo Platform coding standards and architecture. Use when working with services, apps, packages, or infrastructure.
|
||||
compatibility: ".NET 8+, ASP.NET Core, MediatR, Entity Framework Core"
|
||||
compatibility: ".NET 10+, ASP.NET Core, MediatR, Entity Framework Core"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "2.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: repository-pattern
|
||||
description: Entity Framework Core repository và data access patterns. Use for DbContext, repositories, migrations, aggregate roots, Unit of Work, và CQRS queries.
|
||||
compatibility: ".NET 8+, Entity Framework Core 8+, Dapper"
|
||||
compatibility: ".NET 10+, Entity Framework Core 8+, Dapper"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: security
|
||||
description: Security patterns for GoodGo platform. Use for authentication, authorization, data protection, input validation, rate limiting, or secrets management.
|
||||
compatibility: ".NET 8+, ASP.NET Core Identity, Duende IdentityServer"
|
||||
compatibility: ".NET 10+, ASP.NET Core Identity, Duende IdentityServer"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "2.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: testing-patterns
|
||||
description: Unit/Integration testing patterns cho .NET microservices. Use for xUnit, NSubstitute, Testcontainers, và testing MediatR handlers.
|
||||
compatibility: ".NET 8+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost"
|
||||
compatibility: ".NET 10+, xUnit, NSubstitute, Testcontainers, Microsoft.AspNetCore.TestHost"
|
||||
metadata:
|
||||
author: Velik Ho
|
||||
version: "1.0"
|
||||
|
||||
Reference in New Issue
Block a user