前言

我今年二月的时候就准备筹划一整个React的学习系列,但是发现自己真的是不够强,理解还不够深刻,加上真的没有理想的那么多时间,所以直接简化到这几篇闲来无事系列。

本篇用KoaMongoDBRedis 搞一个以json通信的服务器,用于之后所有客户端的接口服务!

API详情

README

系统说明

整个系统在设计下来之后,发现也不是想象的那么简单,但是也算不上复杂,大体上就是一个以发布话题为主体,围绕展开的功能。

数据结构

整个数据结构分为五个大块,分别是用户话题回复消息行为。前三个都好说,消息其实也很好理解,比如一个用户关注了另一个用户,被关注的人肯定是有提醒的,主要说一下行为,为什么要叫行为呢?主要是用户的动作有多种,比如收藏、点赞等等,我不想为每一种行为都单独设定一个数据结构,它们的结构也完全可以类似,所以整体就叫做行为了。

多说两句

大部分内容还是容易理解,我就说一下差不多写完以后才发现错了好多的地方,第一个就是数据层没有单独提出来,也就是操作数据库的部分还是封在了controller里面,但其实这样使得整个controller层中的代码很臃肿,后来我发现我的积分制度居然忘记加了,就开始一个文件一个文件的找每次需要增加积分的地方修改,其实如果抽离了数据层,修改起来就会简单许多。

还有一个容易错的地方,由于我把整个用户行为都封在了一个数据结构中,以type字段区分,但是行为与行为直接还是有些许差异的,如果处理不当,就会看起来很混乱,这也是我不想看到的情况。

目录设计

目录其实完全可以按照个人喜好来规划目录,但是我还是觉得写代码嘛,越规范越好,但是我也不是说我这种目录结构就是最规范的…..只是做个参考,遵循MVC模式,只是这里我们的服务只负责数据,不负责视图,所以就只剩MC,模型和控制器。

1
2
3
4
5
6
7
8
9
10
11
12
├── sever
│ ├── __test__ (单元测试)
│ ├── controllers (控制器)
│ ├── db (数据库连接和操作)
│ ├── logs (日志)
│ ├── middlewares (中间件)
│ ├── models (数据模型)
│ ├── proxy (数据库实际操作)
│ ├── uploads (文件上传临时目录)
│ ├── utils (工具函数)
│ ├── app.js (入口)
│ └── router.js (路由文件)

这个树形结构基本上已经把整个目录描述出来了,写过Node的小伙伴可能一眼就能猜出每个目录的作用了,没有写过也没关系,让我慢慢说。

来一个完整的API

每个地方的拆分来说的话,对于阅读其实没有那么友好,所以先走一个完整的流程可以帮助我们更好更快的
去理解一次请求所经历的所有地方。

开启一个服务

没得说,首先我们肯定是需要一个能够接受到客户端请求的服务器,使用Koa能够快速搭建服务器,这里我就直接贴出更复杂完整一些的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const Koa = require('koa');
const koaBody = require('koa-body');
const koaJwt = require('koa-jwt');
const path = require('path');
// 配置文件
const { jwt: { SECRET }, SERVER_PORT, FILE_LIMIT } = require('../../config');
const router = require('./router');
const logger = require('./utils/logger');
const ErrorHandler = require('./middlewares/error-handler');

// 连接 mongodb
require('./db/mongodb');

const app = module.exports = new Koa();

// middleware
app
.use(koaBody({
multipart: true,
formidable: {
uploadDir: `${__dirname}/uploads`,
keepExtensions: true,
multiples: false,
maxFieldsSize: FILE_LIMIT, // 限制上传文件大小为 512kb
onFileBegin(name, file) {
const dir = path.dirname(file.path);
file.path = path.join(dir, file.name);
}
}
}))
.use(koaJwt({
secret: SECRET,
passthrough: true
}))
.use(ErrorHandler.handleError);

// router
app
.use(router.rt)
.use(router.v1)
.use(router.v2);

// 404
app.use(ctx => {
ctx.status = 404;
ctx.body = '请求的API地址不正确或者不存在';
});

// error handle
app.on('error', err => logger.error(err)); // 记录服务器错误

if (!module.parent) app.listen(SERVER_PORT);

我在上述代码中暂时注释了跨域和域名拦截的代码,方便我测试。为了帮助大家更好的理解其中的流程,我把流程写了下来。

  1. 引入依赖
  2. 引入自写mongodb模块,连接数据库
  3. 实例化Koaapp,同时暴露app方便单元测试
  4. 引入中间件,包括请求体解析,jwt和自写错误处理
  5. 引入路由
  6. 处理请求路径不在路由中的404
  7. 记录服务器错误
  8. 监听配置端口,开启服务

对于上述代码如果还不是很清楚,可以对照流程和代码一起看,我相信看起来应该没有这么复杂了吧!

设置一个路由地址

一起来思考一下,现在我们的服务已经开启了,客户端如何请求,服务器又如何知道客户端请求的是哪一个接口呢?很明显是通过路由(也就是url)来判断的,我们现在以一个登录接口来说明。

在入口文件app.js中,我们引用了router.js,这个文件就是路由的匹配文件,一起来看一下:

1
2
3
4
5
6
7
8
9
10
const Router = require('koa-router');
const User = require('./controllers/user');

const router = new Router();

router.post('/signin', User.signin); // 登录

module.exports = {
rt: router.routes(),
};

我删掉了其他API的路由,只留下了登录一个,这样看起来是不是很简单了,还是来理一下整个流程:

  1. 客户端发起一个请求,请求地址为/signin
  2. 服务器接收到这个请求并去匹配路由。
  3. 找到匹配路由的处理方法交给控制器Usersignin方法处理。

可以看得出来最后请求会交个控制器去处理,也就是我们的逻辑代码。

控制器处理这个请求

到了这一步,也就是最后一步,这个控制器存储了很多个方法,分别处理不同的请求地址,这里我们只看其中signup方法,我像之前那样只留下signup方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const jwt = require('jsonwebtoken');
const Base = require('./base');
const { jwt: { SECRET, EXPIRSE, REFRESH }, qn: { DONAME } } = require('../../../config');
const UserProxy = require('../proxy/user'); // 关于 User 表的数据操作封装

class User extends Base {
constructor() {
super();
this.signup = this.signup.bind(this);
}

// 登录
async signin(ctx) {
const { email, password } = ctx.request.body;

// 校验邮箱
if (!email || !/^([A-Za-z0-9_\-.])+@([A-Za-z0-9_\-.])+\.([A-Za-z]{2,4})$/.test(email)) {
ctx.throw(400, '邮箱格式错误');
}

const user = await UserProxy.getOne({ email });

// 判断用户是否存在
if (!user) {
ctx.throw(404, '尚未注册');
}

const isMatch = await this._comparePass(password, user.password);

if (!isMatch) {
ctx.throw(400, '密码错误');
}

// 返回JWT
const token = jwt.sign(
{
id: user.id,
role: user.role,
exp: Date.now() + EXPIRSE,
ref: Date.now() + REFRESH,
},
SECRET
);

ctx.body = `Bearer ${token}`;
}
}

我没有在代码中写注释是因为我觉得里面的代码并不复杂,我直接来解析一下其中的流程。

  1. 接上述流程来到signin方法。
  2. 方法接收一个参数ctx,这个参数是Koa提供的。
  3. 解析ctx参数,拿到请求体中的参数emailpassword
  4. 校验参数是否合法。
  5. 根据email判断是否存在这个用户。
  6. 使用密码比较方法对比传入的password和数据库中的password是否抑制。
  7. 组装带有用户idrole字段的jwt
  8. 使用ctx参数返回结果。

到这里客户端就能接收到服务器返回的结果了,一个完整的API请求的整个流程也算是完成了,其他API请求类似,读者可以自行对号观看,接下来就说说其中比较特别的地方,API流程我也不再继续展开。

关于测试

本来一开始写这文的时候,还是没有这一节的,当时写代码也觉得写测试太麻烦了,也不是很熟,后来还是觉得做事情确实要好好做,还是把测试加上了,我这里依然以登录为例,带大家走一次完整测试的流程。

创建测试文件

一开始我本来打算以一个控制器去划分一个测试文件,后来发现一个控制器中可能有NAPI,那代码不得超长了,所以后来我就以一个API划分一个测试文件,以注册为例,创建__test__/user/signin.test.js文件。

编写一个测试用例

书写测试能用的依赖有很多,由于我本身对测试不是很熟的原因,还是挑了使用最为广泛的mocha,大家也可以自行选择。下面是一个以mocha为例的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const app = require('../../app').listen();
const request = require('supertest')(app);
const should = require('should');

describe('test /api/signup', function() {
// 错误 - 密码
it('should / status 400 when the password is not match', async function() {
try {
const res = await request
.post('/signin')
.send({
email: '123456@qq.com',
password: 'a123456789'
})
.expect(400);

res.text.should.equal('密码错误');
} catch(err) {
should.ifError(err.message);
}
});
});

我大量简化了其中的代码,留下其中一个测试用例给大家参考,其实这样看起来也并不复杂对吧…所谓的测试,就是去验证返回的结果和预期的结果是否相等,比如上述登录接口是需要验证密码是否正确,而我传的是一个错误密码,所以应该返回的信息是密码错误

模块说明

测试除了使用mocha以外,还用了supertestshould两个模块,这两个东西都用来配合mocha使用,它本身不提供能够直接连接服务器的东西所以用supertest来连接我们的服务器,should是断言库,所谓的断言就是判断得到的结果是否和预期一致。

API设计规范

标题略微狂妄…与其说是规范,不如说是个人习惯吧。我在工作过程中也算是遇到过很多不同的API习惯,有些喜欢直接返回结果,失败客户端自行加入拦截器去拦截错误码,有些又喜欢自己返回是否错误的信息,我比较喜欢后者,所以关于这个服务的API也是这么做的。

由于改由Koa编写后,全部API均遵循RESTful风格,所以去除该部分内容,

关于中间件拦截

在客户端请求的时候,有些接口是有前置条件的,比如查看用户信息,如果没有登录,如何能够查看用户信息,所以这个时候就需要路由拦截,去校验是否有这个权限,这个服务在设计的时候有三种权限类型,分别是普通用户管理员超级管理。路由拦截定义在middleware里面,设置于router.js中,比如用户现在要请求一个查看当前用户信息的接口,来一起看一下:

1
router.get('/info', Auth.userRequired, User.getCurrentUser); // 获取当前用户信息

router.js里面的这个接口中可以发现一个Auth.userRequired的方法,也就是在进入这个接口之前,会先执行这个方法,在到User.getCurrentUser这个方法里面去,来看看Auth里面的东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Auth {
// 用户基础权限
async userRequired(ctx, next) {
const { user } = ctx.state;
if (!user) ctx.throw(401, '尚未登录');

const { is_valid, token } = this._validJWT(user);
if (!is_valid) ctx.throw(401, '登录已过期,请重新登录');
if (token) ctx.set('token', token);

await next();
}
}

module.exports = new Auth();

同上我没有贴上完整的代码,只贴了关于用户校验的部分,这个时候你会发现这个方法里面多了一个next参数,也就是如果回传回来的参数头关于jwt能解析并且没有过期,就会走到User.getUserInfo的方法里面,如果没有在这个方法里面就会抛出错误。

数据模型的额外的事

这个里面没什么太多要说的,主要是看业务用到哪些表,哪些字段是弄这个东西。我单独拿出来要说的是手动生成id,这样做需要多出一个表结构,我命名为ids

在初始化数据库的时候会动态的生成一条数据,这条数据也就是这个表唯一的数据,会每次将对应表的id加一,这种做法我是参照了Github一个比较厉害的人搞得,其实具体有什么优势我内心也是懵逼的(哈哈哈哈),靠大家自行摸索了。

后来我仔细想了一下,也问了一下写后端的同事,确实好像没有必要这么搞去手动生成一个自定义的id,徒增工作量,所以去掉了这一部分。

这两天多看了一下mongoose的文档,做了一点细微的调整,加入一个模型插件/models/plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const moment = require('moment');
require('moment/locale/zh-cn');

module.exports = function(schema) {
schema.methods.create_at_ago = function() {
return moment(this.create_at).fromNow();
};

schema.options.toObject = schema.options.toJSON = {
transform(doc, ret) {
ret.id = ret._id;
ret.create_at = moment(ret.create_at).format('YYYY-MM-DD HH:mm');
if (ret.update_at) ret.update_at = moment(ret.update_at).format('YYYY-MM-DD HH:mm');
delete ret.__v;
delete ret._id;
}
};
};

在数据查出转换的时候,生成一个id并且删除了_id__v,省去了每次都需要加入查询条件,而且id更符合惯性的思维。同时给数据模型手动添加一个个方法create_at_ago,表示从创建时间到现在过了多久。

ProtoType的存在

其实prototype完全是可以省略的,如果你没怎么用上前面说的动态生成id,由于我用了动态生成id,也就意味着在每一个控制器中都需要一个同样的方法去生成id,所以就用到了这个东东,不过在这个服务里面我加入了用Node画图形验证码的方法。

生成Id的方法是getId,画图形验证码用到了gd-bmp库,早在去年十月的时候我就开始想搞一个Node能画图形验证码的库,也搜了好多,由于gd-bmp真的好用加上时间也没有这么空余,就直接用的别人的,不过是真的不错!!

去掉了手动去生成id的方法以后,我一度认为这个东东没什么用了,毕竟画验证码也只是在验证码模块中使用,并不涉及到所有的,后来我加入存用户行为和用户消息这两个结构后,又重新使用了这个东东,改为controllers/base,把一些公共需要的方法放入其中。

在捋一次完整的流程

看完上述你会发现其实真的不难,当然还有很多东西没有去弄,也有很多需要优化的地方,但是一个简单的能作为数据服务的服务器就这么弄出来了,写代码确实是一件很有意思的事情。整个流程再来看一下:

  1. 创建对应的数据模型(就像uesr表)
  2. 创建对应的路由拦截(就像/signin登录一样)
  3. 创建处理方法(就像controller/user里面的signin方法一样)
  4. 开启服务
  5. 客户端去请求这个地址
  6. 客户端得到服务器的返回数据

一个完整的流程就这么简单,所有其他的API也是这么做出来的,代码虽然我还在完善,不过大部分的API都已经完成了,后期也还可能在改一改,也会记录在CHANGELOG里面。

结语

本篇作为我终极实战系列的第一篇,讲述用Node完整搭建一个数据服务器的过程,一次请求的完整流程,也是作为后面所有不同终端的数据服务,所以本篇的意义就是讲述一个提供服务的过程。