外观
防腐层避坑实录:遗留系统渐进替换实战
问题背景:为什么不能直接重写遗留系统
很多团队接手遗留系统时,第一反应是“推倒重来”:旧接口字段混乱、状态码不统一、业务规则散落在代码里,新服务如果直接调用,很容易被旧模型污染。但一次性重写风险极高:数据口径不一致、调用方改造范围大、上线窗口长,任何一个环节出错都可能造成核心交易中断。
更现实的方案,是在遗留系统与新微服务之间建立防腐层(Anti-Corruption Layer,ACL)。它不是简单的网关,也不是数据搬运层,而是面向新领域模型的翻译边界:外部系统可以继续使用旧协议、旧字段和旧状态,新服务内部只表达稳定的业务概念。
典型共存场景包括:订单中心从单体拆出,但库存仍在遗留系统;新支付服务需要兼容旧会员接口;新前台使用 RESTful API,后端仍依赖 SOAP 或私有 RPC。防腐层的价值在于让迁移可以按接口、按租户、按流量比例逐步推进,而不是一次性切换。
方案对比:直连、门面与防腐层怎么选
直连旧系统开发最快,但新服务会直接继承旧系统的字段命名、异常码和数据缺陷。短期看少写了一层,长期却会让旧模型扩散到每个新服务,后续替换成本更高。
门面模式(Facade)更适合封装复杂子系统,目标是简化调用,不一定要隔离模型。例如一个 Facade 可能仍把旧系统的 status=91 原样返回给上游,这对迁移帮助有限。
防腐层强调“外部模型不进入领域层”。它负责协议转换、字段翻译、枚举映射、异常归一化、兼容兜底和迁移路由。调用方看到的是新语义,例如 OrderStatus.PAID,而不是旧系统的 PAY_OK_01。
选型上可以遵循一个判断标准:如果旧系统只是实现细节,调用方仍接受统一语义,使用防腐层;如果只是为了减少调用次数,使用门面;如果旧接口已经稳定且模型合理,才考虑短期直连,并明确技术债退出时间。
领域建模:先定义新语义,再适配旧接口
防腐层最关键的原则,是不要从旧系统 DTO 反推新模型。正确顺序是先定义业务侧需要的领域对象,再为每个外部系统编写 Translator。下面以订单状态迁移为例,使用 Go 给出可运行示例。
go
package main
import (
"context"
"errors"
"fmt"
)
// OrderStatus 是新领域模型中的稳定语义,
// 不允许出现旧系统的 PAY_OK_01 等实现字段。
type OrderStatus string
const (
StatusCreated OrderStatus = "CREATED"
StatusPaid OrderStatus = "PAID"
StatusClosed OrderStatus = "CLOSED"
)
type Order struct {
ID string
BuyerID string
Amount int64 // 单位:分
Status OrderStatus
}
// LegacyOrderDTO 只能出现在 adapter 包或 client 包中,
// 不能进入领域服务。
type LegacyOrderDTO struct {
OrderNo string `json:"order_no"`
Uid string `json:"uid"`
Money string `json:"money"` // 旧系统返回元,字符串类型
State int `json:"state"` // 10 新建,91 已支付,99 关闭
}
type OrderRepository interface {
GetByID(ctx context.Context, id string) (*Order, error)
}旧系统的 money 是字符串元,state 是魔法数字;这些问题都应在适配器内消化。为了避免金额浮点误差,示例统一转换为 int64 分。
go
import (
"strconv"
"strings"
)
type LegacyClient struct{}
func (c *LegacyClient) QueryOrder(ctx context.Context, id string) (*LegacyOrderDTO, error) {
// 实际项目中这里可能是 HTTP、SOAP、私有 RPC 或数据库查询。
return &LegacyOrderDTO{
OrderNo: id,
Uid: "buyer-1001",
Money: "88.50",
State: 91,
}, nil
}
type LegacyOrderAdapter struct {
client *LegacyClient
}
func NewLegacyOrderAdapter(client *LegacyClient) *LegacyOrderAdapter {
return &LegacyOrderAdapter{client: client}
}
func (a *LegacyOrderAdapter) GetByID(ctx context.Context, id string) (*Order, error) {
dto, err := a.client.QueryOrder(ctx, id)
if err != nil {
return nil, fmt.Errorf("查询遗留订单失败: %w", err)
}
return translateLegacyOrder(dto)
}
func translateLegacyOrder(dto *LegacyOrderDTO) (*Order, error) {
amount, err := yuanStringToCents(dto.Money)
if err != nil {
return nil, err
}
status, err := mapLegacyState(dto.State)
if err != nil {
return nil, err
}
return &Order{
ID: dto.OrderNo,
BuyerID: dto.Uid,
Amount: amount,
Status: status,
}, nil
}
func yuanStringToCents(v string) (int64, error) {
parts := strings.Split(v, ".")
if len(parts) > 2 {
return 0, errors.New("非法金额格式")
}
yuan, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, fmt.Errorf("金额整数部分非法: %w", err)
}
cents := yuan * 100
if len(parts) == 2 {
dec := parts[1]
if len(dec) == 1 {
dec += "0"
}
if len(dec) > 2 {
return 0, errors.New("金额最多保留两位小数")
}
n, err := strconv.ParseInt(dec, 10, 64)
if err != nil {
return 0, fmt.Errorf("金额小数部分非法: %w", err)
}
cents += n
}
return cents, nil
}
func mapLegacyState(state int) (OrderStatus, error) {
switch state {
case 10:
return StatusCreated, nil
case 91:
return StatusPaid, nil
case 99:
return StatusClosed, nil
default:
return "", fmt.Errorf("未知遗留订单状态: %d", state)
}
}渐进迁移:防腐层内置路由而不是改调用方
渐进替换的关键,是把“调用旧系统还是新服务”的决策收敛到防腐层内部。业务服务始终依赖 OrderRepository,不感知当前处于哪个迁移阶段。路由可以按订单号哈希、白名单用户、租户、地域或灰度比例配置。
go
type NewOrderClient struct{}
func (c *NewOrderClient) GetOrder(ctx context.Context, id string) (*Order, error) {
// 新服务直接返回领域友好结构;真实代码中通常由 protobuf 生成对象再转换。
return &Order{ID: id, BuyerID: "buyer-1001", Amount: 8850, Status: StatusPaid}, nil
}
type RoutingRepository struct {
legacy *LegacyOrderAdapter
modern *NewOrderClient
grayIDs map[string]bool
}
func NewRoutingRepository(legacy *LegacyOrderAdapter, modern *NewOrderClient) *RoutingRepository {
return &RoutingRepository{
legacy: legacy,
modern: modern,
grayIDs: map[string]bool{"order-gray-001": true},
}
}
func (r *RoutingRepository) GetByID(ctx context.Context, id string) (*Order, error) {
if r.grayIDs[id] {
return r.modern.GetOrder(ctx, id)
}
return r.legacy.GetByID(ctx, id)
}
func main() {
repo := NewRoutingRepository(
NewLegacyOrderAdapter(&LegacyClient{}),
&NewOrderClient{},
)
order, err := repo.GetByID(context.Background(), "order-001")
if err != nil {
panic(err)
}
fmt.Printf("订单=%s,买家=%s,金额=%d分,状态=%s\n",
order.ID, order.BuyerID, order.Amount, order.Status)
}迁移写路径时,不建议一开始就完全双写。更稳妥的步骤是:第一阶段只写旧系统,异步影子写新系统但结果不影响主流程;第二阶段开启双读 diff,比对金额、状态、买家等核心字段;第三阶段小流量以新系统为准,旧系统降级为兜底;第四阶段摘除旧链路和无用转换器。
可观测性:让模型差异在灰度期暴露
防腐层必须记录三类指标:路由命中率、外部依赖错误率、翻译失败数。对于双读场景,还要记录字段不一致数量,但日志中不能直接打印身份证、手机号等敏感信息。
推荐使用结构化日志和指标标签:route=legacy|modern、operation=query_order、result=success|error、diff_field=status|amount。未知状态不要静默映射成默认值,否则数据看似成功,业务语义已经损坏。更合理的做法是告警并走受控兜底,例如返回“处理中”或触发人工补偿。
避坑总结:渐进迁移中的高频问题
1. 把旧 DTO 直接透传到新服务
常见现象是新服务代码里出现 LegacyOrderDTO、state=91、money 字符串判断。短期开发很快,但旧模型会扩散到 Controller、Service 甚至前端协议,后续替换需要全链路修改。修正方法是规定外部 DTO 只能存在于 adapter 包,进入领域层前必须转换为统一模型,并通过代码评审或 ArchUnit 类规则强制约束。
2. 金额、时间和空值转换不严谨
旧系统常使用元字符串、本地时间、可空数字;新服务若用浮点数承接金额,可能出现 88.50 变成 8849 分之类的问题。时间字段也可能混用 yyyy-MM-dd HH:mm:ss 和 Unix 时间戳。修正方法是金额统一使用整数分或 decimal 类型,时间在边界统一转换为带时区时间,空值必须区分“未知”“无数据”和“业务默认值”。
3. 枚举映射使用 default 吞掉未知状态
为了让编译通过,有些代码会把未知旧状态统一映射为“新建”或“关闭”。这会导致订单状态被错误解释,甚至引发重复支付或错误关单。修正方法是未知枚举直接返回翻译错误并打点告警;确需兼容时,应显式定义 UNKNOWN,只允许查询场景展示,不允许触发状态推进。
4. 双写没有幂等和补偿设计
旧系统写成功、新系统写失败时,如果没有幂等键和重试任务,就会产生长期数据分叉。修正方法是每个写操作携带业务唯一键,新服务实现幂等创建;防腐层记录 outbox 事件或补偿任务,按结果对账。不要依赖简单的同步重试,因为调用超时并不代表对方一定失败。
5. 防腐层膨胀成“第二套业务系统”
如果防腐层开始堆积复杂优惠计算、订单履约、库存扣减规则,说明边界已经失控。防腐层应只做翻译、路由、容错和兼容,不承载核心领域决策。复杂规则应沉淀到新的领域服务;旧系统独有且短期无法迁移的规则,可以封装成独立 Legacy Capability Client,并在迁移清单中明确替换责任团队。
结语
防腐层不是为了让旧系统永远存在,而是为了让新服务不被旧模型绑架,让迁移可以被拆解、灰度和回滚。落地时先定义稳定的新领域语义,再用适配器隔离旧 DTO;通过路由、双读 diff、幂等双写和可观测性逐步迁移;最后及时删除已经失效的兼容代码。只有“建得起来”也“拆得下去”的防腐层,才是真正服务于渐进替换的架构边界。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。