TypeScript渐进式改造遗留系统方案
一、背景与动机
在软件开发的漫长历程中,遗留系统是一个常见且棘手的问题。许多早期开发的系统,随着业务的发展和技术的进步,逐渐暴露出维护成本高、扩展性差等问题。这些系统往往基于传统的编程语言,如 JavaScript ,没有类型系统的支持,使得代码在大规模开发和维护过程中容易出现难以排查的错误。
TypeScript 作为 JavaScript 的超集,为其添加了静态类型系统,提供了诸如类型检查、代码智能提示等强大功能,能显著提升代码的可维护性和可靠性。对遗留系统进行渐进式的 TypeScript 改造,既能充分利用 TypeScript 的优势,又能避免一次性全面重写带来的高风险和高成本。
二、改造前的准备工作
- 评估系统复杂度
在开始改造之前,需要对遗留系统的规模和复杂度进行全面评估。这包括代码行数、模块数量、依赖关系以及业务逻辑的复杂程度。例如,可以使用工具如
cloc
来统计代码行数,通过分析package.json
文件以及手动梳理代码中的import
或require
语句来确定依赖关系。对于一个拥有数万行代码、数十个模块以及复杂业务逻辑的遗留系统,改造工作显然比小型简单系统更为艰巨,需要更为细致的规划。 - 确定改造范围 根据评估结果,确定优先改造的部分。通常可以从核心业务模块、频繁修改的模块或者新功能开发所依赖的模块入手。例如,在一个电商遗留系统中,订单处理模块是核心业务模块,涉及到库存管理、支付流程等关键业务逻辑,并且经常需要根据业务需求进行修改,那么可以将其作为首批改造对象。
- 环境搭建
为了顺利进行 TypeScript 改造,需要搭建相应的开发环境。首先,确保项目中安装了 Node.js 。然后,通过
npm
安装 TypeScript 编译器typescript
以及相关的开发工具,如ts - node
用于在开发过程中直接运行 TypeScript 代码。
npm install typescript ts - node --save - dev
同时,在项目根目录下创建 tsconfig.json
文件,用于配置 TypeScript 的编译选项。以下是一个基本的 tsconfig.json
配置示例:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
上述配置中,target
指定了编译后的 JavaScript 版本为 ES6 ,module
选择 commonjs
模块规范,outDir
表示编译后的文件输出目录,rootDir
是 TypeScript 源文件的根目录,strict
开启严格类型检查模式,其他选项如 esModuleInterop
等有助于处理不同模块规范之间的兼容性。
三、渐进式改造策略
- 文件逐一转换
从确定的改造范围中选择一个文件开始。例如,有一个
userService.js
文件,负责处理用户相关的业务逻辑。首先,将其重命名为userService.ts
。此时,由于文件扩展名的改变,TypeScript 编译器会尝试对其进行类型检查,通常会发现大量的类型错误。 假设userService.js
中有如下代码:
function getUserById(id) {
// 这里模拟从数据库获取用户数据
const users = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
return users.find(user => user.id === id);
}
在重命名为 userService.ts
后,TypeScript 编译器会提示 id
参数没有明确的类型。我们可以逐步添加类型信息:
interface User {
id: number;
name: string;
}
function getUserById(id: number): User | undefined {
const users: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
return users.find(user => user.id === id);
}
在上述代码中,我们首先定义了 User
接口,明确了用户对象的结构。然后为 getUserById
函数的参数 id
添加了 number
类型,返回值类型为 User | undefined
,表示可能返回一个用户对象或者 undefined
。同时,对 users
数组也明确了类型为 User[]
。
2. 模块依赖处理
随着文件逐一转换,会涉及到模块之间的依赖关系。在 JavaScript 中,模块导入通常使用 require
或 import
语句。在 TypeScript 中,对于 import
语句,需要确保导入的模块有正确的类型声明。
例如,假设 userService.ts
依赖于一个 database.js
模块来获取用户数据,在 userService.ts
中导入方式如下:
import { getUsersFromDatabase } from './database.js';
interface User {
id: number;
name: string;
}
function getUserById(id: number): User | undefined {
const users = getUsersFromDatabase();
return users.find(user => user.id === id);
}
此时,如果 database.js
没有相应的类型声明,TypeScript 会报错。我们可以为 database.js
创建一个类型声明文件 database.d.ts
。假设 database.js
中的 getUsersFromDatabase
函数返回一个用户对象数组,database.d.ts
可以如下定义:
interface User {
id: number;
name: string;
}
export function getUsersFromDatabase(): User[];
这样,userService.ts
就能正确识别 getUsersFromDatabase
函数的返回类型。
3. 逐步启用严格模式
TypeScript 的严格模式(strict
选项在 tsconfig.json
中设置为 true
)能提供更严格的类型检查,有助于发现更多潜在的错误。但在改造初期,由于遗留代码可能存在大量不符合严格模式要求的地方,直接启用严格模式可能会导致过多的错误,使得改造工作难以推进。
可以先从部分文件开始启用严格模式,例如,在 tsconfig.json
中可以通过 include
和 exclude
选项来指定哪些文件应用严格模式。假设我们先对 userService.ts
启用严格模式,可以如下配置:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/userService.ts"
],
"exclude": [
"src/**/*.test.ts",
"node_modules"
]
}
然后在 userService.ts
文件开头添加 // @ts - strict
注释,这样就对该文件启用了严格模式。随着改造的推进,逐步扩大启用严格模式的文件范围,最终可以将整个项目都置于严格模式之下。
四、处理常见问题
- 第三方库的类型声明
遗留系统中往往依赖众多的第三方库,在转换为 TypeScript 时,需要确保这些库有相应的类型声明。对于一些流行的库,社区通常已经提供了类型声明,可以通过
@types
组织来安装。例如,对于lodash
库,可以通过以下命令安装类型声明:
npm install @types/lodash --save - dev
安装后,在代码中导入 lodash
时就能获得类型提示和检查。假设在 userService.ts
中使用 lodash
的 find
方法来查找用户:
import { find } from 'lodash';
interface User {
id: number;
name: string;
}
function getUserById(id: number): User | undefined {
const users: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
];
return find(users, { id });
}
如果某个第三方库没有社区提供的类型声明,可以手动创建类型声明文件。例如,对于一个自定义的 my - utils.js
库,假设其中有一个 addNumbers
函数用于两个数字相加,在 my - utils.d.ts
中可以这样声明:
export function addNumbers(a: number, b: number): number;
- JavaScript 与 TypeScript 混合编程
在渐进式改造过程中,不可避免地会出现 JavaScript 和 TypeScript 文件共存的情况。为了确保它们之间能够正确交互,需要遵循一些规则。
在 TypeScript 文件中导入 JavaScript 文件时,TypeScript 编译器会自动尝试推断类型。但如果推断不准确,可以手动提供类型声明。例如,有一个
helpers.js
文件,其中有一个formatDate
函数用于格式化日期,在userService.ts
中导入使用:
import { formatDate } from './helpers.js';
interface User {
id: number;
name: string;
createdAt: Date;
}
function getUserById(id: number): User | undefined {
const users: User[] = [
{ id: 1, name: 'John', createdAt: new Date() },
{ id: 2, name: 'Jane', createdAt: new Date() }
];
const user = users.find(u => u.id === id);
if (user) {
console.log(`User ${user.name} created at ${formatDate(user.createdAt)}`);
}
return user;
}
如果 formatDate
函数的返回类型推断不准确,可以在 helpers.d.ts
中手动声明:
export function formatDate(date: Date): string;
反之,在 JavaScript 文件中导入 TypeScript 文件时,需要确保 TypeScript 文件编译后的 JavaScript 代码能被正确引用。例如,在 main.js
中导入 userService.ts
编译后的 userService.js
:
const { getUserById } = require('./userService.js');
const user = getUserById(1);
console.log(user);
- 类型兼容性问题
在添加类型的过程中,可能会遇到类型兼容性问题。例如,将一个函数的参数类型从宽泛类型细化为具体类型时,可能会导致调用该函数的地方出现类型错误。
假设原来有一个
processUser
函数,其参数类型为any
:
function processUser(user) {
console.log(`Processing user ${user.name}`);
}
在改造过程中,将其参数类型细化为 User
接口类型:
interface User {
id: number;
name: string;
}
function processUser(user: User) {
console.log(`Processing user ${user.name}`);
}
此时,如果有其他地方调用 processUser
函数并传入一个不符合 User
接口的对象,就会出现类型错误。需要逐一检查调用点,确保传入的对象符合 User
接口定义。
五、测试与验证
- 单元测试
在改造过程中,为确保代码功能不受影响,需要编写单元测试。对于 TypeScript 代码,可以使用流行的测试框架如
Jest
。首先,安装Jest
及其相关的 TypeScript 支持包:
npm install jest @types/jest ts - jest --save - dev
然后,在 tsconfig.json
中配置 jest
相关的编译选项:
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jest": {
"preset": "ts - jest",
"testEnvironment": "node"
}
}
}
以 userService.ts
为例,编写单元测试如下:
import { getUserById } from './userService';
describe('getUserById', () => {
it('should return the correct user by id', () => {
const user = getUserById(1);
expect(user?.id).toBe(1);
expect(user?.name).toBe('John');
});
it('should return undefined if user is not found', () => {
const user = getUserById(3);
expect(user).toBeUndefined();
});
});
上述测试代码使用 Jest
的 describe
和 it
块来定义测试用例,通过 expect
断言来验证 getUserById
函数的行为是否符合预期。
2. 集成测试
除了单元测试,还需要进行集成测试,以确保改造后的模块与其他模块之间能够正确协作。可以使用工具如 Supertest
来进行 API 集成测试,假设遗留系统中有一个基于 Express
的 API 服务,在改造过程中对用户相关的 API 进行了 TypeScript 改造。
首先,安装 Supertest
:
npm install supertest --save - dev
然后编写集成测试代码:
import request from'supertest';
import app from '../app'; // 假设 app 是 Express 应用实例
describe('User API', () => {
it('should return a user by id', async () => {
const response = await request(app).get('/users/1');
expect(response.status).toBe(200);
expect(response.body.id).toBe(1);
expect(response.body.name).toBe('John');
});
});
上述代码使用 Supertest
发送 HTTP 请求到 Express 应用的 /users/1
接口,并验证响应状态码和响应体是否符合预期。通过单元测试和集成测试,可以有效验证改造后的代码在功能和集成方面的正确性。
六、持续集成与部署
- 持续集成(CI)
为了确保每次代码变更都能正确地进行 TypeScript 编译和测试,需要设置持续集成。常见的 CI 平台有 GitHub Actions、CircleCI、Travis CI 等。以 GitHub Actions 为例,在项目的
.github/workflows
目录下创建一个build - and - test.yml
文件:
name: Build and Test
on:
push:
branches:
- main
jobs:
build:
runs - on: ubuntu - latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup - node@v2
with:
node - version: '14'
- name: Install dependencies
run: npm install
- name: Compile TypeScript
run: npx tsc
- name: Run tests
run: npm test
上述配置表示当 main
分支有代码推送时,在最新的 Ubuntu 环境中执行一系列操作。首先检出代码,然后设置 Node.js 版本为 14 ,安装项目依赖,编译 TypeScript 代码,最后运行测试。如果编译或测试失败,GitHub Actions 会在界面上显示详细的错误信息,方便开发者定位问题。
2. 部署
在持续集成通过后,需要进行部署。部署流程与遗留系统原有的部署方式相关。如果是基于容器化的部署,例如使用 Docker 和 Kubernetes ,在改造过程中需要确保 TypeScript 编译后的 JavaScript 文件能够正确打包到容器镜像中。
假设使用 Docker ,可以在项目根目录下创建一个 Dockerfile
:
FROM node:14 - alpine
WORKDIR /app
COPY package*.json./
RUN npm install
COPY. /app
RUN npx tsc
CMD ["node", "dist/main.js"]
上述 Dockerfile
基于 node:14 - alpine
镜像,在容器内创建 /app
工作目录,复制 package.json
和 package - lock.json
文件并安装依赖,然后复制整个项目代码,编译 TypeScript 代码,最后启动编译后的 JavaScript 应用。将这个 Docker 镜像推送到镜像仓库,然后在 Kubernetes 等容器编排平台上进行部署,就能确保改造后的系统正确运行在生产环境中。
通过以上从准备工作、改造策略、问题处理、测试验证到持续集成与部署的一系列步骤,可以较为稳健地对遗留系统进行渐进式的 TypeScript 改造,提升系统的可维护性和可靠性,为后续的业务发展提供更坚实的技术基础。