跳到主内容

防腐层避坑实录:遗留系统渐进替换实战 ​

问题背景:为什么不能直接重写遗留系统 ​

很多团队接手遗留系统时,第一反应是“推倒重来”:旧接口字段混乱、状态码不统一、业务规则散落在代码里,新服务如果直接调用,很容易被旧模型污染。但一次性重写风险极高:数据口径不一致、调用方改造范围大、上线窗口长,任何一个环节出错都可能造成核心交易中断。

更现实的方案,是在遗留系统与新微服务之间建立防腐层(Anti-Corruption Layer,ACL)。它不是简单的网关,也不是数据搬运层,而是面向新领域模型的翻译边界:外部系统可以继续使用旧协议、旧字段和旧状态,新服务内部只表达稳定的业务概念。

典型共存场景包括:订单中心从单体拆出,但库存仍在遗留系统;新支付服务需要兼容旧会员接口;新前台使用 RESTful API,后端仍依赖 SOAP 或私有 RPC。防腐层的价值在于让迁移可以按接口、按租户、按流量比例逐步推进,而不是一次性切换。

方案对比:直连、门面与防腐层怎么选 ​

直连旧系统开发最快,但新服务会直接继承旧系统的字段命名、异常码和数据缺陷。短期看少写了一层,长期却会让旧模型扩散到每个新服务,后续替换成本更高。

门面模式(Facade)更适合封装复杂子系统,目标是简化调用,不一定要隔离模型。例如一个 Facade 可能仍把旧系统的 status=91 原样返回给上游,这对迁移帮助有限。

防腐层强调“外部模型不进入领域层”。它负责协议转换、字段翻译、枚举映射、异常归一化、兼容兜底和迁移路由。调用方看到的是新语义,例如 OrderStatus.PAID,而不是旧系统的 PAY_OK_01。

新业务服务使用统一领域模型防腐层 ACL协议转换模型翻译路由与兜底遗留单体系统旧字段与旧状态新微服务目标领域能力

选型上可以遵循一个判断标准:如果旧系统只是实现细节,调用方仍接受统一语义,使用防腐层;如果只是为了减少调用次数,使用门面;如果旧接口已经稳定且模型合理,才考虑短期直连,并明确技术债退出时间。

领域建模:先定义新语义,再适配旧接口 ​

防腐层最关键的原则,是不要从旧系统 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、幂等双写和可观测性逐步迁移;最后及时删除已经失效的兼容代码。只有“建得起来”也“拆得下去”的防腐层,才是真正服务于渐进替换的架构边界。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。