03 路由匹配

文章讨论了如何在Web框架中设计和实现路由功能,包括HTTP方法匹配、静态路由、批量通用前缀和动态路由。通过使用两层哈希表处理HTTP方法和静态路由,以及通过Trie树结构解决动态路由的字符串匹配问题,实现了高效查找控制器的功能。此外,文章还介绍了如何处理路由冲突和注册带有通用前缀的批量路由。
摘要由CSDN通过智能技术生成

封装了框架的 Context, 将请求结构 request 和返回结构 responseWriter 都封装在 Context 中。利用这个 Context, 我们将控制器简化为带有一个参数的函数 FooControllerHandler,这个控制器函数的输入和输出都是固定的。在框架层面,我们也定义了对应关于控制器的方法结构 ControllerHandler 来代表这类控制器的函数。

每一个请求逻辑,都有一个控制器 ControllerHandler 与之对应。那么一个请求,如何查找到指定的控制器呢?

路由

路由的功能,具体来说就是让 Web 服务器根据规则,理解 HTTP 请求中的信息,匹配查找出对应的控制器,再将请求传递给控制器执行业务逻辑,简单来说就是制定匹配规则。

一个 HTTP 请求包含请求头和请求体。请求体内一般存放的是请求的业务数据,是基于具体控制业务需要的,所以,我们不会用来做路由。

而请求头中存放的是和请求状态有关的信息,比如 User-Agent 代表的是请求的浏览器信息,Accept 代表的是支持返回的文本类型。以下是一个标准请求头的示例:

GET /home.html HTTP/1.1
Host: developer.mozilla.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:50.0) Gecko/20100101 Firefox/50.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://developer.mozilla.org/testpage.html

每一行的信息和含义都是非常大的课题, 这里重点讲第一行,叫做Request Line,由Method、Request-URI 和 HTTP-Version三部分组成:
在这里插入图片描述
Method 是 HTTP 的方法,标识对服务端资源的操作属性。

 Method         = "OPTIONS"                ; Section 9.2
                | "GET"                    ; Section 9.3
                | "HEAD"                   ; Section 9.4
                | "POST"                   ; Section 9.5
                | "PUT"                    ; Section 9.6
                | "DELETE"                 ; Section 9.7
                | "TRACE"                  ; Section 9.8
                | "CONNECT"                ; Section 9.9
                | extension-method
 extension-method = token

Request-URI 是请求路径,也就是浏览器请求地址中域名外的剩余部分。

在这里插入图片描述
HTTP-Version 是 HTTP 的协议版本,目前常见的有 1.0、1.1、2.0。

Web Service 在路由中使用的就是 Method 和 Request-URI 这两个部分。如果框架支持 REST 风格的路由设计,那么使用者在写业务代码的时候,就倾向于设计 REST 风格的接口;如果框架支持前缀匹配,那么使用者在定制 URI 的时候,也会倾向于把同类型的 URI 归为一类。

这些设计想法通通会体现在框架的路由规则上,最终影响框架使用者的研发习惯,这个就是设计感。

路由规则的需求

我们希望使用者高效、易用地使用路由模块,那出于这一点考虑,基本需求可以有哪些呢?

按照从简单到复杂排序,路由需求有下面四点:

  • HTTP 方法匹配
    早期简单的WebService只用到Request-URI部分,随着REST风格的流行,也需要支持多种HTTP Method

  • 静态路由匹配
    静态路由匹配是一个路由的基本功能,指的是路由规则中没有可变参数,即路由规则地址是固定的,与 Request-URI 完全匹配。
    net/http包中的DefaultServerMux 这个路由器,从内部的 map 中直接根据 key 寻找 value ,这种查找路由的方式就是静态路由匹配。

  • 批量通用前缀
    为某个业务模块注册一批路由,比如/user/info、 /user/login都是以/user开头。
    所以如果路由有能力统一定义批量的通用前缀,那么在注册路由的过程中,会带来很大的便利。

  • 动态路由匹配
    针对静态路由改进,因为URL中某些字段并不是固定的,而是按照一定规则(如数字)变化。

功能Request-URIHTTP Method
用户登录/user/loginPOST
增加专题/subject/addPOST
删除专题/subject/1DELETE
修改专题/subject/1PUT
查找专题/subject/1GET
获取专题列表/subject/listGET

那么该如何实现这几个需求呢?

需求实现

实现 HTTP 方法和静态路由匹配

对于第一、二个需求,很容易想到使用两层哈希表实现:

func NewCore() *Core {
	getRouter := map[string]ControllerHandler{}
	postRouter := map[string]ControllerHandler{}
	putRouter := map[string]ControllerHandler{}
	deleteRouter := map[string]ControllerHandler{}

	router := map[string]map[string]ControllerHandler{}
	router["GET"] = getRouter
	router["POST"] = postRouter
	router["PUT"] = putRouter
	router["DELETE"] = deleteRouter
	return &Core{router: router}
}


// 对应 Method = Get
func (c *Core) Get(url string, handler ControllerHandler) {
	upperUrl := strings.ToUpper(url)
	c.router["GET"][upperUrl] = handler
}

// 匹配路由,如果没有匹配到,返回nil
func (c *Core) FindRouteByRequest(request *http.Request) ControllerHandler {
	// uri 和 method 全部转换为大写,保证大小写不敏感
	uri := request.URL.Path
	method := request.Method
	upperMethod := strings.ToUpper(method)
	upperUri := strings.ToUpper(uri)

	// 查找第一层map
	if methodHandlers, ok := c.router[upperMethod]; ok {
		// 查找第二层map
		if handler, ok := methodHandlers[upperUri]; ok {
			return handler
		}
	}
	return nil
}

func (c *Core) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	ctx := NewContext(r, w)
	router := c.FindRouteByRequest(r)
	if router == nil {
		ctx.Json(404, "not found")
		return
	}

	if err := router(ctx); err != nil {
		ctx.Json(500, "inner error")
		return
	}
}

批量实现通用前缀

批量通用前缀,类似下面这样使用:


// 注册路由规则
func registerRouter(core *framework.Core) {
  // 需求3:批量通用前缀
  subjectApi := core.Group("/subject")
  {
    subjectApi.Get("/list", SubjectListController)
  }
}

这里core.Group方法接收一个字符串前缀,返回的是一个包含Get、Post、Put、Delete方法的结构,我们将其命名为Group,因为是一个实现了各种方法的结构,因此我们这里采用接口定义IGroup:

// IGroup 代表前缀分组
type IGroup interface {
  Get(string, ControllerHandler)
  Post(string, ControllerHandler)
  Put(string, ControllerHandler)
  Delete(string, ControllerHandler)
}


// Group struct 实现了IGroup
type Group struct {
  core   *Core
  prefix string
}

// 初始化Group
func NewGroup(core *Core, prefix string) *Group {
  return &Group{
    core:   core,
    prefix: prefix,
  }
}

// 实现Get方法
func (g *Group) Get(uri string, handler ControllerHandler) {
  uri = g.prefix + uri
  g.core.Get(uri, handler)
}

....

// 从core中初始化这个Group
func (c *Core) Group(prefix string) IGroup {
  return NewGroup(c, prefix)
}

实现动态路由

希望实现如下所示的动态路由:

func registerRouter(core *framework.Core) {
  // 需求1+2:HTTP方法+静态路由匹配
  core.Get("/user/login", UserLoginController)

  // 需求3:批量通用前缀
  subjectApi := core.Group("/subject")
  {
    // 需求4:动态路由
    subjectApi.Delete("/:id", SubjectDelController)
    subjectApi.Put("/:id", SubjectUpdateController)
    subjectApi.Get("/:id", SubjectGetController)
    subjectApi.Get("/list/all", SubjectListController)
  }
}

引入动态路由,则之前的哈希规则无法使用了,因为字符是动态变化的,无法使用URI作为key来匹配。

这个问题本质是一个字符串匹配,通用的方法是trie数,也叫字典树。

trie树是多叉的树形结构,根节点一般是空字符串,而叶子节点保存的通常是字符串,一个节点的所有子孙节点都有相同的字符串前缀。

所以根据 trie 树的特性,我们结合前三条路由规则,可以构建出这样的结构:


1 /user/login
2 /user/logout
3 /subject/name
4 /subject/name/age
5 /subject/:id/name

在这里插入图片描述

这个 trie 树是按照路由地址的每个段 (segment) 来切分的,每个 segment 在 trie 树中都能找到对应节点,每个节点保存一个 segment。树中,每个叶子节点都代表一个 URI,对于中间节点来说,有的中间节点代表一个 URI(比如上图中的 /subject/name),而有的中间节点并不是一个 URI(因为没有路由规则对应这个 URI)。

我们开始动手实现 trie 树。还是照旧先明确下可以分为几步:

  1. 定义树和节点的数据结构
  2. 编写函数:“增加路由规则”
  3. 编写函数:“查找路由”
  4. 将“增加路由规则”和“查找路由”添加到框架中
// 代表树结构
type Tree struct {
  root *node // 根节点
}

// 代表节点
type node struct {
  isLast  bool              // 代表这个节点是否可以成为最终的路由规则。该节点是否能成为一个独立的uri, 是否自身就是一个终极节点
  segment string            // uri中的字符串,代表这个节点表示的路由中某个段的字符串
  handler ControllerHandler // 代表这个节点中包含的控制器,用于最终加载调用
  childs  []*node           // 代表这个节点下的子节点
}

第二步,我们就往 Tree 这个 trie 树结构中添加“增加路由规则”的逻辑。写之前,我们还是暂停一下想一想,会不会出现问题。之前提过会存在通配符,那直接加规则其实是有可能冲突的。比如:

/user/name
/user/:id

这两个路由规则实际上就冲突了,如果请求地址是 /user/name,那么两个规则都匹配,无法确定哪个规则生效。所以在增加路由之前,我们需要判断这个路由规则是否已经在 trie 树中存在了。

这里,我们可以用 matchNode 方法,寻找某个路由在 trie 树中匹配的节点,如果有匹配节点,返回节点指针,否则返回 nil。matchNode 方法的参数是一个 URI,返回值是指向 node 的指针,它的实现思路是使用函数递归,我简单说明一下思路:

首先,将需要匹配的 URI 根据第一个分隔符 / 进行分割,只需要最多分割成为两个段。

如果只能分割成一个段,说明 URI 中没有分隔符了,这时候再检查下一级节点中是否有匹配这个段的节点就行。

如果分割成了两个段,我们用第一个段来检查下一个级节点中是否有匹配这个段的节点。

  • 如果没有,说明这个路由规则在树中匹配不到。
  • 如果下一级节点中有符合第一个分割段的(这里需要注意可能不止一个符合),我们就将所有符合的节点进行函数递归,重新应用于 matchNode 函数中,只不过这时候 matchNode 函数作用于子节点,参数变成了切割后的第二个段。

// 判断一个segment是否是通用segment,即以:开头
func isWildSegment(segment string) bool {
  return strings.HasPrefix(segment, ":")
}

// 过滤下一层满足segment规则的子节点
func (n *node) filterChildNodes(segment string) []*node {
  if len(n.childs) == 0 {
    return nil
  }

  // 如果segment是通配符,则所有下一层子节点都满足需求
  if isWildSegment(segment) {
    return n.childs
  }

  nodes := make([]*node, 0, len(n.childs))
  // 过滤所有的下一层子节点
  for _, cnode := range n.childs {
    if isWildSegment(cnode.segment) {
      // 如果下一层子节点有通配符,则满足需求
      nodes = append(nodes, cnode)
    } else if cnode.segment == segment {
      // 如果下一层子节点没有通配符,但是文本完全匹配,则满足需求
      nodes = append(nodes, cnode)
    }
  }

  return nodes
}

// 判断路由是否已经在节点的所有子节点树中存在了
func (n *node) matchNode(uri string) *node {
  // 使用分隔符将uri切割为两个部分
  segments := strings.SplitN(uri, "/", 2)
  // 第一个部分用于匹配下一层子节点
  segment := segments[0]
  if !isWildSegment(segment) {
    segment = strings.ToUpper(segment)
  }
  // 匹配符合的下一层子节点
  cnodes := n.filterChildNodes(segment)
  // 如果当前子节点没有一个符合,那么说明这个uri一定是之前不存在, 直接返回nil
  if cnodes == nil || len(cnodes) == 0 {
    return nil
  }

  // 如果只有一个segment,则是最后一个标记
  if len(segments) == 1 {
    // 如果segment已经是最后一个节点,判断这些cnode是否有isLast标志
    for _, tn := range cnodes {
      if tn.isLast {
        return tn
      }
    }

    // 都不是最后一个节点
    return nil
  }

  // 如果有2个segment, 递归每个子节点继续进行查找
  for _, tn := range cnodes {
    tnMatch := tn.matchNode(segments[1])
    if tnMatch != nil {
      return tnMatch
    }
  }
  return nil
}

现在有了 matchNode 和 filterChildNodes 函数,我们就可以开始写第二步里最核心的增加路由的函数逻辑了。

首先,确认路由是否冲突。我们先检查要增加的路由规则是否在树中已经有可以匹配的节点了。如果有的话,代表当前待增加的路由和已有路由存在冲突,这里我们用到了刚刚定义的 matchNode。更新刚才框架文件夹中的 tree.go 文件:


// 增加路由节点
func (tree *Tree) AddRouter(uri string, handler ControllerHandler) error {
  n := tree.root
    // 确认路由是否冲突
  if n.matchNode(uri) != nil {
    return errors.New("route exist: " + uri)
  }

  ...
}

然后继续增加路由规则。我们增加路由的每个段时,先去树的每一层中匹配查找,如果已经有了符合这个段的节点,就不需要创建节点,继续匹配待增加路由的下个段;否则,需要创建一个新的节点用来代表这个段。这里,我们用到了定义的 filterChildNodes。


// 增加路由节点
/*
/book/list
/book/:id (冲突)
/book/:id/name
/book/:student/age
/:user/name
/:user/name/:age(冲突)
*/
func (tree *Tree) AddRouter(uri string, handler ControllerHandler) error {
  n := tree.root
  if n.matchNode(uri) != nil {
    return errors.New("route exist: " + uri)
  }

  segments := strings.Split(uri, "/")
  // 对每个segment
  for index, segment := range segments {

    // 最终进入Node segment的字段
    if !isWildSegment(segment) {
      segment = strings.ToUpper(segment)
    }
    isLast := index == len(segments)-1

    var objNode *node // 标记是否有合适的子节点

    childNodes := n.filterChildNodes(segment)
    // 如果有匹配的子节点
    if len(childNodes) > 0 {
      // 如果有segment相同的子节点,则选择这个子节点
      for _, cnode := range childNodes {
        if cnode.segment == segment {
          objNode = cnode
          break
        }
      }
    }

    if objNode == nil {
      // 创建一个当前node的节点
      cnode := newNode()
      cnode.segment = segment
      if isLast {
        cnode.isLast = true
        cnode.handler = handler
      }
      n.childs = append(n.childs, cnode)
      objNode = cnode
    }

    n = objNode
  }

  return nil
}

到这里,第二步增加路由的规则逻辑已经有了,我们要开始第三步,编写“查找路由”的逻辑。这里你会发现,由于我们之前已经定义过 matchNode(匹配路由节点),所以这里只需要复用这个函数就行了。


// 匹配uri
func (tree *Tree) FindHandler(uri string) ControllerHandler {
    // 直接复用matchNode函数,uri是不带通配符的地址
  matchNode := tree.root.matchNode(uri)
  if matchNode == nil {
    return nil
  }
  return matchNode.handler
}

总结:

  • 静态路由时,使用map[method] map[uri] handler这样的两层map格式
  • 动态路由时,将uri按分隔符拆分多个segment,每个segment对应一个node,最后一段segment对应node的isLast字段为true,表明是一个已注册的url路径
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值