Http框架的设计与实践 | 字节青训营笔记

81 阅读15分钟

90982d713e4fa33f243a00a8dd8e97c3.png

image.png

第一层:应用层(业务层)

image.png

第二层:中间件层(服务治理层)

image.png

image.png

image.png

如果没有中间件,需要在每一个接口的开始和结束都加上一句话,如果有中间件就不一样了 (类似只需要写一次,然后复用就OK)

image.png

  1. 配合 Handler 实现一个完整的请求处理生命周期

    • 在一个典型的网络应用程序中,当客户端发送一个请求时,服务器需要对这个请求进行处理并返回响应。这个处理过程通常涉及多个步骤,从接收请求到最终返回响应。
    • Handler是负责实际处理请求的组件。它接收请求,执行必要的业务逻辑(如查询数据库、执行计算等),然后生成响应。
    • 中间件(Middleware) 则是在请求到达 Handler 之前和 Handler 处理完请求之后执行额外逻辑的组件。中间件与 Handler 协同工作,确保请求在整个生命周期内得到完整处理。
    • 例如,在一个 Web 应用中,当用户请求一个网页时,请求首先经过中间件(如身份验证中间件),然后到达处理该请求的 Handler(如返回网页内容的函数),最后可能再次经过中间件(如日志记录中间件),整个过程构成了一个完整的请求处理生命周期。
  2. 拥有预处理逻辑与后处理逻辑

    • 预处理逻辑

      • 预处理逻辑是指在请求到达实际处理程序Handler之前执行的逻辑。它可以用于检查请求的合法性、验证用户身份、设置请求上下文等。
      • 例如,在一个 Web API 中,权限验证中间件可以在请求到达实际的 API 处理函数之前,检查请求中携带的用户令牌是否有效。如果令牌无效,中间件可以直接返回错误响应,阻止请求继续向下传递
    • 后处理逻辑

      • 后处理逻辑是指在 Handler 处理完请求之后执行的逻辑。它通常用于记录请求处理结果、清理资源、修改响应内容等。
      • 例如,在一个 Web 应用中,日志记录中间件可以在 Handler 返回响应之后,记录请求的处理时间、返回的状态码等信息,以便进行性能分析和故障排查。

a031993db3bbaf20a5984a07d8ecc4da.png

将中间件和业务逻辑统一合并为一个Next函数,不再区分中间件和业务逻辑,

image.png

image.png

帮用户主动调用之后的中间件,在任何场景下保持index递增 image.png

出现异常想停止怎么办?index设置成一个最大值,直接跳出来即可。

image.png

image.png 中间件C显示调用业务Handler

Recovery Middleware不能捕获其他协程的panic的, 只能调用本协程或者本调用栈的,如果中间件B是Recovery Middleware,他是捕获不了Handler里的中间件的。所以Recovery Middleware是需要在同一调用栈上的,是必须要调用Next逻辑的。

  1. 可以注册多中间件

    • 在一个复杂的系统中,通常需要多个中间件来处理不同方面的逻辑。系统需要支持注册多个中间件,以便在请求处理过程中依次应用这些中间件。
    • 例如,一个 Web 应用可能同时需要身份验证中间件、日志记录中间件和性能监控中间件。当请求到来时,首先经过身份验证中间件进行身份验证,然后经过性能监控中间件记录请求开始处理的时间,接着到达 Handler 进行实际处理,最后经过日志记录中间件记录请求处理结果和性能数据。
    • 这种多中间件的设计使得系统可以灵活地组合不同的功能模块,满足复杂的业务需求。
  2. 对上层模块用户逻辑模块易用

    • 中间件的设计应该考虑到上层模块(如用户逻辑模块)的易用性。这意味着中间件应该提供简单、直观的接口,使得上层模块可以方便地使用中间件提供的功能,而不需要复杂的配置或调用过程。
    • 例如,在一个基于框架开发的 Web 应用中,开发者应该能够通过简单的配置或装饰器(decorator)来注册和使用中间件,而不需要深入了解中间件的内部实现细节。
    • 这种易用性设计可以提高开发效率,降低系统的复杂性,使得开发者可以更专注于业务逻辑的实现。

中间件在系统设计中起到了至关重要的作用。它通过预处理和后处理逻辑,可以增强系统的功能(如权限验证、日志记录等)和安全性(如防止非法访问),同时保持系统的易用性,使得上层模块可以方便地使用这些功能。多个中间件的灵活组合可以满足复杂的业务需求,提高系统的可扩展性和可维护性。

第三层:路由层

image.png

这张图片展示了关于框架路由的一些技术要点,主要是 URL 匹配对应的处理函数(Handlers)。

  1. 静态路由

    • 示例:/a/b/c/a/b/d
    • 这些是固定路径的路由,没有参数。
  2. 参数路由

    • 示例:/a/:id/c/a/b/c/a/d/c)、/*all
    • 这些路由包含参数,如:id,可以匹配不同的值(比如可以是b也可以是d也可以是。。。)。/*all是一个通配符路由,用于匹配所有路径。/*all能够匹配任意形式的路由————比如既能匹配/a/b/c又能匹配/a/d/c
  3. 路由修复

    • 示例:/a/b/a/b/
    • 这是处理路径结尾是否带斜杠的情况,确保这两种形式都能正确匹配。
  4. 冲突路由以及优先级

    • 示例:/a/b/:id/c

    • 这是处理当多个路由可能匹配同一 URL 时,如何确定优先级的问题。

      1. 冲突路由与优先级的概念
    • 在 Web 开发中,路由系统用于将传入的 URL 请求映射到相应的处理函数。当多个路由可能匹配同一个 URL 时,就会出现冲突。例如,/a/b/:id/c这两个路由,/a/b是一个静态路由,而/:id/c是一个参数路由(:id可以取任意值)。如果有一个 URL 请求是/a/b,它既可以匹配/a/b,也可以匹配/:id/c(当idb时)。这时就需要确定哪个路由应该优先处理这个请求。

      1. 确定优先级的方法
    • 一般来说,在路由系统中,确定优先级有以下几种常见方法:

      • 精确匹配优先:静态路由通常比参数路由具有更高的优先级。在上面的例子中,/a/b是一个精确的路径,而/:id/c是一个带有参数的路径。因此,/a/b应该优先处理/a/b这个 URL 请求。
      • 定义顺序优先:在某些路由系统中,路由的定义顺序决定了优先级。先定义的路由会优先被检查。例如,如果/a/b先被定义,然后是/:id/c,那么/a/b会优先处理/a/b这个 URL 请求。
      • 路由权重:有些路由系统允许为每个路由设置权重,权重高的路由会优先处理。这种方法比较灵活,但也增加了配置的复杂性。
      1. 应用场景和意义
    • 冲突路由和优先级的处理在实际开发中非常重要。例如,在一个电商网站中,可能有一个静态路由/product/123用于显示特定产品的详情,同时有一个参数路由/product/:id用于处理所有产品详情页面。如果不处理好优先级,可能会导致错误的页面显示或者数据加载。正确处理冲突路由可以确保网站的路由系统稳定、高效地运行,为用户提供正确的页面和数据。

  5. 匹配 HTTP 方法

    • 这是处理不同 HTTP 方法(如 GET、POST 等)的路由。
  6. 多处理函数

    • 方便添加中间件

    • 这是处理如何在路由中添加多个处理函数,通常用于添加中间件来处理请求。

      1. 多处理函数与中间件的概念
    • 多处理函数:在路由系统中,多处理函数指的是可以为一个路由指定多个处理函数。这些处理函数会按照一定的顺序被调用,以处理传入的请求

    • 中间件:中间件是一种在请求到达最终处理函数之前(或响应返回之前)进行处理的函数。它可以用于诸如日志记录、身份验证、请求预处理、响应后处理等操作。中间件函数通常会在请求处理管道中被依次调用。

      1. 多处理函数在添加中间件中的应用
    • 当我们想要为路由添加中间件时,多处理函数机制就非常有用。例如,考虑一个简单的 Web 应用程序有一个路由/user/profile

    • 我们可能希望在处理/user/profile这个路由的请求之前,进行以下操作:

      • 首先进行身份验证,确保用户已登录。这可以通过一个身份验证中间件来实现。
      • 然后记录请求的日志,记录哪个用户在什么时间访问了这个页面。这可以通过一个日志记录中间件来实现。
    • 使用多处理函数机制,我们可以将这些中间件函数和最终处理/user/profile页面显示的函数绑定到/user/profile这个路由上

    • 假设我们使用一个类似 Express.js 的框架,代码可能如下:

app.get('/user/profile',
  // 身份验证中间件
  function(req, res, next) {
    if (req.user) {
      next();
    } else {
      res.send('Please login first');
    }
  },
  // 日志记录中间件
  function(req, res, next) {
    console.log(`${req.user.username} accessed /user/profile at ${new Date()}`);
    next();
  },
  // 最终处理函数,显示用户个人资料页面
  function(req, res) {
    res.render('user/profile', { user: req.user });
  }
);
  • 在这个例子中,/user/profile路由有三个处理函数:一个身份验证中间件、一个日志记录中间件和一个最终显示用户资料页面的函数。当有请求到达/user/profile时,这些函数会依次被调用。

      1. 多处理函数机制的优势
    • 模块化和可维护性:通过将不同的功能(如身份验证、日志记录等)分离到不同的中间件函数中,代码变得更加模块化和易于维护。

    • 复用性:中间件函数可以在多个路由中复用。例如,身份验证中间件可能在多个需要用户登录的路由中都可以使用。

    • 灵活的请求处理管道:可以根据需要灵活地添加、删除或重新排列中间件函数,以调整请求处理的流程。

    • 这些内容通常涉及到 Web 开发中的路由机制,特别是在使用框架进行开发时,如何处理不同的 URL 路径和请求方法。

image.png

青铜级别:map [string] handlers

  • 描述:使用map[string]handlers来处理路由。这种方法简单直接,通过将 URL 路径处理函数handlers进行映射来处理请求。

  • 示例

    • 静态路由:/a/b/c/a/b/d
    • 参数路由:/a/:id/c/*all
    • 这里/a/:id/c是参数路由,:id表示路径中的参数部分。/*all是一个通配符路由,用于匹配所有路径。

黄金级别:前缀匹配树

  • 描述:使用前缀匹配树(Prefix Trie)来处理路由。这种方法更高效,适用于处理大量路由的情况。

  • 示例

    • 考虑静态路由/a/b/c/a/b/d

    • 问题:如何处理带参数的路由注册?例如/a/:id/b类型的路由。

    • 图表解释

      • 图表展示了一个前缀匹配树的结构。

      • 根节点(Root)的path/a/label/fullPath/a/

      • 从根节点向下,有一个节点的pathb/labelbfullPath/a/b/

      • 再往下,有两个叶子节点:

        • 一个叶子节点的pathclabelcfullPath/a/b/c
        • 另一个叶子节点的pathdlabeldfullPath/a/b/d
    • 这种结构可以高效地处理路由匹配,特别是对于带参数的路由,可以通过在树中合适的位置插入节点来处理。

对比了两种路由处理方法:一种是简单的映射方法,另一种是更高效的前缀匹配树方法,并通过具体示例和图表展示了前缀匹配树的工作原理。 image.png image.png

image.png

  1. 当一个 HTTP 请求到达时,首先通过外层 Map(根据 HTTP 方法作为key)进行初步筛选。
  2. 初步筛选后,进入前缀树(Trie)结构进行进一步的路由匹配。
  3. 前缀树的头节点是路由匹配的起点,通过前缀树的结构来精确匹配具体的路由。

image.png

  1. 明确需求:考虑清楚要解决什么问题,有哪些需求
  2. 业界调研:业界都有哪些解决方案可供参考
  3. 方案权衡:思考不同方案的取舍
  4. 方案评审:相关同学对不同方案做评审
  5. 确定开发:确定最合适的方案进行开发

路由层是URI进行选择执行的Handler(http在第七层,tcp在第四层)

第四层 协议层设计

抽象出合适的接口

image.png

  1. 抽象出合适的接口

    • 图片中展示了一段 Go 语言代码,定义了一个名为Server的接口。
type Server interface {
  Serve(c context.Context, conn network.Conn) error
}
  • 定义了一个Server接口,该接口包含一个Serve方法。

  • Serve方法接受两个参数:

    • 第一个参数是context.Context类型的c,用于传递上下文信息
    • 第二个参数是network.Conn类型的conn,用于处理网络连接
  • Serve方法返回一个error类型的值,表示可能出现的错误。

  1. 设计原则

    • 第一条原则:不要在结构体类型内部存储上下文(Contexts),而是将上下文显式地传递给每个需要它的函数。上下文应该是第一个参数。
    • 第二条原则:需要在连接上读写数据。

背景知识

  • 在网络编程中,协议层设计至关重要。通过定义接口,可以确保不同的网络服务实现遵循相同的规范。

  • context.Context在 Go 语言中用于处理请求的上下文,例如超时控制、取消操作等。

  • network.Conn是 Go 语言中用于处理网络连接的接口,通过它可以进行数据的读写操作。

这张图片展示了在 Go 语言环境下如何进行协议层设计,通过定义Server接口和遵循特定的设计原则,确保网络服务的高效和可靠。

第五层 网络层设计(传输层)

netpoll:https://github.com/cloudwego/netpoll

针对网络模型进行设计

image.png

1. BIO(阻塞式 I/O)

  • 在左侧展示了 BIO 的代码示例。
  • 代码使用 Go 语言编写。
  • 主要结构是一个for循环,在循环中通过listener.Accept()接受连接,然后使用conn.Read(request)读取请求,接着处理请求(handle...部分被红色椭圆圈住,可能是处理请求的代码被省略了),最后使用conn.Write(response)发送响应。
  • 这种模式下,每个连接在读取和写入时都会阻塞,直到操作完成。

2. NIO(非阻塞式 I/O)

  • 在右侧展示了 NIO 的代码示例。
  • 同样使用 Go 语言编写。
  • 主要结构也是一个for循环,但这里首先通过Monitor(conns)监控连接(conns可能是一个连接集合),有足够的监听器之后唤醒下面的func;然后在循环中遍历可读连接(readableConns),读取请求(conn.Read(request)),read可以读到足够的数据,就不会阻塞了;处理请求(handle...部分被红色椭圆圈住,可能是处理请求的代码被省略了),最后发送响应(conn.Write(response))。
  • 这种模式下,连接的读取和写入操作不会阻塞,通过监控机制来处理多个连接。

3. 对比和意义

  • 对比 BIO 和 NIO 的代码,展示了两种不同的网络层设计模式。
  • BIO 模式简单直接,但在处理多个连接时可能会导致性能问题,因为每个连接都会阻塞。
  • NIO 模式通过非阻塞操作和监控机制,可以更高效地处理多个连接,适合高并发场景。

image.png

  • “go net” :这是指 Go 语言的网络包(net package),Go 语言提供了丰富的网络编程功能。
  • 底层没有数据,read一直等待,等到超时或者连接关闭;write如果写数据一直没有写到底层。go net用户态管理,用户管理缓冲区意味着程序员需要手动处理数据的读取和写入操作,确保数据的完整性和正确性。
  • 在 Go 语言的网络编程中,net包提供了许多用于处理网络连接的类型和函数。
  • 阻塞式 I/O(BIO)是一种传统的 I/O 处理方式,它在执行 I/O 操作时会阻塞当前线程,直到操作完成。

image.png

  • Reader 接口

    • 代码定义了一个名为Reader的接口。
    • 接口中包含一个方法Peekn(int) ([][]byte, error)
    • 这个方法的作用是从缓冲区中查看指定长度的数据,返回一个字节切片的切片和一个错误。
  • Writer 接口

    • 代码定义了一个名为Writer的接口。

    • 接口中包含两个方法:

      • Malloc(n int) (buf []byte, err error):这个方法用于分配指定长度的缓冲区。
      • Flush() error:这个方法用于将缓冲区的数据刷新(写入)出去。
  • netpoll 网络库在网络层设计中如何处理非阻塞 I/O 和缓冲区管理。

image.png

image.png