1. 程式人生 > >原生nodejs編寫個web框架

原生nodejs編寫個web框架

        接觸nodejs挺久了,之前一直用nodejs的一些web框架做開發,如koa,express等,現在想自己寫個簡易的nodejs web框架,我使用es6和es2017的async/await實現個類似於Koa的web框架,文章中的程式碼將會存放到我的github上,歡迎下載學習。

前言

nodejs編寫個伺服器,只需要幾行程式碼

//test.js
const http = require("http");
const server = http.createServer((req,res) => {
    res.end("hello world");
});
server.listen(3000, () => {
  console.log("LISTEN IN 3000")
});

瀏覽器輸入localhost:3000可以看見結果 

上面的例子,當客戶端請求的時候,服務端響應的是一個字串hello world

修改上面的例子,當用戶請求的時候響應HTML網頁

//test.js
const http = require("http");
const fs = require("fs");
const {resolve,join} = require("path");
const server = http.createServer((req,res) => {
  res.writeHead(200,{"Content-type":"text/html"});
  fs.createReadStream(resolve("./index.html")).pipe(res)
});
server.listen(3000, () => {
  console.log("LISTEN IN 3000")
});

<!--index.html-->
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Title</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
     <div id="app">
         <h1>
             Hello world
         </h1>
     </div>
</body>
</html>

瀏覽器輸入localhost:3000

Ok,頁面有點難看,加點js和css美化一下

html檔案

<!--index.html-->
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Title</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="public/css/reset.css" type="text/css"/>
    <link rel="stylesheet" href="a.css" type="text/css"/>
</head>
<body>
<div id="app">
    <a href="/picture">
        &lt;/&nbsp;&nbsp;&gt;
    </a>
</div>
<script src="a.js"></script>
</body>
</html>

css檔案

/*a.css*/
body {
    background: #2d3143;
}

#app {
    width: 100px;
    height: 100px;
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -35%);
    opacity: 0;
    transition: .5s ease-in-out;
}

#app > a {
    display: block;
    width: 100%;
    height: 100%;
    background: blueviolet;
    color: white;
    font-weight: bold;
    font-size: 30px;
    border-radius: 50%;
    transition: .5s ease-in-out;
    text-align: center;
    line-height: 100px;
}
#app > a:hover{
    background: rebeccapurple;
    transform:translateY(3px);
    color: rgba(255,255,255,.6);
}

js檔案

//a.js
!function () {
  function selector(name, scope) {
    scope = scope || document;
    return scope.querySelector(name);
  }

  function selectorAll(name, scope) {
    scope = scope || document;
    return [].slice.call(scope.querySelectorAll(name))
  }

  function setStyle(element,object) {
    element = element || {};
    object = object || {};
    for (var key in object){
      if (object.hasOwnProperty(key)){
        element.style[key] = object[key];
      }
    }
  }
  var app = selector("#app");
  setTimeout(function () {
    setStyle(app,{
      transform:"translate(-50%, -50%)",
      opacity:1
    })
  },100)

}();

瀏覽器上輸入localhost:3000,理論上我們會看到這個頁面

而實際上是這個頁面

WTF!  發現樣式全沒了,而且js互動也沒有

原因很簡單,當瀏覽器上輸入localhost:3000的時候,我們只響應了index.html檔案,而在index.html檔案中有 href="a.css" src="a.js"這兩句,分別是請求a.css檔案和a.js檔案,而我們的服務端可沒有響應這兩個檔案,修改一下服務端,當請求其他檔案的時候,響應這個請求,併發送指定檔案

const http = require("http");
const fs = require("fs");
const {resolve,join,extname} = require("path");
const {parse} = require("url");
//設定對應的mime型別
const mime = {
  ".css":"text/css",
  ".gif":"image/gif",
  ".html":"text/html",
  ".jpeg":"image/jpeg",
  ".jpg":"image/jpeg",
  ".js":"application/javascript",
};

const server = http.createServer((req,res) => {
  let pathname = parse(req.url).pathname;
  if(pathname === "/"){
    //首頁 localhost:3000
    res.writeHead(200,{"Content-type":"text/html"});
    fs.createReadStream(resolve("./index.html")).pipe(res);
  }else if(mime[extname(pathname)]){//請求的是檔案
    let staticPath = join(__dirname,pathname);
    if(fs.existsSync(staticPath)){//檔案是否存在
      res.writeHead(200,{"Content-type":mime[extname(pathname)]});
      fs.createReadStream(staticPath).pipe(res);
    }
  }else{
    res.writeHead(404)
  }
});
server.listen(3000, () => {
  console.log("LISTEN IN 3000")
});

瀏覽器輸入localhost:3000

我們想要的效果出來了,例子看完了,接下來就是本文章的主要內容了,嘗試編寫一個類似於KOA的服務端框架。

nodejs web框架的簡單實現

what is KOA?KOA是一款輕量級nodejs web開發框架,基於洋蔥模型,使用es2017的async/await來處理回撥,不用在編寫過多的回撥。

then, what is 洋蔥模型

模型如下圖所示

簡單來說就是當請求來的時候得經過幾個人的手然後才到響應

接下來看個koa的例子,先安裝koa

yarn add koa --dev 或者npm install --save-dev koa

然後編寫程式碼

//koa.js
const Koa = require("koa");
const app = new Koa();
//中介軟體1
app.use(async (ctx,next) => {
  console.log("middleware1");
  await next()
});
//中介軟體2
app.use(async (ctx,next) => {
  console.log("middleware2");
  await next()
});
app.use(async ctx => {
  console.log("end");
  ctx.body = "hello koa"
});
app.listen(3000,() => {
  console.log("listen in 3000");
});

瀏覽器輸入localhost:3000,可以看到控制檯輸出middleware1  middleware2  end,每一個請求都先經過中間1 -> 中介軟體2 -> 響應,koa部分的內容建議去koa官網看

有了洋蔥模型的思想,然後嘗試編寫一個類似於koa的web框架

我們的目標:

const app = new App();
app.use(中介軟體)
   .use([中介軟體1,中介軟體2,...,中介軟體n])
   .use(中介軟體);
app.listen(port)

有了目標,接下來我們來實現這個App類

App.js

//App.js
const http = require("http");
const events = require("events");

//App類
class App extends events.EventEmitter {
  constructor() {
    super();
    this.middleware = [];//存中介軟體的陣列,每一箇中間件都是async函式,引數為(ctx,next)兩個,返回值是Promise型別
    this.ctx = {};//ctx物件,掛裝物件
    this.mountObject = {};//待掛裝物件
    this.on("error", err => {//錯誤處理
      console.log(err)
    })
  }

  use(fn) {//use方法
    Array.isArray(fn) ? this.middleware.push(...fn) : this.middleware.push(fn);
    return this
  }

  mount(name, fn) {//往this.ctx掛載屬性
    this.mountObject[name] = fn;//儲存待掛載物件
  }

  callback() {
    const fn = compose(this.middleware);//這裡是重點
    return (req, res) => {//這個返回的函式是http.createServer()的引數
      this.ctx = {req,res};
      Object.assign(this.ctx,this.mountObject);//掛載物件
      return fn(this.ctx)
    }
  }

  listen(...args) {//監聽方法
    const server = http.createServer(this.callback());//建立Server
    server.listen(...args)
  }
}

//這是整個框架的核心,接入中介軟體陣列
function compose(middleware) {
  return function (ctx, next) {//返回函式next下一個為中介軟體函式,這裡為undefined
    let index = -1;

    function dispatch(i) {//處理第i箇中間件的函式
      if (i <= index) return Promise.reject(new Error("error"));
      index = i;
      let fn = middleware[i];//第i箇中間件
      if (i === middleware.length) fn = next;//最後一箇中間件,fn指向next,這裡為undefined
      if (!fn) return Promise.resolve();//fn===undefined時返回空Promise物件
      try {
        return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)))//next指向dispatch.bind(null, i + 1),執行下一個中介軟體函式
      } catch (e) {
        return Promise.reject(e);
      }
    }

    return dispatch(0)
  }
}

module.exports = App;

 簡單測試一下App類


const app = new App();
app.use(async (ctx,next) => {//使用中介軟體
  console.log("middleware");//請求來的時候先走這一步
  await next();
}).use(async ctx => {//然後才到這裡
  ctx.res.end("hello app")
}).listen(3000,() => {
  console.log("listen 3000")
});

有了App類,接下來就可以編寫開始中介軟體了

先來第一個中介軟體,router中介軟體,這個中介軟體是處理路由的,我們想要實現的功能是這樣的:

const router = new Router();
const api = new Router();
const app = new App();

api.get("/getData",async ctx => {//處理get方法的路由
     ctx.res.end("data")
});
api.post("/postdata",async ctx => {//處理post方法的路由
     ctx.res.end("data")
});
router.get("/",async ctx => {
     ctx.res.end("index page")
});
router.use("/api",api);//子路由
router.get("/page/:id",async ctx => {//能匹配的路由,這裡叫做標記路由
     ctx.res.end(ctx.url.id)
});

app.use(router.register())//使用路由中介軟體

接下來實現這個Router類

router.js

const {EventEmitter} = require("events");
const {parse} = require("url");
const {extname} = require("path");
const queryString = require("querystring");

/**
 *
 * @param url
 * @param formatUrl
 * @returns {boolean}
 */
function urlJudge(url, formatUrl) {//匹配路由 url:app/any 真實的從請求中獲取的路由 formatUrl:app/:id這類待匹配的路由
  //匹配方式 將url和formatUrl拆分為陣列 然後挨個匹配碰到:id這種的跳過
  let urlArray = url.split("/");
  let formatUrlArray = formatUrl.split("/");
  let sign = formatUrlArray.some(url => url.startsWith(":"));//是否為/app/:name/:id這種的路由
  if (sign) {
    let map = {};//將匹配的路由欄位儲存起來 比如 /app/:id/:name === /app/12/dpf map = {id:"12",name:"dpf"}
    if (urlArray.length === formatUrlArray.length) {
      for (let i = 0; i < urlArray.length; i++) {
        if (urlArray[i] !== formatUrlArray[i] && !formatUrlArray[i].startsWith(":")) return false;//碰到不相等的直接返回false
      }
      for (let i = 0; i < urlArray.length; i++) {
        if (formatUrlArray[i].startsWith(":")) {
          if (urlArray[i].match(/\./) || !urlArray[i]) continue;//這裡不希望匹配請求檔案的路由和空路由 比如app/a.js 或app/
          map[formatUrlArray[i].substring(1)] = urlArray[i]//儲存健值
        }
      }

      return map
    } else {
      return false
    }
  } else {
    return url === formatUrl
  }
}

/**
 *
 * @type {module.Router}
 */
module.exports = class Router extends EventEmitter {
  constructor() {
    super();
    this.getRouter = new Map();//儲存get路由,[path , callback]的形式
    this.postRouter = new Map();//儲存post路由
    this.subRouter = new Map();//儲存子路由
    this.getRouterSign = new Map();//儲存這一類的get路由 api/:method
    this.postRouterSign = new Map();//儲存這一類的post路由 api/:method
    this.on("error", err => {
      console.log(err)
    })
  }

  get(path, callback) {
    let sign = path.split("/").some(p => p.startsWith(":"));//是否為 app/:name這一類路由
    sign ? this.getRouterSign.set(path, callback) : this.getRouter.set(path, callback);
    return this;
  }

  post(path, callback) {
    let sign = path.split("/").some(p => p.startsWith(":"));
    sign ? this.postRouterSign.set(path, callback) : this.postRouter.set(path, callback);
    return this;
  }

  use(path, subRouter) {//子路由
    this.subRouter.set(path, subRouter);
    return this
  }

  /**
   *
   * @param ctx
   * @returns {Promise<void>}
   */
  async routerHandle(ctx) {//路由匹配
    let {pathname, query} = parse(ctx.req.url);//從請求中獲取路由
    let method = ctx.req.method;//獲取請求方法
    if (extname(pathname)) return;//如果有副檔名,跳過該方法
    ctx.param = queryString.parse(query);//將路由裡的引數儲存到ctx.param裡
    ctx.url = {};//儲存 這類路由的值 app/:id => ctx.url.id

    /**
     * \
     * @param router get或post路由
     * @param routerSign get或post帶標記的路由 ==> api/:method
     * @param subRouters 子路由
     * @param method 方法型別
     * @returns {Promise<void>}
     */
    async function execute(router, routerSign, subRouters, method) {
      let callback = router.get(pathname);//獲取對應路由的回撥,如果沒有的話 返回null
      for (let [signUrl, callback] of routerSign.entries()) {//搜尋整個帶標記的路由
        let sign = urlJudge(pathname, signUrl);//是否有匹配上的
        if (sign) {
          Object.assign(ctx.url, sign);//給ctx.url掛載屬性
          await callback && callback(ctx)
        }
      }
      await callback && callback(ctx);
      for (let [parentPath, subRouter] of subRouters.entries()) {//匹配子路由
        //子get路由和post路由
        for (let [childPath, callback] of method === "GET" ? subRouter.getRouter.entries() : subRouter.postRouter.entries()) {
          if (parentPath === "/" && childPath !== "/") parentPath = "";//防止出現 //app/index這類情況
          if (childPath === "/") childPath = "";//同樣防止出現 //app/index 的情況
          if (parentPath + childPath === pathname) {//匹配上執行回撥
            await callback && callback(ctx);
          }
        }
        //子路由的get和post帶標記的路由
        for (let [childPath, callback] of method === "GET" ? subRouter.getRouterSign.entries() : subRouter.postRouterSign.entries()) {
          if (parentPath === "/" && childPath !== "/") parentPath = "";
          if (childPath === '/') childPath = "";
          let sign = urlJudge(pathname, parentPath + childPath);
          if (sign) {
            Object.assign(ctx.url, sign);
            await callback && callback(ctx)
          }
        }
      }
    }

    if (method === "GET") {//處理get方法
      await execute(this.getRouter, this.getRouterSign, this.subRouter, method)
    }
    else if (method === "POST") {//處理post方法
      await execute(this.postRouter, this.postRouterSign, this.subRouter, method)
    }
  }

  register() {
    return async (ctx, next) => {//返回箇中間件
      await this.routerHandle(ctx);//當請求到來,先執行路由匹配
      await next()
    }
  }
};

簡單使用這個路由中介軟體

const Router = require("./router");
const api = new Router();
const router = new Router();
api.get("/:method",async ctx => {
  ctx.res.end(ctx.url.method)
});
router.get("/",async ctx => {
  ctx.res.end("it is router")
});
router.use("/api",api);
const app = new App();
app.use(router.register()).listen(3000,() => {
  console.log("listen 3000")
});

瀏覽器輸入localhost:3000

瀏覽器輸入localhost:3000/api/dpf 

為了處理post方法提交的資料,我們需要中介軟體來接收post請求的資料,然後將資料儲存到ctx.query裡

post中介軟體


/**
 * 
 * @param ctx
 * @param next
 * @returns {Promise<void>}
 */
async function postParse(ctx, next) {
  let {req} = ctx;
  if (req.method === "POST") {//請求方法為post
    ctx.query = await new Promise(resolve => {
      let data = "";
      req.on("data", chunk => {//資料來臨
        data += chunk;
      });
      req.on("end", () => {//資料完成
        resolve(queryString.parse(data))
      });
    });
  }
  await next();
}

使用的話只需在app.use(postParse).use(router.register())即可

中介軟體有了,然後可以設計基本的框架結構,框架目錄如下

root

      |__lib 這裡主要是一些核心庫

              |__App.js  

      |__middleware 這裡是中介軟體

              |__router.js 路由中介軟體

              |__postParse.js 處理post資料中介軟體

              |__resource.js 處理資原始檔的中介軟體

      |__util 一些工具模組

              |__mime.js mime型別對映表

      |__router 存放路由

              |__api.js api路由處理

              |__index.js

       |__pages 儲存頁面

              |__index 首頁頁面檔案 html,js,css檔案,個人覺得這樣設計找對應的css/js檔案好找

                     |__index.html

                     |__index.css

                     |__index.js

        |__public 靜態檔案 image 或 公共css/js之類的

               |__image

               |__js

               |__css

        |__server.js 程式入口

按照目錄結構,我們還需要箇中間件來處理資原始檔,當請求的是資原始檔時,響應資原始檔,即resource中介軟體,用來分發html,js,css,image檔案等

我們想要實現的功能是這樣的:
 

const app = new App();
const router = new Router();
const resource = new Resource(["pages","public"]);//初始化靜態目錄,第一個為pages頁面目錄

app.mount("render",resource.render());

router.get("/",async ctx => {

     await ctx.render("index")  //pages/index/index.html

});

app.use(resource.register())
   .use(router.register())
   .listen(3000,() => {console.log("listen in 3000")})

按照功能來實現這個Rescource類:

//resource.js
const fs = require("fs");
const path = require("path");
const events = require("events");
const url = require("url");
const util = require("util");
const mime = require("../util/mime");

const asyncStat = util.promisify(fs.stat);
const asyncReadFile = util.promisify(fs.readFile);

/**
 *
 * 獲取目錄  輸入/index.js ==> pages/index/index.js  app/node/picture.css ==> pages/picture/picture
 *          輸入app/name/public/css/reset.css ==> public/css/reset.css
 * @param pathname
 * @param folds
 * @returns {*}
 */
function getStaticPath(pathname, folds) {
  let urlArray = null;
  if (pathname.includes("/")) {
    urlArray = pathname.split("/");
  } else {
    urlArray = [pathname]
  }
  for (let i = urlArray.length - 1; i >= 0; i--) {//倒序遍歷 找存在的靜態目錄
    if (folds.includes(urlArray[i])) {
      return urlArray.slice(i).join("/")
    }
  }
  //否則 考慮 index.js ==> pages/index/index.js是否存在
  pathname = `${folds[0]}/${pathname.replace(new RegExp(path.extname(urlArray[urlArray.length - 1]) + "$"), "")}/${urlArray[urlArray.length - 1]}`;
  if (fs.existsSync(path.resolve(`./${pathname}`))) {
    return pathname
  }
  return false
}

/**
 *
 * @type {module.Resource}
 */
module.exports = class Resource extends events.EventEmitter {
  constructor(folds = []) {
    super();
    this.folds = folds;//儲存靜態目錄
    this.on("error", err => {
      console.log(err);
    });

  }

  setFolds(folds = []) {
    this.folds.push(...folds);
  }

  /**
   * 根據輸入路由,傳送指定檔案
   * @param ctx
   * @param pathname
   * @returns {Promise<void>}
   * @private
   */
  async _send(ctx, pathname) {
    if (this.folds.some(fold => pathname.startsWith(fold))) {//保證屬於靜態目錄
      const staticPath = path.resolve(`./${pathname}`);
      if (fs.existsSync(staticPath)) {//檔案是否存在
        try {
          let stats = await asyncStat(staticPath);
          if (stats.isFile()) {//是否為檔案
            let type = mime[path.extname(pathname)];//根據副檔名,獲取對應的mime型別
            let data = await asyncReadFile(staticPath);
            ctx.res.writeHead(200, {"Content-type": type});
            ctx.res.end(data);
          } else {
            ctx.res.writeHead(404)
          }
        } catch (e) {
          this.emit("error", e);
        }
      } else {
        ctx.res.writeHead(404)
      }
    } else {
      ctx.res.writeHead(404)
    }
  }

  /**
   * 分發請求的檔案
   * @param ctx
   * @returns {Promise<void>}
   * @private
   */
  async _dispatch(ctx) {
    let pathname = url.parse(ctx.req.url).pathname.substring(1);
    let extendName = path.extname(pathname);
    if (!extendName) return;//拋棄不是請求資源的路由
    if (getStaticPath(pathname, this.folds)) {
      await this._send(ctx, getStaticPath(pathname, this.folds));
    } else {
      ctx.res.writeHead(404);
    }

  }

  /**
   * ctx.render("index") ==> ctx.send("pages/index/index.html")
   * @param ctx
   * @param fold
   * @returns {Promise<void>}
   * @private
   */
  async _render(ctx, fold) {
    console.log(this.folds,fold);
    let pathname = path.join(this.folds[0], fold, `${fold}.html`);
    await this._send(ctx, pathname)
  }

  dispatch() {
    return async (ctx, next) => {
      await this._dispatch(ctx);
      await next()
    }
  }
  //提供對外的掛載介面 app.mount(name,this.send())
  send() {
    let that = this;
    return async function (pathname) {
      return that._send(this,pathname)
    }
  }

  render() {
    let that = this;
    return async function (page) {
      return that._render(this,page)
    }
  }
};

到此為止,web伺服器的基本功能就實現的差不多了。

還可以繼續編寫其他中介軟體,不過太多的中介軟體自然會影響程式效率的,本文章中寫了3箇中間件來處理基本的web請求,剩下的中介軟體,讀者可以自己嘗試編寫。

最後使用一下這個框架,編寫了個小示例,效果圖如下