引言
Gin框架作为Go语言中最受欢迎的Web框架之一,以其高性能和易用性著称,非常适合用于构建高并发的Web服务。如果你不太熟悉Web框架,可以通过阅读Gin源码,分析Gin框架下HTTP服务器的运行机制,大致就能了解大部分Web框架的原理了。通过这篇文章,你将学习:
- 基于socket网络编程的基本知识
- Gin框架利用Goroutine支持高并发的原理
- Gin框架下HTTP服务器的运行机制
一、预备知识
1.1 socket编程
在学习Web开发之前,首先需要具备socket网络编程的基础知识。因为HTTP是应用层协议,其在传输层使用的是TCP协议,那么在编程过程中需要使用操作系统提供socket接口来实现应用层与底层TCP/IP协议的通信,通过读写socket来完成HTTP报文的解析和回复。
基本的客户端------服务端socket通信过程如下图所示:
服务端编程步骤
-
创建套接字(socket):使用socket()函数创建一个新的套接字。
-
绑定套接字(bind):通过bind()函数将套接字与特定的IP地址和端口号关联起来。
-
监听连接(listen):使用listen()函数使服务器套接字监听来自客户端的连接请求。
-
接受连接(accept):当客户端请求连接时,accept()函数会接受这个连接。
-
读取数据(read/recv):从客户端接收数据。
-
发送数据(write/send):向客户端发送数据。
-
关闭套接字(close):完成数据传输后,关闭连接。
代码示例:
c++
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
int main() {
// 1. 创建Socket
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
std::cerr << "Failed to create socket\n";
return 1;
}
// 2. 绑定IP和端口
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡
server_addr.sin_port = htons(8080); // 端口8080
if (bind(server_fd, (sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
std::cerr << "Failed to bind\n";
close(server_fd);
return 1;
}
// 3. 监听连接
if (listen(server_fd, 5) == -1) { // 最多5个等待连接
std::cerr << "Failed to listen\n";
close(server_fd);
return 1;
}
std::cout << "Server listening on port 8080...\n";
// 4. 接受客户端连接
sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd, (sockaddr*)&client_addr, &client_len); // 阻塞,直到有连接请求
if (client_fd == -1) {
std::cerr << "Failed to accept connection\n";
close(server_fd);
return 1;
}
std::cout << "Client connected!\n";
// 5. 接收和发送数据
char buffer[1024] = {0};
ssize_t bytes_read = recv(client_fd, buffer, sizeof(buffer), 0);
if (bytes_read == -1) {
std::cerr << "Failed to read from client\n";
} else {
std::cout << "Received: " << buffer << std::endl;
const char* response = "Hello from server!";
send(client_fd, response, strlen(response), 0);
}
// 6. 关闭连接
close(client_fd);
close(server_fd);
return 0;
}
客户端编程步骤
-
创建套接字(socket):同服务端。
-
发起连接(connect):使用connect()函数向服务器发起连接请求。
-
发送数据(write/send):向服务器发送数据。
-
读取数据(read/recv):从服务器接收数据。
-
关闭套接字(close):完成数据传输后,关闭连接。
代码示例:
c++
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
int main() {
// 1. 创建Socket
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
if (client_fd == -1) {
std::cerr << "Failed to create socket\n";
return 1;
}
// 2. 连接服务端
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080); // 服务端端口
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr); // 服务端IP
if (connect(client_fd, (sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
std::cerr << "Failed to connect to server\n";
close(client_fd);
return 1;
}
std::cout << "Connected to server!\n";
// 3. 发送和接收数据
const char* message = "Hello from client!";
send(client_fd, message, strlen(message), 0);
char buffer[1024] = {0};
ssize_t bytes_read = recv(client_fd, buffer, sizeof(buffer), 0);
if (bytes_read == -1) {
std::cerr << "Failed to read from server\n";
} else {
std::cout << "Received: " << buffer << std::endl;
}
// 4. 关闭连接
close(client_fd);
return 0;
}
分析
让我们来关注一下服务器端的程序,上述的例子只是一个最简单的示例,实际的服务器程序远比这个复杂。示例中,服务器调用accept
时会阻塞,直到有客户端发起connect
调用,此时会完成TCP的三次握手:
步骤 | 操作 | 说明 |
---|---|---|
1 | 客户端调用 connect() |
内核发起第一次握手(SYN) |
2 | 服务端内核响应 SYN-ACK |
服务端未调用 accept() 前已完成第二次握手 |
3 | 客户端内核响应 ACK |
握手完成,connect() 返回成功 |
4 | 服务端调用 accept() |
从已建立的连接队列中取出客户端 Socket |
plaintext
客户端 服务端
| |
| SYN |
|---------------------------> | 内核完成第二次握手(SYN-ACK)
| | (此时 accept() 可能仍在阻塞)
| ACK |
|<--------------------------- | 握手完成
| |
connect() 返回成功 accept() 返回 client_fd
| |
连接建立后,服务端调用recv
等待客户端发送数据,这一步也是阻塞,直到客户端调用send
,然后服务端读取数据,并调用send
给客户端回复,最后close
关闭socket。
这个简单的服务端程序无法支持多个客户端的并发连接,实际项目中,需结合多线程或 I/O 多路复用处理并发连接。
1.2 I/O 多路复用
之前写过一篇【Linux开发】浅析select/poll/epoll与IO多路复用,具体介绍了什么是IO多路复用、为什么要使用IO多路复用、怎样使用IO多路复用,这里就不展开了赘述了。
如果读者还不清楚相关的概念,只需要知道:任何服务端程序要想支持高并发,必须使用操作系统提供的IO多路复用机制,在 Linux 系统上,使用epoll
;在 FreeBSD 和 Mac OS X 上,使用kqueue
;在 Windows 上,使用IOCP
。这些机制允许操作系统在一个线程中同时监控多个 socket 的读写事件,从而实现高效的并发处理。
Go 语言的net/http
包在处理网络连接时,默认使用了操作系统提供的 IO 多路复用机制。
二、一图看懂Gin的运行机制
虽然上一章节的socket编程示例跟实际项目相差甚远,但是它描述了所有服务器程序的必须步骤。因此我们可以寻找一下Gin框架中这些步骤的身影。

上图大致揭示了Gin框架的运行机制,首先再回顾一下服务端的基本步骤:
plaintext
服务端: socket() → bind() → listen() → accept() → send()/recv() → close()
bind()
对应图中创建Server
实例,address
就是服务器监听的地址listen()
对应图中Server
的Listen
,开启监听accept()
对应图中Accept
,并且是在一个循环中不断接收新连接send()/recv()
对应图中蓝色部分,创建一个连接对象扔到Goroutine中进行读写,不阻塞Accpet
,这一步是Gin支持高并发的关键- 具体怎么处理的?那就要看这个框架注册的路由和处理函数、中间件等东西了,简单说就是预先对不同的HTTP请求和不同的URL的指定相应的处理方法
基本上所有的Web框架都是类似的运行机制,而Gin的优点就在于直接利用Go语言的Goroutine来实现并发编程,不用手动编写许多复杂的代码。并且,其他语言都得使用一个线程处理一个客户端的读写,而Go语言的协程比线程更加轻量级,一个线程可以处理多个客户端的读写,所以Go语言编写的服务端程序天然能支持更多的并发。这就是为什么Go语言特别适合用来做服务端开发的原因。
三、 Gin源码赏析
上一章介绍了Gin框架的运行机制,如果读者对源码感兴趣,可以继续扒一扒Gin的源码。
Gin使用示例
以下是一个简单的 Gin 框架示例,展示了如何创建基本的路由、处理请求参数、使用中间件以及进行 JSON 响应:
go
package main
import (
"github.com/gin-gonic/gin"
"net/http"
)
// Book 结构体用于绑定 JSON 数据
type Book struct {
Title string `json:"title"`
Author string `json:"author"`
}
// 中间件函数
func loggingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 在请求处理前记录日志
c.Next()
// 在请求处理后记录日志
}
}
func main() {
// 创建一个默认的 Gin 引擎
r := gin.Default()
// 使用中间件
r.Use(loggingMiddleware())
// 定义 GET 请求路由,用于获取所有书籍(简单模拟)
r.GET("/books", func(c *gin.Context) {
books := []Book{
{Title: "Book1", Author: "Author1"},
{Title: "Book2", Author: "Author2"},
}
c.JSON(http.StatusOK, books)
})
// 定义 GET 请求路由,带有参数
r.GET("/book/:title", func(c *gin.Context) {
title := c.Param("title")
// 这里可以根据 title 从数据库或其他数据源获取书籍信息
// 简单模拟返回一个包含 title 的响应
c.JSON(http.StatusOK, gin.H{"title": title})
})
// 定义 POST 请求路由,用于创建新书籍
r.POST("/books", func(c *gin.Context) {
var newBook Book
if err := c.ShouldBindJSON(&newBook); err!= nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 这里可以将 newBook 保存到数据库
c.JSON(http.StatusCreated, newBook)
})
// 启动服务器,监听 8080 端口
r.Run(":8080")
}
在这个示例中:
- 路由定义 :
- 使用
r.GET
定义了两个 GET 请求路由,一个用于获取所有书籍,另一个用于根据书籍标题获取特定书籍。 - 使用
r.POST
定义了一个 POST 请求路由,用于创建新的书籍。
- 使用
- 参数处理 :
- 在
/book/:title
路由中,使用c.Param("title")
获取 URL 中的参数。 - 在
/books
的 POST 请求中,使用c.ShouldBindJSON
将请求体中的 JSON 数据绑定到Book
结构体。
- 在
- 中间件使用 :
- 定义了一个简单的
loggingMiddleware
中间件,并通过r.Use(loggingMiddleware())
将其应用到所有路由上。该中间件在请求处理前后都可以执行一些逻辑,这里只是简单示例,实际应用中可以记录请求的详细信息。
- 定义了一个简单的
- JSON 响应 :
- 使用
c.JSON
方法返回 JSON 格式的响应。对于 GET 请求,返回书籍列表或特定书籍信息;对于 POST 请求,返回创建的新书籍信息。如果请求处理过程中出现错误,也以 JSON 格式返回错误信息。
- 使用
源码跟踪
Engine
在 Gin 框架中,Engine
是一个核心结构体,它承载了整个 Web 应用的配置、路由信息以及请求处理逻辑,起到了中枢的作用。以下是Engine
的定义:
go
// gin.go
type Engine struct {
// 继承 RouterGroup 的所有字段和方法(如路由前缀 prefix、中间件 handlers 等),使 Engine 具备路由注册、分组管理的能力(如 GET()、Group() 方法)
RouterGroup
// 控制是否自动重定向 "尾部带斜杠" 的路由到 "不带斜杠" 的版本(或反之)
// 例如:若为 true,访问 /api/ 会自动重定向到 /api(或根据路由定义反向重定向)
RedirectTrailingSlash bool
// 当路由匹配失败时,是否尝试通过 "修复路径"(如移除重复斜杠、替换编码字符)后再次匹配
// 例如:访问 /api//v1 会尝试修复为 /api/v1 后匹配路由
RedirectFixedPath bool
// 当请求的 HTTP 方法(如 POST)不被路由支持时,是否返回 405 Method Not Allowed 错误
// 若为 false,则会返回 404 Not Found
HandleMethodNotAllowed bool
//是否从请求头(如 X-Forwarded-For 或 X-Real-IP)中获取客户端真实 IP,而非直接使用连接的 IP 地址(适用于反向代理场景)
ForwardedByClientIP bool
// AppEngine 已弃用
// 是否运行在 Google App Engine 环境中,用于适配该环境的特殊处理(如请求代理)
AppEngine bool
// 是否使用原始未解码的 URL 路径进行路由匹配(默认使用解码后的路径)
// 例如:%2F 会被解码为 /,若开启此选项,则按 %2F 匹配
UseRawPath bool
// 是否对路径参数进行 URL 解码(如将 %20 转为空格)。默认开启,确保参数取值符合预期
UnescapePathValues bool
// 是否自动移除 URL 中的重复斜杠(如将 // 合并为 /)。与 RedirectFixedPath 配合使用,用于标准化路径。
RemoveExtraSlash bool
// 用于提取客户端真实 IP 的请求头列表,默认包含 X-Forwarded-For 和 X-Real-IP
RemoteIPHeaders []string
// 指定信任的平台(如 X-Appengine-Remote-Addr),用于从特定平台的请求头中获取客户端 IP
TrustedPlatform string
// 解析 multipart/form-data 表单时的最大内存限制(默认 32MB),超过此限制的内容会写入临时文件
MaxMultipartMemory int64
// 是否启用 HTTP/2 明文模式(H2C),允许在非 HTTPS 环境下使用 HTTP/2 协议
UseH2C bool
// 当 c.Request.Context() 不存在时,是否使用自定义的 fallback 上下文(兼容旧版本 Go 的上下文机制)
ContextWithFallback bool
// HTML 模板的分隔符(默认是 {{ 和 }}),可自定义为其他符号(如 [[ 和 ]])避免与前端模板冲突
delims render.Delims
// 为 SecureJSON() 方法添加的前缀(默认是 ")]}'\n"),用于防御 JSON 劫持攻击(避免浏览器将 JSON 解析为脚本)
secureJSONPrefix string
// HTML 模板渲染器,用于 c.HTML() 方法渲染模板文件(可自定义实现,如集成 html/template 或第三方模板引擎)
HTMLRender render.HTMLRender
// 注册到 HTML 模板中的自定义函数映射(如 {{ len .Name }} 中的 len 函数),用于扩展模板功能
FuncMap template.FuncMap
// 内部使用,存储合并了全局中间件的 404/405 处理链(全局中间件 + 自定义 noRoute/noMethod 处理函数)
allNoRoute HandlersChain
allNoMethod HandlersChain
// 自定义 "404 路由未找到" 的处理函数链(中间件 + 业务逻辑)
// 例如:通过 engine.NoRoute(custom404Handler) 设置自定义 404 页面
noRoute HandlersChain
// 自定义 "405 方法不允许" 的处理函数链
// 例如:通过 engine.NoMethod(custom405Handler) 设置自定义 405 响应
noMethod HandlersChain
// Context 对象的对象池,用于复用 Context 实例(避免每次请求创建新对象,减少内存分配和 GC 压力)
pool sync.Pool
// 存储所有 HTTP 方法(GET/POST/PUT 等)的路由树(基于 httprouter 的前缀树实现),是路由匹配的核心数据结构
trees methodTrees
// 路由路径中允许的最大参数数量(如 /user/:id/post/:pid 包含 2 个参数),用于限制复杂路由的性能损耗
maxParams uint16
// 路由路径中允许的最大 "段数"(以 / 分隔的部分),例如 /a/b/c 包含 3 个段,用于限制过长路径
maxSections uint16
// 信任的代理服务器 IP 列表,用于在 ForwardedByClientIP 开启时,仅从信任的代理中提取客户端 IP
trustedProxies []string
// 信任的代理服务器 IP 网段(CIDR 格式),功能与 trustedProxies 类似,但支持网段批量配置
trustedCIDRs []*net.IPNet
}
Run
Engine
结构体的 Run
方法,启动一个 HTTP 服务器并开始监听指定地址的请求
go
// gin.go
func (engine *Engine) Run(addr ...string) (err error) {
defer func() { debugPrintError(err) }()
if engine.isUnsafeTrustedProxies() {
debugPrint("[WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.\n" +
"Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.")
}
engine.updateRouteTrees()
address := resolveAddress(addr)
debugPrint("Listening and serving HTTP on %s\n", address)
err = http.ListenAndServe(address, engine.Handler())
return
}
engine.Handler()
中,engine.UseH2C
是 Engine
结构体中的一个布尔字段,用于指示是否启用 HTTP/2 明文模式(H2C)。如果 UseH2C
为 false
,说明不使用 H2C,直接返回 engine
本身;反之,返回一个经过 H2C 包装的处理器,以支持 HTTP/2 明文模式的请求处理
go
// gin.go
func (engine *Engine) Handler() http.Handler {
if !engine.UseH2C {
return engine
}
h2s := &http2.Server{}
return h2c.NewHandler(engine, h2s)
}
使用传入的 addr
(监听地址)和 handler
(HTTP 请求处理器)创建一个新的 Server
结构体实例
go
// server.go
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
Listen
调用新创建的 server
实例的 ListenAndServe
方法。ListenAndServe
方法会执行以下主要操作:
- 创建一个
net.Listener
,用于监听指定的Addr
地址。 - 在一个循环中,不断接受来自客户端的连接。
- 对于每个接受的连接,创建一个新的
conn
实例,并在一个新的 goroutine 中调用conn.serve
方法来处理该连接的请求。conn.serve
方法负责解析 HTTP 请求、调用相应的处理器处理请求并返回响应。
ListenAndServe
方法会一直阻塞,直到出现错误(例如无法监听指定端口),此时会返回错误信息。ListenAndServe
方法返回的错误会被外层的 ListenAndServe
函数返回,这样调用者可以根据返回的错误进行相应的处理。
go
//server.go
func (s *Server) ListenAndServe() error {
if s.shuttingDown() {
return ErrServerClosed
}
addr := s.Addr
if addr == "" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return s.Serve(ln)
}
Accept
Serve
中,for
循环不断调用Accept
接收客户端连接,每个连接通过一个go
协程处理读写,这一步实现了高并发。
go
// server.go
func (s *Server) Serve(l net.Listener) error {
if fn := testHookServerServe; fn != nil {
fn(s, l) // call hook with unwrapped listener
}
origListener := l
l = &onceCloseListener{Listener: l}
defer l.Close()
if err := s.setupHTTP2_Serve(); err != nil {
return err
}
if !s.trackListener(&l, true) {
return ErrServerClosed
}
defer s.trackListener(&l, false)
baseCtx := context.Background()
if s.BaseContext != nil {
baseCtx = s.BaseContext(origListener)
if baseCtx == nil {
panic("BaseContext returned a nil context")
}
}
var tempDelay time.Duration // how long to sleep on accept failure
ctx := context.WithValue(baseCtx, ServerContextKey, s)
for {
rw, err := l.Accept()
if err != nil {
if s.shuttingDown() {
return ErrServerClosed
}
if ne, ok := err.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
s.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
}
return err
}
connCtx := ctx
if cc := s.ConnContext; cc != nil {
connCtx = cc(connCtx, rw)
if connCtx == nil {
panic("ConnContext returned nil")
}
}
tempDelay = 0
c := s.newConn(rw)
c.setState(c.rwc, StateNew, runHooks) // before Serve can return
go c.serve(connCtx)
}
}
readRequest
for
循环不断读取socket缓冲区的数据,解析成HTTP请求。
serverHandler{c.server}.ServeHTTP(w, w.req)
处理HTTP请求。
go
// server.go
func (c *conn) serve(ctx context.Context) {
if ra := c.rwc.RemoteAddr(); ra != nil {
c.remoteAddr = ra.String()
}
ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
// 省略部分代码...
for {
w, err := c.readRequest(ctx)
if c.r.remain != c.server.initialReadLimitSize() {
// If we read any bytes off the wire, we're active.
c.setState(c.rwc, StateActive, runHooks)
}
if c.server.shuttingDown() {
return
}
// 省略部分代码...
inFlightResponse = w
serverHandler{c.server}.ServeHTTP(w, w.req)
inFlightResponse = nil
w.cancelCtx()
if c.hijacked() {
c.r.releaseConn()
return
}
w.finishRequest()
c.rwc.SetWriteDeadline(time.Time{})
if !w.shouldReuseConnection() {
if w.requestBodyLimitHit || w.closedRequestBodyEarly() {
c.closeWriteAndWait()
}
return
}
c.setState(c.rwc, StateIdle, runHooks)
c.curReq.Store(nil)
if !w.conn.server.doKeepAlives() {
// We're in shutdown mode. We might've replied
// to the user without "Connection: close" and
// they might think they can send another
// request, but such is life with HTTP/1.1.
return
}
if d := c.server.idleTimeout(); d > 0 {
c.rwc.SetReadDeadline(time.Now().Add(d))
} else {
c.rwc.SetReadDeadline(time.Time{})
}
// Wait for the connection to become readable again before trying to
// read the next request. This prevents a ReadHeaderTimeout or
// ReadTimeout from starting until the first bytes of the next request
// have been received.
if _, err := c.bufr.Peek(4); err != nil {
return
}
c.rwc.SetReadDeadline(time.Time{})
}
}
ServeHTTP
调用Server
对象的Handler
处理HTTP请求
上面解释过,handler
要么是engine
本身,要么是支持h2c
的Handler
,二者都实现http.Handler
接口的ServerHTTP
方法
go
// server.go
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
if !sh.srv.DisableGeneralOptionsHandler && req.RequestURI == "*" && req.Method == "OPTIONS" {
handler = globalOptionsHandler{}
}
handler.ServeHTTP(rw, req)
}
go
// server.go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
go
// gin.go
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := engine.pool.Get().(*Context)
c.writermem.reset(w)
c.Request = req
c.reset()
engine.handleHTTPRequest(c)
engine.pool.Put(c)
}
go
// h2c.go
func (s h2cHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Handle h2c with prior knowledge (RFC 7540 Section 3.4)
if r.Method == "PRI" && len(r.Header) == 0 && r.URL.Path == "*" && r.Proto == "HTTP/2.0" {
if http2VerboseLogs {
log.Print("h2c: attempting h2c with prior knowledge.")
}
conn, err := initH2CWithPriorKnowledge(w)
if err != nil {
if http2VerboseLogs {
log.Printf("h2c: error h2c with prior knowledge: %v", err)
}
return
}
defer conn.Close()
s.s.ServeConn(conn, &http2.ServeConnOpts{
Context: r.Context(),
BaseConfig: extractServer(r),
Handler: s.Handler,
SawClientPreface: true,
})
return
}
// Handle Upgrade to h2c (RFC 7540 Section 3.2)
if isH2CUpgrade(r.Header) {
conn, settings, err := h2cUpgrade(w, r)
if err != nil {
if http2VerboseLogs {
log.Printf("h2c: error h2c upgrade: %v", err)
}
w.WriteHeader(http.StatusInternalServerError)
return
}
defer conn.Close()
s.s.ServeConn(conn, &http2.ServeConnOpts{
Context: r.Context(),
BaseConfig: extractServer(r),
Handler: s.Handler,
UpgradeRequest: r,
Settings: settings,
})
return
}
s.Handler.ServeHTTP(w, r)
return
}
总结
本文介绍了Web开发的基础知识,描述了Gin框架的运行机制,并且结合部分Gin框架的源码,让读者对Gin框架有一个初步的认识。重点在于结合基础的socket通信过程,在Gin框架中寻找对应的步骤,这样就找到了Gin框架的骨架,之后可以继续探究Gin的其他特性。