领域防腐层(ACL)在遗留系统改造中的落地

领域防腐层(ACL)在遗留系统改造中的落地


📚目录

  • 领域防腐层(ACL)在遗留系统改造中的落地
    • [TL;DR 🎯](#TL;DR 🎯)
    • [二、目录结构与交付物 📦](#二、目录结构与交付物 📦)
    • [三、背景与问题定义 🧩](#三、背景与问题定义 🧩)
    • [四、架构与边界 🧭](#四、架构与边界 🧭)
      • [请求全链路(含租户与追踪) 🔗](#请求全链路(含租户与追踪) 🔗)
    • [五、ABP 落地 🏷️](#五、ABP 落地 🏷️)
    • [六、Ports / Adapters / Translators / Policy(骨架) 🔧](#六、Ports / Adapters / Translators / Policy(骨架) 🔧)
    • [七、语义对齐与 `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 门禁;灰度双写 + 对账 + 回滚。

二、目录结构与交付物 📦

交付物

  1. Acme.LegacyAcl 模块样板(Port/Adapter/Translator/Policy)
  2. semantic-map.yaml + 启动强校验 + "语义覆盖率"报告
  3. Pact 契约测试 + 回归矩阵(含阈值)
  4. 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-IDX-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 复制代码
# 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%。


八、可观测性 🔎

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 门禁) 🧪

  • Pact(.NET:PactNet ):GitHub
  • can-i-deploy :作为合并/发布门槛(Docs
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

参考资料 📚