用 Deno 造一个简单 Router | 🏆 技术专题第一期征文

1,193 阅读10分钟

《Deno 入门到放弃》 类的文章已经有不少大佬写了,而且还是比较详细描述的,那么我也就不蹭这种类型的文章了。

我打算从造轮子的角度来聊聊如何用 Deno 造一个 Router 的轮子(其实是我 Web Server 这一套没写完,只能拿完成度比较高的 Router 这部分出来说 😅)

路由的由来

路由最早的出现是起源于服务端,在早期 Web 系统架构中路由是用来描述 URL 与处理函数或资源之间的映射关系。 例如 .asp .aspx .jsp .shtml 等,是通过 SSR(Serve Side Render) 来实现渲染,最后直接返回显示页面。 (可以这么理解,现在一些前端概念都是早年玩剩下的。)

早期的路由是通过 请求 URI 和框架自定义的文件目录 + 文件类 + 类函数,来实现路由的映射。 假定路由为:/xxx/user/get_user_info

那么文件目录结构为:

Application
    ├── src
    │   ├── user
    │   │   ├── UserController.cs

那么在 UserController.cs 里会有类似函数:

// C# asp.net MVC 示例代码
// xxx 为项目名 -> /xxx
namespace xxx.Controllers
{
    // 控制器名 -> /xxx/user
    public class UserController : Controller // MVC 软件架构模式中的 C -> Controller
    {
        // 函数 -> /xxx/user/get_user_info
        public GetUserInfo Index() {
            return View(); // View 指向的是要返回的视图文件,调用的时候会由服务端来进行渲染
        }
    }
}

由此可看出早期的 Web 应用是整个框架是通过项目目录结构、文件名、类名、函数名等方式对路由的 Path 进行统一的约束,然后再实现对 URI 匹配的。 在这里每次请求的 URI 都会进行大量的系统调用,放在现在是很低效的一件事了,但是在早期这是非常先进的做法,因为这可能是最快实现一个接口的方式,同时也避免了去定义路由。

路由的原理

了解完了路由的前生,那么就可以来了解一下路由的今世了:现代路由

目前路由的解析方式可分为三种:

  • 文件夹路径
  • 正则匹配
  • 字典树/前缀树

文件夹路径

文件夹结构决定了路由复杂度和清晰度,同时也暴露了真实项目目录,且对于动态路由参数处理能力有限。

e.g.
假定路由为:/xxx/user/info

那么文件结构可能如下:

xxx
  ├── user
  │   ├── info
  │   │   ├── index.html

这么看来的话其实和早期路由的相似性很大。

正则匹配

基于正则表达式实现的 URI Path 匹配,可实现动态参数,在复杂路径或长字符串规则上维护困难,需要熟悉正则编写。

e.g.
假定路由为:/xxx/user/123456789/info

那么代码中的实现可能为:

app.route('/xxx/user/{\d{9}}/info', getUserInfo);

只要是请求 /xxx/user/符合9位数字/info 的 URI,都会被 getUserInfo 函数所接受处理。

正则匹配的路由 URI 只适合小而美或者规划清晰的项目使用,如果是大型项目参数多路径深那么正则的维护和编写难度也会随之增加。

字典树/前缀树

字典树/前缀树的实现都是通过以 “/” 切分将定义的 Path 组装成节点树,然后通树节点来匹配 URI 最终找到该节点信息,然后执行函数。

e.g.
假定路由为:

  • /xxx/user/123456789/info
  • /xxx/article/123456

路由定义为:

  • /xxx/user/{\d{9}}/info
  • /xxx/user/repwd
  • /xxx/article/{\d{6}}

最终树结构如下:

前缀树相比基础的字典树来说,在匹配很长的字符串上有更好的性能,所以更适合 URI 的匹配场景。

字典树为全字符匹配,也就是说需要广度遍历当前节点,而前缀树是在父级节点维护子级节点的前缀列表,在匹配时先进行索引的查找。

路由实现

OK,逼逼了那么多那么可以开始实现一个简单的路由了,在这里我使用的是字典树的方式。后续不断的完善优化后我会更新成前缀树的方式,因为从字典树到前缀树的差异来说,代码的改动量并不大。

要实现路由那么首先,需要定义路由,每一个路由都需要一个处理函数。那么每一个定义的路由就需要 一个 url 属性和一个 handle 属性。 然后根据字典树路由的原理以 “/” 进行切分 Path,每一个 Path 都是一个节点。

Deno 原生支持 TypeScript 那么我这里也就直接使用 TypeScript 来进行编写了。

代码实现如下:

// 定义路由列表类型
type RouteList = { url: string, handle: () => void }[];

// 以 “/” 来分割 Route Path
function splitRoutePath (routeList: RouteList = []) {
  routeList.forEach((item) => {
    const splitPath = item.url.split('/');
    const splitPathLen = splitPath.length - 1;

    // 如果不以 “/” 开头或者以 “/” 结尾,需要处理空字符串项
    if (!splitPath[splitPathLen]) { splitPath.splice(splitPathLen, 1); }
    if (!splitPath[0]) { splitPath.splice(0, 1); }

    // 调用 GenerateNodeTree 来生成节点树
    GenerateNodeTree(splitPath, item.handle);
  });
}

// 假设定义的url,这里先不考虑使用正则匹配 Path 的情况
// 定义的路由列表需要一个 url 属性和一个 handle 属性
const rrl = [
  { url: '/user/repwd', handle: () => { console.log('/user/repwd'); } },
  { url: '/user/:id/info', handle: () => { console.log('/user/:id/info'); } },
  { url: '/article/:id', handle: () => { console.log('/article/:id'); } },
];

// 调用 splitRoutePath 对路由路径进行分割处理
splitRoutePath(rrl);

splitRoutePath 执行完后会得到三个 Route Path 的列表:

  • user -> repwd => handle
  • user -> :id -> info => handle
  • article -> :id => handle

转化成字典树结构

然后我们还需要将这些列表转化成树形结构,即路由的字典树。所以需要实现一个 GenerateNodeTree 函数来进行处理。

// 节点类型定义
type NodeType = { [key in string]: RouteNode };

// 路由节点接口定义
export interface RouteNode {
  key: string;
  name: string;
  handler: handlerType | undefined;
  children: NodeType | undefined;
}

// 路由参数提取 - 正则表达式
const regExpParamKey: RegExp = /^:(\w+)\??$/;

// 映射节点树 - 字典树
export const mapNodeTree: NodeType = {};

// 生成节点数
function GenerateNodeTree (pathArr: string[] = [], handle: handlerType) {
  const len = pathArr.length; // path 数组的长度
  let tempNode: NodeType = mapNodeTree; // 

  pathArr.forEach((item: string, index: number) => {
    if (!tempNode[item]) {
      // 匹配是否是路由参数
      const regResult = regExpParamKey.exec(item);
      // 如果是路由参数则以参数名作为节点 key,否则将 Path 作为节点 key
      const tempKey = regResult !== null ? regResult[1] : item; 
      // 组装节点所需信息
      tempNode[item] = {
        key: tempKey,
        name: item,
        handler: index === len - 1 ? handle : undefined,
        children: index !== len - 1 ? {} : undefined
      }
    }
    if (index !== len - 1) {
      // 处理子节点信息
      if (!tempNode[item].children) { tempNode[item].children = {}; }
      tempNode = tempNode[item].children || {};
    }
  });
}

调用 GenerateNodeTree 函数后 mapNodeTree 对象就是最终的字典树了。

打印树结构如下:

// 省略了一些其他字段信息
mapNodeTree = {
  user: {
    key: "user",
    children: {
      repwd: {
        key: "repwd",
        handler: [Function: handle]
      },
      :id: {
        key: "id",
        children: {
          info: {
            key: "info",
            handler: [Function: handle]
          }
        } 
      }
    }
  },
  article: {
    key: "article",
    children: {
      :id: {
        key: "id",
        handler: [Function: handle]
      }
    }
  }
}

自此,通过定义路由 path 生成路由的字典树,就已经完成了。

(嗨呀~你这里只有生成路由树捏,那我请求进来了怎么处理这些路由和调用相应的函数捏?)

遍历匹配树节点

不要着急,路是一步一步走,胖子也不是一口就吃成的。这里当然是需要实现一个可以来遍历字典树匹配的函数。

// 遍历 - 匹配字典树节点
export const IterateNodeTree = (pathArr: string[] = []) => {
  const pathParams = new Map();
  const len = pathArr.length;
  let tempIndex = 0;
  let tempNode: NodeType = mapNodeTree;

  // 匹配节点递归检查
  const recursiveCheck = (nodItem: NodeType, nodeKey: string, isEnd: boolean) => {
    const currentNode = nodItem[nodeKey];
    const nodeChild = currentNode.children;
    const nodeHandler = currentNode.handler;
    if (isEnd) { // 如果是路径终点,且有执行函数则执行,否则退出
      // currentNode.key
      nodeHandler && pathParams.size > 0 ? nodeHandler(pathParams) : undefined;
    } else {
      if (nodeChild) {
        tempIndex++;
        tempNode = nodeChild;
        return true;
      }
    }
    return false;
  }

  // 遍历匹配字典树
  while (tempIndex < len) {
    // 获取当前节点项的所有 keyName
    const tempNodeKeys = Object.keys(tempNode);
    // 是否路径终点
    const isEnd = tempIndex === len - 1;
    const isPathParam = tempNodeKeys.find(item => { return regExpParamKey.exec(item); });
    if (tempNodeKeys.indexOf(pathArr[tempIndex]) !== -1){
      if (!recursiveCheck(tempNode, pathArr[tempIndex], isEnd)) { return; }
    } else if (isPathParam) {
      pathParams.set(tempNode[isPathParam].key, pathArr[tempIndex]);
      if (!recursiveCheck(tempNode, isPathParam, isEnd)) { return; }
    } else { return; }
  }
}

实现了 IterateNodeTree 函数之后,我们就可以来简单测试一下这个字典树的匹配结果了。

// 假设请求的url
const rl: string[] = [
  '/user/repwd',
  '/user/123/info',
  '/user/456/other/',
  '/article/123',
  '/article/type',
  '/article/type/info',
];

我们来 run 一下看看结果:

deno run troute.ts

从运行结果中可以看到,只有匹配命中的 URI 才会触发函数执行,而未命中的则没有触发函数执行。

(嗨呀~你都是本地字符串捏,我说的是请求!请求!!)

哎哟,不要着急嘛,一步一步来嘛,如果本地字符串测试都过不了那么还谈什么处理请求噢。

Deno Web Server

现在我们先来实现一个简单的 http 服务

import { serve } from "https://deno.land/std/http/server.ts";

console.log("http://localhost:8000/");

const webServe = serve({ port: 8000 });

for await (const request of webServe) {
  const splitPath = req.url.split('/');
  const splitPathLen = splitPath.length - 1;

  // 如果不以 “/” 开头或者以 “/” 结尾,需要清楚空字符串项
  if (!splitPath[splitPathLen]) { splitPath.splice(splitPathLen, 1); }
  if (!splitPath[0]) { splitPath.splice(0, 1); }

  console.log('拆分 URL 层级:', splitPath);
  IterateNodeTree(splitPath);
}

只要 run 一下,这个简单的 HTTP 就已经启动了。
当然,有些小伙伴可能不太熟悉或者刚接触没记住,那么我就重复提一下:

默认情况下,Deno 是安全的。因此 Deno 模块没有文件、网络或环境的访问权限,除非您为它授权。在命令行参数中为 Deno 进程授权后才能访问安全敏感的功能。

这里因为我们需要访问网络权限,所以不要忘了再命令中加入 --allow-net 这个权限标志,并且权限标志符位置是跟着 run 之后的,需要注意不要写错位置了。

deno run --allow-net troute.ts

(这是一个错误示范)

(密码正确)

这时我们就可以看到 HTTP 服务已经启动了并且命令行中打印了 console.log 的内容。

通过 fetch 发起请求

再来造几个请求:

fetch('http://localhost:8000/user/123/info', { method: 'GET' });
fetch('http://localhost:8000/user/repwd', { method: 'POST' });
fetch('http://localhost:8000/user/456/other/', { method: 'POST' });
fetch('http://localhost:8000/article/123', { method: 'GET' });
fetch('http://localhost:8000/article/type', { method: 'POST' });
fetch('http://localhost:8000/article/type/info', { method: 'POST' });

// 记得刚刚定义的路由
// 定义的路由列表需要一个 url 属性和一个 handle 属性
const rrl = [
  { url: '/user/repwd', handle: () => { console.log('/user/repwd'); } },
  { url: '/user/:id/info', handle: (data: any) => { console.log('/user/:id/info', data); } },
  { url: '/article/:id', handle: () => { console.log('/article/:id'); } },
];

编写完成后我们再启动一个命令行,执行以下命令:

deno run --allow-net aa.ts

当然也需要注意,不要忘记加上访问网络权限的权限标志符 --allow-net

(我这里命名比较随意图方便,大家在写项目的时候千!万!不!要!学我,这是一个不好的习惯)

自此一个路由核心的匹配逻辑就完成了,但是如果非要说业务能力的话,这个 Router 的实现是达不到的。

因为这里我只是讲述了路由的由来和概念,以及目前的实现方式,但是有兴趣的话通过以上代码改造一下是可以实现简易的 Web Server 功能的。

当然你也可以尝试将它构建一下:

deno bundle [OPTIONS] <source_file> [out_file]

deno bundle trouter.ts Router.js

我为什么学习 Deno

最早我也是通过 2018年6月的柏林 JS 大会上 Ryan Dahl 提到了 Deno。当时我自己对 Node.js 的好感也并不大反而我还是挺期待 Deno 的。因为我想知道一个人在认为自己失败后再次尝试同类产品之后会有什么变化,吸取教训改进、还是会踩到同一个坑?

不过随着时间的推移,我看到了 Deno 借鉴了大量优秀的实现,哪些他诟病失败的东西不再复现且具备了更多的能力。

我第一次上手 Deno 的时候给我的感觉和第一次上手 Golang 的感觉一样,虽然感觉上有点怪,但是写起来却很舒服。

如果说非要我找一个理由,那就是:“它和 Golang 一样,很强也很‘轻’”。

🏆 技术专题第一期 | 聊聊 Deno的一些事儿......

版权声明:
本文版权属于作者 林小帅,未经授权不得转载及二次修改。
转载或合作请在下方留言及联系方式。