Node 迁移 TypeScript 记录

时间:2023-03-1 作者:剧中人

这篇文章是记录 2023 年小剧客栈服务端代码迁移的笔记。

Github: https://github.com/bh-lay/blog/tree/master/backEnd

一、回顾下最早期的版本

熟悉小剧客栈的同学可能知道,小剧客栈开设于 2012 年 3 月,至今已经是第十一个年头了。

但可能很少有人了解,小剧客栈最早版本的发布异常简陋。

域名信息

因为刚入行不久,对前端略知一点儿,但是对服务器、Services 以及 Linux 完全一窍不通。所以最早版本的博客用了最 Low 的方式实现了博客的发布上线。

租用了一台 Windows 服务器,远程桌面的方式连接服务器。用了一款已经记不得叫什么的可视化软件搭建 NGINX、PHP、MySQL 运行环境。采用了当时很流行的 PHP 内容框架:帝国CMS。

小剧客栈以这样一种很粗糙的方式,陪伴了小剧工作的第一个年头。

同样这一年也是小剧各方面技能飞速增长的一年,这种粗糙也在以各种方式限制着小剧想象力的发挥。经历过这个阶段的同学应该有印象,NodeJS 在这段时间飞速发展,前端由此衍生出来了无限可能。

基于这种粗糙的限制,以及 NodeJS 的魅惑,小剧客栈在 2013 年 6 月迎来了全新的改版。NodeJS + MongoDB 的服务端架构让小剧在前端个人博客这个小圈子里小小的火了一把。

新博客 新心情 这篇文章很简短地记录了小剧改版后的心情。

二、为什么这次要做迁移

2.1、小剧客栈服务端现状

可能很多小伙伴会比较奇怪,为什么小剧客栈没有采用 Express、Koa 等流行的 NodeJS 服务端框架进行开发。而是使用一堆零散的工具、方法拼凑出一个极其简陋的服务端实现。

其实把时间倒退回 2013 年 6 月,一切就会变的合理起来。同期 Express 虽然已经发布了 3.0.0 版本,但在 NodeJS 社区中的认可度并没有那么高。Koa 在小剧客栈 NodeJS 版本正式发布的一个月后,才从 Express 中剥离出来并发布。

学习、实践、记录是小剧客栈建立至今的主旋律。如果能够由自己编码实现更多的细节,就可以更深入的了解服务端开发的底层原理,这是小剧所期望的。因此 2013- 2015 小剧大量的业余时间都投入在了个人博客的开发上。

其中最重要的就是服务端开发。实现了服务端 Route、Controller、View、Component、Session、Cache、Mongo 数据库管理以及静态资源管理等模块。

回看那段时间的代码,服务端在经历了完整的功能堆叠,和核心逻辑与业务代码分离后。迭代到了 2014 年之后几乎就没有大的功能性改动了。

目前小剧客栈服务端依赖较少,主要逻辑在仓库代码中,简单列一下代码结构。

package.json 部分

"dotenv": "^6.2.0",
"formidable": "~1.2.1",
"juicer": "~0.6.5-stable",
"path-to-regexp": "^3.0.0",
"jssha": "^2.0.1",
"mongodb": "^3.1.13",
"node-isbot": "0.0.8",
"nodemailer": "~6.6.1",
"showdown": "^1.9.1"
"cron": "1.0.9",
"request": "^2.81.0"

/core/ 目录下的服务端核心逻辑

2.2、现阶段存在的问题

如果本着能跑就行的原则,小剧客栈的服务端已经满足了最基本的要求,毕竟已经稳定运行了近十年。

若考虑继续维护迭代,有以下痛点亟待解决:

  1. 逻辑组织基于 NodeJS 经典的回调实现,代码维护较为混乱。
  2. Controller、Views、Cache 等模块之间任务处理较为松散,无法统一进行错误、超时处理。
  3. 各种服务端模块由自己实现,没有写以后也不打算写正式的文档,导致方法调用需要大量翻阅代码参考,且容易出错。
  4. Github dependabot 安全检查,局部升级 npm 包,导致大量依赖不匹配,无法稳定运行

2.3、打算如何处理

毕竟是七八年前的代码,这些问题在日常工作中积累了很多方法去处理。个人项目要求不多,逻辑简单、代码稳健、好维护就行。因此决定将原代码做以下处理:

2.3.1、基于 Promise 重新组织调用逻辑

这几乎是近些年来 JS 处理异步逻辑的标准操作了,在消除回调地狱方面有着天然的优势。

除此之外将复杂逻辑基于 Promise 链串联起来,在对 Controller 进行错误捕捉(HTTP Code 500)、超时处理等方面实现更直观

2.3.2、使用 TypeScript 重构代码

TypeScript 并非灵丹妙药,很多前端项目使用 TypeScript 后对稳定性的提升和投入不一定匹配。然而在 NodeJS 后端逻辑上,TypeScript 则可以大展身手。

其一可以通过 type 定义,约定很多相似的数据类型,减少学习成本,如 userConfigRoute、configRoute、matchedRoute。

其二是借助于 VSCode 的语法提醒和错误提示,在代码编写阶段就可以轻松获取参数数量及类型,真正实现代码即文档。

三、代码迁移过程

代码迁移 80% 都是重复的体力活,这里挑一些重点的环节进行介绍。

3.1、TypeScript 本地开发准备

这一步出乎意料的简单,记得很久以前想尝试 nodeJS TS 开发,需要配置大量周边环境,而且还需要预编译。现在只需要三个工具即可完成开发。

3.2、项目代码 TypeScript 改造

这里是个体力活,需要大量修改代码才能完成,并且由于 TypeScript 的类型检查,导致很容易因为迁移过程导致项目运行不起来。

任何 JS 项目迁移 TS 都会经历这个过程,并且是整个迁移任务耗时最久工作量最大的过程。

这个过程有很多种实践方案,比如兼容 JS 代码并逐步迁移至 TS。或者一次性更改全部文件为 TS 版本,关闭类型检查并逐步放开类型检查,等等。

这里说下我的处理方式,因为迁移 TS 和 Promise 改造同步进行,涉及大量的代码逻辑调整,因此之前积累的迁移方案都不适用,或者说耗时较久不愿意接受。

小剧最终采用的是由入口文件开始迁移,一开始注释掉全部的调用逻辑。顺着依赖调用逻辑迁移至 TS。

这样的好处有两个,一是 TS 检查从始至终是严格的,不兼容 JS 的,迁移完成后无需回溯代码去补全健壮性。二是项目迁移的任意阶段代码均可以稳定运行,只是过程中存在功能缺失。

之所以说这个工作是体力活,是因为整个过程像是把一棵树的所有主干、树枝、树杈、树叶全部打散,再从根部一节节嫁接回来。

3.3、NPM Packge TypeScript 支持

这一步耗时不久,但是却很困扰新手朋友。从经验上来说有三种处理方案

3.3.1、升级依赖包

大多数模块的贡献者在经历过数个版本迭代后,都会不可避免的面对 TypeScript 兼容的问题。因此可以尝试查看新版本 NPM 包是否已经提供了 TS 支持。

此次迁移 path-to-regexp、showdown 等类库都是通过升级依赖包完成的 TS 支持。

3.3.2、安装 @types/[packageName]

一些流行的类库可能已经稳定运行了数年之久,无需新的功能迭代,或者内部有大量的奇技淫巧不适合用 TypeScript 重构,因此作者或者其他贡献者会编写 @types/[packageName] 描述文件,安装对应的依赖包也可以完成 TS 的兼容。

迁移中 node、request、formidable、cron 等依赖都使用了这个方法。

3.3.3、编写 .d.ts 描述文件

一般情况下前两个方法足以应对项目里的绝大多数 NPM 依赖包。如果不幸前两种方式均无效,那大概率是这个依赖已经处于无人维护的状态,你需要换一个包。

开个玩笑,事实上大量面向小众领域,或者功能稳定但面世较早的的包都不支持 TS。因此你需要自行编写 .d.ts 描述文件。

其实不需要你把依赖内部实现全部编写一遍,你只需要关注你使用的属性、方法、返回值进行定义即可。

当然,如果你能把细节描述的足够完整,可以参考 3.3.2 发布到 npm 或者公司的私服上。

如果你是 TypeScript 新手,对编写描述文件没有信心,还有个更方便的方法。

小剧找到了一个牛逼的工具,可以替我们生成 .d.ts 文件。少量包会生成失败,但绝大多数依赖包都能顺利生成描述文件,只不过细节很粗糙,需要在生成的基础上修改。

dts-gen:https://github.com/Microsoft/dts-gen

此次迁移 juicer 因为历史比较久远没有对应的描述文件支持,node-isbot 因为比较冷门,也不支持 TS,都是借助于编写.d.ts 描述文件解决。

3.4、TypeScript Debugger 支持

因为早期开发博客后端代码的时候,NodeJS debugger 并不容易实现。为了提升开发便利性,此次迁移刚好把 Debugger 也做了支持。

借助于 VSCode 对 Nodejs 语言 和 TypeScript 的支持,相较于其他 IDE Debugger 更容易实现。

这里接不介绍实现方式了,你可以参考下面的链接配置。

Debugging TypeScript:

https://code.visualstudio.com/docs/typescript/typescript-debugging

3.5、异步逻辑 Promise 化处理

核心逻辑 Promise 化其实和 TypeScript 迁移很像,只是 Promise 迁或不迁并不影响迁移进度。

针对 NPM 依赖包,绝大多数升级版本即可以解决。或者手动借助 new Promise 方法也可以封装。比如 Mongo 连接库 mongodb 升级到最新版本后已支持 Promise。

常用的 NodeJS 内置的 FS 文件操作库,早期有第三方封装的 Promisify 版本,这次迁移发现 FS 已经默认支持了 Promise 返回值,为了迁移方便小剧把 FS 的引入全部改为了下面的方式。

import { promises as fs } from 'fs'

业务逻辑的 Promise 化改造同样是体力活,尤其是它还和 TypeScript 改造同步进行。

这里就不展开介绍具体的过程了,后面会有改造前后的代码对比。

3.6、TypeScript 别名支持

随着项目目录越来越深,结构分化越来越明显,模块路径别名可以很大程度上简化代码复用时的依赖调整过程。

例如高频使用的核心逻辑类型定义文件,如若使用相对路径则会非常麻烦。

import { routeItemMatched, Connect, App } from '@/core/types'

TypeScript 的别名支持其实还算简单,只需要在 tsconfig 文件中的 paths 字段做配置即可完成。

只是在 nodeJS 项目中,借助于 ts-node 运行时会忽略别名设置。需要借助 tsconfig-paths/register 来进行参数补全。

3.7、NodeJS 错误处理

大家都知道 NodeJS 服务器是单进程,一旦服务内部发生错误会影响服务的稳定性,甚至整个服务挂掉。

早期在实现服务端代码时,尽可能的依靠代码的严谨性来保证基础的稳定性。线上运行则借助 pm2 工具进行兜底的重启工作。

虽然保障了服务的稳定可用,但却有几个问题没有解决。

1、线上发生故障不清楚,无法通过日志发现、解决问题。

2、Controller 逻辑错误,无法给用户及时反馈。

针对这两种场景,结合核心逻辑 Promise 化改造的完成,实施起来就很容易了。

因为这两类错误都在预期之外,因此错误位置的定位很重要。这里使用了统一的错误处理方法,打印出错误本身的基础上,借助于 trace 打印出调用栈,更有利于排查问题。

其次是 Controller 逻辑 Promise 化之后,可以很容易的做错误扑捉和超时处理。(这里仅做了错误捕捉和响应的延续,超时逻辑尚未处理。)

另外增加了进程级别的错误扑捉逻辑,保障服务可用性的最后一道防线。

//错误处理
function errorHandler(error: Error){
    console.log(error)
    console.trace('Crazy Error')
}

// controller 错误捕捉
const usedRoute = matchedRoutes\[matchedRoutes.length - 1\]
newConnect.route = usedRoute
usedRoute.controller(usedRoute, newConnect, this).catch(async (error) => {
  console.error('\\ncontroller failed\\n|-------------->\\n$', usedRoute, '\\n', error, '<--------------|')
  const html = await newConnect.views('system/mongoFail',{error})
  newConnect.writeHTML(500, html)
  errorHandler(error)
})

// 全局错误捕捉
process.on('uncaughtException', (error: Error) => {
  errorHandler(error)
})
process.on('unhandledRejection', (error: Error) => {
  errorHandler(error)
})

四、迁移后的优势

4.1、语法提醒,代码即文档

在之前的版本,仅仅 Javascript 原生属性方法、NodeJS 内置等常用的语法才有提示功能,偶尔修改代码的时候学习成本较高。

经过严格的 TypeScript 迁移后,代码拼写简单了很多,无需反复翻阅前期的代码做参考。

语法提醒

4.2、Promise 简化业务逻辑

因为之前版本的异步逻辑依靠 callback 层层传递实现,无法保证 callback 一定被调用或者不会被多次重复调用。

而且在异步逻辑中,数据本身也需要被反复传递,很容易发生丢失或者类型错误。

这里以首页的 Controller 为例,看下改造前后的对比。

4.2.1、首页 Controller 改造前

首页 Controller 改造前

首页的 Controller 很简单,借助于 app.cache 工具检查是否有缓存的 html。

整个过程在 Controller 这一侧看来,有四个回调函数。首页因为没有复杂的逻辑,看起来还好,换做博文列表或者其他 API Controller 回调简直是灾难。

这里有两点比较容易让人迷惑:

1、app.cache 第二个回调执行完,再调用回调内参数的回调,会执行 app.cache 的第一个回调。直观上看不出来,理解、学习成本较高。

2、回调众多,任何一个环节出错都会导致外侧不清楚 Controller 任务是否完成。如第十五行捕捉到了错误,也给用户做了响应,但外部并不知晓。

4.2.2、首页 Controller 改造后

首页 Controller 改造后

改造后的 Controller 其实只有两个方法,

1、通过 app.cache 获取首页 html

2、将html 返回给用户。

只是【1】中若没有找到缓存,会执行回调中定义的生成缓存方法。可能你会发现,保存缓存方法去哪儿了?

这其实也是 Promise 异步管理的优势之一,在省掉回调的同时也接管了数据的传递。在执行生成 html 的下一个环节自然能拿到对应的内容,因此 app.cache 可以直接保存缓存,业务无需执行存储缓存逻辑。

另外一个优势,整个Controller 任务是以 Promise 链存在的,在【3.7、NodeJS 错误处理】中进行错误捕捉才有了可能。这一点在之前的架构上实现,几乎难于登天。


因为只能在带娃的空闲时间处理,断断续续经历了近一个月的迁移才勉强完成。

期间一度忘了 mongo 数据库如何安装、启动、配置用户、备份恢复数据。好在经过这次迁移,小剧又重新“学会了”小剧客栈个人博客的安装部署,算是为十月份服务器迁移做准备工作。