小钱袋 Node 应用开发经验总结
小钱袋是蚂蚁金服体验技术部基于余额宝子卡开发的一个产品,家长通过在开通一个余额宝子卡,可以给孩子创建一个单独的存钱账户,可以支付宝中搜索小钱袋找到应用。
特色
小钱袋就是支付宝上一个非常简单小应用,但是因为来自于体验技术部,所以小钱袋应用整体技术栈都是 js 的,和大部分面向用户端的 node 应用不一样的点在于,小钱袋不仅仅是 BFF 应用,我们有自己完整的数据层。
很多来自前端团队的内部产品也都会有自己的数据层,比如 凤蝶、basement 应用,和这些应用相比小钱袋最大的特点是,小钱袋用户是支付宝 C 端用户。依托于支付宝 App ,我们可以运营的用户是亿级别的,产品要做到千万级用户规模,并不算很难。
总结来说,技术上有两个特点
小钱袋是有数据层
面向 C 端用户,用户规模 1000w+
下面我们主要来讲一下,这样一个应用如何基于 NodeJS 开发。
TypeScript
必要性
2016 年开始做第一个产品时,当时我做的第一个决定就是后端使用 TypeScript 。在这之前,我在做凤蝶,凤蝶应用发展到第三年,代码越来越难以维护,中间经过两次大的架构升级,但历史的包袱太重了,后来云凤蝶彻底重写了。
凤蝶最初还是 chair + java 结构部署的,我们都有参与 java 应用的开发,相对说 java 应用要好维护得多。我觉得强类型更适合这种多人维护的复杂应用。
解决代码腐化的方案
单元测试,测试保证修改后代码还能正确运行
重构,再好的架构设计,也都需要不停进化的
凤蝶最初试遇到了很严重的稳定性问题,后面我们把测试用例补全,但是代码还是不可逆的变得难以维护。单元测试可以部分提高稳定性,但是对于可维护性还是不够。
TypeScript 的主要优势
增加代码和数据之间的强关联,并且这种关联是具有约束性的
函数有准确的接口和入参
强大的类型体系
函数接口 js 中也可以通过繁琐的注释来声明,但是注释的表达性比起 TypeScript 语言级支持来说,只能算是一个小宝宝,并且 TypeScript 的类型体系还在不断进化。比如 ts 中我们可以声明一个这样描述函数:
// example 1
function validate<K extends string>
(payload: unknow, fields: K[]): { [key in K]: string; } {
// function body
}
// const data: { a: string; }
const data = validate({ a: 'a', b: 'b' }, ['a']);
// example 2
type QueryFilter<T> = Partial<{ [P in keyof T]: T[P] | Array<T[P]>}>;
function query<T>(filter: QueryFilter<T>): Promise<T> {
return this.mysql.get('table', filter);
}第一个例子,data 可以动态得到类型 { a: string} 。第二个例子,可能不大好看懂,实际上就是 egg-mysql 底层 ali-rds 的 get 方法。我们可以看到官方文档接口描述是这样的,对应加上接口描述后。
这里类型是支持运算的,注释无论如何也是做不到的,这种类型描述有很多,可以极大提高函数透明度。上面函数我们不需要写注释,函数本身接口可以说明函数如何运行。
数据和代码之间关联性
此外,我们可以解决重构 js 代码最大难点,代码和数据之间是割裂的,我们有一个模型,这个模型在哪里使用,在哪里被修改是不可知的。
已有的数据模型中随意增加一个字段,或者给字段增加一种枚举类型,在 js 中是很艰难的,改动的时候缺少数据和代码之间关联性,风险不可知,这时候我们通常选择不断新增字段,老的字段继续保留,这样数据模型会越来越臃肿。
**TypeScript 增加的类型,让代码关联性更加明确。**比如我们的数据层基本结构如下
所有的方法都和泛型 T 自动关联,这样后面对数据层的所有操作都可以和 Data Object 接口关联上,数据和代码真正关联上,后面重构会非常方便。
有类型,就这一个理由就非常值得我们尝试了。虽然当时整体上基础设施还非常差,蚂蚁这边很少应用使用 ts 。而后面我们的应用经历过好几次大的技术升级改造,TypeScript 确实给予我们很多的帮助。
下面先说说我们使用过程中遇到的问题
发展过程
egg 使用 TypeScript 主要在于解决这几个问题。
异常定位
类型规范
最佳实践
异常定位
最开始,最担心的是异常定位问题,ts 运行是编译后的 js ,正常情况错误拿到的应该是编译后的 js 堆栈。如果异常定位是错误的,对于后续线上问题排查是非常麻烦的,这是基础能力。
这个问题,社区很早之前就有解决方案。V8 提供了一个自定义错误堆栈的接口 Stack Trace API,基于这个接口我们可以在异常抛出的时候错误定位到 ts 源码上,具体过程参考 Node.js 中 sourcemap 使用问题总结 。
类型规范
使用 TypeScript 最大的一个问题就是外部依赖需要类型声明,chair 当时并没有类型声明,当时几个内部应用比如凤蝶、basement 各自写了自己的类型描述文件。chair 开源后,外部也很多人问题类型的问题,chair 也就有了类型的需求。这个问题好解决,纯粹体力活,后来我发了一个 pr ,把第一版类型文件加上了。有了官方版本后,大家使用类型有了一个公共的约定。
最佳实践
前面几个点主要是使用的基础设施问题,这些基础功能完成了。后面大家就想能不能把最佳实践整理下,让更多人方便使用。
当时遇到主要几个问题
开发环境什么时候编译 ts ,如何调试
类型声明怎么写
第一个问题,一开始都是开启 watch 实时编译 ts ,后来 egg 的 loader 支持 ts 加载,也可以在单元测试的时候直接依赖 ts-node 运行。这个问题天猪改造了 egg 底层,终于完善了。
第二个问题,因为 egg 底层是自动加载,service 是自动注入到请求上下文的,最初的方案,我们需要写了一个 service 文件后,需要把这个 service 的类型手动 merge 到请求上下文中。这个后来 egg 官方推荐的是 egg-ts-helper ,自动生成这个类型描述文件。
import News from './News';
declare module 'egg' {
interface IService {
news: News;
}
}自动注入 vs 依赖注入
egg-ts-helper 是一种解决方案,但这种方案我感觉并不理想。这个描述文件手动还是自动,我觉得并不核心问题。核心问题在于 egg 底层的自动加载机制。egg 应用通过目录约定,自动把所有的服务都挂载到请求上下文。这也就是 egg 奉行的 约定优于配置 原则。
这种方式优点是使用简单,所有的服务可以随便用。但是,对于复杂应用来说,service 分层是必要的,所有的服务都注入到上下文对象,这样不利于服务分层。
当时我看到凤蝶重写的时候使用了依赖注入,从凤蝶最开始到后来的小钱袋,我们都慢慢遇到了需要对 service 进行分组、整理的需求,这时候自动注入反而不合适。我们可以通过目录来隔离,但那种还只是约定上的隔离。依赖注入的方式,让我们显式申明了依赖的服务,也可以解决类型需要额外手动 merge 的问题。
依赖注入的引入,实际上更多是一种心理上的负担更小。在写一个服务的时候,需要用到那个服务的时候,声明一下依赖,这样我们面对的就不是所有服务。这种方式在多人开发的情况,更不容易乱。
当一个维护者面对一个 service 文件时候,如果他可以一眼看到这个服务依赖,这样他只需要了解依赖那么几个服务。如果他需要面对整个应用所有的服务,这样可能更加容易无从下手。
所以,后来我们放弃了 egg 自动注入,采用了依赖注入的方式来处理依赖。并且,对 service 进行了分层。这里给 egg-di 打一个广告,使用 TypeScript 的开发者可以看看。
依赖注入使用的基本例子如下
import UserService from 'service/user/user';
import { inject } from 'egg-di';
export default class UserController extends BaseController {
@inject() readonly userService: UserService;
async createUser(payload) {
await this.userService.createUser(payload);
}
}分层架构设计
服务分层的基本原理
选择依赖注入,服务之间可见性是可控的,我们可以从思维上摆脱 chair 自动注入导致的服务不可隔离问题。同时可以突破 chair 应用目录约束,服务可以在 npm 包中,也可以在任意其他目录。
服务分层,需要做到分层之间满足以下规则
分层之间数据是单向流动
层级之间依赖关系需要简单、清晰,也就是所谓的强内聚、松耦合
单向数据流在前端也越来越流行,从 react 渲染到 flux 分层架构都是如此。单向数据流的优势在于这种数据结构流动是非常简单、清晰的,非常利于理解。下面的结构,大家基本上一看就能理解。
小钱袋的应用架构遵循着这么几个标准
标准化,每一层逻辑标准化,层级之间保持隔离
灵活
类型完备
这个标准,我们具体看在后面讲怎么实现的。这里小钱袋应用架构图
数据是从上往下,或者从下往上的单向流动。服务进行分组,同一层级依赖也是单向的,尽量减少相互交叉依赖。
下面依次看每个层具体如何设计的。
接入层
接入层是最标准化的一层,与其说是写代码,不如说是写配置。
import UserService from 'service/biz/user';
import { inject } from 'egg-di';
import BaseController, { LogType } from 'common/rpcBase';
export default class UserController extends BaseController {
@inject() readonly userService: UserService;
@bizLogger(LogType.CREATE_USER, { transaction: true })
async createUser(payload: unknow) {
const params = this.validate(payload, [ 'nick', 'gender', 'birthday' ]);
const data = await this.userService.createUser(params);
return { result: data, bizId: data.userId };
}
}函数最开头一个装饰器,两个入参,第一个配置了日志枚举,第二个定义了 MySQL事物控制开启。隐藏的还附带了一层逻辑,异常处理,通过这个装饰器装饰,会把函数包裹到一层 try catch 中。这个 try catch 中我们可以增加很多业务属性的逻辑的,包括
异常标准化
日志处理
事务控制
接入层 base 处理基本逻辑如下流程图
这几个事情实际上需要也最应该在接入层统一处理了,有了明确异常处理,我们可以记录这个请求的结果是成功还是失败。对于失败,也可以区分已知异常还是未知异常,对于未知异常可以进行业务报警。
有了统一的异常处理,在其他层中,可以更放心的把异常抛出来。在 service 层,我们可以遵循 所有 service 方法只返回成功结果,异常信息通过自定义异常抛出 的标准。遵循这样的原则,对于减少思维负担很有用的。比如我们写鉴权逻辑
/**
* 是否是小孩家庭的户主
*/
async checkIsMaster(userId: string, guardianId: string) {
// 是否是小孩的家长
const guardian = await this.checkIsGuardian(userId, guardianId);
// 是否是户主
if (!guardian.isMaster) {
throwCustomError(ERROR.E_NOT_MASTER);
}
return guardian;
}如果一个函数同时返回成功和失败两种数据,那么这对于使用者是很麻烦的事情,如果当前层无法处理的异常,需要把异常一层层手动抛出去。同时,这种结构在 ts 中是很难写类型描述的。后来我们慢慢全部改成了异常直接抛出的方式,这种方式更适合在应用内部之间处理异常。对外接口,我们可能需要把结果包装为成功和失败两个不同的状态。
如果后续需要处理异常,在这里可以开启事务,后面可以自动化处理事务提交和回滚逻辑。业务代码开发过程,就可以完全忽略事务存在了。
这里单独记录日志,可以准确区分系统异常和业务自定义异常。对于系统异常,这种属于非常严重的问题,我们可以加入到报警系统。另外,有了统一的日志,可以在 xflush 上做一个业务日志报表。
业务层 & 基础层
这两层,就是 chair 文件结构中的 service 。这里每个框就是一个文件夹,一开始我们就全部放在 service 目录里面,打平结构,可以说只有业务层。
分层的核心在于数据单向流动,服务之间尽量少的相互依赖。一开始小钱袋我们在服务层经常会出现循环依赖的情况,这样会导致整体结构混乱,服务之间还是交叉、混杂在一起。有了隔离,但是隔离做的还不够。
分层不只是目录结构更清晰,更重要的是内在逻辑结构的清晰,这样看代码的可以一眼理解系统逻辑,写代码的时候尽量有据可依,不需要太纠结新的代码放哪里,有了这样的结构,多人合作才不会混乱。如果新加代码放哪里这个问题是模糊的,很容易出现同一份代码多处实现,有多种使用方式的情况。这非常不利于后续的维护,碎片化的代码多了,代码就开始腐化了。
小钱袋一开始做的时候,是没有领域层概念。当我们慢慢把用户服务中公共部分抽离出来,我发现用户服务会被所有业务层依赖,但实际上依赖的方法并不多。这几个方法抽离出来,就可以构成 user 的领域服务了。这样业务层之间不再需要相互依赖,user 领域服务层下沉。这样一改结构清晰多了。大致变动如下
一开始是相互交叉依赖,这个图是已经被我简化过很多的依赖关系,实际依赖关系图要复杂得多
第一次改进,把 User 拆分成 BizUser 和 UserDomain ,依赖关系一下子清晰多了,我们可以进一步优化,把 Trade 拆分
Trade 拆分后,依赖会更加清晰,不过线还是很多,但都是单向流动,就不至于乱,我们也可以直接抽象成分层之间的依赖 Biz --> Domain
小钱袋这样规模的应用,领域层并不是必须的,我们参考了领域驱动设计的思想,但并不完全基于此实现。
业务层和领域层的区别没有那么明显,关键是是否会形成循环依赖。简单业务情况下,可以直接写业务层,更加简单灵活,比如上面 User --> Diary 的依赖 。当一个服务被多个服务依赖的时候,需要把业务层和领域层分开,否则会业务之间会耦合在一起,越来越难以维护。
数据层
数据层主要处理数据库相关逻辑,每个数据表对应一个 dao(Data Access Object)。数据层封装了两个 Base
BaseDAO 负责处理基础的日志、简单 crud 封装、事务控制、分表相关逻辑
CacheBaseDAO 缓存相关逻辑
对于开发者,我们大部分时候只需要写简单的 BizDAO ,基本结构如下
BaseDAO
主要负责处理逻辑
数据结构转换,保证输出的结构都是和标准 do(Data Object) 对象
日志、简单 crud 封装、事务控制、分表相关逻辑
mysql 是关系型数据库,所以只能表示二维结构,在 ts 对象中,会包含更多数据结构。我们的数据库要求数据表命名必须是蛇形(snake case)命名,在 ts 里面,我们用的都是驼峰命名,数据流入数据库之前,需要做一次 deconvert ,key 转换为蛇形,反之亦然。
基础的 create、update、read(query/queryAll)、delete(update) 方法,而且都和泛型 T 保证关系。这样 BizDAO 写的所有方法,基本上都可以有完备的类型。
类型完备分为两个流程,数据流出和数据流入,流出入口是在 BaseDAO ,从这里出去的数据,全部都是 do 对象,通过手动写接口描述文件。数据流入,来自 controller ,经过统一的 validate 方法处理,得到过滤后的合法的数据结构。
从 controller 过来的数据都是 string ,如果我们不小心处理,很容易当成 number 来用了,这种在 js 中有时候会给你意想不到的惊喜。
日志实现也很简单,所有的 sql 都经过 base ,我们只需要对 sql 查询方法进行一次代理。记录 sql 日志,我们可以做一个 sql 报表了,这本应该是 zdal 提供的逻辑,但是 chair 版本的 zdal 有点太简单了。
有了这个报表,可以对 sql 进行更细致的业务报警处理,比如小钱袋处理的
sql 查询超过 300ms 报警
对查询量最大的 sql 加上缓存层,提高性能优化
在分表业务的时候,可以定制一个分表切流大盘,观测分表流量是否正常
对于大流量业务,监控是强需求。没有监控,后续的优化无法做,做了也无法评估。
事务控制和接入层相呼应,接入层控制开关,开启事务逻辑后,在 base 层代理使用事务连接进行查询,两边都在 base 层中处理,对于业务开发来说,基本上不需要考虑事务。
踩过的坑
最后总结一下小钱袋应用遇到过的一些故障,服务端的问题相对来说比较少,大部分异常都和数据库相关。这里主要说下三个比较典型的问题
数据库性能
小钱袋最开始上线的时候没有做压测,业务量一直很小,问题并没有暴露。有一次运营在腰封推广了 1000w 余额宝活跃用户,然后服务器直接挂掉了。数据库服务器直接 cpu 慢了,导致所有的请求都开始堆积,业务基本上挂了半个小时。
问题原因就是没有命中索引,导致每次查询都需要扫描几十万行代码,耗时 100ms 左右。这在业务量小的情况,问题并没有暴露。后来我们加上了 MySQL 监控,并且在 MySQL 查询超过 20ms 的情况,就要开始排查问题了。
后来我们结果几次压测,MySQL 命中索引的情况,性能是非常好的。最高集群压到 1w qps ,数据库毫无压力,可以继续压,不过八台应用服务器已经有点扛不住了。小钱袋也完全达不到那么的业务量,就没有继续压测了。
业务唯一性保证
我的经验,唯一索引是唯一值得信赖的。业务逻辑总是可能会有漏洞,最终只能数据库兜底,来保证业务的唯一性。
小钱袋遇到一个故障是子卡重复问题,这个问题本质上是业务唯一性失效导致的。
业务上,每个小孩对应一个余额宝子卡,资金存在余额宝子卡中,我们有两个表,一个记录小孩,一个记录子卡信息。有一段时间,有一部分用户最多只能开一个子卡,当时我们在业务逻辑上保证了,一个用户只能创建一个小孩。
但漏掉了一个非常小的场景,导致这个逻辑失效,一个人可以同时创建三个子卡,但是子卡创建的时候,余额宝返回了同一个子卡。这样,三个小孩用同一个资金账号,最终影响了线上四百多个用户。这是我们距离资损最近的一次故障了。
这种场景如果有加小孩 id 和子卡号唯一索引,那么这个用户只会开户失败,不会出现数据错误,这种错误在金融领域有可能导致严重的资损,一定要非常小心。
金额不对等的问题
小钱袋多人转入过程,实际上是 A 转给 B ,然后 B 转入子卡,两个流程。上线初期,我们遇到过 A 转给 B 0.88 元,到第二个步骤,变成 B 转入子卡 0.89 元的故障。
因为在第一步只冻结了 0.88 ,到第二个流程自然就失败了。收到了线上解冻失败报警后,排查了一下发现问题是这样的
在一个活动场景,用户输入了 0.888 的金额,在第一阶段,这个金额被转账处理成了 0.88 。同时数字传递到小钱袋服务器,小钱袋并没有过滤处理,直接给了 MySQL 数据库,数据库字段是 decimal(15,2) ,自动四舍五入变成了 0.89 。
这个问题解决很简单,这种 0.888 的金额,应该做一层服务端拦截的,否则还是很容易出现问题的。后来看 Java 应用,一般金额用 int 存,单位为分,而不是元,这样处理更合理。
总结
小钱袋应用首先选择了 TypeScript 作为服务端开发语言,并且逐步完善类型体系,尽量做到应用整体类型完备。
经过一段时间发展,业务层越来越复杂,在尝试对业务层进行分组的时候,我们选择了使用依赖注入的方式,做到服务之间可见性可控。慢慢在业务层中抽象一些领域服务下沉到基础服务中,最终实现业务层和基础层数据单向流动的结构。
小钱袋应该规模相对于 Java 应用来说,还是非常简单的应用。小钱袋本身也是在逐步发展、演变成现在这样,还是有很多不完善的,感觉整体上参考 Java 是一个比较合理的方向。