使用Koa2编写简单的JSON-RPC服务示例,支持文件上传

前言

在早前就写过关于JSON-RPC的文章(NodeJS编写简单的JSON-RPC协议服务端类库)。之所以研究JSON-RPC是因为当时使用了aria2下载时,学习部署发现其web界面与服务是分离的,处于了解到了JSON-RPC。也是刚好那段时间,从习惯使用的PythonFlask框架往Nodejs框架学习转变。此后在自己编写的小项目中都会使用到JSON-RPC作为前后端的数据交互方式。

我相信很多小伙伴都接触与使用过REST风格的后端接口规范,这里需要注意的是REST与RPC是两种不同的思维方式。
RPC一般指远程过程调用,RPC是远程过程调用(Remote Procedure Call)的缩写形式,其主要思想是函数映射到API。
我喜欢使用JSON-RPC的原因一方面,平时喜欢用NodeJSKoa框架快速实现个人小项目或简单的CRUD项目,项目不是前后端分离的,只是静态html,异步请求的方式,js的数据结构前后端是统一的。
而且实现JSON-RPC也非常简单,在编写webSocket项目中也非常实用。所以这里分享一下使用Koa2编写简单的JSON-RPC服务

代码实现

不到50行代码,实现服务端的JSONRPC类

我们根据JSON-RPC 2.0 规范就能非常快速实现JSON-RPC

首先handle函数来判断是否是批处理,接着在parse函数中校验数据,校验成功后调用apply传参并执行。

util/jsonrpc2.js:

class JSONRPC {
    constructor(methods, debug) {
        this.VERSION = '2.0';//版本
        this.errorMsg = {//错误字典
            '-32700': 'Parse Error.',
            '-32600': 'Invalid Request.',
            '-32601': 'Method Not Found.',
            '-32602': 'Invalid Params.',
            '-32603': 'Internal Error.',
        };
        this.methods = Object.assign({}, methods);
        this.debug = !!debug;//是否调试
    }
    validRequest(rpc) {//数据校验
        return rpc.jsonrpc === this.VERSION /*版本校验*/
            && (typeof rpc.id === 'number' || typeof rpc.id === 'string')/*id校验*/
            && typeof rpc.method === 'string';/*函数名校验*/
    }
    normalize(rpc, obj) {//输出标准化
        if (obj.error && !obj.error.message) obj.error.message = this.errorMsg[obj.error.code];
        return Object.assign(obj, { jsonrpc: this.VERSION, id: rpc.id });
    }
    parse(rpc) {//协议解析
        if (!this.validRequest(rpc))//请求协议不规范
            return this.normalize(rpc, { error: { code: -32600 } });
        let method = this.methods[rpc.method];
        if (typeof method !== 'function')//函数匹配
            return this.normalize(rpc, { error: { code: -32601 } });
        let params = Array.isArray(rpc.params) ? rpc.params : [rpc.params];//参数解析
        try {
            return this.normalize(rpc, { result: method.apply(this.methods, params) });// 函数调用
        } catch (err) {
            return this.normalize(rpc, {//解析异常捕获
                error: {
                    code: err.code || -32603,
                    message: this.debug ? "[debug] " + String(err) : this.errorMsg[-32603]//非调试情况不输出错误详情
                }
            });
        }
    }
    handle(rpc) {//处理入库
        return Array.isArray(rpc) ? rpc.map(r => this.parse(r)) : this.parse(rpc);//判断是否批处理
    }
}
module.exports = JSONRPC

编写koa主服务

我主要使用koa-body来解析post请求参数

app.js:

const Koa = require('koa');
const app = new Koa();
const json = require('koa-json');
const onerror = require('koa-onerror');
const staticServer = require('koa-static-server');
const bodyparser = require('koa-body');
const path = require('path');
const logger = require('koa-logger');
const config = require("./config");
const networkInterfaces = require('os').networkInterfaces(); //获取网卡信息

// 客户端异常响应
onerror(app);

// post请求参数解析与上传
app.use(bodyparser({
  jsonLimit: '10mb',
  multipart: true,
  formidable: {
    maxFileSize: 2000 * 1024 * 1024    // 设置上传文件大小最大限制,默认2M
  },
  onerror: function (err, ctx) {
    ctx.throw('body parse error', 422);
  }
}));
// 自动解析对象返回客户端为json
app.use(json());
// 开发中显示日志
app.use(logger());
//静态文件服务
app.use(staticServer({ rootDir: path.join(__dirname, 'www', 'static'), rootPath: '/static' }));

// 控制台 请求输出
app.use(async (ctx, next) => {
  const start = new Date();
  await next();
  const ms = new Date() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

// 控制台 异常输出
app.on('error', (err, ctx) => {
  console.error('server error', err, ctx);
});

app.use(require('./routes'));
app.listen(config.port, () => {
  const ens = Object.keys(networkInterfaces)[0];
  const address = networkInterfaces[ens][1].address || networkInterfaces[ens][0].address; // 获取内网ip
  const notice = `open:
        http://localhost:${config.port},
        http://${address}:${config.port}
      `
  console.log(notice);
});

编写Koa路由

routes/index.js:

const router = require('koa2-router')();   //引入路由函数

router.get('/', async (ctx, next) => {
    ctx.redirect('/index')
})

router.get('/index', async (ctx, next) => {
    ctx.response.redirect('/static/index.html');
})

router.post('/jsonrpc', require("./jsonrpc"))

module.exports = router

编写jsonrpc测试路由

默认情况下请求头是使用application/json作为内容格式标识请求头。

在上传文件的时候通过请求头判断是否是文件上传,因为个人习惯使用multipart/form-data请求头来上传文件,所以这里就用此请求头作为示例。

如果包含文件则将文件数据体放到调用的参数中。

需要注意的是在上传文件的时候是不支持批处理的请求的。

routes/jsonrpc.js

const Jsonrpc = require('../util/jsonrpc2');

const jsonrpc = new Jsonrpc({
    test1(a) {
        console.log(a);
        return a;
    },
    test2(a, b) {
        console.log(a + b);
        return a + b;
    },
    test3(a) {
        console.log(this);
        return this;
    },
    test4: (o) => {
        //异常模拟
        return o / asdsad;
    },
    upload1() {
        return arguments;
    },
    upload2(o) {
        return o;
    }
}, true);


module.exports = function (ctx) {
    let data = ctx.request.body;
    if (ctx.request.header["content-type"].indexOf("multipart/form-data") >= 0) {
        params = JSON.parse(data.params);
        if (Array.isArray(params)) params.push(ctx.request.files);
        else if (params.constructor === Object) params.files = ctx.request.files;
        else params = [params, ctx.request.files];
        data.params = params;
    }
    ctx.body = jsonrpc.handle(data);
}

编写测试页面

根据请求协议可自定义封装JSONRPC的web客户端

www/static/iddex.html:

<button id="btn1">测试1</button>
<button id="btn2">测试2</button>
<label>单文件<input type="file" id="upll1"></label>
<label>多文件<input type="file" id="upll2" multiple="multiple"></label>
<textarea id="demo" rows="3" cols="20" style="display: block;width: 80%;height: 515px;"></textarea>
<script>
    const jsonRPC = {
        _url: '/jsonrpc',
        _currentId: 0,
        pack(method, params) {
            this._currentId++;
            return { "jsonrpc": "2.0", method, params, id: this._currentId };//
        },
        fetch(method, params) {
            let data;
            if (Array.isArray(method)) {
                data = method.map(o => this.pack(o.method, o.params));
            } else if (typeof method == "object") {
                data = this.pack(method.method, method.params);
            } else {
                data = this.pack(method, params);
            }
            return fetch(this._url, {
                headers: {
                    "Content-Type": "application/json"
                },
                method: 'POST',
                body: JSON.stringify(data)
            }).then(v => {
                return v.json();
            });
        },
        upload(method, params, files) {
            let formdata = new FormData();
            formdata.append('jsonrpc', "2.0");
            formdata.append('id', this._currentId++);
            formdata.append('method', method);
            formdata.append('params', JSON.stringify(typeof params == "object" ? params : [params]));
            if (files.constructor === FileList) {
                for (let file of files) {
                    formdata.append('files', file);
                }
            } else if (files.constructor === File) {
                formdata.append('file', files);
            }
            return fetch(this._url, {
                method: 'POST',
                body: formdata
            }).then(v => {
                return v.json();
            });
        }
    };
    document.getElementById("btn1").onclick = function () {
        jsonRPC.fetch("test2", [4, 2]).then(v => {
            document.querySelector("#demo").innerHTML = JSON.stringify(v, null, 2);
        });
    };
    document.getElementById("btn2").onclick = function () {
        jsonRPC.fetch([
            { "method": "test1", "params": [1, 2, 3, 4, 5] },
            { "method": "test2", "params": [1, 2, 3, 4, 5] },
            { "method": "test3", "params": [1, 2, 3, 4, 5] },
            { "method": "test", "params": { a: 1, b: 2, c: 3, d: 4, e: 5 } },
            { "method": "test1", "params": { a: 1, b: 2, c: 3, d: 4, e: 5 } },
            { "method": "test3", "params": { a: 1, b: 2, c: 3, d: 4, e: 5 } },
        ]).then(v => {
            document.querySelector("#demo").innerHTML = JSON.stringify(v, null, 2);
        });
    };
    document.getElementById("upll1").onchange = function () { 
        jsonRPC.upload('upload1', [1, 2, 3, 4, 5], this.files[0]).then(v => {
            document.querySelector("#demo").innerHTML = JSON.stringify(v, null, 2);
        });
    };
    document.getElementById("upll2").onchange = function () {
        let f = this.files;
        jsonRPC.upload('upload2', { a: 1, b: 2, c: 3 }, this.files).then(v => {
            document.querySelector("#demo").innerHTML = JSON.stringify(v, null, 2);
        });
    };
</script>

最终测试效果

node_simple_koa_jsonrpc

参考资料

推荐阅读更多精彩内容