← 文章 / 编程开发
freeCodeCamp 1小时前 · 2026-09-09 17:44:48 · 1 阅读

迁移前先重构:如何为遗留应用做迁移准备

团队一旦决定迁移遗留应用,往往就会面临"赶紧搬代码"的压力。

搬数据库、搬 API、搬 UI,把应用迁到新的 framework、runtime、云服务商或架构上。

听起来合理,但有个问题。

如果现有系统把业务规则、持久化、基础设施、外部集成和编排全部混在同一组模块里,迁移的难度会被不必要地放大。

你搬的不仅是软件本身,而是那些在多年开发中逐渐纠缠在一起的若干职责。

这就是我为什么通常倾向于先重构再迁移。不是为了把遗留系统改得漂亮,也不是全盘重新设计,更不是把准备阶段变成另一次重写。

目标要窄得多:调整结构到足够程度,让关键行为可以独立搬迁。

在这个流程的前几步中,我们先理解代码库,然后用 characterization tests 保护即将变更的行为。

下面,我们来看如何着手调整结构。

这篇教程将展示如何为遗留应用的迁移做准备:

  • 确定迁移边界,

  • 将业务规则与基础设施分离,

  • 引入 seam,

  • 隔离副作用,

  • 为外部系统创建 adapter,

  • 减少依赖方向问题,

  • 提取内聚的应用行为,

  • 在整个重构过程中使用 characterization tests,

  • 合理使用 AI,避免让它盲目重新设计系统,

  • 以及判断应用何时可以开始迁移。

示例使用 TypeScript,但这套流程适用于大多数语言和架构。

目标不是:

legacy application
↓
perfect architecture

而是:

legacy application
↓
migration-friendly structure
↓
incremental migration

这个区别能省掉大量无谓的工作。

前置条件

你需要熟悉以下内容:

  • 阅读现有代码库

  • TypeScript 或类似语言

  • 单元测试与集成测试

  • 依赖注入

  • 接口与适配器

  • 基础软件架构

  • 渐进式重构

此外,你还需要对计划修改的功能建立行为保护。

方式可能包括:

  • 特征测试(characterization tests)

  • 集成测试

  • 契约测试(contract tests)

或者其他能可靠验证现有行为的方法。

没有这些保护也能重构,但你很难区分结构性改进和意外的行为变化,工作起来也会困难得多。

目录

为什么迁移问题往往在动手之前就埋下了

假设你需要迁移一个订单处理应用。

检查主服务时,你发现了类似这样的代码:

async function processOrder(orderId: string) {
  const connection = await mysql.getConnection();

  const [rows] = await connection.query(
    "SELECT * FROM orders WHERE id = ?",
    [orderId]
  );

  const order = rows[0];

  if (!order) {
    throw new Error("Order not found");
  }

  if (order.customer_type === "PREMIUM") {
    order.total = order.total * 0.9;
  }

  if (
    order.country === "AR" &&
    order.payment_method === "TRANSFER"
  ) {
    order.total -= 500;
  }

  await connection.query(
    "UPDATE orders SET total = ?, status = ? WHERE id = ?",
    [order.total, "PROCESSED", order.id]
  );

  await paymentProvider.createPayment({
    orderId: order.id,
    amount: order.total,
  });

  await eventBus.publish("order.processed", {
    id: order.id,
    total: order.total,
  });

  await emailClient.send({
    to: order.customer_email,
    template: "order-processed",
  });

  return order;
}

假设迁移目标是:

MySQL       → PostgreSQL
Old runtime → New runtime
Legacy API  → New service

最直接的冲动,就是把这段函数原样翻译成目标技术栈。

但你真正要迁移的到底是什么?

这个函数里塞了:

database access
business rules
state transition
payment integration
event publication
email delivery
application orchestration

现在换数据库,可能波及定价逻辑;换支付客户端,又可能影响持久化。而把这个函数挪到另一个服务里,它的全部依赖也得跟着一起搬。

迁移之所以难,部分原因在于当前的代码结构。所以动手迁移之前,先做出足够的解耦,让各个关注点能独立移动。

先划定迁移边界,再动手重构

不要一上来就说:

先把整个应用清理干净。

而应该说:

我们想先迁移什么?

假设你决定第一个要迁移的能力是"处理订单",这样重构就有了明确的边界。

现在你可以画出这样的映射:

输入:
orderId

业务行为:
加载订单
计算调整项
标记为已处理

副作用:
持久化订单
创建支付
发布事件
发送邮件

输出:
处理后的订单

这比"把 src/services/ 重构一下"有用得多,因为文件夹不一定等于业务边界。

能围绕能力(capability)来推理,迁移才更高效。

比如:

处理订单
取消订单
生成发票
注册客户
续约订阅

每一项都可能成为一个迁移单元。

不要重构整个应用

一旦你开始发现架构问题,就会忍不住想全部修好。

你可能会注意到:

循环依赖
重复的 repository
全局配置
过大的 service
静态工具类
直接访问数据库
错误处理不一致
领域模型混杂

这些问题都值得处理,但迁移并不要求你清零所有技术债。

假设你的目标是"处理订单"这个能力,一个实用的原则是:只重构阻碍该能力安全迁移的部分。

比如:

问题:
订单处理直接调用 MySQL。

相关?
是。

问题:
报表模块日期格式不统一。

相关?
大概率不相关。

问题:
订单处理直接调用支付 SDK。

相关?
是。

问题:
管理后台 CSS 有重复。

相关?
否。

这样可以把准备工作控制在一个有边界的范围内,避免它变成无休止的清理工程。

遗留系统现代化需要范围纪律。

将业务规则与基础设施分离

最有价值的结构变更,往往是将业务行为与技术实现细节解耦。

看这段代码:

async function processOrder(orderId: string) {
  const order = await mysqlOrders.find(orderId);

  if (order.customerType === "PREMIUM") {
    order.total *= 0.9;
  }

  if (
    order.country === "AR" &&
    order.paymentMethod === "TRANSFER"
  ) {
    order.total -= 500;
  }

  await mysqlOrders.update(order);

  await stripe.createPayment({
    orderId: order.id,
    amount: order.total,
  });
}

这段定价逻辑本身并不依赖 MySQL 或 Stripe。

你可以把它抽出来:

type Order = {
  id: string;
  total: number;
  customerType: "STANDARD" | "PREMIUM";
  country: string;
  paymentMethod: "CARD" | "TRANSFER";
};

function calculateOrderTotal(order: Order): number {
  let total = order.total;

  if (order.customerType === "PREMIUM") {
    total *= 0.9;
  }

  if (
    order.country === "AR" &&
    order.paymentMethod === "TRANSFER"
  ) {
    total -= 500;
  }

  return Math.max(total, 0);
}

这样一来:

pricing behavior

就不再和下面这些耦合在一起了:

MySQL
Stripe

这不需要彻底重构成领域驱动设计,只是做一次有用的分离。

接下来的迁移步骤就可以替换基础设施,而这段业务逻辑保持不变。

在硬依赖周围引入接缝

遗留代码里常常有一些依赖,很难在测试或迁移代码中替换掉。

比如:

class OrderService {
  async process(orderId: string) {
    const client = new LegacyDatabaseClient();

    const order = await client.findOrder(orderId);

    // ...
  }
}

数据库依赖是在方法内部创建的。

这就导致替换起来很困难。

只需一个小规模的前置重构,就能引入一个接缝:

interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

然后:

class OrderService {
  constructor(
    private readonly orders: OrderRepository
  ) {}

  async process(orderId: string) {
    const order = await this.orders.findById(orderId);

    if (!order) {
      throw new Error("Order not found");
    }

    // existing behavior
  }
}

现有的 MySQL 实现只需实现这个接口即可:

class MySqlOrderRepository implements OrderRepository {
  async findById(id: string) {
    // existing MySQL behavior
  }

  async save(order: Order) {
    // existing MySQL behavior
  }
}

之后,迁移时可以引入:

class PostgresOrderRepository implements OrderRepository {
  // new implementation
}

注意我们没改什么:业务行为没有变化。我们改变的是依赖的可替换性

这正是有助于迁移的重构。

将副作用与决策逻辑隔离

另一种有用的分离方式是:

deciding

与:

performing

假设当前取消订单的实现是这样的:

async function cancelOrder(order: Order) {
  if (order.status === "SHIPPED") {
    throw new Error("Cannot cancel shipped order");
  }

  order.status = "CANCELLED";

  await orders.save(order);
  await inventory.release(order.id);
  await payment.refund(order.id);
  await audit.log("ORDER_CANCELLED", order.id);
}

这里存在两种不同的职责。

业务决策:

Can this order be cancelled?
What should its new state be?

以及执行层面的操作:

persist
release inventory
refund
audit

可以先提取出决策部分:

function cancelOrderState(order: Order): Order {
  if (order.status === "SHIPPED") {
    throw new Error("Cannot cancel shipped order");
  }

  return {
    ...order,
    status: "CANCELLED",
  };
}

剩下的编排逻辑:

async function cancelOrder(order: Order) {
  const cancelled = cancelOrderState(order);

  await orders.save(cancelled);
  await inventory.release(cancelled.id);
  await payment.refund(cancelled.id);
  await audit.log("ORDER_CANCELLED", cancelled.id);

  return cancelled;
}

行为保持不变,但状态转换现在可以独立测试和迁移。

如果目标架构改变了副作用的执行方式,这一点就很重要。

例如,未来版本可能会使用:

transactional outbox
event-driven workflow
queue
workflow engine

你不需要现在就引入这些机制。你只需要让当前的决策逻辑不再直接依赖它们。

用适配器隔离外部系统

外部 SDK 往往会深入渗透到遗留代码中。

例如:

const result = await stripe.paymentIntents.create({
  amount: order.total,
  currency: "usd",
  metadata: {
    orderId: order.id,
  },
});

如果几十个应用模块直接依赖 Stripe SDK,替换或迁移支付处理逻辑就会非常困难。

改为在应用层面建立边界:

type PaymentRequest = {
  orderId: string;
  amount: number;
};

type PaymentResult = {
  paymentId: string;
};

interface PaymentGateway {
  charge(
    request: PaymentRequest
  ): Promise<PaymentResult>;
}

Stripe 适配器负责封装供应商特有的细节:

class StripePaymentGateway implements PaymentGateway {
  async charge(
    request: PaymentRequest
  ): Promise<PaymentResult> {
    const result =
      await stripe.paymentIntents.create({
        amount: request.amount,
        currency: "usd",
        metadata: {
          orderId: request.orderId,
        },
      });

    return {
      paymentId: result.id,
    };
  }
}

应用现在只需要知道:

PaymentGateway

而不是:

Stripe SDK

这对迁移很有帮助,因为供应商相关代码被收敛到了局部。

同样的模式适用于:

email providers
message brokers
cloud storage
ERP integrations
CRM APIs
identity providers
search engines

适配器的价值不在于接口设计时髦,而在于它划出了一条可以移动的边界。

不推翻重来,改善依赖方向

遗留系统的依赖关系往往长这样:

business logic
    ↓
database SDK
    ↓
framework utilities

这让基础设施难以替换。

不一定非要全面贯彻 Clean Architecture,只需在迁移涉及的地方改善依赖方向即可。

例如:

改造前:

OrderService
   ↓
MySQL

改造后:

OrderService
   ↓
OrderRepository
   ↑
MySqlOrderRepository

应用依赖抽象,基础设施负责实现。

支付模块同理:

OrderService
   ↓
PaymentGateway
   ↑
StripePaymentGateway

以及消息发送:

OrderService
   ↓
OrderEvents
   ↑
KafkaOrderEvents

这样替换基础设施时,就不需要重写应用服务了。这正是关键成果。

提取内聚的应用边界

经过几次小型重构后,这项能力可能会演变成这样:

interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

interface PaymentGateway {
  charge(request: {
    orderId: string;
    amount: number;
  }): Promise<void>;
}

interface OrderEvents {
  processed(order: Order): Promise<void>;
}

class ProcessOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly payments: PaymentGateway,
    private readonly events: OrderEvents
  ) {}

  async execute(orderId: string) {
    const order = await this.orders.findById(orderId);

    if (!order) {
      throw new Error("Order not found");
    }

    const total = calculateOrderTotal(order);

    const processed: Order = {
      ...order,
      total,
      status: "PROCESSED",
    };

    await this.orders.save(processed);

    await this.payments.charge({
      orderId: processed.id,
      amount: processed.total,
    });

    await this.events.processed(processed);

    return processed;
  }
}

这并不一定就是最终架构。这一点很重要。

我们并不是在说:

应用就应该永远保持这个样子。

我们想表达的只是:

这项能力现在有了清晰的边界,迁移起来更容易了。

基础设施可以独立变更。

业务规则可以测试,编排逻辑清晰可见,外部契约一目了然。

这就足够让我们开始考虑迁移了。

重构期间保持行为测试持续运行

上一步创建的特征测试(characterization tests)在这里就能派上用场了。

假设原始行为已经有如下测试保护:

it("preserves premium order processing behavior", async () => {
  const result = await processOrder("order-1");

  expect(result.total).toBe(9000);
  expect(result.status).toBe("PROCESSED");

  expect(payment.charge).toHaveBeenCalledWith({
    orderId: "order-1",
    amount: 9000,
  });

  expect(events.processed).toHaveBeenCalled();
});

接下来可以这样改:

直接数据库访问

改为:

Repository

然后跑一次测试。

接着把:

直接调用支付 SDK

改为:

Payment Adapter

再跑一次测试。

然后提取:

定价逻辑

再跑一次测试。

节奏就变成了:

小的结构变更
↓
测试
↓
小的结构变更
↓
测试
↓
小的结构变更
↓
测试

这样做很重要,因为结构重构和行为变更不同时发生时,推理起来会容易得多。

一次小改动后测试挂了,可能的原因很有限。

两周大重写后测试挂了,几乎任何东西都可能是原因。

结构重构中如何使用 AI

这个阶段 AI 能帮上大忙。

但真正有用的 prompt 跟下面这种不一样:

用 Clean Architecture 重构这个应用。

而是给模型一个有约束的转换任务。

比如:

这个服务目前直接访问 MySQL。

我想引入一个 OrderRepository 接缝,
且不改变可观察的行为。

任务:

1. 找出该服务用到的所有数据库操作,
2. 提出所需的最小 Repository 接口,
3. 把现有数据库调用移到适配器后面,
4. 在相关处保留返回值、错误和调用顺序,
5. 不要改动业务规则,
6. 不要引入额外的抽象层。

生成代码之前,先解释每一个结构变更。

这样 AI 的任务就窄多了。

另一个有用的请求是:

对比重构前后的实现。

找出任何可能改变的可观察行为。

重点检查:

- 异常,
- 返回值,
- 副作用,
- 副作用的顺序,
- null 处理,
- 事务边界,
- 重试行为。

不要因为代码看起来相似就假设等价。

AI 在这里可以作为第二审查者发挥作用。

它能比手动逐行扫描更快地发现差异,但测试仍然是更强的证据。

别太早让 AI 设计目标架构

AI 很擅长识别常见的架构模式,但这也可能是危险的。

把一个大遗留服务丢给模型,问它:

这个服务该怎么现代化?

你可能会收到:

microservices
event-driven architecture
CQRS
repository pattern
domain events
message broker
API gateway
distributed cache

这些都是合理的技术或模式,但没有一个是天然就该用的。

选目标架构之前,你得先明确约束条件。

比如:

部署频率
团队规模
事务需求
延迟要求
容错能力
数据归属
集成边界
运维成熟度
流量
成本
合规要求

边界清晰的单体应用可能比 microservices 更适合作为目标。同步流程可能比事件驱动处理更合适。数据库迁移也不一定需要改动领域模型。

架构应该由约束驱动,而不是由模式识别驱动。

用 AI 来评估方案,别让「这个模式我见过」就变成采用的理由。

怎么判断一个能力可以迁移了

到某个阶段,你得停下来停止重构。这个决定很关键。

代码不必完美。当你能清楚回答以下问题时,一个能力就基本具备迁移条件了。

能描述它的输入吗?

例如:

orderId
customer
request payload
event

能描述它的输出吗?

例如:

已处理的订单
HTTP response
event
数据库变更

关键业务规则是否可见?

不一定要写得多完美,但你得知道它们在哪里。

外部依赖是否显式?

例如:

OrderRepository
PaymentGateway
OrderEvents
EmailSender

基础设施能否替换?

如果换成 MySQL 就得改定价逻辑,那边界大概率还没就绪。

关键行为是否有测试保护?

你应拥有足够的测试来捕获意外变更。

你知道副作用吗?

例如:

persist order
create payment
publish event
send email

主要的未知因素是否已记录在案?

总会存在一些不确定性,但不能让它隐身不见。

如果这些问题都能回答上来,说明结构已经足够清晰,可以开始迁移该能力了。

迁移前的实用重构流程

下面是我会采用的流程。

1. 选定一个能力

不要重构整个应用。

从这类功能中挑一个:

Process Order
Generate Invoice
Renew Subscription

2. 确保行为有测试保护

在动结构之前,先确认关键行为已有测试覆盖。

捕获的内容包括:

outputs
state transitions
side effects
errors
contracts

3. 找出迁移阻碍

留意这类耦合:

direct database access
provider SDKs
global state
framework-specific objects
static dependencies
shared mutable state

4. 尽可能抽出纯业务逻辑

把计算和决策逻辑从基础设施中剥离出来。

例如:

calculate price
validate transition
choose status
calculate commission

5. 引入接缝

在这些位置周围建立最小边界:

database
payments
events
email
storage
external APIs

没有迁移上的理由,就不要凭空造抽象。

6. 集中收拢基础设施

把与具体技术相关的行为移到 adapter 里。

例如:

MySqlOrderRepository
StripePaymentGateway
KafkaOrderEvents
SendGridEmailSender

7. 让编排流程清晰可见

目标是让一个能力的执行顺序一目了然:

load
↓
decide
↓
persist
↓
perform side effects
↓
return

8. 每完成一步就跑行为测试

别把十次重构攒在一起做,保持每次改动都很小。

9. 对比重构前后

检查:

inputs
outputs
errors
side effects
data shapes
ordering
transactions

10. 迁移条件具备时就停下

不要因为“代码还能更干净”就继续重构——它永远可以更干净。

目标是达到可迁移的状态。

迁移前不该重构的东西

这个阶段,除非某项改动直接阻碍迁移,否则我会避开以下几类操作。

全局重命名

几百个旧名字看着确实难受。但全部重命名只会产生大量 diff,对迁移本身几乎没有价值。

全仓库格式化

道理一样。格式噪音会让行为变更的 review 变得困难。

替换所有设计模式

老系统里可能塞满了:

singletons
service locators
static utilities
large classes

有些可以暂时保留。重点处理那些跨越迁移边界的。

重写稳定算法

如果某个老算法写得丑但被测试保护着、又足够隔离,先原样搬过去、以后再优化,往往更稳妥。

顺手修掉所有发现的 bug

这一条尤其关键。

迁移准备过程中发现 bug,先记录下来,再判断修不修该不该放在同一次提交里。

把下面三件事混在一起:

structural refactor
+
behavioral correction
+
platform migration

会让排查失败变得非常困难。

有时正确的做法是:

preserve bug
migrate
fix bug intentionally afterward

这听着让人不舒服。但迁移中无意间改变行为,风险往往更大。

重构是准备,不是迁移本身

迁移前的重构很容易变成一场没有尽头的架构改造。

你一开始说:

我们需要把数据库隔离出来。

接着:

应该重新设计领域模型。

然后:

也许该引入事件机制。

再然后:

既然做到这一步了,这块干脆拆成微服务吧。

几个月过去,什么都没迁完。重构本身变成了项目目标。

这也是一种失败模式。目标必须始终具体。

改造前:

ProcessOrder
├── MySQL
├── pricing rules
├── Stripe
├── Kafka
├── email
└── framework internals

改造后:

ProcessOrder
├── OrderRepository
├── pricing rules
├── PaymentGateway
├── OrderEvents
└── EmailSender

做到这一步就够了。

现在你有了选择空间。

你可以迁移:

MySQL → PostgreSQL

而不需要重新设计计价逻辑。

你可以替换:

Stripe adapter

而不改变订单编排逻辑。

你可以把:

ProcessOrder

迁移到另一个 runtime,同时保持其契约不变。

重构创造了选择空间,这就是它的价值所在。

总结

当多种类型的变更同时发生时,遗留系统的迁移风险会急剧上升。

你同时改动了:

behavior
architecture
infrastructure
runtime
data
deployment

然后还要去排查到底是哪项变更导致了故障。

更稳妥的做法是在迁移开始之前先消除这种不确定性。

先理解这项能力,再刻画它的行为,然后在不改变其功能的前提下调整内部结构。

围绕依赖划定边界,把业务决策和基础设施分开,将外部系统本地化,让副作用保持可观测,每次结构变更后跑行为测试,等能力变得可移动时停止重构。

流程就变成了:

Understand
↓
Characterize
↓
Refactor
↓
Migrate

AI 可以大幅加速重构阶段。

它能识别依赖、提取接口、把调用挪到 adapter 后面、对比不同实现、审查大段 diff。

但重构更快并不意味着不需要架构判断了,反而让它更重要。

因为目标不是把遗留系统改得最干净,而是搭建刚好够用的结构,让它能被安全地迁移

当你改基础设施不再影响行为时,迁移就不再像是一次重写。

而变成一系列可控的变更。

原始来源: freeCodeCamp

评论 (0)