yliu

时来天地皆同力,运去英雄不自由

初探 node 接口开发


接口请求与其它资源请求没有什么不同,都是借助 http 协议返回对应的资源,这篇文章简单介绍一下 node 开发接口以及如何管理多个接口情况和接口风格。

标题关联了 node,主要因为 node 开启一个服务器是很简单,而且语法基本相同没有太多负担,这篇文章主要讲解思路,换算到其它语言也是可以的。

先看一个官网的例子,稍微改造一下让它返回一个固定的json数据

const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.write(JSON.stringify({ name: 'hello wrold' }));
  res.end();
});

server.listen(port, hostname, () => {
  console.log(`服务器运行在 http://${hostname}:${port}/`);
});

将上面代码复制到文件中,之后借助 node xxx.js 的形式就可以预览到效果了。

koa

上面是借助 node 的 http 原生模块实现的,当然这种实现没有什么问题,不过追求可扩展和简化开发的目的,这里选择了 koa 作为下面使用的的框架。

koa 号称是下一代的 web 开发框架,同样以上面的例子安装一下 koa ,看它怎么实现上面的功能

yarn add koa
const Koa = require('koa');

const hostname = '127.0.0.1';
const port = 3000;

const app = new Koa();

app.use(async (ctx) => {
  ctx.type = 'application/json';
  ctx.body = { name: 'hello wrold' };
});
app.listen(port, hostname, () => {
  console.log(`服务器运行在 http://${hostname}:${port}/`);
});

代码方面还是十分简洁的,这里主要介绍实现思路不过多介绍 koa 的语法,而且实际上 koa 只是对 http 模块进行了封装,文档也没多少推荐看一下官网的介绍即可。

说到koa这里还是聊一下 koa 的中间件,下面的代码会经常使用到,koa 借助中间件来实现各种拓展,就是类似于插件的功能,它本身非常像洋葱结构 洋葱

例如上面的app.use就是中间件,中间件的执行顺序以next为分割,先执行next的前半部分,之后按照倒叙的结构执行后半部分的next代码,看一下例子

app.use(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
});

app.use(async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
});

app.use(async (ctx, next) => {
  console.log(5);
  await next();
  console.log(6);
});

上面代码的打印结果是1,3,5,6,4,2,这块有点绕可以多想一下。

接口开发中一般都是通过json来传递消息,koa本身的语法已经很简洁了,但是每次都需要返回想重复的部分,时间长了肯定也会有失误或者漏写拼错的情况,还有抛出错误也需要有一个公共的方法,下面是一个返回信息和抛出错误的设想。

app.use(async (ctx) => {
  ctx.sendData({ name: 'hello wrold' });
  // 如果发生错误
  ctx.throwError('不满足xxx');
});

如果代码都通过这种形式返回就简单多了,而且实际写在中间件部分的也是可能出现问题的,这里可以通过 koa 自带的监听错误来处理,或者通过一个try来包裹,可以预料的是一个个手动管理try一定会让人抓狂。

借助中间件的机制很容易编写出一个带有sendDatathrowError的功能,只需要在 ctx 中返回,之后调用 next 让后面的实例执行

app.use(async (ctx, next) => {
  ctx.sendData = () => {};
  ctx.throwError = () => {};
  await next();
});

上面的例子是简化过的,这里稍微错开一下具体实现之后再详细讲解

中间件的顺序非常重要

接口结构

上面说了要有一个sendDatathrowError的方法来统一返回信息和抛出错误,这里就说下这两个方法的具体参数以及实现。

首先接口的返回信息,期待它是固定成下面这种结构

{
  "data": {},
  "message": "ok",
  "code": 200
}

这里 data 部分是需要手动返回的,message 是可选的,默认的时候可以给一个 ok 以及 200 的 code,这里code值是固定死的,方法不允许修改,这样做是因为成功返回一般不需要额外的 code 值

而错误信息,期待它是这种结构

{
  "message": "",
  "code": "400"
}

这里 message 是必填,而 code 则是可选的。

这里稍微说一下错误到底使用 code 来做区分?还是通过message来做区分? 如果通过code来做不同状态的区分,那么必然要维护一个 code 列表,其实这是很繁琐的而且单纯的数字记忆也不符合人的记忆,而通过message来做提示则基本上可以做到大概可以猜到错误情况,例如可以这样返回

{
  "message": "error_用户名不能为空"
}

前面类型后面提示,是不是简洁很多,这两种错误提示自己选择一种即可。

说了需要实现的功能,方法的实现就很简单了,下面代码是code值风格的实现

// 忽略顶层语法问题,这里是把实现提取出来了
async (ctx, next) => {
  const content = {
    ...ctx,
    sendData: (data, message = 'ok') => {
      ctx.body = {
        data,
        message,
        code: 200,
      };
      ctx.type = 'application/json';
    },
    throwError: (message = '错误', code = 400) => {
      ctx.body = {
        code,
        message,
      };
      ctx.type = 'application/json';
    },
  };
  try {
    await callback(content);
  } catch (e) {
    ctx.body = {
      code: 400,
      message: (e instanceof Error ? e.message : e) || '系统出现错误',
    };
    ctx.status = 400;
  }
  await next();
};

REST 风格

rest 是一种接口风格,简单可以概括成以下几种

  • 使用get来获取资源
  • 使用post来发送请求
  • 使用put来更新资源
  • 使用delete来删除资源

说了这么多使用rest的好处有哪些呢?

首先 rest 只是一种规范,定义这种规范更方便理解和阅读,和代码规范是一个性质

自动导入

在项目开发中必然存在不同的接口,如何管理这些接口就很有必要的,一个个手动导入管理固然可以,不过当项目足够大的时候,业务变更的时候一个个调整一定让人抓狂。

下面借助koa-router和中间件就编写一个自动导入接口的功能,先看一下koar-router的简单使用

yarn add @koa/router
const Koa = require('koa');
const Router = require('@koa/router');

const hostname = '127.0.0.1';
const port = 3000;
const app = new Koa();
const router = new Router();

router.get('/', (ctx, next) => {
  ctx.type = 'application/json';
  ctx.body = { name: 'hello wrold' };
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(port, hostname, () => {
  console.log(`服务器运行在 http://${hostname}:${port}/`);
});

要实现这个功能先定义一下规则

  • 只导入src目录下index.js结尾的接口文件

    搜索所有符合要求的index.js文件,可以借助glob模块来实现,借助通配符'src/**/index.js'即可。

  • 导入文件,把对应模板返回的字段添加到router

    这里可以通过 node 原生require来读取文件,在具体实现的时候需要稍微注意,必须满足格式的模块才能被导入,而且要添加try来捕捉不是modules的文件

在动手实现这个函数之前,还要约定一下index.js文件的内的模块格式是什么样的

const api = {
  url: '',
  methods: 'get' || ['post'],
  async callback(ctx) {},
};

上面是约定的格式,只有满足这样的结构才会被导入进来,因为开发用的是ts这里就不做转换js的操作了,如果不想使用 ts 直接忽略掉类型标注看大概实现即可。

utils.ts

import glob from 'glob';
import path from 'path';
import _ from 'lodash';
import { Iobj, Istructure } from '../../typings/structure';

export const globFile = (pattern: string): Promise<Array<string>> => {
  return new Promise((resolve, reject) => {
    glob(pattern, (err, files) => {
      if (err) {
        return reject(err);
      }
      return resolve(files);
    });
  });
};

export const importModule = async () => {
  const pattern = 'src/**/index.ts';
  const list = await globFile(pattern);
  const listMap = list.map((item) => {
    const f = path.resolve(process.cwd(), item);
    return import(f)
      .then((res) => {
        // 过滤掉default的属性,其它的返回
        return _.omit(res, ['default']);
      })
      .catch(() => null);
  });
  return (await Promise.all(listMap)).filter((f) => f) as Array<Iobj<Istructure>>;
};

这里注意一下,因为用的 ts 所以用了 import()如果只是用 node 语法直接 require 即可

index.ts

import Router from '@koa/router';
import _ from 'lodash';
import { Ictx, Iobj } from '../../typings/structure';

import { importModule } from './utils';
import Koa from 'koa';

const route = async (koa: Koa) => {
  const router = new Router();
  const list = await importModule();

  for (const fileAll of list) {
    // 将数据解构,这里返回的是{xxx: {url,methods,callback}}这样解构

    // 过滤不符合条件的模块
    for (const file of Object.values(fileAll)) {
      if (
        !_.isObjectLike(file) ||
        !['url', 'methods', 'callback'].every((f) =>
          Object.keys(file).includes(f)
        )
      ) {
        continue;
      }
      const { url, methods, callback } = file;
      const methodsArr = _.isArray(methods) ? methods : [methods];
      for (const met of methodsArr) {
        router[met](url, async (ctx, next) => {
          const content: Ictx = {
            ...ctx,
            sendData: (data: Iobj, message = 'ok') => {
              ctx.body = {
                data,
                message,
                code: 200,
              };
              ctx.type = 'application/json';
            },
            throwError: (message = '错误', code = 400) => {
              ctx.body = {
                code,
                message,
              };
              ctx.type = 'application/json';
            },
          };
          try {
            await callback(content);
          } catch (e) {
            ctx.body = {
              code: 400,
              message: (e instanceof Error ? e.message : e) || '系统出现错误',
            };
            ctx.status = 400;
          }
          await next();
        });
      }
    }
  }
  koa.use(router.routes()).use(router.allowedMethods());
};

export default route;

日志

借助 koa 的中间件也很容易实现日志的功能,这里以winston为例

日志主要记录系统运行时的错误,还记的上面通过try来捕捉错误的例子么,现在让他继续抛出错误,直接通过中间件 try 捕捉错误写入到文件。

import winston from 'winston';
import Koa from 'koa';

import 'winston-daily-rotate-file';
const transport = new winston.transports.DailyRotateFile({
  filename: 'log/%DATE%.log',
  datePattern: 'YYYY-MM-DD-HH',
  zippedArchive: true,
  maxSize: '20m',
  maxFiles: '14d',
});

const logger = winston.createLogger({
  transports: [transport],
});

const asyncwinston = async (
  _ctx: Koa.ParameterizedContext<Koa.DefaultState, Koa.DefaultContext>,
  next: Koa.Next
) => {
  try {
    await next();
  } catch (err) {
    const data = {
      data: err,
      time: new Date().valueOf(),
    };
    if (err instanceof Error) {
      data.data = {
        content: err.message,
        name: err.name,
        stack: err.stack,
      };
    }
    logger.error(JSON.stringify(data));
  }
};

export default asyncwinston;

启动

启动就很简单了,把上面暴露的 index.js 通过koa的 use 引入

App.js

const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const route = require('./middleware/route');
const winston = require('./middleware/winston');

const App = async () => {
  const app = new Koa();
  app.use(winston);
  app.use(bodyParser());
  await route(app);
  return app;
};

module.exports = App;

start.js

const Koa = require('koa');
const ip = require('ip');
const App = require('./App');

const start = async () => {
  const app = await App();
  notice(app);
};

const notice = (koa: Koa) => {
  const port = 3000;
  const ipStr = ip.address();
  const str = `http://${ipStr}:${port}`;
  koa.listen(port, () => {
    console.log(`服务器运行在\n${str}`);
  });
};

start();

这里稍微说明一下为什么分成两个文件,这是因为方便接口测试特意分层的,start只做启动的用途

最后添加一个node-dev的模块,就大功告成了

10/21 补充

上面的 node-dev,是开发环境下使用的,方便代码的快速重启,在生产环境下可以使用 pm2

// 安装
yarn add node-dev
// 启动
node-dev start.js

通过node-dev启动主要是可以方便修改接口可以直接重载以及通知的方式更明显

接口测试

12/21 补上

首先在 src 目录下 新建一个 index.ts 文件,用于测试的接口

import { Istructure } from '../typings/structure';

const testGet: Istructure = {
  url: '/api/:id',
  methods: 'get',
  async callback(ctx) {
    const { id } = ctx?.params;
    ctx?.sendData({ name: 'hello', id });
  },
};
const testPost: Istructure = {
  url: '/api',
  methods: 'post',
  async callback(ctx) {
    const body = ctx?.request.body;
    ctx?.sendData(body || {});
  },
};

export { testGet, testPost };

上面是一个很简单的 post 和 get 请求,之后我们新建一个__test__目录,在里面新建一个index.test.js文件

yarn add @babel/core @babel/preset-env @babel/preset-typescript babel-jest jest supertest

在根目录新建babel.config.js文件

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', { targets: { node: 'current' } }],
    '@babel/preset-typescript',
  ],
};

简单说下,这个 babel 的作用是让我们可以在 js 里面使用 es6 module 的语法,同时将 ts 文件转成 js,否则我们这个测试用例是 ts 的根本跑不起来,测试框架方面选用了jest测试 http 库使用了supertest,其实这块都是可以调整的,单元测试的目的就是对比数据是否符合预期

__test__ index.test.js

import App from '../App';

import supertest from 'supertest';

test('get请求测试', async () => {
  const app = await App();
  const request = supertest(app.listen());
  const id = 6;
  const data = { name: 'hello', id: `${id}` };
  const res = await request.get(`/api/${id}`).expect(200);
  const body = res.body.data;
  expect(body).toEqual(data);
});

test('post请求测试', async () => {
  const app = await App();
  const request = supertest(app.listen());
  const data = { name: 'hello', id: 8 };
  const res = await request.post(`/api/`).send(data).expect(200);
  const body = res.body.data;
  expect(body).toEqual(data);
});

运行 npx jest ,命令行如果没有抛出异常说明我们的代码符合预期,关于更多的 jest 内容可以查看文档

最后

如果对你有帮助欢迎 stat,如果有什么错误之处欢迎指出,关于代码本来全部想用 ts 举例的,但是 ts 并不是一定要上的,所以某些场景我就手动转了一下,看起来有点风格不统一还望谅解