Egg HttpClient

2020-02-06 14:11 更新

互聯(lián)網(wǎng)時代,無數(shù)服務(wù)是基于 HTTP 協(xié)議進行通信的,Web 應(yīng)用調(diào)用后端 HTTP 服務(wù)是一種非常常見的應(yīng)用場景。

為此框架基于 urllib 內(nèi)置實現(xiàn)了一個 HttpClient,應(yīng)用可以非常便捷地完成任何 HTTP 請求。

通過 app 使用 HttpClient

框架在應(yīng)用初始化的時候,會自動將 HttpClient 初始化到 app.httpclient。 同時增加了一個 app.curl(url, options) 方法,它等價于 app.httpclient.request(url, options)。

這樣就可以非常方便地使用 app.curl 方法完成一次 HTTP 請求。

// app.js
module.exports = app => {
app.beforeStart(async () => {
// 示例:啟動的時候去讀取 https://registry.npm.taobao.org/egg/latest 的版本信息
const result = await app.curl('https://registry.npm.taobao.org/egg/latest', {
dataType: 'json',
});
app.logger.info('Egg latest version: %s', result.data.version);
});
};

通過 ctx 使用 HttpClient

框架在 Context 中同樣提供了 ctx.curl(url, options) 和 ctx.httpclient,保持跟 app 下的使用體驗一致。 這樣就可以在有 Context 的地方(如在 controller 中)非常方便地使用 ctx.curl() 方法完成一次 HTTP 請求。

// app/controller/npm.js
class NpmController extends Controller {
async index() {
const ctx = this.ctx;

// 示例:請求一個 npm 模塊信息
const result = await ctx.curl('https://registry.npm.taobao.org/egg/latest', {
// 自動解析 JSON response
dataType: 'json',
// 3 秒超時
timeout: 3000,
});

ctx.body = {
status: result.status,
headers: result.headers,
package: result.data,
};
}
}

基本 HTTP 請求

HTTP 已經(jīng)被廣泛大量使用,盡管 HTTP 有多種請求方式,但是萬變不離其宗,我們先以基本的4個請求方法為例子, 逐步講解一下更多的復雜應(yīng)用場景。

以下例子都會在 controller 代碼中對 https://httpbin.org 發(fā)起請求來完成。

GET

讀取數(shù)據(jù)幾乎都是使用 GET 請求,它是 HTTP 世界最常見的一種,也是最廣泛的一種,它的請求參數(shù)也是最容易構(gòu)造的。

// app/controller/npm.js
class NpmController extends Controller {
async get() {
const ctx = this.ctx;
const result = await ctx.curl('https://httpbin.org/get?foo=bar');
ctx.status = result.status;
ctx.set(result.headers);
ctx.body = result.data;
}
}
  • GET 請求可以不用設(shè)置 options.method 參數(shù),HttpClient 的默認 method 會設(shè)置為 GET。
  • 返回值 result 會包含 3 個屬性:status, headers 和 datastatus: 響應(yīng)狀態(tài)碼,如 200, 302, 404, 500 等等headers: 響應(yīng)頭,類似 { 'content-type': 'text/html', ... }data: 響應(yīng) body,默認 HttpClient 不會做任何處理,會直接返回 Buffer 類型數(shù)據(jù)。 一旦設(shè)置了 options.dataType,HttpClient 將會根據(jù)此參數(shù)對 data 進行相應(yīng)的處理。

完整的請求參數(shù) options 和返回值 result 的說明請看下文的 options 參數(shù)詳解 章節(jié)。

POST

創(chuàng)建數(shù)據(jù)的場景一般來說都會使用 POST 請求,它相對于 GET 來說多了請求 body 這個參數(shù)。

以發(fā)送 JSON body 的場景舉例:

// app/controller/npm.js
class NpmController extends Controller {
async post() {
const ctx = this.ctx;
const result = await ctx.curl('https://httpbin.org/post', {
// 必須指定 method
method: 'POST',
// 通過 contentType 告訴 HttpClient 以 JSON 格式發(fā)送
contentType: 'json',
data: {
hello: 'world',
now: Date.now(),
},
// 明確告訴 HttpClient 以 JSON 格式處理返回的響應(yīng) body
dataType: 'json',
});
ctx.body = result.data;
}
}

下文還會詳細講解以 POST 實現(xiàn) Form 表單提交和文件上傳的功能。

PUT

PUT 與 POST 類似,它更加適合更新數(shù)據(jù)和替換數(shù)據(jù)的語義。 除了 method 參數(shù)需要設(shè)置為 PUT,其他參數(shù)幾乎跟 POST 一模一樣。

// app/controller/npm.js
class NpmController extends Controller {
async put() {
const ctx = this.ctx;
const result = await ctx.curl('https://httpbin.org/put', {
// 必須指定 method
method: 'PUT',
// 通過 contentType 告訴 HttpClient 以 JSON 格式發(fā)送
contentType: 'json',
data: {
update: 'foo bar',
},
// 明確告訴 HttpClient 以 JSON 格式處理響應(yīng) body
dataType: 'json',
});
ctx.body = result.data;
}
}

DELETE

刪除數(shù)據(jù)會選擇 DELETE 請求,它通??梢圆恍枰诱埱?body,但是 HttpClient 不會限制。

// app/controller/npm.js
class NpmController extends Controller {
async del() {
const ctx = this.ctx;
const result = await ctx.curl('https://httpbin.org/delete', {
// 必須指定 method
method: 'DELETE',
// 明確告訴 HttpClient 以 JSON 格式處理響應(yīng) body
dataType: 'json',
});
ctx.body = result.data;
}
}

高級 HTTP 請求

在真實的應(yīng)用場景下,還是會包含一些較為復雜的 HTTP 請求。

Form 表單提交

面向瀏覽器設(shè)計的 Form 表單(不包含文件)提交接口,通常都要求以 content-type: application/x-www-form-urlencoded 的格式提交請求數(shù)據(jù)。

// app/controller/npm.js
class NpmController extends Controller {
async submit() {
const ctx = this.ctx;
const result = await ctx.curl('https://httpbin.org/post', {
// 必須指定 method,支持 POST,PUT 和 DELETE
method: 'POST',
// 不需要設(shè)置 contentType,HttpClient 會默認以 application/x-www-form-urlencoded 格式發(fā)送請求
data: {
now: Date.now(),
foo: 'bar',
},
// 明確告訴 HttpClient 以 JSON 格式處理響應(yīng) body
dataType: 'json',
});
ctx.body = result.data.form;
// 響應(yīng)最終會是類似以下的結(jié)果:
// {
// "foo": "bar",
// "now": "1483864184348"
// }
}
}

以 Multipart 方式上傳文件

當一個 Form 表單提交包含文件的時候,請求數(shù)據(jù)格式就必須以 multipart/form-data 進行提交了。

urllib 內(nèi)置了 formstream 模塊來幫助我們生成可以被消費的 form 對象。

// app/controller/http.js
class HttpController extends Controller {
async upload() {
const { ctx } = this;

const result = await ctx.curl('https://httpbin.org/post', {
method: 'POST',
dataType: 'json',
data: {
foo: 'bar',
},

// 單文件上傳
files: __filename,

// 多文件上傳
// files: {
// file1: __filename,
// file2: fs.createReadStream(__filename),
// file3: Buffer.from('mock file content'),
// },
});

ctx.body = result.data.files;
// 響應(yīng)最終會是類似以下的結(jié)果:
// {
// "file": "'use strict';\n\nconst For...."
// }
}
}

以 Stream 方式上傳文件

其實,在 Node.js 的世界里面,Stream 才是主流。 如果服務(wù)端支持流式上傳,最友好的方式還是直接發(fā)送 Stream。 Stream 實際會以 Transfer-Encoding: chunked 傳輸編碼格式發(fā)送,這個轉(zhuǎn)換是 HTTP 模塊自動實現(xiàn)的。

// app/controller/npm.js
const fs = require('fs');
const FormStream = require('formstream');
class NpmController extends Controller {
async uploadByStream() {
const ctx = this.ctx;
// 上傳當前文件本身用于測試
const fileStream = fs.createReadStream(__filename);
// httpbin.org 不支持 stream 模式,使用本地 stream 接口代替
const url = `${ctx.protocol}://${ctx.host}/stream`;
const result = await ctx.curl(url, {
// 必須指定 method,支持 POST,PUT
method: 'POST',
// 以 stream 模式提交
stream: fileStream,
});
ctx.status = result.status;
ctx.set(result.headers);
ctx.body = result.data;
// 響應(yīng)最終會是類似以下的結(jié)果:
// {"streamSize":574}
}
}

options 參數(shù)詳解

由于 HTTP 請求的復雜性,導致 httpclient.request(url, options) 的 options 參數(shù)會非常多。 接下來將會以參數(shù)說明和代碼配合一起講解每個可選參數(shù)的實際用途。

HttpClient 默認全局配置

// config/config.default.js
exports.httpclient = {
// 是否開啟本地 DNS 緩存,默認關(guān)閉,開啟后有兩個特性
// 1. 所有的 DNS 查詢都會默認優(yōu)先使用緩存的,即使 DNS 查詢錯誤也不影響應(yīng)用
// 2. 對同一個域名,在 dnsCacheLookupInterval 的間隔內(nèi)(默認 10s)只會查詢一次
enableDNSCache: false,
// 對同一個域名進行 DNS 查詢的最小間隔時間
dnsCacheLookupInterval: 10000,
// DNS 同時緩存的最大域名數(shù)量,默認 1000
dnsCacheMaxLength: 1000,

request: {
// 默認 request 超時時間
timeout: 3000,
},

httpAgent: {
// 默認開啟 http KeepAlive 功能
keepAlive: true,
// 空閑的 KeepAlive socket 最長可以存活 4 秒
freeSocketTimeout: 4000,
// 當 socket 超過 30 秒都沒有任何活動,就會被當作超時處理掉
timeout: 30000,
// 允許創(chuàng)建的最大 socket 數(shù)
maxSockets: Number.MAX_SAFE_INTEGER,
// 最大空閑 socket 數(shù)
maxFreeSockets: 256,
},

httpsAgent: {
// 默認開啟 https KeepAlive 功能
keepAlive: true,
// 空閑的 KeepAlive socket 最長可以存活 4 秒
freeSocketTimeout: 4000,
// 當 socket 超過 30 秒都沒有任何活動,就會被當作超時處理掉
timeout: 30000,
// 允許創(chuàng)建的最大 socket 數(shù)
maxSockets: Number.MAX_SAFE_INTEGER,
// 最大空閑 socket 數(shù)
maxFreeSockets: 256,
},
};

應(yīng)用可以通過 config/config.default.js 覆蓋此配置。

data: Object

需要發(fā)送的請求數(shù)據(jù),根據(jù) method 自動選擇正確的數(shù)據(jù)處理方式。

  • GET,HEAD:通過 querystring.stringify(data) 處理后拼接到 url 的 query 參數(shù)上。
  • POST,PUT 和 DELETE 等:需要根據(jù) contentType 做進一步判斷處理。contentType = json:通過 JSON.stringify(data) 處理,并設(shè)置為 body 發(fā)送。其他:通過 querystring.stringify(data) 處理,并設(shè)置為 body 發(fā)送。
// GET + data
ctx.curl(url, {
data: { foo: 'bar' },
});

// POST + data
ctx.curl(url, {
method: 'POST',
data: { foo: 'bar' },
});

// POST + JSON + data
ctx.curl(url, {
method: 'POST',
contentType: 'json',
data: { foo: 'bar' },
});

dataAsQueryString: Boolean

如果設(shè)置了 dataAsQueryString=true,那么即使在 POST 情況下, 也會強制將 options.data 以 querystring.stringify 處理之后拼接到 url 的 query 參數(shù)上。

可以很好地解決以 stream 發(fā)送數(shù)據(jù),且額外的請求參數(shù)以 url query 形式傳遞的應(yīng)用場景:

ctx.curl(url, {
method: 'POST',
dataAsQueryString: true,
data: {
// 一般來說都是 access token 之類的權(quán)限驗證參數(shù)
accessToken: 'some access token value',
},
stream: myFileStream,
});

content: String|Buffer

發(fā)送請求正文,如果設(shè)置了此參數(shù),那么會直接忽略 data 參數(shù)。

ctx.curl(url, {
method: 'POST',
// 直接發(fā)送原始 xml 數(shù)據(jù),不需要 HttpClient 做特殊處理
content: '<xml><hello>world</hello></xml>',
headers: {
'content-type': 'text/html',
},
});

files: Mixed

文件上傳,支持格式: String | ReadStream | Buffer | Array | Object。

ctx.curl(url, {
method: 'POST',
files: '/path/to/read',
data: {
foo: 'other fields',
},
});

多文件上傳:

ctx.curl(url, {
method: 'POST',
files: {
file1: '/path/to/read',
file2: fs.createReadStream(__filename),
file3: Buffer.from('mock file content'),
},
data: {
foo: 'other fields',
},
});

stream: ReadStream

設(shè)置發(fā)送請求正文的可讀數(shù)據(jù)流,默認是 null。 一旦設(shè)置了此參數(shù),HttpClient 將會忽略 data 和 content。

ctx.curl(url, {
method: 'POST',
stream: fs.createReadStream('/path/to/read'),
});

writeStream: WriteStream

設(shè)置接受響應(yīng)數(shù)據(jù)的可寫數(shù)據(jù)流,默認是 null。 一旦設(shè)置此參數(shù),那么返回值 result.data 將會被設(shè)置為 null, 因為數(shù)據(jù)已經(jīng)全部寫入到 writeStream 中了。

ctx.curl(url, {
writeStream: fs.createWriteStream('/path/to/store'),
});

consumeWriteStream: Boolean

是否等待 writeStream 完全寫完才算響應(yīng)全部接收完畢,默認是 true。 此參數(shù)不建議修改默認值,除非我們明確知道它的副作用是可接受的, 否則很可能會導致 writeStream 數(shù)據(jù)不完整。

method: String

設(shè)置請求方法,默認是 GET。 支持 GET、POST、PUT、DELETE、PATCH 等所有 HTTP 方法。

contentType: String

設(shè)置請求數(shù)據(jù)格式,默認是 undefined,HttpClient 會自動根據(jù) data 和 content 參數(shù)自動設(shè)置。 data 是 object 的時候默認設(shè)置的是 form。支持 json 格式。

如需要以 JSON 格式發(fā)送 data:

ctx.curl(url, {
method: 'POST',
data: {
foo: 'bar',
now: Date.now(),
},
contentType: 'json',
});

dataType: String

設(shè)置響應(yīng)數(shù)據(jù)格式,默認不對響應(yīng)數(shù)據(jù)做任何處理,直接返回原始的 buffer 格式數(shù)據(jù)。 支持 text 和 json 兩種格式。

注意:設(shè)置成 json 時,如果響應(yīng)數(shù)據(jù)解析失敗會拋 JSONResponseFormatError 異常。

const jsonResult = await ctx.curl(url, {
dataType: 'json',
});
console.log(jsonResult.data);

const htmlResult = await ctx.curl(url, {
dataType: 'text',
});
console.log(htmlResult.data);

fixJSONCtlChars: Boolean

是否自動過濾響應(yīng)數(shù)據(jù)中的特殊控制字符 (U+0000 ~ U+001F),默認是 false。 通常一些 CGI 系統(tǒng)返回的 JSON 數(shù)據(jù)會包含這些特殊控制字符,通過此參數(shù)可以自動過濾掉它們。

ctx.curl(url, {
fixJSONCtlChars: true,
dataType: 'json',
});

headers: Object

自定義請求頭。

ctx.curl(url, {
headers: {
'x-foo': 'bar',
},
});

timeout: Number|Array

請求超時時間,默認是 [ 5000, 5000 ],即創(chuàng)建連接超時是 5 秒,接收響應(yīng)超時是 5 秒。

ctx.curl(url, {
// 創(chuàng)建連接超時 3 秒,接收響應(yīng)超時 3 秒
timeout: 3000,
});

ctx.curl(url, {
// 創(chuàng)建連接超時 1 秒,接收響應(yīng)超時 30 秒,用于響應(yīng)比較大的場景
timeout: [ 1000, 30000 ],
});

agent: HttpAgent

允許通過此參數(shù)覆蓋默認的 HttpAgent,如果你不想開啟 KeepAlive,可以設(shè)置此參數(shù)為 false。

ctx.curl(url, {
agent: false,
});

httpsAgent: HttpsAgent

允許通過此參數(shù)覆蓋默認的 HttpsAgent,如果你不想開啟 KeepAlive,可以設(shè)置此參數(shù)為 false。

ctx.curl(url, {
httpsAgent: false,
});

auth: String

簡單登錄授權(quán)(Basic Authentication)參數(shù),將以明文方式將登錄信息以 Authorization 請求頭發(fā)送出去。

ctx.curl(url, {
// 參數(shù)必須按照 `user:password` 格式設(shè)置
auth: 'foo:bar',
});

digestAuth: String

摘要登錄授權(quán)(Digest Authentication)參數(shù),設(shè)置此參數(shù)會自動對 401 響應(yīng)嘗試生成 Authorization 請求頭, 嘗試以授權(quán)方式請求一次。

ctx.curl(url, {
// 參數(shù)必須按照 `user:password` 格式設(shè)置
digestAuth: 'foo:bar',
});

followRedirect: Boolean

是否自動跟進 3xx 的跳轉(zhuǎn)響應(yīng),默認是 false。

ctx.curl(url, {
followRedirect: true,
});

maxRedirects: Number

設(shè)置最大自動跳轉(zhuǎn)次數(shù),避免循環(huán)跳轉(zhuǎn)無法終止,默認是 10 次。 此參數(shù)不宜設(shè)置過大,它只在 followRedirect=true 情況下才會生效。

ctx.curl(url, {
followRedirect: true,
// 最大只允許自動跳轉(zhuǎn) 5 次。
maxRedirects: 5,
});

formatRedirectUrl: Function(from, to)

允許我們通過 formatRedirectUrl 自定義實現(xiàn) 302、301 等跳轉(zhuǎn) url 拼接, 默認是 url.resolve(from, to)。

ctx.curl(url, {
formatRedirectUrl: (from, to) => {
// 例如可在這里修正跳轉(zhuǎn)不正確的 url
if (to === '//foo/') {
to = '/foo';
}
return url.resolve(from, to);
},
});

beforeRequest: Function(options)

HttpClient 在請求正式發(fā)送之前,會嘗試調(diào)用 beforeRequest 鉤子,允許我們在這里對請求參數(shù)做最后一次修改。

ctx.curl(url, {
beforeRequest: options => {
// 例如我們可以設(shè)置全局請求 id,方便日志跟蹤
options.headers['x-request-id'] = uuid.v1();
},
});

streaming: Boolean

是否直接返回響應(yīng)流,默認為 false。 開啟 streaming 之后,HttpClient 會在拿到響應(yīng)對象 res 之后馬上返回, 此時 result.headers 和 result.status 已經(jīng)可以讀取到,只是沒有讀取 data 數(shù)據(jù)而已。

const result = await ctx.curl(url, {
streaming: true,
});

console.log(result.status, result.data);
// result.res 是一個 ReadStream 對象
ctx.body = result.res;

注意:如果 res 不是直接傳遞給 body,那么我們必須消費這個 stream,并且要做好 error 事件處理。

gzip: Boolean

是否支持 gzip 響應(yīng)格式,默認為 false。 開啟 gzip 之后,HttpClient 將自動設(shè)置 Accept-Encoding: gzip 請求頭, 并且會自動解壓帶 Content-Encoding: gzip 響應(yīng)頭的數(shù)據(jù)。

ctx.curl(url, {
gzip: true,
});

timing: Boolean

是否開啟請求各階段的時間測量,默認為 false。 開啟 timing 之后,可以通過 result.res.timing 拿到這次 HTTP 請求各階段的時間測量值(單位是毫秒), 通過這些測量值,我們可以非常方便地定位到這次請求最慢的環(huán)境發(fā)生在那個階段,效果如同 Chrome network timing 的作用。

timing 各階段測量值解析:

  • queuing:分配 socket 耗時
  • dnslookup:DNS 查詢耗時
  • connected:socket 三次握手連接成功耗時
  • requestSent:請求數(shù)據(jù)完整發(fā)送完畢耗時
  • waiting:收到第一個字節(jié)的響應(yīng)數(shù)據(jù)耗時
  • contentDownload:全部響應(yīng)數(shù)據(jù)接收完畢耗時
const result = await ctx.curl(url, {
timing: true,
});
console.log(result.res.timing);
// {
// "queuing":29,
// "dnslookup":37,
// "connected":370,
// "requestSent":1001,
// "waiting":1833,
// "contentDownload":3416
// }

ca,rejectUnauthorized,pfx,key,cert,passphrase,ciphers,secureProtocol

這幾個都是透傳給 HTTPS 模塊的參數(shù),具體請查看 https.request(options, callback)。

調(diào)試輔助

如果你需要對 HttpClient 的請求進行抓包調(diào)試,可以添加以下配置到 config.local.js:

// config.local.js
module.exports = () => {
const config = {};

// add http_proxy to httpclient
if (process.env.http_proxy) {
config.httpclient = {
request: {
enableProxy: true,
rejectUnauthorized: false,
proxy: process.env.http_proxy,
},
};
}

return config;
}

然后啟動你的抓包工具,如 charles 或 fiddler

最后通過以下指令啟動應(yīng)用:

$ http_proxy=http://127.0.0.1:8888 npm run dev

然后就可以正常操作了,所有經(jīng)過 HttpClient 的請求,都可以你的抓包工具中查看到。

常見錯誤

創(chuàng)建連接超時

  • 異常名稱:ConnectionTimeoutError
  • 出現(xiàn)場景:通常是 DNS 查詢比較慢,或者客戶端與服務(wù)端之間的網(wǎng)絡(luò)速度比較慢導致的。
  • 排查建議:請適當增大 timeout 參數(shù)。

服務(wù)響應(yīng)超時

  • 異常名稱:ResponseTimeoutError
  • 出現(xiàn)場景:通常是客戶端與服務(wù)端之間網(wǎng)絡(luò)速度比較慢,并且響應(yīng)數(shù)據(jù)比較大的情況下會發(fā)生。
  • 排查建議:請適當增大 timeout 參數(shù)。

服務(wù)主動斷開連接

  • 異常名稱:ResponseError, code: ECONNRESET
  • 出現(xiàn)場景:通常是服務(wù)端主動斷開 socket 連接,導致 HTTP 請求鏈路異常。
  • 排查建議:請檢查當時服務(wù)端是否發(fā)生網(wǎng)絡(luò)異常。

服務(wù)不可達

  • 異常名稱:RequestError, code: ECONNREFUSED, status: -1
  • 出現(xiàn)場景:通常是因為請求的 url 所屬 IP 或者端口無法連接成功。
  • 排查建議:請確保 IP 或者端口設(shè)置正確。

域名不存在

  • 異常名稱:RequestError, code: ENOTFOUND, status: -1
  • 出現(xiàn)場景:通常是因為請求的 url 所在的域名無法通過 DNS 解析成功。
  • 排查建議:請確保域名存在,也需要排查一下 DNS 服務(wù)是否配置正確。

JSON 響應(yīng)數(shù)據(jù)格式錯誤

  • 異常名稱:JSONResponseFormatError
  • 出現(xiàn)場景:設(shè)置了 dataType=json 并且響應(yīng)數(shù)據(jù)不符合 JSON 格式,就會拋出此異常。
  • 排查建議:確保服務(wù)端無論在什么情況下都要正確返回 JSON 格式的數(shù)據(jù)。

全局 request 和 response 事件

在企業(yè)應(yīng)用場景,常常會有統(tǒng)一 tracer 日志的需求。 為了方便在 app 層面統(tǒng)一監(jiān)聽 HttpClient 的請求和響應(yīng),我們約定了全局 request 和 response 來暴露這兩個事件。

init options
|
V
emit `request` event
|
V
send request and receive response
|
V
emit `response` event
|
V
end

request 事件:發(fā)生在網(wǎng)絡(luò)操作發(fā)生之前

請求發(fā)送之前,會觸發(fā)一個 request 事件,允許對請求做攔截。

app.httpclient.on('request', req => {
req.url //請求 url
req.ctx //是發(fā)起這次請求的當前上下文

// 可以在這里設(shè)置一些 trace headers,方便全鏈路跟蹤
});

response 事件:發(fā)生在網(wǎng)絡(luò)操作結(jié)束之后

請求結(jié)束之后會觸發(fā)一個 response 事件,這樣外部就可以訂閱這個事件打印日志。

app.httpclient.on('response', result => {
result.res.status
result.ctx //是發(fā)起這次請求的當前上下文
result.req //對應(yīng)的 req 對象,即 request 事件里面那個 req
});

示例代碼

完整示例代碼可以在 eggjs/examples/httpclient 找到。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號