1. 程式人生 > >130行實現Express風格的Node.js框架

130行實現Express風格的Node.js框架

很多時候我們使用Express,只是用到了它方便的路由和中介軟體系統。其實這個功能我們用一百多行程式碼可以輕鬆實現,且沒有任何依賴,而不必專門引入Express。

我們先來分析一下需求,我們要做的是一個路由系統,書寫的方式為:
//路由系統
app.method(path, handler);
//例如
app.get('/', (req, res) => {
  //do something
});
app.post('/user', (req, res) => {
  //do something
});
//中介軟體
app.use('/blog', (req, res, next) => {
  if
(/*校驗通過*/) { next(); } else { //校驗不能通過的錯誤資訊 } })
; //模式匹配 app.get('/blog/:id', (req, res) => { const id = req.params.id; }); //監聽啟動服務 app.listen(port, host);

一個簡單的Node.js伺服器

在開始之前,我們先看看普通的Node.js伺服器是什麼樣的:

const http = require('http');
http.createServer((req, res) => {
  //do something
})
.listen(port, host, callback);

每當http請求到來,就會執行回撥函式(即do something位置)的程式碼。那麼我們要做的就是實現一個路由池,當請求到來的時候通過httpServer的回撥函式遍歷路由池,選擇匹配的路由,執行響應的邏輯。一個路由包括三個屬性:請求方法(method),請求路徑(path)和處理函式(handler)。

實現路由池

路由池是一個物件陣列,我們要定義好如何新增路由。按照Express的API,我們通過app.method(path, handler)來新增路由。

const app = {};
const routes = [];
['get'
, 'post', 'put', 'delete', 'options', 'all'].forEach((method) => { app[method] = (path, fn) => { routes.push({method, path, fn}); }; });

現在,我們呼叫app的get、post、put、delete、options, all方法時,就會新增一個路由物件到routes陣列中了。例如:

app.get('/', (req, res) => {
  res.end('hello world');
});
//此時routes為
[{
  method: 'get', 
  path: '/', 
  fn: (req, res)=>{
    res.end('hello world');
  }
}]

路由池的遍歷

路由池的遍歷很簡單,通過迴圈遍歷陣列即可。

const passRouter = (method, path) => {
  let fn;
  for(let route of routes) {
    if((route.path === path 
      || route.path === '*')
      && (route.method === method
      || route.method === 'all')) {
      //匹配到了符合的路由
      //路由的method為all時匹配所有請求的方法
      //路由path為*時匹配所有請求的路徑
      fn = route.fn;
    }
  }
  if(!fn) {
    fn = (req, res) => {
      res.end(`Cannot ${method} ${pathname}.`);
    }
  }
  return fn;
}

這樣我們就寫好了遍歷router的函式,現在要做的就是把它新增到server中。

http.createServer((req, res) => {
  //獲取請求的方法
  const method = req.method.toLowerCase()
  //解析url
  const urlObj = url.parse(req.url, true)
  //獲取path部分
  const pathname = urlObj.pathname
  //遍歷路由池
  const router = passRouter(method, pathname);
  router(req, res);
}).listen(port, host, callback);

我們可以把建立server的方法放在app物件中,把這個方法和app一起暴露出去。

app.listen = (port, host) => {
  http.createServer((req, res) => {
    const method = req.method.toLowerCase()
    const urlObj = url.parse(req.url, true)
    const pathname = urlObj.pathname
    const router = passRouter(method, pathname);
    router(req, res);
  }).listen(port, host, () => {
    console.log(`Server running at ${host}\:${port}.`)
  });
}

這樣,我們只要呼叫app.listen(port, host),就可以建立伺服器了。

新增中介軟體

什麼是中介軟體?中介軟體是請求到達匹配的路由前經過的一層邏輯,這層邏輯可以對請求進行過濾、修改等操作。舉個例子:

app.use('/blog', (req, res, next) => {
  if(req.username) {
    next();
  } else {
    res.writeHead(404, {'Content-Type': 'text/html'});
    res.end('對不起,你沒有相應許可權');
  }
});

在這個例子中,每當請求/blog這個路徑的時候,請求都會經過這個中介軟體,只有request物件有username這個方法時,請求才能繼續向後傳遞,否則就會返回一個404資訊。要實現中介軟體也很簡單,我們把中介軟體與get, post等方法一樣看成是一種路由即可。於是問題的核心就變成了由於中介軟體中使用next函式來確認請求通過了中介軟體,我們不再能通過for..in遍歷的方法來遍歷路由池了。如果你對ES6足夠熟悉,那麼這個next方法一定能讓你想起一個很有趣的新語法:generator函式。

使用generator函式來遍歷陣列

generator函式是一種生成器函式,允許我們在退出函式後重新進入之前的狀態(可以理解為一個狀態機),我們可以用它實現函式式中的惰性求值特性,用這種辦法來遍歷陣列,舉個例子:

const lazy = function* (arr) {
  yield* arr;
}
const lazyArray = lazy([1, 2, 3]);
lazy.next(); // {value: 1, done: false}
lazy.next(); // {value: 2, done: false}
lazy.next(); // {value: 3, done: false}
lazy.next(); // {value: undefined, done: true}

重寫路由遍歷函式

那麼我們現在可以重寫路由遍歷的函數了,需要注意的是,中介軟體匹配過程中是可以匹配子目錄的,例如/path可以匹配到/path/a、/path/a/b/c這些目錄。

//lazy函式,使陣列可被惰性求值
const lazy = function* (arr) {
  yield* arr;
}
//路由遍歷
const passRouter = (routes, method, path) => (req, res) => {
  const lazyRoutes = lazy(routes);
  (function next () {
    //當前遍歷狀態
    const it = lazyRoutes.next().value;
    if (!it) {
      //已經遍歷所有路由,沒有匹配的路由,停止遍歷
      res.end(`Cannot ${method} ${pathname}`)
      return;
    } else if (it.method === 'use' 
      && (it.path === '/'
      || it.path === path
      || path.startsWith(it.path.concat('/')))) {
      //匹配到了中介軟體
      it.fn(req, res, next);
    } else if ((it.method === method
      || it.method === 'all')
      && (it.path === path
      || it.path === '*')) {
      //匹配到了路由
      it.fn(req, res);
    } else {
      //繼續匹配
      next();
    }
  }());
};

這樣我們就得到了一個可以新增中介軟體的路由系統。

模式匹配

匹配路由

模式匹配是每個後端框架必不可少的功能之一。他允許我們匹配一類路由,例如/blog/:id可以匹配類似/blog/123、/blog/qw13之類的一系列請求路徑。既然是模式匹配,那麼肯定少不了正則表示式了。我們以/blog/:id為例,想要匹配一系列這樣的路由,只要請求的路徑能夠通過正則表示式/^\/blog\/\w[^\/]+$/即可。也就是說,我們把路由中的:whatever替換成正則表示式\w[^\/]+就能匹配到相應的路由了。JavaScript中提供了new Exp來把字串轉換為正則表示式因此轉化的步驟為:

  • 將路由中模式匹配的部分轉換為\w[^\/]+

  • 用替換好的字串生成正則表示式

  • 用這一正則表示式匹配請求路徑,判斷是否匹配

實現:

//轉換模式為相應正則表示式
const replaceParams = (path) => new RegExp(`\^${path.replace(/:\w[^\/]+/g, '\\w[^\/]+')}\$`);
//判斷模式是否吻合
//...在passRouter函式中最後一個else之前新增一層if else
} else if ( it.path.includes(':')
  && (it.method === method
  || it.method === 'all')
  && (replaceParams(it.path).test(path))) {
  //匹配成功
} else {
  next();
}

轉換匹配到的路徑為相應物件

匹配成功後我們需要把模式轉為物件以便呼叫:

//匹配成功時邏輯
let index = 0;
//分割路由
const param2Array = it.path.split('/');
//分割請求路徑
const path2Array = path.split('/');
const params = {};
param2Array.forEach((path) => {
  if(/\:/.test(path)) {
    //如果是模式匹配的路徑,就新增入params物件中
    params[path.slice(1)] = path2Array[index]
  }
  index++
})
req.params = params
it.fn(req, res);

我們把params物件加入了req物件中,呼叫時很方便,例如:/blog/:id在呼叫時為const id = req.params.id。

靜態檔案處理

請求時如果請求了靜態檔案,我們的伺服器還沒有做出處理,這點很不合理,我們需要新增靜態檔案處理邏輯。

//常用的靜態檔案格式
const mime = {
  "html": "text/html",
  "css": "text/css",
  "js": "text/javascript",
  "json": "application/json",
  "gif": "image/gif",
  "ico": "image/x-icon",
  "jpeg": "image/jpeg",
  "jpg": "image/jpeg",
  "png": "image/png"
}
//處理靜態檔案
function handleStatic(res, pathname, ext) {
  fs.exists(pathname, (exists) => {
    if(!exists) {
      res.writeHead(404, {'Content-Type': 'text/plain'})
      res.write('The request url' + pathname + 'was not found on this server')
      res.end()
    } else {
      fs.readFile(pathname, (err, file) => {
        if(err) {
          res.writeHead(500, {'Content-Type': 'text/plain'})
          res.end(err)
        } else {
          const contentType = mime[ext] || 'text/plain'
          res.writeHead(200, {'Content-Type': contentType})
          res.write(file)
          res.end()
        }
      })
    }
  })
}

然後我們找到app.listen函式,新增判斷靜態檔案的邏輯。

let _static = 'static' //預設靜態資料夾位置
//更改靜態資料夾的函式
app.setStatic = (path) => {
  _static = path;
};
//...server回撥函式中內容
const method = req.method.toLowerCase()
const urlObj = url.parse(req.url, true)
const pathname = urlObj.pathname
//獲取字尾
const ext = path.extname(pathname).slice(1)
//如果有後綴,則是靜態檔案
if(ext) {
  handleStatic(res, _static + pathname, ext)
} else {
  passRouter(_routes, method, pathname)(req, res)
}

至此,我們已經實現了一個完整的後端路由控制器,有中介軟體功能,靜態檔案處理和模式匹配功能。

一個彩蛋

有時我們希望node應用從命令列退出時不是直接退出,而是向我們輸出一些資訊(比如道個別),就像這樣:

^C

Good Day!

這一功能借助node中process模組的SIGINT事件也可以輕鬆實現,我們只需要在建立server成功的回撥函式加上幾行就可以了:

http.createServer(/*...*/).listen(port, host, () => {
  console.log(`Server running at ${host}\:${port}.`)
  //新增的程式碼:
  process.stdin.resume();
  process.on('SIGINT', function() {
    console.log('\n');
    console.log('Good Day!');
    process.exit(2);
  });
});

現在,我們的退出小彩蛋也完成了。

完整程式碼放在我的gist上。

至此,我們就完成了整個應用,如果重量級的框架對你來說比較多餘,就試試自己動手實現吧。水平有限,歡迎吐槽。

作者:mirone
連結:https://zhuanlan.zhihu.com/p/24781172
來源:知乎
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。