领域防腐层(ACL)在遗留系统改造中的落地
📚目录
- 领域防腐层(ACL)在遗留系统改造中的落地
-
- [TL;DR 🎯](#TL;DR 🎯)
- [二、目录结构与交付物 📦](#二、目录结构与交付物 📦)
- [三、背景与问题定义 🧩](#三、背景与问题定义 🧩)
- [四、架构与边界 🧭](#四、架构与边界 🧭)
-
- [请求全链路(含租户与追踪) 🔗](#请求全链路(含租户与追踪) 🔗)
- [五、ABP 落地 🏷️](#五、ABP 落地 🏷️)
- [六、Ports / Adapters / Translators / Policy(骨架) 🔧](#六、Ports / Adapters / Translators / Policy(骨架) 🔧)
-
- 依赖包清单(放在"注册"前)📦
- [Resilience Pipeline 结构(读/写分离 + 限流 + 指标) 🛡️](#Resilience Pipeline 结构(读/写分离 + 限流 + 指标) 🛡️)
- [七、语义对齐与 `semantic-map.yaml`(配置即契约) 📜](#七、语义对齐与
semantic-map.yaml
(配置即契约) 📜) - [八、可观测性 🔎](#八、可观测性 🔎)
- [九、契约测试与回归矩阵(CI 门禁) 🧪](#九、契约测试与回归矩阵(CI 门禁) 🧪)
-
- [CI/CD 门禁流程 🧱](#CI/CD 门禁流程 🧱)
- [十、灰度/双写/对账与回滚(SOP) 🚦](#十、灰度/双写/对账与回滚(SOP) 🚦)
- [十一、性能与容量(压测与基线) 📈](#十一、性能与容量(压测与基线) 📈)
- [十二、安全与输入校验 🔐](#十二、安全与输入校验 🔐)
- [十三、Demo & Compose 🚀](#十三、Demo & Compose 🚀)
- [参考资料 📚](#参考资料 📚)
TL;DR 🎯
- 端口在 Domain/Domain.Shared ;Adapter 在 Infrastructure ;编排在 Application。
- Application 不碰 HTTP 细节,HttpApi 层做 ProblemDetails 映射。
- ICurrentTenant/CorrelationId 全链路(W3C Trace Context)。
- semantic-map.yaml + 启动强校验 + 覆盖率。
- HTTP 用 标准 Resilience Handler ;自定义用 Polly v8 Keyed Pipeline。
- 契约/回归进 CI 门禁;灰度双写 + 对账 + 回滚。
二、目录结构与交付物 📦
交付物
Acme.LegacyAcl
模块样板(Port/Adapter/Translator/Policy)semantic-map.yaml
+ 启动强校验 + "语义覆盖率"报告- Pact 契约测试 + 回归矩阵(含阈值)
- Docker Compose(
wiremock-legacy
/acl-gateway
/promtail+loki+grafana
)
参考目录
Acme.LegacyAcl/
Domain.Shared/ // Ports、Domain DTO
Application/ // 用例编排、Result&错误语义映射
Infrastructure/ // Adapters、Translators、Policies、Pipelines
HttpApi/ // Controller & ProblemDetails
Tests/
Contract/ // Pact
Regression/ // 回归矩阵 + 覆盖率
etc/
semantic-map.yaml
wiremock/ // __files & mappings
loki/local-config.yaml
promtail/config.yml
docker-compose.yml
tests/perf/k6-smoke.js
三、背景与问题定义 🧩
痛点 :字段同名异义、单位/时区不一致、状态机差异、错误码风格不一。
目标:
- 隔离腐化 :以 DDD 的 Anti-Corruption Layer(ACL)屏蔽遗留语义入侵新域(Azure 架构中心 · ACL)。
- 可回滚 :灰度放量 + 一键回切(常与 Strangler 组合,见现代化指南)。
- 可测试 :契约测试 + 回归矩阵 → CI 门禁(Pact can-i-deploy)。
评估指标:成功率、p95、重试率、降级率、语义映射覆盖率、回归通过率。
四、架构与边界 🧭
- Application 负责编排与领域语义;
- Domain 只"看见" Port 接口;
- Infrastructure 实现 Port,与遗留交互;
- HttpApi 负责 HTTP/ProblemDetails/Headers;
- Cross-cutting :ICurrentTenant、CorrelationId(
traceparent
)、Telemetry、Resilience。
CrossCutting ICurrentTenant CorrelationId & traceparent ActivitySource/OTel Polly v8 Pipelines HttpApi Controller Application Domain.Shared Ports Infrastructure Adapters Legacy System
请求全链路(含租户与追踪) 🔗
Client HttpApi(Controller) Application(AppService) Domain Port Adapter(Infra) Legacy API GET /api/inventory/{id}\nX-Tenant-Id, X-Correlation-ID 1 附带 W3C traceparent/ tracestate 调用用例(不含HTTP细节) 2 调用端口(领域语义) 3 端口实现(Infra) 4 HTTP 调用(Resilience Handler)\n传递 X-Tenant-Id / traceparent 5 响应(外部语义) 6 Result<.., AdapterError> 7 领域返回 8 领域返回 9 ProblemDetails/DTO(含 correlationId) 10 Client HttpApi(Controller) Application(AppService) Domain Port Adapter(Infra) Legacy API
五、ABP 落地 🏷️
- 租户作用域 :
ICurrentTenant.Change(tenantId)
(ABP 多租户)。 - 灰度分流 :ABP Feature Management(文档)。
- 统一头 :
X-Correlation-ID
、X-Tenant-Id
进出都透传。
六、Ports / Adapters / Translators / Policy(骨架) 🔧
依赖包清单(放在"注册"前)📦
bash
dotnet add package Microsoft.Extensions.Http.Resilience
dotnet add package Polly --version 8.*
dotnet add package Polly.Extensions
dotnet add package Polly.RateLimiting
dotnet add package NetEscapades.Configuration.Yaml
dotnet add package PactNet # 如使用 Pact 契约测试
Port(Domain.Shared)
csharp
public interface IInventoryPort {
Task<Result<StockInfo, AdapterError>> GetStockAsync(ProductId id, TenantId tenant, CancellationToken ct);
Task<Result<bool, AdapterError>> ReserveAsync(ProductId id, int qty, ReservationId rid, TenantId tenant, CancellationToken ct);
}
Typed HttpClient(LegacyClient)
(HTTP 弹性:.NET 官方 Resilience Handler)
csharp
public sealed class LegacyClient
{
private readonly HttpClient _http;
public LegacyClient(HttpClient http) => _http = http;
public async Task<LegacyItem?> GetItemAsync(ProductId id, TenantId tenant, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"/items/{id.Value}");
req.Headers.TryAddWithoutValidation("X-Tenant-Id", tenant.Value.ToString());
var res = await _http.SendAsync(req, ct);
if (!res.IsSuccessStatusCode) return null;
return await res.Content.ReadFromJsonAsync<LegacyItem>(cancellationToken: ct);
}
}
Adapter 注册(Program.cs)
- HTTP 走 标准 Resilience Handler;
- 非 HTTP 或自定义逻辑用 Polly v8 Pipeline (Keyed Services:.NET 8 的 Keyed DI;Polly 文档见 pollydocs.org)。
csharp
services.AddHttpClient<LegacyClient>(c => c.BaseAddress = new("http://wiremock-legacy:8081"))
.AddStandardResilienceHandler(); // 推荐默认策略
services.AddResiliencePipeline("legacy.read", b => b
.AddTimeout(TimeSpan.FromSeconds(2))
.AddRetry(new() { MaxRetryAttempts = 2, BackoffType = DelayBackoffType.Exponential, UseJitter = true })
.AddCircuitBreaker(new() { FailureRatio = 0.2, SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 10, BreakDuration = TimeSpan.FromSeconds(15) }));
services.AddResiliencePipeline("legacy.write", b => b
.AddTimeout(TimeSpan.FromSeconds(3))
.AddRetry(new() { MaxRetryAttempts = 1 })
.AddRateLimiter(new RateLimiterStrategyOptions {
RateLimiter = PartitionedRateLimiter.Create<string, string>(
_ => RateLimitPartition.GetConcurrencyLimiter("legacy.write",
_ => new ConcurrencyLimiterOptions {
PermitLimit = 50, QueueLimit = 100,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}))
}));
csharp
// Adapter:不抛领域异常,只返回 Result/AdapterError
using Microsoft.Extensions.DependencyInjection; // FromKeyedServices
public sealed class InventoryAdapter : IInventoryPort
{
private readonly LegacyClient _cli;
private readonly ITranslator<LegacyItem, DomainItem> _map;
private readonly ResiliencePipeline _read;
private readonly IErrorMapper _err;
public InventoryAdapter(
LegacyClient cli,
ITranslator<LegacyItem, DomainItem> map,
[FromKeyedServices("legacy.read")] ResiliencePipeline read,
IErrorMapper err)
{ _cli = cli; _map = map; _read = read; _err = err; }
public async Task<Result<StockInfo, AdapterError>> GetStockAsync(
ProductId id, TenantId tenant, CancellationToken ct)
=> await _read.ExecuteAsync(async token => {
var res = await _cli.GetItemAsync(id, tenant, token);
if (res is null) return AdapterError.NotFound("item");
var d = _map.ToDomain(res);
return Result.Success(new StockInfo(id, d.CurrentQty));
}, ct);
// ReserveAsync(...) 类似
}
Translator(受 semantic-map.yaml
驱动)
csharp
public sealed class ItemTranslator : ITranslator<LegacyItem, DomainItem> {
private readonly SemanticMap _map;
private static readonly HashSet<String> _coverage = [];
public DomainItem ToDomain(LegacyItem s) {
_coverage.Add($"status:{s.Status}");
_coverage.Add($"unit:{s.Weight?.Unit}");
return new(
new ProductId(s.Id),
s.DisplayName?.Trim(),
UnitConvert.ToGram(s.Weight, _map.Units.WeightBase),
StatusMap.ToDomain(s.Status, _map.StatusMap));
}
public static IReadOnlyCollection<string> GetCoverage() => _coverage;
}
Application 与 HttpApi 分层(避免在 Application 里处理 HTTP)
csharp
// Application
public interface IInventoryAppService {
Task<Result<StockInfo, AdapterError>> GetStockAsync(Guid productId, Guid tenantId, CancellationToken ct);
}
public class InventoryAppService : ApplicationService, IInventoryAppService
{
private readonly ICurrentTenant _ten; private readonly IInventoryPort _port;
public InventoryAppService(ICurrentTenant ten, IInventoryPort port){ _ten = ten; _port = port; }
public async Task<Result<StockInfo, AdapterError>> GetStockAsync(Guid productId, Guid tenantId, CancellationToken ct)
{
using var scope = _ten.Change(tenantId);
return await _port.GetStockAsync(new(productId), new(tenantId), ct);
}
}
// HttpApi
[Route("api/inventory")]
public class InventoryController : AbpController
{
private readonly IInventoryAppService _svc; private readonly ProblemDetailsFactory _pdf;
public InventoryController(IInventoryAppService svc, ProblemDetailsFactory pdf) { _svc = svc; _pdf = pdf; }
[HttpGet("{productId}")]
public async Task<IActionResult> GetStock(Guid productId, [FromHeader(Name="X-Tenant-Id")] Guid tenantId, CancellationToken ct)
{
var res = await _svc.GetStockAsync(productId, tenantId, ct);
return res.Match<IActionResult>(
ok => Ok(ok),
err => {
var pd = _pdf.CreateProblemDetails(HttpContext, statusCode: err.ToHttpStatus(),
title: err.Code, detail: err.Message);
pd.Extensions["correlationId"] = HttpContext.TraceIdentifier;
return new ObjectResult(pd){ StatusCode = pd.Status };
});
}
}
Resilience Pipeline 结构(读/写分离 + 限流 + 指标) 🛡️
Write Pipeline ConcurrencyLimiter 50/Queue100
Metric: queue_len,pending Timeout 3s Retry x1 Read Pipeline CircuitBreaker 20%/30s
Metric: breaks Timeout 2s Retry x2 + Jitter
Tag: retry_count
七、语义对齐与 semantic-map.yaml
(配置即契约) 📜
- YAML + 启动强校验 :引入 YAML 配置提供器(NetEscapades.Configuration.Yaml),绑定根节点;
- Options 验证 :启动即失败(官方文档)。
yaml
# etc/semantic-map.yaml
units:
weight_base: "g"
legacy_units: ["g","kg"]
status_map:
Cancelled: ["Voided","Cancel_OK","CNL"]
errors:
L-INV-404: InventoryNotFound
L-INV-409: Conflict
csharp
// Program.cs ------ 绑定与校验
builder.Configuration.AddYamlFile("etc/semantic-map.yaml", optional: false, reloadOnChange: true);
services.AddOptions<SemanticMap>()
.Bind(builder.Configuration) // 绑定根
.ValidateDataAnnotations()
.Validate(m => new[] {"g","kg"}.Contains(m.Units.WeightBase), "invalid weight_base")
.ValidateOnStart();
覆盖率 :回归测试收集
ItemTranslator.GetCoverage()
,生成"语义映射覆盖率",CI 阈值建议 ≥95%。
八、可观测性 🔎
- ActivitySource(.NET 官方推荐) :分布式追踪
csharp
public static class Telemetry { public static readonly ActivitySource Source = new("Acme.LegacyAcl"); }
app.Use(async (ctx, next) => {
const string Key = "X-Correlation-ID";
var corr = ctx.Request.Headers[Key].FirstOrDefault() ?? Guid.NewGuid().ToString("n");
ctx.Response.Headers[Key] = corr;
using var act = Telemetry.Source.StartActivity($"{ctx.Request.Method} {ctx.Request.Path}");
act?.SetTag("tenant", ctx.Request.Headers["X-Tenant-Id"].ToString());
act?.SetTag("correlation_id", corr);
await next();
});
- 指标:成功率、p50/p95、重试率、熔断次数、降级率、缓存命中;
- 日志:按租户采样与脱敏(PII/订单号)。
九、契约测试与回归矩阵(CI 门禁) 🧪
csharp
[Fact]
public async Task GetStock_contract()
{
using var pact = Pact.V3("acl-consumer", "legacy-provider", new PactConfig())
.WithHttpInteractions();
pact.UponReceiving("get stock")
.Given("item 1001 exists")
.WithRequest(HttpMethod.Get, "/items/1001")
.WillRespond().WithStatus(HttpStatusCode.OK)
.WithJsonBody(new { id="1001", qty=12, status="OK" });
await pact.VerifyAsync(async ctx => {
var cli = new HttpClient { BaseAddress = new Uri(ctx.MockServerUri) };
var res = await cli.GetAsync("/items/1001");
res.EnsureSuccessStatusCode();
});
}
回归矩阵
用例 | 输入 | 映射点 | 期望输出 | 备注 |
---|---|---|---|---|
库存查询-四舍五入 | sku=1001, qty=11.6 | 精度规则 | qty=12 | 半入策略 |
取消订单-状态映射 | status=Cancelled | 状态机 | legacy=Voided | 双向对齐 |
税价换算-含税 | price=100CNY(含),13%税 | 税率/精度 | 88.5 | 精度=2 |
CI 门禁:Pact 通过 + 回归通过率 ≥95% + 语义覆盖率 ≥95%。
CI/CD 门禁流程 🧱
Yes No Git Push/PR Build & Unit Pact Consumer/Provider Regression Matrix + 语义覆盖率 k6 性能阈值检查 can-i-deploy? Deploy/灰度 Fail & Block Merge
十、灰度/双写/对账与回滚(SOP) 🚦
- 灰度:Feature Flag 按租户/组织/用户组放量;
- 双写:新域与遗留并写,ACL 记录差异快照哈希;
- 对账:分可自动修复/需人工/忽略;不符即回滚(Feature 一键关闭)。
按租户/组织开关 指标达标/对账通过 指标异常/对账失败 DarkLaunch Canary GeneralAvailability Rollback
十一、性能与容量(压测与基线) 📈
- k6 文档:k6.io/docs
- 阈值即 SLO(不达标→失败)
js
// tests/perf/k6-smoke.js
import http from 'k6/http';
import { check } from 'k6';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.5.0/index.js'; // ✅ 兼容的 UUID
export const options = {
vus: __ENV.VUS ? Number(__ENV.VUS) : 20,
duration: __ENV.DUR ? __ENV.DUR : '2m',
thresholds: {
http_req_failed: ['rate<0.001'],
http_req_duration: ['p(95)<200']
}
};
export default function () {
const h = {
'X-Tenant-Id': '00000000-0000-0000-0000-000000000001',
'X-Correlation-ID': uuidv4()
};
const res = http.get(`${__ENV.ACL_URL}/api/inventory/1001`, { headers: h });
check(res, { 'status is 200': r => r.status === 200 });
}
十二、安全与输入校验 🔐
- 入站 FluentValidation/DataAnnotations 做 Schema 校验;
- 日志默认脱敏;
semantic-map.yaml
的变更必须过 启动期校验 + 回归。
十三、Demo & Compose 🚀
docker-compose.yml(已修复 Promtail 容器日志采集)
yaml
version: "3.9"
services:
wiremock-legacy:
image: wiremock/wiremock:3.7.0
ports: ["8081:8080"]
volumes: [ "./etc/wiremock:/home/wiremock" ]
acl-gateway:
build: ./Acme.LegacyAcl
environment:
- ASPNETCORE_URLS=http://+:8080
ports: ["8080:8080"]
depends_on: [ wiremock-legacy ]
loki:
image: grafana/loki:2.9.8
command: -config.file=/etc/loki/local-config.yaml
volumes: [ "./etc/loki/local-config.yaml:/etc/loki/local-config.yaml" ]
ports: ["3100:3100"]
promtail:
image: grafana/promtail:2.9.8
command: -config.file=/etc/promtail/config.yml
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- "/var/lib/docker/containers:/var/lib/docker/containers:ro" # ✅ 关键挂载
- "./etc/promtail/config.yml:/etc/promtail/config.yml"
depends_on: [ loki ]
grafana:
image: grafana/grafana:11.0.0
ports: ["3000:3000"]
depends_on: [ loki ]
Promtail 最小配置(etc/promtail/config.yml,已映射 json 日志)
yaml
server:
http_listen_port: 9080
grpc_listen_port: 0
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
relabel_configs:
- source_labels: ['__meta_docker_container_name']
target_label: container
- source_labels: ['__meta_docker_container_log_stream']
target_label: stream
- source_labels: ['__meta_docker_container_id']
target_label: container_id
- source_labels: ['__meta_docker_container_id']
target_label: __path__
replacement: /var/lib/docker/containers/$1/$1-json.log
Loki 最小配置(etc/loki/local-config.yaml)
(开发用,生产按官方文档加固:Loki Docs)
yaml
auth_enabled: false
server: { http_listen_port: 3100 }
ingester:
lifecycler:
ring: { kvstore: { store: inmemory }, replication_factor: 1 }
schema_config:
configs:
- from: 2023-01-01
store: boltdb-shipper
object_store: filesystem
schema: v13
index: { prefix: index_, period: 24h }
storage_config:
boltdb_shipper:
active_index_directory: /tmp/loki/index
cache_location: /tmp/loki/cache
filesystem: { directory: /tmp/loki/chunks }
limits_config:
ingestion_rate_mb: 8
ingestion_burst_size_mb: 16
WireMock 映射(etc/wiremock/mappings/get-item-1001.json)
json
{
"request": { "method": "GET", "url": "/items/1001" },
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "id": "1001", "qty": 12, "status": "OK", "weight": { "value": 0.5, "unit": "kg" } }
}
}
运行
bash
# 依赖包(再次提醒)
dotnet add package Microsoft.Extensions.Http.Resilience
dotnet add package Polly --version 8.*
dotnet add package Polly.Extensions
dotnet add package Polly.RateLimiting
dotnet add package NetEscapades.Configuration.Yaml
dotnet add package PactNet
# 起服务
docker compose up -d --build
# 压测(可调 VUS/DUR)
export ACL_URL=http://localhost:8080
k6 run tests/perf/k6-smoke.js
参考资料 📚
- Anti-Corruption Layer(Azure)
- 应用现代化生命周期总览
- ABP 多租户与 ICurrentTenant
- .NET Resilience(HTTP)
- Polly v8 文档
- .NET 8 Keyed Services
- Options 验证/启动校验
- 分布式追踪/ActivitySource
- PactNet(.NET) / can-i-deploy
- k6 文档与阈值
- Loki/Promtail/Grafana