原生nodejs编写个web框架

        接触nodejs挺久了,之前一直用nodejs的一些web框架做开发,如koa,express等,现在想自己写个简易的nodejs web框架,我使用es6和es2017的async/await实现个类似于Koa的web框架,文章中的代码将会存放到我的github上,欢迎下载学习。

github地址:https://github.com/sundial-dreams/nodeServer

前言

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文件

扫描二维码关注公众号,回复: 5347358 查看本文章
<!--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请求,剩下的中间件,读者可以自己尝试编写。

最后使用一下这个框架,编写了个小示例,效果图如下

示例已存放在我的github上了,欢迎下载学习,github地址:https://github.com/sundial-dreams/nodeServer

猜你喜欢

转载自blog.csdn.net/daydream13580130043/article/details/83445437
今日推荐