愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

Egg学习笔记(四):HttpClient

Egg基于urllib内置实现了HttpClient,因此在应用里可以很方便地发起HTTP请求

1、使用HttpClient

  • 框架初始化时,会将HttpClient实例化到app.httpClient,故可以通过app.httpClient或者app.curl(url, options)来发起请求
  • 框架也在Context中提供了ctx.curl(url, options)ctx.httpClient,故在可以使用Context实例的地方,也可以很方便地使用HttpClient

例子:

// app/controller/npm.js
class NpmController extends Controller {
    async index() {
        const { ctx } = this
        const result = await ctx.curl('https://registry.npm.taobao.org/egg/latest', {
            dataType: 'json',
            timeout: 3000
        })
        const { status, headers, data: package } = result
        ctx.body = {
            status,
            headers,
            package
        }
    }
}

2、参数介绍

对于curl(url, options),它的参数介绍如下:

  • url 请求的URL
  • options 请求配置
    • method: String,请求方法,不配置的情况下,默认使用GET方法
    • data: Object,请求发送的数据,在GET/HEAD方法中,会通过querystring.stringify(data)处理后拼接到URL的查询参数中;而在POST/PUT/DELETE这些方法中,则会根据contentType进行处理(为json时,通过JSON.stringify(data)处理,其他情况下用querystring.stringify(data)),然后设置为用body发送
    • dataAsQueryString: Boolean,为true时,即使是POST情况下,options.data也会被当做查询字符串拼接到URL中(如此可以解决以stream发送数据,额外的请求参数用URL查询字符串的情况)
    • content: String|Buffer,发送请求正文,若设置了该参数,options.data会被忽略
    • stream: ReadStream,设置发送请求正文的可读数据流,默认为null,一旦设置该参数,data/content两个配置项会被忽略
    • writeStream: WriteStream,设置接受响应数据的可写数据流,默认为null。一旦设置该参数,那么result.data会为null,数据都会被写入writeStream,如:ctx.curl(url, { writeStream: fs.createWriteStream('/path/to/store') })
    • consumeWriteStream: Boolean,是否等待writeStream完全写完才算响应接收完毕(默认为true
    • contentType: String,设置请求数据格式,默认为undefined。一般情况下会根据datacontent参数自动设置,不过当data是object时默认设置的是form
    • dataType: String,设置响应数据格式,默认不对响应数据做任何处理,直接返回原始的Buffer数据。支持textjson两种格式,当设为json时,若解析失败,则会抛出JSONResponseFormatError
    • fixJSONCtlChars: Boolean,是否自动过滤响应数据中的特殊控制字符(U+0000~U+001F,通常会在一些CGI系统返回的JSON数据中包含),默认为false
    • headers: Object,自定义请求头
    • timeout: Number|Array,设置请求超时时间,默认是[5000, 5000](创建连接超时5s,接收响应超时5秒,两个参数都相等时,可以简写为timeout: 5000
    • agent: HttpAgent,允许通过此参数覆盖默认的HttpAgent,若不想开启KeepAlive,可设置该参数为false
    • httpsAgent: HttpsAgent,同上,不过是针对https的
    • auth: String,用于Basic Authentication场景,如:ctx.url(url, { auth: 'user:pwd' })
    • digestAuth: String,摘要登录授权参数,设置此参数会自动对401响应尝试生成Authorization请求头,尝试以授权方式请求一次
    • followRedirect: Boolean,是否跟进重定向,默认为false
    • maxRedirects: Number,设置最大自动跳转次数,避免循环跳转无法终止,默认为10次
    • formatRedirectUrl: Function(from, to),允许自定义实现302/301等跳转URL拼接
    • beforeRequest: Function(options),发送请求前的钩子,这里可以对请求参数做最后一次的修改
    • streaming: Boolean,是否直接返回响应流,默认为false。开启后,HttpClient会在拿到响应对象res后立即返回,此时result.headers/result.status已经可以读取到,但是还没有读取data数据(注意,若result.res没有传给body,那么必须要消费掉)
    • gzip: Boolean,开启后,请求时将设置头部中的Accept-Encoding: gzip,并且自动解压带Content-Encoding: gzip响应头的数据
    • ca/rejectUnauthorized/pfx/key/cert/passphrase/ciphers/secureProtocol,透传给https模块的参数
    • timing: Boolean,是否开启请求各阶段的时间测量,默认为false。开启后,可以通过result.res.timing拿到HTTP请求各阶段的时间测量值(单位是ms),其返回的对象如下:
{
    queue,          // 分配Socket耗时
    dnslookup,      // DNS查询耗时
    connected,      // Socket三次握手连接成功耗时
    requestSent,    // 请求数据完整发送完毕耗时
    waiting,        // 收到第一个字节的响应数据耗时
    contentDownload // 全部响应数据接收完毕耗时
}

此外,HttpClient有一些默认的全局配置,如下:

exports.httpclient = {
    // 是否开启本地DNS缓存,开启后:
    // 1. 所有的DNS查询都会默认优先使用缓存,即使DNS查询错误也不影响应用
    // 2. 对同一个域名,在dnsCacheLookupInterval的间隔内(默认10s)只会查询一次
    enableDNSCache: false,
    // 对同一个域名进行DNS查询的最小间隔时间
    dnsCacheLookupInterval: 10000,
    // DNS同时缓存的最大域名数量
    dnsCacheMaxLength: 1000,
    
    request: {
        timeout: 3000
    }

    httpAgent: {
        // 默认开启http keepAlive
        keepAlive: true,
        // 空闲的KeepAlive socket最长可以存活的时间
        freeSocketKeepAliveTimeout: 4000,
        // 当socket超时没活动,会被处理掉
        timeout: 30000,
        // 允许创建的最大socket数
        maxSockets: Number.MAX_SAFE_INTEGER,
        // 最大空闲socket数
        maxFreeSockets: 256
    }

    httpsAgent: { /* 配置同httpAgent,不过是针对Https */ }
}

若需覆盖这些默认配置,则可以通过修改config/config.default.js进行

3、返回值

一般情况下,请求的返回的result会包含status/headers/和data

  • status,状态码
  • headers,响应头,如:{ 'content-type': 'text/html', ... }
  • data,响应body,默认情况下直接返回Buffer数据,设置了options.dataType后则对返回的数据进行相应的处理

1)Form表单情况

// app/controller/npm.js
class NpmController extends Controller {
    async submit() {
        const { ctx } = this
        const result = await ctx.curl('https://httpbin.org/post', {
            method: 'POST',
            // 由于默认情况下会使用`application/x-www-form-urlencoded`,所以不需要指定contentType
            data: {
                now: Date.now(),
                foo: 'bar'
            }
            dataType: 'json'
        })
        ctx.body = result.body.form
    }
}

2)Multipart方式上传文件
若一个表单包含了文件,那么请求数据则需要以multipart/form-data方式进行提交,需要引入formstream模块,如下:

// app/controller/npm.js
const FormStream = require('formstream')
class NpmController extends Controller {
    async upload() {
        const { ctx } = this
        const form = new FormStream()
        // 设置普通的表单域
        form.field('foo', 'bar')
        // 上传文件
        form.file('file1', path.resolve('path/to/file1'))
        form.file('file2', path.resolve('path/to/file2'))
        const result = await ctx.curl('https://httpbin.org/post', {
            method: 'POST',
            headers: form.headers(),
            stream: form,
            dataType: 'json'
        })
        ctx.body = result.data.files
    }
}

3)Stream方式上传文件
若服务端支持流式上传,那么还可以直接发送Stream。Stream实际会以Transfer-Encoding: chunked传输编码格式发送。实例如下:

// app/controller/npm.js
const fs = require('fs')
const FormStream = require('formstream')
class NpmController extends Controller {
    async uploadByStream() {
        const { ctx } = this
        const fileStream = fs.createReadStream(__filename)
        const url = `${ctx.protocol}://${ctx.host}/stream`
        const result = await ctx.curl(url, {
            method: 'POST',
            stream: fileStream
        })
        ctx.status = result.status
        ctx.set(result.headers)
        ctx.body = result.data
    }
}

4、事件

框架提供了requestresponse两个事件,可以用于统一tracer日志,对于HttpClient而言,它的生命周期中会进行如下过程:

初始化options -> 发送`request`事件 -> 发送请求、接收响应 -> 发送`response`事件 -> 结束

因此,request发生在网络操作发生之前,response则发生在网络操作结束之后,如:

app.httpclient.on('request', req => {
    req.url // 获取请求url
    req.ctx // 获取请求上下文
    // ...
})
app.httpclient.on('response', result => {
    result.res.status
    result.ctx
    result.req
})