MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

TypeScript渐进式改造遗留系统方案

2021-01-226.5k 阅读

一、背景与动机

在软件开发的漫长历程中,遗留系统是一个常见且棘手的问题。许多早期开发的系统,随着业务的发展和技术的进步,逐渐暴露出维护成本高、扩展性差等问题。这些系统往往基于传统的编程语言,如 JavaScript ,没有类型系统的支持,使得代码在大规模开发和维护过程中容易出现难以排查的错误。

TypeScript 作为 JavaScript 的超集,为其添加了静态类型系统,提供了诸如类型检查、代码智能提示等强大功能,能显著提升代码的可维护性和可靠性。对遗留系统进行渐进式的 TypeScript 改造,既能充分利用 TypeScript 的优势,又能避免一次性全面重写带来的高风险和高成本。

二、改造前的准备工作

  1. 评估系统复杂度 在开始改造之前,需要对遗留系统的规模和复杂度进行全面评估。这包括代码行数、模块数量、依赖关系以及业务逻辑的复杂程度。例如,可以使用工具如 cloc 来统计代码行数,通过分析 package.json 文件以及手动梳理代码中的 importrequire 语句来确定依赖关系。对于一个拥有数万行代码、数十个模块以及复杂业务逻辑的遗留系统,改造工作显然比小型简单系统更为艰巨,需要更为细致的规划。
  2. 确定改造范围 根据评估结果,确定优先改造的部分。通常可以从核心业务模块、频繁修改的模块或者新功能开发所依赖的模块入手。例如,在一个电商遗留系统中,订单处理模块是核心业务模块,涉及到库存管理、支付流程等关键业务逻辑,并且经常需要根据业务需求进行修改,那么可以将其作为首批改造对象。
  3. 环境搭建 为了顺利进行 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 等有助于处理不同模块规范之间的兼容性。

三、渐进式改造策略

  1. 文件逐一转换 从确定的改造范围中选择一个文件开始。例如,有一个 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 中,模块导入通常使用 requireimport 语句。在 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 中可以通过 includeexclude 选项来指定哪些文件应用严格模式。假设我们先对 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 注释,这样就对该文件启用了严格模式。随着改造的推进,逐步扩大启用严格模式的文件范围,最终可以将整个项目都置于严格模式之下。

四、处理常见问题

  1. 第三方库的类型声明 遗留系统中往往依赖众多的第三方库,在转换为 TypeScript 时,需要确保这些库有相应的类型声明。对于一些流行的库,社区通常已经提供了类型声明,可以通过 @types 组织来安装。例如,对于 lodash 库,可以通过以下命令安装类型声明:
npm install @types/lodash --save - dev

安装后,在代码中导入 lodash 时就能获得类型提示和检查。假设在 userService.ts 中使用 lodashfind 方法来查找用户:

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;
  1. 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);
  1. 类型兼容性问题 在添加类型的过程中,可能会遇到类型兼容性问题。例如,将一个函数的参数类型从宽泛类型细化为具体类型时,可能会导致调用该函数的地方出现类型错误。 假设原来有一个 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 接口定义。

五、测试与验证

  1. 单元测试 在改造过程中,为确保代码功能不受影响,需要编写单元测试。对于 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();
    });
});

上述测试代码使用 Jestdescribeit 块来定义测试用例,通过 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 接口,并验证响应状态码和响应体是否符合预期。通过单元测试和集成测试,可以有效验证改造后的代码在功能和集成方面的正确性。

六、持续集成与部署

  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.jsonpackage - lock.json 文件并安装依赖,然后复制整个项目代码,编译 TypeScript 代码,最后启动编译后的 JavaScript 应用。将这个 Docker 镜像推送到镜像仓库,然后在 Kubernetes 等容器编排平台上进行部署,就能确保改造后的系统正确运行在生产环境中。

通过以上从准备工作、改造策略、问题处理、测试验证到持续集成与部署的一系列步骤,可以较为稳健地对遗留系统进行渐进式的 TypeScript 改造,提升系统的可维护性和可靠性,为后续的业务发展提供更坚实的技术基础。