golang学习笔记02——gin框架及基本原理

2024-09-05 10:20

本文主要是介绍golang学习笔记02——gin框架及基本原理,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

目录

    • 1.前言
    • 2.必要的知识
    • 3.路由注册流程
      • 3.1 核心数据结构
      • 3.2 执行流程
      • 3.3 创建并初始化gin.Engine
      • 3.4 注册middleware
      • 3.5 注册路由及处理函数
        • (1)拼接完整的路径参数
        • (2)组合处理函数链
        • (3)注册完成路径及处理函数链到路由树
      • 3.6 服务端口监听
    • 4. 请求处理
    • 5. 请求绑定和响应渲染
      • 5.1. 请求绑定
      • 5.2 响应渲染
    • 结束语

1.前言

  • gin框架是golang中比较常见的web框架,截止到目前(2023-03-21),github上已经累计了67.3K的star数,这足以表明其优秀。作为一名想要知其然亦想知其所以然的程序员,希望通过学习gin框架的实现原理来提高自己的技术能力,也希望通过分享来帮助想要进行学习的同学。

  • 框架源码地址: https://github.com/gin-gonic/gin

2.必要的知识

  • 其实golang本身的标准库已经足以实现简单的web服务,但是出于以下原因,使得直接使用标准库开发难以满足我们的需求:

    • 标准库本身提供了比较简单的路由注册能力,只支持精确匹配,而实际开发时难免会遇到需要使用通配、路径参数的场景
    • 标准库暴露给开发者的函数参数是(w http.ResponseWriter, req *http.Request),这就导致我们需要直接从请求中读取数据、反序列化,响应时手动序列化、设置Content-Type、写响应内容,比较麻烦
    • 有时候我们希望能够在不过多地侵入业务的前提下,对请求或响应进行一些前置或后置处理。直接基于标准库开发,业务和非业务代码难免会耦合在一起
  • 基于gin开发的一般流程可总结为:

    • 创建gin.Engine、注册middleware
    • 注册路由,编写处理函数,在函数内通过gin.Context获取参数,进行逻辑处理,通过gin.Context暴露的方法(如JSON())写回输出
    • 监听端口

相对于标准库的net/http简洁了很多,不用再关注响应内容的序列化和状态码问题了。

gin框架自身也是基于标准库net/http开发的,很多功能实现都是在标准库的基础上进行的封装,因此本文在剖析gin框架的过程中,点到为止,不会过多的对标准库的细节进行说明(后续会专门学习标准库的源码)。

3.路由注册流程

3.1 核心数据结构

使用gin开发前需要先调用gin.Default()函数,该函数返回一个*gin.Engine对象,该对象就是gin中的一个核心对象。

func Default() *Engine {debugPrintWARNINGDefault()engine := New()engine.Use(Logger(), Recovery())return engine
}

其实是先调用New方法创建了Engine对象,再调用Use注册middleware,这里先忽略。

  • gin.Engine
func New() *Engine {...engine := &Engine{// NOTE: 实例化RouteGroup,路由管理相关(Engine自身也是一个RouterGroup)RouterGroup: RouterGroup{Handlers: nil,basePath: "/",root:     true,},...// NOTE: 负责存储路由和处理方法的映射,采用类似字典树的结构(这里构造了几棵树,每棵树对应一个http请求方法)trees:            make(methodTrees, 0, 9),...}...// NOTE: 基于sync.Pool实现的context池,能够避免context频繁销毁和重建engine.pool.New = func() any {return engine.allocateContext(engine.maxParams)}return engine
}
  • 该结构中包含三个核心对象:
    • RouterGroup: 路由组,和路由管理相关
    • 路由树数组trees: 标准库本身的路由是不区分请求方法的,也就是说注册一个路由后,GET、POST都能匹配到该路由。这显然不是我们想要的,我们希望的是同一个路由在不同的请求方法下,由不同的逻辑进行处理。其实就是通过路由树实现的,gin的针对每个请求方法都有一棵路由树
    • context对象池: gin.Context是gin框架暴露给开发的另一个核心对象,可以通过该对象获取请求信息,业务处理的结果也是通过该对象写回客户端的。为了实现context对象的复用,gin基于sync.Pool实现了对象池

如果了解golang的http标准库,应该知道: http.ListenAndServe函数的第二个参数是一个接口类型,只要实现了该接口的ServeHTTP(ResponseWriter, *Request)方法,就能够对请求进行自定义处理。

type Handler interface {ServeHTTP(ResponseWriter, *Request)
}

gin.Engine对象其实就是该接口的一个实现,因为它实现了该方法。至于具体处理过程,后续会详细说明。

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {...
}
  • 路由组RouterGroup

路由组的目的是为了实现配置的复用。
比如有一组对user的请求: /user/add、/user/get、/user/update等,我们希望在注册路由时尽量简单(不要每次都写/user),并且与user相关的请求使用一组单独的middleware(与其他对象的请求隔离开),这时候就可以使用路由组。
下面是其定义:

type RouterGroup struct {// 路由组处理函数链,其下路由的函数链将结合路由组和自身的函数组成最终的函数链Handlers HandlersChain// 路由组的基地址,一般是其下路由的公共地址basePath string// 路由组所属的Engine,这里构成了双向引用engine *Engine// 该路由组是否位于根节点,基于RouterGroup.Group创建路由组时此属性为falseroot bool
}

需要注意的是gin.Engine对象本身就是一个路由组。

  • 处理器链 HandlersChain

上述路由组对象中有一个很重要的字段,即Handlers,用于收集该路由组下注册的middleware函数。在运行时,会按顺序执行HandlersChain中的注册的函数。

type HandlerFunc func(*Context)// HandlersChain defines a HandlerFunc slice.
// NOTE: 路由处理函数链,运行时会根据索引先后顺序依次调用
type HandlersChain []HandlerFunc

3.2 执行流程

一般情况下使用gin框架开发时使用默认的engine即可,因为相对于直接使用gin.New()创建Engine对象,它只是多注册了两个中间件。

下面是一般流程:

  • 创建并初始化Engine对象
  • 注册middleware
  • 注册路由及处理函数
  • 服务端口监听

3.3 创建并初始化gin.Engine

我们调用gin.Default创建一个默认的gin.Engine对象,其实际上会调用gin.New

func New() *Engine {...engine := &Engine{// NOTE: 实例化RouteGroup,路由管理相关(Engine自身也是一个RouterGroup)RouterGroup: RouterGroup{Handlers: nil,basePath: "/",root:     true,},...// NOTE: 负责存储路由和处理方法的映射,采用类似字典树的结构(这里构造了几棵树,每棵树对应一个http请求方法)trees:            make(methodTrees, 0, 9),...}...// NOTE: 基于sync.Pool实现的context池,能够避免context频繁销毁和重建engine.pool.New = func() any {return engine.allocateContext(engine.maxParams)}return engine
}

对以下对象进行初始化:

  • 创建根路径下的路由组
  • 创建九棵路由树
  • 初始化context对象池

3.4 注册middleware

gin.Default调用gin.New创建gin.Engine后,紧接着就会调用gin.Use函数进行middleware的注册。默认会注册Logger()和Recovery()这两个中间件函数。

func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes {// NOTE: 将注册的中间件添加到RouterGroup的Handlers处理函数链中engine.RouterGroup.Use(middleware...)engine.rebuild404Handlers()engine.rebuild405Handlers()return engine
}func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {group.Handlers = append(group.Handlers, middleware...)return group.returnObj()
}

注册中间件其实就是将中间件处理函数添加到HandlersChain结构(HandlerFunc切片)中

3.5 注册路由及处理函数

mux.GET("/user", func(c *gin.Context) {m := map[string]string{"username": "用户名123",}c.JSON(http.StatusOK, m)
})

以我们的案例中的GET为例,这里的GET方式其实是gin.Engine对象的方法。

除了GET,http协议中的九个请求方法都在该对象中有一个同名的实现,这九个方法都是通过调用RouterGroup.handle方法实现的。

func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes {return group.handle(http.MethodPost, relativePath, handlers)
}// GET is a shortcut for router.Handle("GET", path, handlers).
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {return group.handle(http.MethodGet, relativePath, handlers)
}
...

下面是handle方法的定义,该方法主要做了以下几件事:

  • 拼接完整的路径参数
  • 组合处理函数链
  • 注册完成路径及处理函数链到路由树
func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {// 将路由组的基地址和传入的相对地址组合成绝对路径absolutePath := group.calculateAbsolutePath(relativePath)// 将路由组的处理函数链和当前路由的处理函数组合成完成的处理函数链handlers = group.combineHandlers(handlers)// 将路由及其对应的处理函数链添加到路由树中group.engine.addRoute(httpMethod, absolutePath, handlers)return group.returnObj()
}
(1)拼接完整的路径参数

这个很好理解,上面说过使用路由组之后,注册路由时不用每次都写前缀。比如/user/add、/user/get、/user/update这几个,路由组的路径是/user,基于该路由组注册路由时只需要注册/add、/get、/update就行了。其实就是在这里进行拼接的。

func (group *RouterGroup) calculateAbsolutePath(relativePath string) string {return joinPaths(group.basePath, relativePath)
}func joinPaths(absolutePath, relativePath string) string {if relativePath == "" {return absolutePath}finalPath := path.Join(absolutePath, relativePath)if lastChar(relativePath) == '/' && lastChar(finalPath) != '/' {return finalPath + "/"}return finalPath
}
(2)组合处理函数链

我们可以针对每个路由组单独设置middleware,实际执行时会先执行注册的中间件,最后才执行注册的业务处理函数。实现上,则是将路由组中注册的中间件和业务处理函数组合在一起。由于是按照顺序append到切片中的,所以执行顺序其实就是注册顺序。

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {// 构造新的切片,其长度为路由组过滤器链长度 + 路由的处理链长度finalSize := len(group.Handlers) + len(handlers)// 这里要求处理器链的长度最大为63,超过此长度注册路由会失败(Abort就是通过设置Index为63来提前中断处理器链的执行的)assert1(finalSize < int(abortIndex), "too many handlers")mergedHandlers := make(HandlersChain, finalSize)// 深拷贝路由组处理器链copy(mergedHandlers, group.Handlers)// 深拷贝路由处理器链copy(mergedHandlers[len(group.Handlers):], handlers)return mergedHandlers
}
(3)注册完成路径及处理函数链到路由树

前面说过gin针对每个http请求方法,都构造了一棵路由树。这里就需要根据注册路由的请求方法获取对应的路由树,再将路由的完整路径和对应的处理函数链注册到路由树中,后续才能根据请求路径调用对应的处理函数链进行处理。

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {...// 每个请求方法(GET/POST...)都对应一棵前缀树,这里获取当前方法的前缀树root := engine.trees.get(method)// 首次添加此方法的路由,构造前缀树if root == nil {root = new(node)root.fullPath = "/"engine.trees = append(engine.trees, methodTree{method: method, root: root})}// 将路由的绝对路径和对应的完整处理函数链添加到路由树root.addRoute(path, handlers)...
}

这里只需要先知道,路由树是用压缩前缀树实现的,由于比较复杂,后面再讲。

3.6 服务端口监听

前面已经完成了接收请求前的准备工作,现在只差一步,即调用Engine.Run进行端口监听即可。

func (engine *Engine) Run(addr ...string) (err error) {defer func() { debugPrintError(err) }()...err = http.ListenAndServe(address, engine.Handler())return
}

4. 请求处理

在3.1 - (1)中有说,由于Engine实现了http.ServeHTTP方法,所以http标准库收到请求后,对请求的处理入口其实就是Engine.ServeHTTP方法。

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {c := engine.pool.Get().(*Context)c.writermem.reset(w)c.Request = reqc.reset()engine.handleHTTPRequest(c)engine.pool.Put(c)
}

其核心处理处理逻辑如下:

  • 从context对象池取一个可用的context对象,后续交互就是靠这个对象完成的
  • 将http.ResponseWriter和http.Request对象保存到context中。我们通过context获取请求参数、写入响应,其实是因为其底层封装了这两个对象的方法
  • 调用Engine的handleHTTPRequest方法,对请求进行处理。注意到其参数已经变成了gin.Context了。
  • 请求处理完毕,回收context,以便下次复用。

下面来看handleHTTPRequest的具体实现:

func (engine *Engine) handleHTTPRequest(c *Context) {httpMethod := c.Request.MethodrPath := c.Request.URL.Path...// Find root of the tree for the given HTTP methodt := engine.treesfor i, tl := 0, len(t); i < tl; i++ {// 根据http请求方法获取对应的路由树if t[i].method != httpMethod {continue}root := t[i].root// Find route in tree// 根据请求路径获取路由树节点信息,包括处理器链和路径value := root.getValue(rPath, c.params, c.skippedNodes, unescape)if value.params != nil {c.Params = *value.params}// 将处理器链注入到context中if value.handlers != nil {c.handlers = value.handlersc.fullPath = value.fullPath// NOTE: 开启 handlers 链的遍历调用流程c.Next()c.writermem.WriteHeaderNow()return}...break}...
}

前面讲到过,gin为每一个http请求方法创建了一棵路由树,每棵树保存了完整的路由路径和对应的处理器链。所以这部分逻辑其实是:

  • 根据当前客户端的请求方法,获取到对应的路由树。
  • 根据请求的路径在路由树中进行路径匹配,能够获取到路径参数和该路由的完整处理器链(包括预先设置的middleware处理函数),并保存到context对象中。有关路由树匹配的细节将在下一章节详细讲解。
  • 调用c.Next(),其实是开始按顺序调用处理器链中的每一个处理器,对请求进行处理。
  • 一般情况下,会在业务处理函数中调用context暴露的方法将响应写入到http输出流中。但是如果没调用,这里会帮忙做这件事(WriteHeaderNow),给客户端一个响应。代码如下:
func (w *responseWriter) WriteHeaderNow() {if !w.Written() {w.size = 0w.ResponseWriter.WriteHeader(w.status)}
}func (w *responseWriter) Written() bool {return w.size != noWritten
}

上面说过,注册处理器时,会将所属RouterGroup注册的中间件函数和路由处理器组合在一个切片中。

由于采用的是append操作,所以注册的顺序就是实际执行的顺序。

正常情况下,注册的处理器会依次执行,通过context中的index字段控制执行进度,比如想要对请求进行一系列的前置操作。

也可以通过在处理器中调用c.Next()提前进入下一个处理器,待其执行完后再返回到当前处理器,这种比较适合需要对请求做前置和后置处理的场景,如请求执行时间统计。

func (c *Context) Next() {c.index++for c.index < int8(len(c.handlers)) {c.handlers[c.index](c)c.index++}
}

有时候我们可能会希望,某些条件触发时直接返回,不再继续后续的处理操作。Context提供了Abort方法帮助我们实现这样的目的。这也是通过index字段实现的,gin中要求一个路由的全部处理器个数不超过63,每次执行一个处理器时,会先判断index是否超过了这个限制,如果超过了就不会执行。如下:

func (c *Context) Abort() {c.index = abortIndex
}const abortIndex int8 = math.MaxInt8 >> 1

5. 请求绑定和响应渲染

基于标准库开发时,我们可以从请求体中以字节流的方式读取请求内容,也可以将内容以字节流的方式写回去。但是会比较麻烦,

请求时我们需要基于请求的数据格式,决定应该怎样反序列化输入流、自己实现数据校验。

响应时,需要自己去序列化响应结构、设置content-type、写入响应流。

这几个过程不仅重复,而且需要多次判断error,最好是交给框架来做这件事,从而将开发的注意力集中在业务逻辑上。

5.1. 请求绑定

问题在于,从请求中读取的数据应该以什么类型组织呢,是string、int还是某个自定义的结构体?

为此gin提供了一系列的方式,用于从请求中获取参数和数据等信息,如常用的ShoudBindJson。

func (c *Context) ShouldBindJSON(obj any) error {return c.ShouldBindWith(obj, binding.JSON)
}func (c *Context) ShouldBindWith(obj any, b binding.Binding) error {return b.Bind(c.Request, obj)
}

这里binding.Binding是一个接口,所有用于实现请求数据绑定的类型都应该实现这个接口。如上述调用的是jsonBinding,最终会使用json包的反序列化方法进行反序列化。

func (jsonBinding) Bind(req *http.Request, obj any) error {if req == nil || req.Body == nil {return errors.New("invalid request")}return decodeJSON(req.Body, obj)
}func decodeJSON(r io.Reader, obj any) error {decoder := json.NewDecoder(r)if EnableDecoderUseNumber {decoder.UseNumber()}if EnableDecoderDisallowUnknownFields {decoder.DisallowUnknownFields()}if err := decoder.Decode(obj); err != nil {return err}return validate(obj)
}

反序列化完毕后,还涉及输入内容的校验,哪些字段必填、长度是否固定等,如果我们要在程序中判断,会比较繁琐。我们一般会采用 https://github.com/go-playground/validator 这个库的实现。实际上,gin也是基于这个库实现的。

var Validator StructValidator = &defaultValidator{}type defaultValidator struct {once     sync.Oncevalidate *validator.Validate
}func validate(obj any) error {if Validator == nil {return nil}return Validator.ValidateStruct(obj)
}

5.2 响应渲染

除了文章开头案例中提到的JSON方法,gin还提供了针对以下类型的的处理方法:

├── any.go
├── data.go
├── html.go
├── json.go
├── msgpack.go
├── protobuf.go
├── reader.go
├── redirect.go
├── render.go
├── text.go
├── toml.go
├── xml.go
└── yaml.go

以context.JSON方法为例:

func (c *Context) JSON(code int, obj any) {c.Render(code, render.JSON{Data: obj})
}func (c *Context) Render(code int, r render.Render) {c.Status(code)if !bodyAllowedForStatus(code) {r.WriteContentType(c.Writer)c.Writer.WriteHeaderNow()return}if err := r.Render(c.Writer); err != nil {// Pushing error to c.Errors_ = c.Error(err)c.Abort()}
}

首先调用Status设置状态码,然后调用r.Render进行渲染。

func (c *Context) Status(code int) {c.Writer.WriteHeader(code)
}

这里r是一个接口类型,该类型用于对所有响应内容的方法进行抽象。需要实现的方法包括:

  • Render: 渲染方法,用于将响应内容写入到http.ResponseWriter中
  • WriteContentType: 用于设置响应头中的Content-Type
type Render interface {// Render writes data with custom ContentType.Render(http.ResponseWriter) error// WriteContentType writes custom ContentType.WriteContentType(w http.ResponseWriter)
}

以JSON类型为例。

Render其实就是基于json库将相应结构体序列化为字节数据,再写入http.ResponseWriter中。

func (r JSON) Render(w http.ResponseWriter) error {return WriteJSON(w, r.Data)
}func WriteJSON(w http.ResponseWriter, obj any) error {writeContentType(w, jsonContentType)jsonBytes, err := json.Marshal(obj)if err != nil {return err}_, err = w.Write(jsonBytes)return err
}

WriteContentType则是直接将响应头设置为application/json.

jsonContentType = []string{"application/json; charset=utf-8"}func writeContentType(w http.ResponseWriter, value []string) {header := w.Header()if val := header["Content-Type"]; len(val) == 0 {header["Content-Type"] = value}
}

结束语

由于篇幅较长,核心数据结构、gin.Context的讲解、前缀树、压缩前缀树和代码实现,我将放到下一篇文章《golang学习笔记03——gin框架的核心数据结构》中详细说明

本人技术水平有限,文章中可能存在不足和遗漏,如果有同学愿意一起学习golang和gin的代码,也可以留言补充,一起学习共同成长!

关注我,带你发现更多有意思的技术和应用~👉👉

这篇关于golang学习笔记02——gin框架及基本原理的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1138684

相关文章

HarmonyOS学习(七)——UI(五)常用布局总结

自适应布局 1.1、线性布局(LinearLayout) 通过线性容器Row和Column实现线性布局。Column容器内的子组件按照垂直方向排列,Row组件中的子组件按照水平方向排列。 属性说明space通过space参数设置主轴上子组件的间距,达到各子组件在排列上的等间距效果alignItems设置子组件在交叉轴上的对齐方式,且在各类尺寸屏幕上表现一致,其中交叉轴为垂直时,取值为Vert

Ilya-AI分享的他在OpenAI学习到的15个提示工程技巧

Ilya(不是本人,claude AI)在社交媒体上分享了他在OpenAI学习到的15个Prompt撰写技巧。 以下是详细的内容: 提示精确化:在编写提示时,力求表达清晰准确。清楚地阐述任务需求和概念定义至关重要。例:不用"分析文本",而用"判断这段话的情感倾向:积极、消极还是中性"。 快速迭代:善于快速连续调整提示。熟练的提示工程师能够灵活地进行多轮优化。例:从"总结文章"到"用

【前端学习】AntV G6-08 深入图形与图形分组、自定义节点、节点动画(下)

【课程链接】 AntV G6:深入图形与图形分组、自定义节点、节点动画(下)_哔哩哔哩_bilibili 本章十吾老师讲解了一个复杂的自定义节点中,应该怎样去计算和绘制图形,如何给一个图形制作不间断的动画,以及在鼠标事件之后产生动画。(有点难,需要好好理解) <!DOCTYPE html><html><head><meta charset="UTF-8"><title>06

学习hash总结

2014/1/29/   最近刚开始学hash,名字很陌生,但是hash的思想却很熟悉,以前早就做过此类的题,但是不知道这就是hash思想而已,说白了hash就是一个映射,往往灵活利用数组的下标来实现算法,hash的作用:1、判重;2、统计次数;

零基础学习Redis(10) -- zset类型命令使用

zset是有序集合,内部除了存储元素外,还会存储一个score,存储在zset中的元素会按照score的大小升序排列,不同元素的score可以重复,score相同的元素会按照元素的字典序排列。 1. zset常用命令 1.1 zadd  zadd key [NX | XX] [GT | LT]   [CH] [INCR] score member [score member ...]

【机器学习】高斯过程的基本概念和应用领域以及在python中的实例

引言 高斯过程(Gaussian Process,简称GP)是一种概率模型,用于描述一组随机变量的联合概率分布,其中任何一个有限维度的子集都具有高斯分布 文章目录 引言一、高斯过程1.1 基本定义1.1.1 随机过程1.1.2 高斯分布 1.2 高斯过程的特性1.2.1 联合高斯性1.2.2 均值函数1.2.3 协方差函数(或核函数) 1.3 核函数1.4 高斯过程回归(Gauss

【学习笔记】 陈强-机器学习-Python-Ch15 人工神经网络(1)sklearn

系列文章目录 监督学习:参数方法 【学习笔记】 陈强-机器学习-Python-Ch4 线性回归 【学习笔记】 陈强-机器学习-Python-Ch5 逻辑回归 【课后题练习】 陈强-机器学习-Python-Ch5 逻辑回归(SAheart.csv) 【学习笔记】 陈强-机器学习-Python-Ch6 多项逻辑回归 【学习笔记 及 课后题练习】 陈强-机器学习-Python-Ch7 判别分析 【学

系统架构师考试学习笔记第三篇——架构设计高级知识(20)通信系统架构设计理论与实践

本章知识考点:         第20课时主要学习通信系统架构设计的理论和工作中的实践。根据新版考试大纲,本课时知识点会涉及案例分析题(25分),而在历年考试中,案例题对该部分内容的考查并不多,虽在综合知识选择题目中经常考查,但分值也不高。本课时内容侧重于对知识点的记忆和理解,按照以往的出题规律,通信系统架构设计基础知识点多来源于教材内的基础网络设备、网络架构和教材外最新时事热点技术。本课时知识

cross-plateform 跨平台应用程序-03-如果只选择一个框架,应该选择哪一个?

跨平台系列 cross-plateform 跨平台应用程序-01-概览 cross-plateform 跨平台应用程序-02-有哪些主流技术栈? cross-plateform 跨平台应用程序-03-如果只选择一个框架,应该选择哪一个? cross-plateform 跨平台应用程序-04-React Native 介绍 cross-plateform 跨平台应用程序-05-Flutte

线性代数|机器学习-P36在图中找聚类

文章目录 1. 常见图结构2. 谱聚类 感觉后面几节课的内容跨越太大,需要补充太多的知识点,教授讲得内容跨越较大,一般一节课的内容是书本上的一章节内容,所以看视频比较吃力,需要先预习课本内容后才能够很好的理解教授讲解的知识点。 1. 常见图结构 假设我们有如下图结构: Adjacency Matrix:行和列表示的是节点的位置,A[i,j]表示的第 i 个节点和第 j 个