手写一个http容器【上】决策树与路由表



Linux之父说过,伪代码是最好的语言,因为它能够表达所有的逻辑。所以本文所有的代码示例都是伪代码。


  • ALFP协议

如果让我来定义http协议的话,我会给他取一个完全不同的名字:ALFP(Application Layer Fetch Protocol,应用层请求协议)。2020年我甚至忘了“HTTP”的全称是什么?好像是“超文本传输协议”?然后意识到这种古老的,对新人不友好的首字母缩写还是不要拆开来读比较好,况且“超文本”这个词已经鲜为人知了,但至少“超文本”是存在于应用层的东西,再加上“fetch”这个单词能够非常形象的概括出http协议的特点:“抓取”意味着有请求有回应。所以我认为HTTP协议如果改名叫ALPF协议会更有爱,更名的灵感来自ALPN协议(应用层协议协商),如果更名成功,ALFP协议能让00后快速地了解这个协议的功能,减少他们的学习成本,同时还能满足我们老玩家的沙雕强迫症。

————————正经的分割线————————


  • 手写一个http路由器

一本正经的胡说八道结束了,下面聊聊正题。当我们用烂了Express,Koa等http后端框架就忍不住自己手写一个,如果你懒的去学习一个新的框架,仅仅是为了实现一个小的web app,手写后端框架往往是最好的选择。我将用nodejs制作一个最简单的http后端路由模型,让大家明白web框架一点也不复杂,人人都可以手写。

由于是精简版的后端框架,不用考虑什么负载均衡和容灾,在一台虚拟机服务器上只要考虑ALFP的核心理念就行,关键词就是“应用层”和“抓取”,整个http协议不过如此,我们只要考虑当一个请求进来以后我们先要做什么,再做什么,最后做完什么以后响应回去就完事。中间的每一步就是相互独立的“中间件”。

但是为了写一个通用的后端框架,还是要考察一下大多数网络app通常都有哪些架构,再将这些常见的需求组合起来创造我们自己的web框架。关于需求,Koa框架提出的“洋葱图”给了我们一些参考:

别的不用关心,洋葱图上有一些比较重要的后端功能是我们参考的对象,比如路由,会话,缓存和异常处理。绝大多数web app的架构拆开来看也就这些东西。

由于21世纪20年代以后前端架构基本统一了,浏览器端,桌面端,移动端已经没什么区别了,统统都叫app,只不过需要服务器支持的叫做网络app,不需要的就叫app,甚至所有的主流app都由JS,wasm,H5和Css这4个基本语言写成。

以上是功能,再从性能上考虑,为了不阻塞主线程,所有的中间件都要运行在事件循环引擎之上,换言之每个中间件都是一个promise。


  • 决策树与路由表

中间件之间不仅是串行的,而且是树形的:上一个中间件的计算结果有可能决定下一个中间件,所以整个中间件网络是一棵决策树,在决策树上迭代的过程就叫“路由”,路由的寻路依据就是我们的“路由表”。

路由表有多种形式,不同的业务逻辑可以设计不同的路由表,这里推荐一种常用的,根据Restful动词来构建路由表的策略。Restful动词也就是对数据所有可能操作组成的分类网,我们熟悉的“增删改查”指的就是这些动词,下图展示了这些动词的一部分:

根据数据操作动词来构建决策树不失为一个很好的选择,动词既可以写在http method头部字段里,也可以写在url路径上。至于决策树在代码上怎样体现,可根据剧情选择if/else树或者嵌套的散列表,通常散列表可以让每个决策花费的时间相等,比较适合决策树较大的情况。


  • URL路径的优雅处理

说到路径,后端框架一般都把url上所有路径存放到一个列表中,但由于url路径之间是正斜杠分隔的,为了和空格分隔符统一,多个连续的正斜杠可以看成一个,列表只存放有意义的路径名,所以/path/to和/path//to和/path/to/表达了相同的含义,对应的路由表都是[‘path’, ‘to’]。生成路由列表的表达式如下:

// 生成路由表的伪代码
request.paths = request.urlPath.split("/").filter(p => p.trim());

request.paths就是路由表,保存着url路径上从左到右每一个路径,每当经过一层路由就让paths.shift()一下,然后根据request.paths[0]来选择下一个中间件。


  • 入口设计(index.html)

入口设计很简单,即当url路径为空的时候需要返回index.html这个静态文件,这时候request.paths是一个空数组。

// 网站入口的伪代码


if (request.paths.length === 0) {
  await new Promise((resolve, reject) => {
    response.setHeader("Content-Type", "text/html");


    const r = require("fs").createReadStream("path/to/index.html");
    r.on("error", err => reject(err));
    r.on("end", resolve);
    r.pipe(response);
  });
}



  • 会话层与认证

很多地方都要考虑入口的特殊性,除了index.html,还要考虑每个request进来后要做的第一件事是什么?第一件事通常是要对这个request进行认证,对不对?无论是通过用户名密码认证还是根据会话凭证来认证,这都是必须要做的(即使它请求的是只读的资源)。

根据剧情需要,可以将会话凭证之外的会话信息存放在客户端或者服务端。反正流行的凭证格式JWT是建议将其他信息存在客户端,比如一些用户的个人信息,反正加密后的数据寄存在前端没有危害,但是数据量大的情况下可以考虑存在后端。下面是一个将token存在前端的例子:

// 会话层token认证的伪代码


module.exports = async function() {
  const req = this.request;
  const res = this.response;
  // myToken是假想的一种凭证插件,类似JWT
  const myToken = require("path/to/myToken");


  req.session = "";
  //   authorization头部用来存放加密的token
  const token = req.headers["authorization"];
  if (!token) return;


  //   secretKey是一个密钥,用于加解密token
  req.session = await myToken.verifyWithKey(token, secretKey).catch(err => {
    if (err.name === "TokenExpiredError") {
      // my-token-expire这个自定义头部提示前端应该删除这个token了
      res.setHeader("my-token-expire", 1);
    } else if (err.name === "invalidTokenError")
      throw "凭证损坏:" + err.message;
  });
};


request进来的时候,我们将它携带的token中解密出来的数据存放到request自己身上,供之后的中间件使用,同时还要做好错误处理。


(未完待续。

发布了568 篇原创文章 ·
获赞 1651 ·
访问量 115万+