使用Node.js+Express搭建App開發除錯Server
在開發手機端App時,通常會出現移動端新頁面開發的差不多了,後臺介面還沒Ready,導致前後端聯調浪費了大量時間。
聯調過程中又往往涉及到測試服務的切換、抓包驗證以及測試異常資料等測試。進而涉及到App重新打包,配置Charles等抓包工具,後臺改資料等等工作,非常麻煩。
今天就使用Node.js實現一個用來除錯的server,簡化這些除錯工作。
這個server涉及到以下幾個功能:
1. 介面資料的Mock。訪問介面即返回我們定義好的假資料,便於在服務端開發完成前就可以測試介面效果。也便於異常值的驗證。
2. 介面轉發。在聯調階段不需要Mock資料時,可將請求轉發給真正的後臺介面。這樣無需App更改url打包。
3. 抓包列印。將App發給除錯Server的請求以及服務端返回的資料輸出在Web頁面上。這可以滿足最基本的抓包需求。
這樣我們就可以把除錯當中大部分配置工作都放在這個Server上。在Server上做了更改,重啟就可生效,省時省力。
服務搭建
安裝Node與Express框架
首先安裝好Node.js,這個比較簡單,用官網的安裝包就搞定了。
接下來配置Express,使用npm命令
npm install express-generator -g
安裝好以後使用Express的命令列工具生成Server框架。
給server起個名字,比如 avalon
執行命令
express -e avalon
暫時還用不到頁面模板的功能,這裡就使用 -e 引數用ejs作為頁面模板。
Express的工具會生成一個預設的目錄結構,我們在此基礎上開發就可以。
預設的目錄結構大概如下:
.
├── app.js
├── bin
│ └── www
├── package.json
├── public
│ ├── images
│ ├── javascripts
│ └── stylesheets
│ └── style.css
├── routes
│ ├── index.js
│ └── users.js
└── views
├── error.ejs
└── index.ejs
bin下的www是啟動指令碼;public用來提供web頁面的靜態資源;routes資料夾下是請求路由的程式碼;views是頁面模板檔案。
接下來進入到server目錄安裝依賴
cd avalon
npm install save
啟動server
在server目錄下執行命令就可以啟動服務了。
node ./bin/www
Mock介面資料
Mock資料的功能是最好實現的,單純使用Node.js就可以了。引入Express框架可以更方便的配置路由。
假設我們的介面路徑是:
www.test.com/api/business_one/some.action
修改的步驟如下:
新增介面的路由配置
在app.js中
var businessOneRouter = require('./routes/router_one');
app.use('/api', businessOneRouter);
新增路由程式碼
接下來在routes資料夾下新增名為router_one.js的檔案
var express = require('express');
var router = express.Router();
var result = {
"data": {
"location": "北京",
"lat": "39.90498734",
"lon": "116.40528870"
},
"status": "ok"
};
router.get('/business_one/some.action', function(req, res, next) {
res.send(result);
});
module.exports = router;
這樣就完成了介面資料的mock。
介面轉發
介面轉發流程
接下來實現介面的轉發。
為了避免其他應用也通過除錯server轉發,帶來大量無效資料。因此與通用的抓包工具不同,我們僅讓待開發的App請求除錯server——通過更改debug版本中的伺服器host實現。這樣也就不需要手機或PC端配置代理了。
手機端發來請求以後,server向後端發起真正的請求,保持Header及引數與客戶端完全一致,將host替換為真正的伺服器地址。接收到服務端返回結果後,同樣將Header與資料原樣返回給客戶端。
這一大致流程如下:
客戶端發起請求–>除錯server接收–>除錯server向後端發起真正請求–>後端返回結果–>除錯server將結果返回給客戶端
為了方便描述,下面我們使用 cnodejs.org 與 v2ex.com 兩個網站公開的api作為測試介面。
封裝轉發模組
我們將轉發封裝為模組,向外暴露一個介面即可。在專案下建立檔案 proxyHelper.js
。
首先定義一個轉發的入口方法,供我們在路由中呼叫:
function proxyRequest(req, res, hostStr, method) {
let options = optionFactoryWithHost(req, hostStr); // 生成請求配置
if (method === 'https') {
// https的轉發
} else {
// http的轉發
}
}
req 與 res 均為router回撥傳遞進來的引數。考慮到後臺介面的服務可能不同,在這裡需要提供伺服器地址。此外為支援http與https兩種方式訪問,這裡也通過引數來進行處理。
這個介面對外暴露,直接在 router
裡使用:
router.get('/api/nodes/show.json', function (req, res, next) {
proxyRequest(req, res, 'www.v2ex.com', 'https');
});
在 proxyHelper.js
末尾匯出函式
exports.proxyRequest = proxyRequest;
在node中發起請求需要使用 http.request
方法,這個介面的第一個引數為請求的配置。因此在轉發前,先根據傳遞進來的引數生成請求配置
/**
* 建立request的option, 使用者指定host
*/
function optionFactoryWithHost(req, hostStr) {
let option = {
host: hostStr,
path: req.url,
method: req.method,
headers: getHeader(req)
}
return option
}
/**
* 拷貝原request的header欄位
*/
function getHeader(req) {
let ret = {};
for (let i in req.headers) {
if (i !== 'host') { // 去掉host
ret[i] = req.headers[i];
}
}
return ret;
};
請求中的 host 與 path 分別來自 proxyRequest
的 hostStr 與 req 引數。getHeader
函式遍歷並複製客戶端請求中的header欄位。
獲取header後,就可以請求真正的服務端了。我們通過 http.request
發起非同步請求,返回結果後主要做兩件事:
1. 將結果原樣返回給客戶端。
2. 分別抽取請求頭與響應,把資料傳送給指定頁面打log。
第二步稍後再做。考慮到整個操作是非同步的,我們可以使用Promise來封裝,讓程式碼更利於維護。呼叫時就是這個形式:
doRequest(options, req, res)
.then(handleRealResponse) // 響應服務端返回結果
.then(handleMessage) // 向頁面傳送資料
.catch(function (e) {
console.error(`request error: ${e.message}`);
});
完整的 proxyRequest
函式如下:
function proxyRequest(req, res, hostStr, method) {
let options = optionFactoryWithHost(req, hostStr);
console.log(options);
if (method === 'https') {
doRequestHttps(options, req, res)
.then(handleRealResponse)
.then(handleMessage)
.catch(function (e) {
console.error(`request error: ${e.message}`);
});
} else {
doRequest(options, req, res)
.then(handleRealResponse)
.then(handleMessage)
.catch(function (e) {
console.error(`request error: ${e.message}`);
});
}
}
接下來是 doRequest
的實現( doRequestHttps
替換為 https.request
即可,其餘一致):
/**
* 使用http請求介面
*/
function doRequest(options, req, res) {
return new Promise(function (resolve, reject) {
// 請求真正的api介面
const innerReq = http.request(options, (innerRes) => {
let data = {
'req': req,
'res': res,
'options': options,
'_res': innerRes
};
resolve(data);
});
innerReq.on('error', (e) => {
reject(e);
});
innerReq.end();
});
}
Promise 的 resolve
只有一個引數,因此將所有需要的引數封裝到物件中。這裡的 resolve
對應 handleRealResponse
函式:
function handleRealResponse(data) {
return new Promise(function (resolve, reject) {
let req = data['req']; // 客戶端請求
let res = data['res']; // 返回給客戶端的response
let options = data['options']; // 請求引數
let _res = data['_res']; // 服務端的返回
console.log(`STATUS: ${_res.statusCode}`);
console.log(`HEADERS: ${JSON.stringify(_res.headers)}`);
res.writeHead(_res.statusCode, _res.headers);
let gzip = null;
let responseData = '';
if (_res.headers['content-encoding'] == 'gzip') { // gzip壓縮情況下
console.log('handle gzip response');
gzip = zlib.createGunzip();
_res.on('data', (chunk) => {
res.write(chunk);
gzip.write(chunk);
});
_res.on('end', () => {
console.log('res complete');
res.end();
gzip.end();
});
gzip.on('data', (chunk) => {
responseData += chunk;
});
gzip.on('end', () => { // 由gzip流end向socket.io傳送
let data = {
'req': req,
'type': 'request log',
'message': 'request header: ' + JSON.stringify(options) + ' response data: ' + responseData
}
resolve(data);
});
} else { // 未壓縮情況下
console.log('handle normal response');
_res.on('data', (chunk) => {
res.write(chunk);
responseData += chunk;
});
_res.on('end', () => {
console.log('res complete');
res.end();
let data = {
'req': req,
'type': 'request log',
'message': 'request header: ' + JSON.stringify(options) + ' response data: ' + responseData
}
resolve(data);
});
}
});
}
handleRealResponse
中 res 是要給客戶端返回的結果, _res 是服務端返回給除錯server的結果。在這裡有以下幾步處理
1. 把要返回的header複製給客戶端
2. 判斷服務端是否使用gzip對資料進行了壓縮,若有壓縮建立一個gzip資料流還原資料
3. 在服務端 _res 的 data 事件中向客戶端 res 與 gzip流(資料壓縮的情況下)複製資料
4. 在 _res 或 gzip流 的 end 事件中呼叫 resolve
觸發下一步處理
抓包列印
客戶端每請求一次介面,就向指定頁面傳送請求資料。這與聊天軟體的場景比較相似,因此可以使用socket.io庫來實現這個功能。恰好socket.io官網的demo就是聊天室服務,直接在這個demo基礎上做更改就可以了。參考地址( https://socket.io/get-started/chat/ )
新增log檢視頁面
首先把Socket.io 官網Demo中的頁面檔案複製到專案下,做一些更改(把聊天傳送訊息相關程式碼刪掉):
<!doctype html>
<html>
<head>
<title>Avalon Test Page</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font: 13px Helvetica, Arial;
}
#messages {
list-style-type: none;
margin: 0;
padding: 0;
}
#messages div {
padding: 5px 10px;
}
#messages div:nth-child(odd) {
background: #eee;
}
</style>
<script src="/javascripts/socket.io.dev.js"></script>
<script src="https://code.jquery.com/jquery-1.11.1.js"></script>
<script>
$(function () {
var socket = io();
socket.on('request log', function (msg) {
$('#messages').append($('<div>')).text(msg));
});
});
</script>
</head>
<body>
<div id="messages"></div>
</body>
</html>
頁面檔案在使用者訪問特定url時通過瀏覽器載入,因此需要在express配置一個路由。
app.js
中新增
var logRouter = require('./routes/log_page');
app.use('/avalon_log', logRouter);
routes資料夾下新增 log_page.js
檔案
var express = require('express');
var path = require('path');
var router = express.Router();
router.get('/', function(req, res, next) {
res.sendFile(path.join(__dirname, '../pages', 'log_page.html'));
});
module.exports = router;
需要注意express的router中必須使用絕對路徑,因此通過 path.join
生成。
在頁面載入時就會初始化客戶端的 socket.io ,我們定義接收訊息的名稱是request log。相應的,服務端轉發模組下添加發送訊息的函式 handleMessage
function handleMessage(data) {
if (data === null) {
return;
}
let req = data['req'];
let type = data['type'];
let message = data['message'];
emitData(req, type, message);
}
function emitData(req, type, message) {
let io = req.app.get('logcaster');
if (io !== null) {
io.emit(type, message);
}
}
內部呼叫 emitData
傳送資料。這裡的socket.io物件是通過下面這條語句獲取的。
let io = req.app.get('logcaster');
我們已經在最初的Promise呼叫中設定 handleMessage
函數了,因此無需再新增呼叫。
引入socket.io庫
服務端的socket.io需要在服務端啟動時初始化。在bin/www檔案中新增初始化程式碼:
var server = http.createServer(app);
var io = require('socket.io')(server);
app.set('logcaster', io); // 把socket.io新增到全域性物件中,以便router中獲取
io.on('connection', function(socket){
console.log('a user connected'); // 客戶端有連線上後,列印一條語句
});
這樣socket.io相關的程式碼就編寫完成了。
但此時專案中還沒有socket.io的庫檔案。向package.json中新增依賴
"dependencies": {
"socket.io": "^2.1.0"
}
並使用npm命令安裝
npm install save
剛才我們在頁面檔案中指定了客戶端載入socket.io程式碼的路徑,
src=”/javascripts/socket.io.dev.js”
因此我們需要把socket.io的庫檔案拷貝到 javascripts 資料夾下。庫檔案在npm命令安裝後,可在 node_modules 資料夾裡找到。
使用server
現在除錯server的基本功能就開發完成了。可以使用幾個介面簡單測試一下。
直接在 index.js
裡新增幾個路由:
const express = require('express');
const router = express.Router();
const proxyRequest = require('../proxyHelper').proxyRequest; // 引入轉發模組
router.get('/api/v1/user/alsotang', function (req, res, next) {
proxyRequest(req, res, 'cnodejs.org', 'https');
});
router.get('/api/topics/latest.json', function (req, res, next) {
proxyRequest(req, res, 'www.v2ex.com', 'https');
});
router.get('/api/nodes/show.json', function (req, res, next) {
proxyRequest(req, res, 'www.v2ex.com', 'https');
});
module.exports = router;
在命令列下啟動server
node ./bin/www
開啟瀏覽器訪問頁面 http://127.0.0.1:3000/avalon_log
我們在socket.io中配置了connection事件的處理,因此node的控制檯上會輸出 a user connected
應該可以看到瀏覽器顯示了正確的json資料,同時log頁面上刷新出了最新的請求資料資訊。
總結
每次新增新的介面,需要以下幾步
1. 新增一個router
2. 在router中新增轉發或mock資料邏輯
3. 重啟伺服器
4. 開啟log頁面,以便檢視請求抓包結果
TODO
目前這個除錯Server基本能用,但非常簡陋,有很多可以完善的地方:
1. 完善post介面的轉發
2. 請求log是實時傳送到連線了服務端的頁面,可以使用資料庫持久化儲存
3. 一些特殊請求,資料中的特殊字元的處理
4. Mock資料需要每次更改server原始碼並重啟,可以新增上傳mock資料或從檔案讀取的功能
5. log檢視頁面的優化
附: 工程連線