1. 程式人生 > >使用Node.js+Express搭建App開發除錯Server

使用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.orgv2ex.com 兩個網站公開的api作為測試介面。

封裝轉發模組

我們將轉發封裝為模組,向外暴露一個介面即可。在專案下建立檔案 proxyHelper.js
首先定義一個轉發的入口方法,供我們在路由中呼叫:

function proxyRequest(req, res, hostStr, method) {
  let options = optionFactoryWithHost(req, hostStr);  // 生成請求配置

  if (method === 'https') {
    // https的轉發
  } else {
    // http的轉發
  }
}

reqres 均為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;
};

請求中的 hostpath 分別來自 proxyRequesthostStrreq 引數。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();
  });
}

Promiseresolve 只有一個引數,因此將所有需要的引數封裝到物件中。這裡的 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);
      });
    }
  });
}

handleRealResponseres 是要給客戶端返回的結果, _res 是服務端返回給除錯server的結果。在這裡有以下幾步處理
1. 把要返回的header複製給客戶端
2. 判斷服務端是否使用gzip對資料進行了壓縮,若有壓縮建立一個gzip資料流還原資料
3. 在服務端 _resdata 事件中向客戶端 resgzip流(資料壓縮的情況下)複製資料
4. 在 _resgzip流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檢視頁面的優化

附: 工程連線