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框架,截止到目前,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的代码,也可以留言补充,一起学习共同成长!

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/web/52211.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

精通Java微服务

第1章 微服务是在面向服务架构SOA的基础上进一步发展而来的&#xff0c;它比SOA做得更加彻底&#xff0c;其单体服务被更加彻底地划分&#xff0c;最大限度地实现了服务的单一职责。 1.1.2互联网 即计算机网络&#xff0c;连接了世界上数以万计的计算机设备&#xff08;可联…

15、Django Admin添加自定义字段功能

修改模型类HeroAdmin admin.register(Hero) class HeroAdmin(admin.ModelAdmin):change_list_template "entities/heroes_changelist.html"... # 此处原代码不动&#xff0c;只增加此前后代码def get_urls(self):urls super().get_urls()my_urls [path(immort…

最新版 | SpringBoot3如何自定义starter(面试常考)

文章目录 一、自定义starter介绍二、自定义Starter的好处及优势三、自定义starter应用场景四、自定义starter1、创建autoconfigure的maven工程2、创建starter的maven工程3、在autoconfigure的pom文件中引入MyBatis的所需依赖4、编写自动配置类MyBatisAutoConfiguration5、编写i…

pdf文件编辑器有哪些?分享适合新手用的5个PDF编辑器(解锁教程)

pdf是一种通用文件格式&#xff0c;也是一种夸操作系统平台的文件格式。 好用的PDF文件编辑器可以让您更改和添加文本、编辑图像、添加图形、签署签名、填写表单数据等。下面整理了关于pdf文件编辑方法介绍&#xff0c;以及一些好用的pdf编辑器&#xff0c;有需要的可以了解下…

JavaScript初级——Navigatior

1、Navigator 代表当前浏览器的信息&#xff0c;通过该对象可以来识别不同的浏览器。 2、由于历史原因&#xff0c;Navigator 对象中的大部分属性已经不能帮助我们是被浏览器了。 3、一般使用 userAgent 来判断浏览器的信息。 4、 userAgent 是一个字符串&#xff0c;包含有用来…

C# 通过拖控件移动窗体

目录 引言一、通过控件事件移动窗体1、创建窗体界面2、添加控件事件3、添加代码 二、通过windowsAPI移动窗体1、 构建窗体和添加事件2、代码展示 引言 在C#Form窗体设计中&#xff0c;如果我们不需要使用默认边框设计自己个性化的窗体&#xff08;FromBorderStylenone时&#…

LEAN 类型理论之注解(Annotations of LEAN Type Theory)-- 商类型(Quotient Type)

商类型&#xff08;Quotient Type&#xff09;&#xff0c;也称划分类型&#xff0c;通过给定义一个定义在某一类型 α 上的关系R&#xff1a;α → α→ ℙ&#xff0c;将类型α 中&#xff0c;满足关系R的元素摘出来&#xff0c;组成该商类型&#xff08;Quotient&#xff09…

2024国赛数学建模C题完整论文:农作物的种植策略

农作物种植策略优化的数学建模研究&#xff08;完整论文&#xff0c;持续更新&#xff0c;大家持续关注&#xff0c;更新见文末名片 &#xff09; 摘要 在本文中&#xff0c;建立了基于整数规划、动态规划、马尔科夫决策过程、不确定性建模、多目标优化、相关性分析、蒙特卡洛…

20:HAL--RNG

一&#xff1a;RNG 伪随机数就是rand函数产生的数。 F1系列的没有RNG,使用本次说的都是F407的&#xff0c;我使用的是STM32F407VET6 B&#xff1a;框架 C&#xff1a;寄存器 /*RNG_SR寄存器的DRDY位*/ while ((__HAL_RNG_GET_FLAG(&rng_handle, RNG_FLAG_DRDY) RESET) …

Oracle start with connect BY 死循环

解决办法 检查start with前有没有where条件&#xff0c; 如果有的话&#xff0c;套一层select&#xff0c;再 Oracle start with connect BY

Opencv中的直方图(1)计算反向投影直方图函数calcBackProject()的使用

操作系统&#xff1a;ubuntu22.04 OpenCV版本&#xff1a;OpenCV4.9 IDE:Visual Studio Code 编程语言&#xff1a;C11 算法描述 计算直方图的反向投影。 cv::calcBackProject 函数计算直方图的反向投影。也就是说&#xff0c;类似于 calcHist&#xff0c;在每个位置 (x, y)…

PostgreSQL的repmgr工具介绍

PostgreSQL的repmgr工具介绍 repmgr&#xff08;Replication Manager&#xff09;是一个专为 PostgreSQL 设计的开源工具&#xff0c;用于管理和监控 PostgreSQL 的流复制及实现高可用性。它提供了一组工具和实用程序&#xff0c;简化了 PostgreSQL 复制集群的配置、维护和故障…

CSS解析:定位和层叠上下文

许多开发人员对定位的理解很粗略&#xff0c;如果不完全了解定位&#xff0c;就很容易给自己挖坑。有时候可能会把错误的元素放在其他元素前面&#xff0c;要解决这个问题却没有那么简单。 一般的布局方法是用各种操作来控制文档流的行为。定位则不同&#xff1a;它将元素彻底…

虚幻地形高度图生成及测试

虚幻地形高度图生成及测试 虚幻引擎地形系统将高度数据存储在高度图中&#xff0c;这是一个灰阶图像&#xff0c;使用黑白色值来存储地貌高程。在高度图中&#xff0c;纯黑色值表示最低点&#xff0c;纯白色值表示最高点。支持16位灰阶PNG、8位灰阶r8及16位灰阶r16格式。 本文…

华为 HCIP-Datacom H12-821 题库 (8)

有需要题库的可以看主页置顶 1.在 DHCP 运行过程中&#xff0c;如果客户端 IP 地址在相约过去 87.5%还没有完成续约的话&#xff0c;客户将发送什么报文进行再次续约&#xff1f; A、DHCP discover 广播报文 B、DHCP release 单播报文 C、DHCP request 广播报文 D、DHCP reques…

硬刚苹果还得是华为

文&#xff5c;琥珀食酒社 作者 | 璇子 牛皮啊 华为发三折叠不意外 意外的是 这各种翻转简直颠覆想象 市面上没见过这么能“翻转”的&#xff1f; 要不怎么说硬刚苹果 还得看华为 就跟你同天怎么了&#xff1f; 拼创新、拼技术、拼热度 你就说哪比你差吧&#xff1f…

使用modelsim小技巧

1、当我们不想打开modelsim gui界面进行工程仿真时&#xff0c;modelsim提供了命令仿真的方法 vsim -c -do test_sim.do执行上述命令可以在不打开gui界面的情况下直接仿真工程 2、当我们代码里有systemverilog语法时编译代码方式 vlog -sv -sv09compat defineT133 test.v

基于VUE的校园二手物品交易管理系统的设计与实现 (含源码+sql+视频导入教程)

&#x1f449;文末查看项目功能视频演示获取源码sql脚本视频导入教程视频 1 、功能描述 基于VUE的校园二手物品交易管理系统8拥有两种角色 管理员&#xff1a;闲置物品管理、订单管理、用户管理 用户&#xff1a;登录注册、购物车、发布闲置物品、评论、发货、收货地址管理等…

电力设计院10大排行榜!这个大院屠榜!

今天晚上阅读了中国电力规划设计协会《2022年度电力勘测设计行业统计分析报告》&#xff0c;这本报告是依据协会会员企业统计报表数据进行编制分析的。报告共收集了167家勘测设计企业上报的数据信息&#xff0c;统计的企业数量较2021 年166家企业增加1 家。 按业务板块划分为&…

element ui form 表单出现英文提示的解决方案

场景再现&#xff1a; 在使用 form 表单的时候&#xff0c;一般都需要对表单元素进行验证&#xff0c;错误就出现在了这里&#xff0c;除了配置的错误信息&#xff0c;还会出现一个 英文校验提示&#xff0c;如下图&#xff1a; 解决方案 出现的原因是在el-form-item中使用…