本文做者:网易云音乐前端工程师 包勇明
无论 Node.js 在实际产品中的使用状况如何,相信如今使用 Node.js 做为服务端来开发的项目是数以百万计的,其中绝大多数的开发人员都是前端工程师,由于 Node.js 是他们的自然语言工具。未来,愈来愈多的前端工程师会加入到 Node.js 的开发中来。前端
既然使用了 Node.js 做为服务端开发语言,咱们确定要开发 API 接口。本文用一个示例需求,来说述一下如何高效开发高质量的服务端 API 接口。git
首先来看下需求,一共有 3 张数据库表(数据库是 MySQL),分别为:github
CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '惟一标识', `name` VARCHAR(50) NULL DEFAULT '' COMMENT '账号', `email` VARCHAR(50) NULL DEFAULT '' COMMENT '邮箱', `nickname` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '真实姓名', `createTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账号建立时间', PRIMARY KEY (`id`), UNIQUE INDEX `uk_email` (`email` ASC) ) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='用户表'; CREATE TABLE `project` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '惟一标识', `name` VARCHAR(100) NULL DEFAULT NULL COMMENT '名称', `description` VARCHAR(500) NULL DEFAULT '' COMMENT '描述', `creatorId` INT UNSIGNED NOT NULL COMMENT '建立者标识', `createTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '建立时间', `deletedAt` DATETIME(3) COMMENT '删除时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 COMMENT='项目表'; CREATE TABLE `project_user` ( `role` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户角色0-成员;9-管理员;10-建立者', `userId` INT UNSIGNED NOT NULL COMMENT '用户标识', `projectId` INT UNSIGNED NOT NULL COMMENT '项目标识', `createTime` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '建立时间', PRIMARY KEY (`userId`, `projectId`), INDEX `idx_projectId` (`projectId` ASC) ) ENGINE=InnoDB COMMENT='项目-用户关系表';
含义以下:面试
user
,主键是 id
,自增,惟一索引是 email
。project
,主键是 id
,自增。用户能够建立项目,creatorId
字段是建立者的标识。项目和用户的关系表,它表示项目和用户的映射关系,联合主键是 userId
和 projectId
。一个项目能够有多个用户,用户有不一样的角色,用 role
字段来表示,不一样的角色有不一样的权限,以下:sql
用户相关的操做不是本文要讲述的重点,本文只讲述如何实现和项目相关的 CRUD 接口:数据库
由于要高效地开发高质量接口,咱们不能等有了 UI 界面才去开发接口,开发接口和开发页面只有作到并行开发,才能提高团队的总体开发效率。和服务端开发打过交道的朋友应该知道,服务端在本地开发接口的时候,会使用诸如 Postman 这样的工具来测试接口的正确性。咱们必定要尽量地作到接口开发要脱离页面,虽然在页面中开发接口很是直观方便,可是它有不少的局限性。为了表述方便,咱们使用 eggjs 框架,数据交换格式使用 JSON,接口使用 RESTful 规范,而且都以
/api
开头。另外,本文只介绍接口自己功能的开发,其余诸如缓存等方面的内容不会涉及。编程
根据 RESTful 规范,接口地址是 POST /api/projects
。虽然咱们是脱离 UI 界面在开发接口,但页面的交互逻辑是必须清楚的(通常来讲,会有交互设计稿)。对于建立项目来讲,通常就是一个让用户填写项目信息的表单,里面有项目的名称和描述,用户填写完后,点击“提交”按钮提交表单数据,后端接收到数据后,在数据库中插入一条记录。这个就是建立项目的过程。json
客户端发送过来的请求,会先通过一些通用的中间件处理,而后到达 Controller 层,这是编写业务逻辑代码的地方。咱们不能信任用户提交过来的数据,须要对数据进行最基本的校验。根据 project
数据库表,name
字段是字符串类型,而且长度不能超过 100,description
字段也是字符串类型,长度不能超过 500,二者均可觉得空。后端
但对于一个实际的项目来讲,name
为空是没有意义的,因此还应该对 name
进行非空判断。代码大体以下:api
// ~ /controller/project.js const createRule = { name: { type: 'string', max: 100, }, description: { type: 'string', max: 500, required: false, }, }; async create() { const ctx = this.ctx; // 接收到的 JSON 数据 const data = ctx.request.body; // 对数据进行验证,若是验证不经过,会直接报错返回 ctx.validate(createRule, data); // 调用 service 方法去建立项目 const id = await ctx.service.project.create(data); }
相信不少朋友已经看出上述代码有一个严重的问题,就是 data
对象中缺乏项目建立者的信息。建立者是谁?显然,它应该是当前的登陆用户。用户能建立项目的前提是他已经登陆系统了。在现代的 Web 项目中,当前登陆用户的信息通常保存在客户端的 Cookie 中,服务端的话相应地保存在 Session 中。客户端在发送请求的时候,请求默认就会带上 Cookie 信息,不须要显式地发送这个数据。服务端根据请求的 Cookie 去 Session 中取出相应的用户信息。不过这一切的工做,框架或者中间件都帮咱们实现好了,这不是本文要讲述的重点,就不展开了。
除了建立者没有设置的问题,还有如下须要注意的地方:
name
进行校验的时候,要去掉它两边的空格,毕竟名称不能全是空格。description
无所谓,就不作处理。name
、description
和 creatorId
。注意,咱们为了保证 Service 方法的纯粹性(方便复用),不在 Service 中去 Session 里面取当前登陆用户的用户数据,而是在 Controller 中将用户传递给 Service。
修改后的代码大体以下:
// ~ /controller/project.js const createRule = { name: { ... // 去掉两边的空格,默认是 false trim: true }, ... }; async create() { const ctx = this.ctx; // extract 是自定义的根据 rule 规则抽取有效数据的方法 const data = ctx.helper.extract(ctx.request.body, createRule); ctx.validate(createRule, data); // 设置建立者 data.creatorId = ctx.session.user.id; const id = await ctx.service.project.create(data); }
建立项目的代码看起来已经“无懈可击”了,但总以为还少作了点什么工做。
咱们来分析一下实际状况:就算是新开发一个接口,接口代码是逐步完善的,期间可能被重构了不少次,也就是说,存在实现了这个功能但会破坏以前已经实现好的功能,这是实际开发过程当中的广泛现状。
是的,咱们不能相信本身,不能相信不靠谱的人类,咱们须要工具来保障咱们以前实现的功能仍旧能够正常工做,咱们须要给代码的每一个逻辑分支编写测试用例,只有在开发好接口后,若是所有测试用例都能经过,咱们才能认为这个接口已经开发完成。
将本地开发中的测试数据以测试用例的形式保存下来,这样的工具应该有不少,好比后端开发工程师最常使用的 Postman 工具就能够作到。今天也向你们推荐一款工具 NEI。
NEI 是一个接口管理平台,目前由网易云音乐在开发和维护。在 NEI 平台上能够定义 HTTP 接口契约,还能够为定义好的接口建立测试用例,测试时会自动验证接口响应中的字段类型是否匹配、字段是否有缺失、字段是否有多余等等异常状况,能为开发人员节省不少宝贵的时间。关于 NEI 的更多信息请参考它的官方说明文档和使用教程。
在测试本小节讲解的“建立项目”接口时,不该该去关注后端的具体实现逻辑,因此从理论上来讲,咱们须要建立如下测试用例:
只发送 name
字段,但它的值是非法的。非法的状况又分三种,
name
字段,它的值是合法的。description
字段,它的值是非法的。description
字段,它的值是合法的。name
和 description
字段,它们的值都是非法的。name
和 description
字段,它们的值都是合法的。name
和 description
字段,name
的值是合法的,description
的值是非法的。name
和 description
字段,name
的值是非法的,description
的值是合法的。name
和 description
字段外,还发送了其余字段。因为实际代码实现的不肯定性,彻底依赖测试用例来保障接口的正确性,从理论上来讲是作不到的。实际开发过程当中也不可能会按照上面这样的逻辑去建立测试用例。
咱们只要建立几个关键的测试用例就能够了。对于这个“建立项目”的接口,有一个正常能够建立成功的用例再加上一到两个因为发送非法值致使建立失败的用例就能够了。
有同窗看到这里,可能会想,要不要测试用户没有登陆的状况,由于没有登陆确定没法建立项目。这个问题听起来问的很是合理,实际上是无效的,由于登陆认证这个工做,在框架或者中间件层面已经解决掉了,也就是说,若是没登陆,代码都不会进入到 Controller,因此不用担忧没有登陆的问题。
项目建立完后,确定要在页面上显示出来,有多是单独的项目详情页面,也有多是项目列表页面,都须要用到项目数据。因此须要有查询项目的接口,最多见的就是按项目 id 查单个项目,还有就是显示用户的项目列表。
按照前面咱们约定的规范:
GET /api/projects/:id
,其中 :id
叫路径参数(Path Variable),表示项目的 id。GET /api/projects
。咱们先来分析第一种接口,即按照项目 id 查询单个项目,它应该作到:
参数无效
。这里须要注意的是,路径参数自己的类型是一个字符串,它是一个字符串类型的整数,须要作下类型转换。参数无效
。你们已经注意到,id 不能转换成整数或者数据库中没有这个 id 的项目时,咱们都返回了参数无效
这个错误信息。可能有人会问,为何不返回很是明确的错误信息呢?这样排查问题就很快,客户端开发人员或者产品用户也看得更加明白。
如何返回有效的、合理的错误信息,实际上是一门比较大的学问,几乎全部的研发团队都会朝规范化的错误信息方向努力,但实际状况仍是一团糟,极可能仍是由一线开发人员随意决定的。关于这个问题,个人建议是不用返回很是明确的错误信息给客户端,由于这个信息有可能会被不法分子利用,他们会根据错误信息来猜想代码的具体实现从而试探可能存在的漏洞。详细的错误信息应该用 Log 记录下来,方便接口开发人员排查问题。好比你们登陆一些网站的时候,会提示账号或者密码不对,此时就不该该明确地告诉用户究竟是账号不对仍是密码不对,接口开发人员有 Log 记录就能够了。
咱们再回到查询项目这个接口。上面分析了出来两个逻辑分支,但忘了一个很是严重的问题,也就是数据库中是存在这个 id 的项目,但当前登陆用户是没有权限查看的。后端开发,有两个基本概念,一个叫 认证 (authentication),一个叫 鉴权(authorization)。咱们刚才遇到的问题就是鉴权问题,须要对资源进行鉴权。由于有不少的操做都须要判断权限,好比查询、更新、删除等等,因此应该把最基本的鉴权逻辑(由于权限问题还和具体的业务逻辑有关,能抽离出来的只能是一些最基本的通用逻辑)单独抽离成一层,在 Node.js 中咱们叫中间件。通常框架也会提供这样的中间件,好比 eggjs 配套的 egg-cancan。
有了上述分析后,就不难写出以下的代码:
// ~ /controller/project.js async get() { const ctx = this.ctx; const id = parseInt(ctx.params.id, 10); if (Number.isNaN(id)) { // wrapResponse 是自定义封装方法,此处省略实现 ctx.body = this.wrapResponse(id, 'BAD_REQUEST'); } else { // canReadProject 方法会去调用鉴权中间件的方法,此处省略实现 const canRead = await this.canReadProject(id); if (canRead) { const project = await ctx.service.project.get(id); ctx.body = this.wrapResponse(project); } else { ctx.body = this.wrapResponse(id, 'BAD_REQUEST'); } } }
咱们再来看第二种接口,也就是查询用户的项目列表。首先要明白业务需求是什么,也就是用户能够见到哪些项目。咱们在最开始已经写明了:只有项目的成员、管理员、建立者对该项目可见。项目和用户的关系是用了一张单独的表 project_user
来保存的,咱们再回顾下这张表的设计:
CREATE TABLE `project_user` ( `role` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户角色0-成员;9-管理员;10-建立者', ... )
请注意 role
字段的注释说明,它的值是一个数字,每种数字表示不一样的角色,好比 10
表示是这个项目的建立者。细心的朋友可能已经注意到,咱们前面在分析“建立项目”接口时,并无分析到在建立项目的时候,在往 project
表中插入一条记录的同时还要往 project_user
表插件一条表示项目和建立者的关系记录。有朋友可能会反驳说这条记录实际上是多余的,由于 project
表中已经有了 creatorId
字段来记录项目和建立者的关系了。
那么到底需不须要往 project_user
表插入这条记录呢?这多是一个仁者见仁智者见智的问题。就咱们今天讲解的这个需求,按照 project_user
表中 role
的字段注释,最好是插入这条记录,有时候适当地冗余一些数据多是件好事,说不定还能够提高数据库的查询性能。
根据上述分析,查询用户的项目列表,须要查询两张表,首先是根据用户 id 去 project_user
表把全部 userId
为用户 id 的项目 id(也就是projectId) 查出来,结果是一个数组集合,而后根据这个项目 id 集,批量去 project
表把项目查出来就能够了。具体的代码就不演示了。
另外有一个细节须要注意的是,project
表中有一个 deletedAt
字段,表示项目的删除时间。删除项目,咱们选择了使用字段标记方案,而不是直接物理删除数据。在把项目数据返回给客户端的时候,须要过滤掉这个 deletedAt
字段,这是一个后端内部逻辑使用的字段,不必给客户端开发者看到。这是一个通用的处理逻辑,封装成一个方法就能够了。
最后不要忘了,咱们还须要给这两个接口添加测试用例:
根据 id 查询单个项目的测试用例:
查询用户的项目列表的测试用例:
首先仍是分析业务需求,修改这种操做,和具体的业务逻辑关系很是大,好比能够限制只有项目建立者能够修改项目,也可让全部项目成员均可以修改项目。咱们的需求最开始也已经描述过了“只有项目的管理员和建立者能够修改项目”。
根据 project
表,项目的名称和描述能够被修改,须要注意的是,对它们的校验,和建立项目的逻辑须要保持一致,好比名称不能是空字符串。
咱们还有一张表 project_user
表,因此应该有这么一张页面,能够在上面设置项目的成员,好比将某个用户添加到项目中来,或者将某个用户设置为管理员。虽然更合理的作法是给这种操做开发单独的接口,以和“修改项目的名称和描述”这个操做作下区分,代码写起来也更清晰明了。
本节要实现的“修改项目”接口会支持以上两种情形。根据以前的分析,接口地址应该是 PATCH /api/project/:id
。下面咱们来分析代码逻辑:
name
,就不该该去更新它,因为咱们要实现的接口可能会涉及到两张表,这样一来,还能减小数据库操做,要知道操做数据库是很是昂贵的,和操做 DOM 对象相似,能避免就尽可能避免。{ "members": [], "admins": [] }
这样,role
这个信息对客户端能够作到透明,客户端开发不须要去设置这个值,能省点沟通成本就省点沟通成本,要否则还须要告诉客户端在添加成员的时候,要把 role
字段的值设置为 0,设置管理员的时候要把 role
的值设置为 9,这不但须要沟通成本,并且容易引入 Bug。
若是客户端发送过来的数据包含了 members
或者 admins
字段,此时就须要去更新 project_user
表,有如下情形须要考虑:
members
是一个空数组,它表示删除了全部的项目成员。members
是非空数组,则须要计算出哪些成员被删除,哪些成员被添加,而后再批量更新数据库。admins
和 members
的逻辑同样,再也不赘述。members
和 admins
数组中。虽然经过 UI 界面操做能够避免这种状况,但服务端不该该彻底信任客户端发送过来的数据,由于请求数据是能够经过工具来伪造的。显然,members
和 admins
中也不该该出现项目的建立者。经过上述分析,代码就不难实现了,实际代码较长,这里就不贴出来了。
一样的,须要为这个接口添加适当的测试用例,比较关键的有:
members
、admins
数据合法。members
、admins
中出现没有在系统中注册的用户。members
、admins
中出现同个用户。members
、admins
中出现了建立者。根据前面的分析,删除项目的接口地址是 DELETE /api/projects/:id
,而且咱们不是物理删除记录,而是去给 deletedAt
字段赋值。这个字段有值表示项目已经被删除。
服务端的 Project Controller 代码,只要调用 Project Service 的 update
方法就能够了,固然别忘记对项目鉴权,咱们的需求已经规定只有项目的建立者才能删除项目,最终代码大体以下:
// ~ /controller/project.js async remove() { const ctx = this.ctx; const id = parseInt(ctx.params.id, 10); if (Number.isNaN(id)) { ctx.body = this.wrapResponse(id, 'BAD_REQUEST'); } else { const canDelete = await this.canDeleteProject(id); if (canDelete) { // 删除时更新 deletedAt 字段,不是真正物理删除 const result = await ctx.service.project.update({ id, deletedAt: new Date() }); if (result.success) { ctx.body = this.wrapResponse({ id }); } else { ctx.body = this.wrapResponse( {}, result.resType || 'SERVER_ERROR' ); } } else { ctx.body = this.wrapResponse({}, 'BAD_REQUEST'); } } }
上述代码只更新了 deletedAt
字段。还有一个细节问题咱们没有考虑,就是删除项目的时候,要不要把 project_user
中和这个项目相关的记录所有删除?否则项目被删除了,这些记录也没用了,留着不是占用数据库空间吗?若是是一个用户量很是大的产品,这个问题是必需要处理的,那时可能都不会使用 deletedAt
字段来标记项目是否被删除的状态,极可能是其余方案了。对于小项目来讲,删项目的时候,删不删 project_user
的记录,都无所谓,看实际状况现作决定便可。通常来讲最好是别删,能够减小恢复项目时的工做量。
本文演示的“项目更新”接口已经处理了项目成员及管理员的逻辑,因此要删除这些记录,须要修改的代码也不多:
const result = await ctx.service.project.update({ id, deletedAt: new Date(), // 设置成空数组表示将成员记录所有删除 members: [], admins: [] });
最后,须要给这个接口添加两个关键的测试用例:
本文较为详细地分析了开发服务端 CRUD 接口的过程,须要考虑的点仍是很是多的,这和前端工程师的开发思惟有较大的不一样,特别是资源鉴权、数据合法性校验、关键测试用例等等,须要花费较大的精力。
无论是前端工程师仍是后端工程师,想要高效地开发高质量的 API 接口,都务必作到如下几点:
不少刚工做不久的人问我应该如何提高本身的能力?由于编程语言层面的问题他们以为都已经掌握了。咱们试想一下,在评估一我的能力的时候,会考虑哪些因素?一个是知识面,这个通常在面试环节就能被问出来。第二个即是实际作需求的时候,考量的维度有:问题难度、引发的 Bug 数量、和同事的协做等等,这些都是能够证实本身能力的地方,若是都作得很好,在同事的心中就是一个能力强的人。就好比本文所讲述的 API 接口开发,若是开发出来的 API 接口实现得很是正确也没有漏洞,那在客户端同事的心中你就是一位能力强的人。
在完成本文初稿的时候,你们都以为这是一篇教你们如何开发 API 接口的教程,由于写得很是详细,基本上是到了手把手的地步。这个目的是首要的,但并非本文真正的目的。本文的最终目的是想证实把一件事情作到极致须要花费怎样的努力,同时也顺便回答了如何提高我的能力的问题。
本文发布自 网易云音乐前端团队,可自由转载,转载请在标题标明转载并在显著位置保留出处。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们!