精华 如何用 Node.js 编写一个 API 客户端
发布于 8 年前 作者 leizongmin 23388 次浏览 来自 分享

原文地址:http://morning.work/page/2016-05/how-to-write-a-nodejs-api-client-package.html 转载请注明出处


说几句无关主题的话

尽管这几年来 Node.js 已经得到越来越多的关注,连市场卖菜的老太婆都能分别得出哪个是写 Node.js的,哪个是写 PHP 的。然而,终究是不能跟老大哥 Java 比的。我们在使用一些第三方服务时常常会碰到一时半会还没有官方的 Node.js SDK 的问题,所以能自己随手撸一个刚好够用的 API 客户端来应急成了必备技能。

说到这里,我忍不住要先吐槽一下:

前几天在 CNodeJS 上看到一个帖子,拥抱ES6——阿里云OSS推出JavaScript SDK 对其中的滥用 generator洋洋自得 的行为有点不满,之前也遇到过该厂的 SDK 强行返回generator 而放弃使用,我想说我 已经忍了很久 了。

「我自己写得爽,也希望把这种“爽”带给用户」该 SDK 的维护者如是说

作为一个 SDK(尤其是官方出品的),应该使用最 common 的技术或规范来实现。比如在Node.js 中的异步问题,应该使用传统的 callback 或者 ES6 里面的 promise,而不是使用 比较奇葩的 generator 来做。generator 来做不妥的地方是:

  • generator 的出现不是为了解决异步问题
  • 使用 generator 是会传染的,当你尝试 yield 一下的时候,它要求你也必须在一个 generator function

当然,如果这是一个内部项目,使用各种花式姿势都是没问题的,只要定好规范就行。而如果这是要给别人使用的东西,应该照顾其他人的感受。

所以我们要自己动手写一个 SDK 还有另外一种情况就是 对官方的 SDK 并不满意

好了,我吐槽完了。

运行环境

最近一年来,Node.js 相继发布了 4.0、5.0、6.0(前几天),7.0 也已经蓄势待发,但目前来看主流还是 4.x 版本。Node.js 4.x 支持一部分的 ES6 语法,比如箭头函数、letconst等,解决异步问题也可以直接使用 ES6 的 promise

如果没有特殊情况,新写的程序可以不用考虑在 0.12 或者更早的 0.10 上运行,如果以后确实需要在这些版本上执行,可以借用 Babel 来编译成 ES5 语法的程序。

API 接口将同时支持 callbackpromise 两种回调方式。promise 直接使用 ES6 原生的Promise 对象而不是使用 bluebird 模块。尽管使用 bluebird 会有更多的功能和更好的性能,但在这样一个需要网络 IO 的场景下,那么一点性能差别基本可以忽略不计,而作为一个极简主义者,觉得没太大必要引入这么一个依赖库。

功能设计

本文将以 CNodeJS 提供的 API 为例。CNodeJS 的API分两种:

  • 公共接口,比如获取主题列表和详情等
  • 用户接口,需要提供 accesstoken 参数来验证用户权限(accessToken 可以在个人设置界面中得到)

程序的使用方法如下:

'use strict';

const client = new CNodeJS({
  token: 'xxxxxxx', // accessToken,可为空
});

// promise 方式调用
client.getTopics({page: 1})
  .then(list => console.log(list))
  .catch(err => console.error(err));

// callback 方式调用
client.getTopics({page: 1}, (err, list) => {
  if (err) {
    console.error(err);
  } else {
    console.log(list);
  }
});

初始化项目

1、首先新建项目目录:

$ mkdir cnodejs_api_client
$ cd cnodejs_api_client
$ git init

2、初始化 package.json

$ npm init

3、新建文件 index.js

'use strict';

const rawRequest = require('request');

class CNodeJS {

  constructor(options) {

    this.options = options = options || {};
    options.token = options.token || null;
    options.url = options.url || 'https://cnodejs.org/api/v1/';

  }

  baseParams(params) {

    params = Object.assign({}, params || {});
    if (this.options.token) {
      params.accesstoken = this.options.token;
    }

    return params;

  }

  request(method, path, params, callback) {
    return new Promise((resolve, reject) => {

      const opts = {
        method: method.toUpperCase(),
        url: this.options.url + path,
        json: true,
      };

      if (opts.method === 'GET' || opts.method === 'HEAD') {
        opts.qs = this.baseParams(params);
      } else {
        opts.body = this.baseParams(params);
      }

      rawRequest(opts, (err, res, body) => {

        if (err) return reject(err);

        if (body.success) {
          resolve(body);
        } else {
          reject(new Error(body.error_msg));
        }

      });

    });
  }

}

module.exports = CNodeJS;

说明:

  • 使用 request 模块来发送 HTTP 请求,需要执行命令来安装该模块: npm install request --save
  • 我们实现了一个带有 request 方法的 CNodeJS 类,可以通过该方法来发送任意 API 请求,比如请求主题首页是 request('GET', 'topics', {page: 1})
  • 如果初始化 CNodeJS 实例时传入了 token,则每次请求都会自动带上 accesstoken 参数
  • 返回的结果 success=true 表示 API 请求成功,则直接回调该结果;如果失败则 error_msg 表示出错信息

4、新建测试文件 test.js

'use strict';

const CNodeJS = require('./');
const client = new CNodeJS();

client.request('GET', 'topics', {page: 1})
  .then(ret => console.log(ret))
  .catch(err => console.error(err));

5、执行命令 node test.js 即可看到类似以下的结果:

{ success: true,
  data:
   [ { id: '572afb6b15c24e592c16e1e6',
       author_id: '504c28a2e2b845157708cb61',
       tab: 'share',
       content: '.......'
...

至此我们已经完成了一个 API 客户端最基本的功能,接下来根据不同的 API 封装一下 request 方法即可。

支持 callback

前文已经提到,「作为一个 SDK,应该使用最 common 的技术或规范来实现」,所以除了 promise之外还需要提供 callback 的支持。

1、修改文件 index.jsrequest(method, path, params) { } 定义部分:

request(method, path, params, callback) {
  return new Promise((_resolve, _reject) => {

    const resolve = ret => {
      _resolve(ret);
      callback && callback(null, ret);
    };

    const reject = err => {
      _reject(err);
      callback && callback(err);
    };

    // 以下部分不变
    // ...
  });
}

说明:

  • new Promise() 中的 resolvereject 分别改名为 _resolve_reject
  • 在函数开头新建 resolvereject,其作用是调用原来的 _resolve_reject,同时判断如果有 callback 参数,则也调用该函数

2、将文件 test.jsclient.request() 部分改为 callback 方式调用:

client.request('GET', 'topics', {page: 1}, (err, ret) => {
  if (err) {
    console.error(err);
  } else {
    console.log(ret);
  }
});

3、重新执行 node test.js 可以看到结果跟之前是一样的。

通过简单的修改我们就已经实现了同时支持 promisecallback 两种异步回调方式。

封装 API

前文我们实现的 request() 方法已经可以调用任意的 API 了,但是为了是方便,一般需要为每个 API 单独封装一个方法,比如:

  • getTopics() - 获取主题首页
  • getTopicDetail() - 获取主题详情
  • testToken() - 测试 token 是否正确

对于 getTopics() 可以这样简单地实现:

getTopics(params, callback) {
  return this.request('GET', 'topics', params, callback);
}

但其返回的结果是这样结构的:

{ success: true,
  data: []
}

要取得结果还要读取里面的 data,针对这种情况我们可以改成这样:

getTopics(params, callback) {
  return this.request('GET', 'topics', params, callback)
             .then(ret => Promise.resolve(ret.data));
}

getTopicDetail()testToken() 可以这样实现:

getTopicDetail(params, callback) {
  return this.request('GET', `topic/${params.id}`, params, callback)
             .then(ret => Promise.resolve(ret.data));
}

testToken(callback) {
  return this.request('POST', `accesstoken`, {}, callback);
}

对于其他的 API 也可以采用类似的方法一一实现。

结尾

由此看来编写一个简单的 API 客户端也不是一件很难的事情,本文介绍的方法已经能适用大多数的情况了。当然还有些问题是没提到的,比如阿里云 OSS 这种 SDK 还要考虑 stream 上传问题,还有断点续传。对于安全性要求较高的 SDK 可能还需要做数据签名等等。

在编写本文的时候,通过阅读 request 的 API 文档我才发现原来可以通过 json=true 选项来让它自动解析返回的结果,这样确实能少写好几行代码了。

另外我还是忍不住再吐槽一下,CNodeJS 的 API 接口设计得并不一致,响应成功时并不是所有数据都放在data 里面(比如 testToken())。

发觉最近有点上火了 ^_^

32 回复

支持楼主 nodejs像Java那样搞出SSH框架是有必要的,这样会减轻开发负担。但这样一来,也会让nodejs失掉开源精神。

就像现在大学一样,所有学Java的都学SSH框架,但没有几个人真能理解SSH都解决了什么问题。 我希望阿里或其它大公司搞出像SSH的东西,但又害怕这样的东西。真是矛盾。

我觉得不应该吐槽生成器,其实你如果习惯用co,就无所谓promise和生成器了,co4.0都能很好的支持,也能在es7时很方便的替换成async和await。

@klausgao 这不是要吐槽生成器,而是吐槽为何要强迫别人使用生成器。

确实,generator本来不是用来解决异步问题的,虽然有co之类的以后会让generator像async和await,但是需要依赖额外的包,而且给调试也带来了难度,koa 也在放弃generator,回归promise和async/await

来自酷炫的 CNodeMD

支持楼主 自豪地采用 CNodeJS ionic

@leizongmin 雷叔,为什么不告诉我怎么使用生成器…

@leizongmin 如果关于user有很多api操作,一般是把这操作当做类方法放到user类里么?

@fangker 一般是这样吧

就我自身而言,我写 SDK 甚至连 ES6 特性都不会用上,比如 letconst、箭头函数、class 等等。

虽然我自己的项目都会用上这些特性,但是写 SDK 就不会这么任性——我感觉这才是友好的做法。

@zouzhenxing ssh刚开始感觉高大上,其实不适合互联网开发,尤其是敏捷开发,nodejs简单,开发快捷,express,koa,restify等框架很实用

楼主不管是回帖还是文章,都很注重新手操作,大赞

同意,api设计就只用考虑callback和promise就可以。。在应用层有co和es7 await的存在promise一点问题没有

const resolve = ret => {
      _resolve(ret);
      callback && callback(null, ret);
    };

    const reject = err => {
      _reject(err);
      callback && callback(err);
    };

个人觉得 callback没有考虑到同步还是异步的设计,如果不是 promise 要扑街。

@htoooth 这里的promise.resolve和callback都会被调用,你指的是在哪种情况下这种实现会仆街?

@ayiis 恩我知道了,你说的没错。 但是这样写会误导别人,至少我觉得不太合理,因为这样 callback 就直接调用,而不是异步调用。为了证明我的观点,我去找了 parse-js-sdk ,学学他们是怎么做的。看了看,也是使用 promise 的 then 进行异步的调用,没有直接 callback。直接上代码,链接

find 方法支持回调和promise:

find(options?: FullOptions): ParsePromise {
    options = options || {};

    let findOptions = {};
    if (options.hasOwnProperty('useMasterKey')) {
      findOptions.useMasterKey = options.useMasterKey;
    }
    if (options.hasOwnProperty('sessionToken')) {
      findOptions.sessionToken = options.sessionToken;
    }

    let controller = CoreManager.getQueryController();

    return controller.find(
      this.className,
      this.toJSON(),
      findOptions
    ).then((response) => {
      return response.results.map((data) => {
        // In cases of relations, the server may send back a className
        // on the top level of the payload
        let override = response.className || this.className;
        if (!data.className) {
          data.className = override;
        }
        return ParseObject.fromJSON(data, true);
      });
    })._thenRunCallbacks(options);
  }
_thenRunCallbacks(optionsOrCallback, model) {
    var options = {};
    if (typeof optionsOrCallback === 'function') {
      options.success = function(result) {
        optionsOrCallback(result, null);
      };
      options.error = function(error) {
        optionsOrCallback(null, error);
      };
    } else if (typeof optionsOrCallback === 'object') {
      if (typeof optionsOrCallback.success === 'function') {
        options.success = optionsOrCallback.success;
      }
      if (typeof optionsOrCallback.error === 'function') {
        options.error = optionsOrCallback.error;
      }
    }

    return this.then(function(...results) {
      if (options.success) {
        options.success.apply(this, results);
      }
      return ParsePromise.as.apply(ParsePromise, arguments);
    }, function(error) {
      if (options.error) {
        if (typeof model !== 'undefined') {
          options.error(model, error);
        } else {
          options.error(error);
        }
      }
      // By explicitly returning a rejected Promise, this will work with
      // either jQuery or Promises/A+ semantics.
      return ParsePromise.error(error);
    });
  }

@iyuq 想问一下generator应该用来解决什么问题

@htoooth 如果一定要保证异步,可以这样:

const resolve = ret => {
  _resolve(ret);
  callback && process.nextTick(() => callback(null, ret));
};

const reject = err => {
  _reject(err);
  callback && process.nextTick(() => callback(err));
};

不过在平时使用的时候,我从来不会假设callback就一定是异步的,所以习惯上不会专门处理以保证它一定不能同步调用

@htoooth 关于你提到的这个问题,我也尝试通过网上搜索别人关于callback的看法,比如这篇文章:http://mao.li/javascript/javascript-callback-function/ 其观点是,callback 不一定用于异步,一般同步(阻塞)的场景下也经常用到回调,比如要求执行某些操作后执行回调函数。,与我的想法是相吻合的

@leizongmin 恩,理解了。从你的文章中学到了很多的东西,谢谢了!

别吐槽ssh了 首先 现在已经不会有新项目用stucts 然后 springboot这个项目带来了快速开发的希望 并且 sh的门槛其实不高

撸async/await的路过。表示受不了promise,特别是逻辑比较复杂的时候,写起来特别尴尬!

支持撸主,用Promise完全可以达到要求。ali sdk搞的那个generator让我用的时候很angry!

@bajian 接口要写成Promise的才能支持async/await

request 在 browserify的时候太大了,有撒可用的替代库吗?我看很多都是用原生的 http,但是要封装好多功能啊。

@leizongmin 选用了 superagent,大小45k左右,能接受。

学到很多东西,赞

回到顶部