领域防腐层(ACL)在遗留系统改造中的落地
领域防腐层(ACL)在遗留系统改造中的落地
📚目录
- 领域防腐层(ACL)在遗留系统改造中的落地
- TL;DR 🎯
- 二、目录结构与交付物 📦
- 三、背景与问题定义 🧩
- 四、架构与边界 🧭
- 请求全链路(含租户与追踪) 🔗
- 五、ABP 落地 🏷️
- 六、Ports / Adapters / Translators / Policy(骨架) 🔧
- 依赖包清单(放在“注册”前)📦
- Resilience Pipeline 结构(读/写分离 + 限流 + 指标) 🛡️
- 七、语义对齐与 `semantic-map.yaml`(配置即契约) 📜
- 八、可观测性 🔎
- 九、契约测试与回归矩阵(CI 门禁) 🧪
- CI/CD 门禁流程 🧱
- 十、灰度/双写/对账与回滚(SOP) 🚦
- 十一、性能与容量(压测与基线) 📈
- 十二、安全与输入校验 🔐
- 十三、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 DTOApplication/ // 用例编排、Result&错误语义映射Infrastructure/ // Adapters、Translators、Policies、PipelinesHttpApi/ // Controller & ProblemDetailsTests/Contract/ // PactRegression/ // 回归矩阵 + 覆盖率etc/semantic-map.yamlwiremock/ // __files & mappingsloki/local-config.yamlpromtail/config.ymldocker-compose.ymltests/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。
请求全链路(含租户与追踪) 🔗
五、ABP 落地 🏷️
- 租户作用域:
ICurrentTenant.Change(tenantId)
(ABP 多租户)。 - 灰度分流:ABP Feature Management(文档)。
- 统一头:
X-Correlation-ID
、X-Tenant-Id
进出都透传。
六、Ports / Adapters / Translators / Policy(骨架) 🔧
依赖包清单(放在“注册”前)📦
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)
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)
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)。
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}))}));
// Adapter:不抛领域异常,只返回 Result/AdapterError
using Microsoft.Extensions.DependencyInjection; // FromKeyedServicespublic 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
驱动)
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)
// 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 结构(读/写分离 + 限流 + 指标) 🛡️
七、语义对齐与 semantic-map.yaml
(配置即契约) 📜
- YAML + 启动强校验:引入 YAML 配置提供器(NetEscapades.Configuration.Yaml),绑定根节点;
- Options 验证:启动即失败(官方文档)。
# etc/semantic-map.yaml
units:weight_base: "g"legacy_units: ["g","kg"]
status_map:Cancelled: ["Voided","Cancel_OK","CNL"]
errors:L-INV-404: InventoryNotFoundL-INV-409: Conflict
// 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 官方推荐):分布式追踪
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 门禁) 🧪
- Pact(.NET:PactNet):GitHub
- can-i-deploy:作为合并/发布门槛(Docs)
[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 门禁流程 🧱
十、灰度/双写/对账与回滚(SOP) 🚦
- 灰度:Feature Flag 按租户/组织/用户组放量;
- 双写:新域与遗留并写,ACL 记录差异快照哈希;
- 对账:分可自动修复/需人工/忽略;不符即回滚(Feature 一键关闭)。
十一、性能与容量(压测与基线) 📈
- k6 文档:k6.io/docs
- 阈值即 SLO(不达标→失败)
// 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'; // ✅ 兼容的 UUIDexport 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 容器日志采集)
version: "3.9"
services:wiremock-legacy:image: wiremock/wiremock:3.7.0ports: ["8081:8080"]volumes: [ "./etc/wiremock:/home/wiremock" ]acl-gateway:build: ./Acme.LegacyAclenvironment:- ASPNETCORE_URLS=http://+:8080ports: ["8080:8080"]depends_on: [ wiremock-legacy ]loki:image: grafana/loki:2.9.8command: -config.file=/etc/loki/local-config.yamlvolumes: [ "./etc/loki/local-config.yaml:/etc/loki/local-config.yaml" ]ports: ["3100:3100"]promtail:image: grafana/promtail:2.9.8command: -config.file=/etc/promtail/config.ymlvolumes:- "/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.0ports: ["3000:3000"]depends_on: [ loki ]
Promtail 最小配置(etc/promtail/config.yml,已映射 json 日志)
server:http_listen_port: 9080grpc_listen_port: 0clients:- url: http://loki:3100/loki/api/v1/pushscrape_configs:- job_name: dockerdocker_sd_configs:- host: unix:///var/run/docker.sockrelabel_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)
auth_enabled: false
server: { http_listen_port: 3100 }
ingester:lifecycler:ring: { kvstore: { store: inmemory }, replication_factor: 1 }
schema_config:configs:- from: 2023-01-01store: boltdb-shipperobject_store: filesystemschema: v13index: { prefix: index_, period: 24h }
storage_config:boltdb_shipper:active_index_directory: /tmp/loki/indexcache_location: /tmp/loki/cachefilesystem: { directory: /tmp/loki/chunks }
limits_config:ingestion_rate_mb: 8ingestion_burst_size_mb: 16
WireMock 映射(etc/wiremock/mappings/get-item-1001.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" } }}
}
运行
# 依赖包(再次提醒)
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