开源 Golang 微服务入门一: HTTP 框架 Hertz

举报
yumuing 发表于 2023/06/21 18:21:05 2023/06/21
【摘要】 Hertz 是一个 Golang 微服务 HTTP 框架,在设计之初参考了其他开源框架 fasthttp、gin、echo 的优势, 并结合字节跳动内部的需求,使其具有高易用性、高性能、高扩展性等特点

👏 Hi! 我是 Yumuing,一个技术的敲钟人

👨‍💻 每天分享技术文章,永远做技术的朝拜者

📚 欢迎关注我的博客:Yumuing’s blog

前言

从本篇笔记开始将介绍 Go 框架三件套(Web / RPC / ORM),框架的学习有助于后续课程的学习以及大项目的完成。本文主要介绍字节跳动的开源 Golang 微服务 HTTP 框架 Hertz。先了解一下三件套的相关基本知识,做一下铺垫:

Gorm

gorm是Golang语言中一个已经迭代数十年且功能强大、性能极好的ORM框架

ORM:Object Relational Mapping(对象关系映射),其主要作用是在编程中,把面向对象的概念跟数据库中表的概念对应起来,

简单来说,在golang中,自定义的一个结构体对应着一张表,结构体的实例则对应着表中的一条记录。

Kitex

Kitex是字节内部Golang微服务RPC框架 具有高性能、强可扩展的主要特点 支持多协议并且拥有丰富的开源扩展

Hertz

Hertz是字节内部的Http框架 参考了其他开源框架的优势 结合字节跳动内部的需求 具有高可用、高性能、高扩展性的特点

@[toc]

知识点介绍

Hertz 简介

Hertz 是一个 Golang 微服务 HTTP 框架,在设计之初参考了其他开源框架 fasthttp、gin、echo 的优势, 并结合字节跳动内部的需求,使其具有高易用性、高性能、高扩展性等特点,目前在字节跳动内部已广泛使用。 如今越来越多的微服务选择使用 Golang,如果对微服务性能有要求,又希望框架能够充分满足内部的可定制化需求,Hertz 会是一个不错的选择。

架构设计:Hertz采用了4层分层设计(应用层、路由层、协议层、传输层),保证各个层级功能内聚,同时通过层级之间的接口达到灵活扩展的目标。

image.png

框架特点:

  • 高易用性:在开发过程中,快速写出来正确的代码往往是更重要的。因此,在 Hertz 在迭代过程中,积极听取用户意见,持续打磨框架,希望为用户提供一个更好的使用体验,帮助用户更快的写出正确的代码。

  • 高性能:Hertz 默认使用自研的高性能网络库 Netpoll,在一些特殊场景相较于 go net,Hertz 在 QPS、时延上均具有一定优势。

  • 高扩展性:Hertz 采用了分层设计,提供了较多的接口以及默认的扩展实现,用户也可以自行扩展。同时得益于框架的分层设计,框架的扩展性也会大很多。

  • 多协议支持:Hertz 框架原生提供 HTTP1.1、ALPN 协议支持。除此之外,由于分层设计,Hertz 甚至支持自定义构建协议解析逻辑,以满足协议层扩展的任意需求。

  • 网络层切换能力:Hertz 实现了 Netpoll 和 Golang 原生网络库 间按需切换能力,用户可以针对不同的场景选择合适的网络库,同时也支持以插件的方式为 Hertz 扩展网络库实现。

功能特性

中间件

Hertz 除了提供 Server 的中间件能力,还提供了 Client 中间件能力。用户可以使用中间件能力将通用逻辑(如:日志记录、性能统计、异常处理、鉴权逻辑等等)和业务逻辑区分开,让用户更加专注于业务代码。Server 和 Client 中间件使用方式相同,使用 Use 方法注册中间件,中间件执行顺序和注册顺序相同,同时支持预处理和后处理逻辑。

Server 和 Client 的中间件实现方式并不相同。对于 Server 来说,我们希望减少栈的深度,同时也希望中间件能够默认的执行下一个,用户需要手动终止中间件的执行。因此,我们将 Server 的中间件分成了两种类型,即不在同一个函数调用栈(该中间件调用完后返回,由上一个中间件调用下一个中间件,如图 2 中 B 和 C)和在同一个函数调用栈的中间件(该中间件调用完后由该中间件继续调用下一个中间件,如图 2 中 C 和 Business Handler)。

中间件链路

其核心是需要一个地方存下当前的调用位置 index,并始终保持其递增。恰好 RequestContext 就是一个存储 index 合适的位置。但是对于 Client,由于没有合适的地方存储 index,我们只能退而求其次,抛弃 index 的实现,将所有的中间件构造在同一调用链上,需要用户手动调用下一个中间件。

流式处理

Hertz 提供 Server 和 Client 的流式处理能力。HTTP 的文件场景是十分常见的场景,除了 Server 侧的上传场景之外,Client 的下载场景也十分常见。为此,Hertz 支持了 Server 和 Client 的流式处理。在内部网关场景中,从 Gin 迁移到 Hertz 后,CPU 使用量随流量大小不同可节省 30%—60% 不等,服务压力越大,收益越大。Hertz 开启流式功能的方式也很容易,只需要在 Server 上或 Client 上添加一个配置即可,可参考 CloudWeGo 官网 Hertz 文档的流式处理部分。

由于 Netpoll 采用 LT 的触发模式,由网络库主动将将数据从 TCP 缓冲区读到用户态,并存储到 buffer 中,否则 epoll 事件会持续触发。因此 Server 在超大请求的场景下,由于 Netpoll 持续将数据读到用户态内存中,可能会有 OOM 的风险。HTTP 文件上传场景就是一个典型的场景,但 HTTP 上传服务又是很常见的场景,因此我们支持标准网络库 go net,并针对 Hertz 做了特殊优化,暴露出 Read() 接口,防止 OOM 发生。

对于 Client,情况并不相同。流式场景下会将连接封装成 Reader 暴露给用户,而Client 有连接池管理,那这样连接就多了一种状态,何时关连接,何时复用连接成了一个问题。由于框架侧并不知道该连接何时会用完,框架侧复用该连接不现实,会导致串包问题。由于 GC 会关闭连接,因此我们起初设想流式场景下的连接交由用户后,由 GC 负责关闭,这样也不会导致资源泄漏。但是在测试后发现,由于 GC 存在一定时间间隔,另外 TCP 中主动关闭连接的一方需要等待 2RTT,在高并发场景下会导致 fd 被打满的情况。最终我们提供了复用连接的接口,对于性能有场要求用户,在使用完连接后可以将连接重新放入连接池中复用。

性能指标

Hertz 使用字节跳动自研高性能网络库 Netpoll,在提高网络库效率方面有诸多实践,参考已发布文章字节跳动在 Go 网络库上的实践。除此之外,Netpoll 还针对 HTTP 场景进行优化,通过减少拷贝和系统调用次数提高吞吐以及降低时延。为了衡量 Hertz 性能指标,我们选取了社区中有代表性的框架 Gin(net/http)和 Fasthttp 作为对比,如图3所示。可以看到,Hertz 的极限吞吐、TP99 等指标均处于业界领先水平。未来,Hertz 还将继续和 Netpoll 深度配合,探索 HTTP 框架性能的极限。

Hertz 和其他框架性能对比

快速开始

准备 Golang 开发环境

首先需要安装 Golang,推荐安装最新版本。可参考

2023 最新萌新学习 Golang 环境配置详细步骤,附带图文

目前,Hertz 支持 Linux、macOS、Windows 系统。

快速上手

  1. 安装命令行工具 hz

    确保环境变量已经正确定义,如下:

    go install github.com/cloudwego/hertz/cmd/hz\@latest
    
  2. 生成/编写示例代码

    • 在当前目录下创建 hertz_demo 文件夹,进入该目录中

    • 创建 main.go 文件

    • 在 main.go 文件中添加以下代码

    package main
    
    import (
    "context""github.com/cloudwego/hertz/pkg/app""github.com/cloudwego/hertz/pkg/app/server""github.com/cloudwego/hertz/pkg/common/utils""github.com/cloudwego/hertz/pkg/protocol/consts"
    )
    
    funcmain() {
    h := server.Default()
    
        h.GET("/ping", func(c context.Context, ctx *app.RequestContext) {
                ctx.JSON(consts.StatusOK, utils.H{"message": "pong"})
        })
    
        h.Spin()
    
    }
    
    • 生成 go.mod 文件
    go mod init hertz\_demo
    
    • 整理 & 拉取依赖
    go mod tidy
    
    • 打包并运行示例代码
    go build -o hertz\_demo
    go run hertz\_demo
    
    
    • 如果成功,将看到
    2023/01/30 11:26:27.824241 engine.go:617: \[Debug] HERTZ: Method=GET    absolutePath=/ping                     --> handlerName=main.main.func1 (num=2 handlers)
    2023/01/30 11:26:27.836518 engine.go:389: \[Info] HERTZ: Using network library=standard
    2023/01/30 11:26:27.837518 transport.go:65: \[Info] HERTZ: HERTZ: HTTP server listening on address=\[::]:8888
    
    • 对接口进行测试:
    curl <http://127.0.0.1:8888/ping>
    

    如果成功就可以看到以下输出:

    StatusCode        : 200
    StatusDescription : OK
    Content           : {"message":"pong"}
    RawContent        : HTTP/1.1 200 OK
    Content-Length: 18
    Content-Type: application/json; charset=utf-8
    Date: Mon, 30 Jan 2023 03:26:53 GMT
    Server: hertz
    
                        {"message":"pong"}
    
    Forms             : {}
    Headers           : {\[Content-Length, 18], \[Content-Type, application/json; charset=utf-8], \[Date, Mon, 30 Jan 2023 03:26:53 GMT], \[Server,\
    hertz]}
    Images            : {}
    InputFields       : {}
    Links             : {}
    ParsedHtml        : mshtml.HTMLDocumentClass
    RawContentLength  : 18
    

    到此就已经成功启动了 Hertz Server,并完成了一次调用。

路由

路由注册

Hertz 提供了 GET、POST、PUT、DELETE、ANY 等方法用于注册路由。

方法 介绍
Hertz.GET 用于注册 HTTP Method 为 GET 的方法
Hertz.POST 用于注册 HTTP Method 为 POST 的方法
Hertz.DELETE 用于注册 HTTP Method 为 DELETE 的方法
Hertz.PUT 用于注册 HTTP Method 为 PUT 的方法
Hertz.PATCH 用于注册 HTTP Method 为 PATCH 的方法
Hertz.HEAD 用于注册 HTTP Method 为 HEAD 的方法
Hertz.OPTIONS 用于注册 HTTP Method 为 OPTIONS 的方法
Hertz.Handle 这个方法支持用户手动传入 HTTP Method 用来注册方法,当用于注册普通的 HTTP Method 方法时和上述的方法作用是一致的,并且这个方法同时也支持用于注册自定义 HTTP Method 方法
Hertz.Any 用于注册所有 HTTP Method 方法
Hertz.StaticFile/Static/StaticFS 用于注册静态文件

示例代码:

package main

import (
"context"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
"github.com/cloudwego/hertz/pkg/protocol/consts"
)

funcmain(){
h := server.Default(server.WithHostPorts("127.0.0.1:8080"))

    h.StaticFS("/", &app.FS{Root: "./", GenerateIndexPages: true})

    h.GET("/get", func(ctx context.Context, c *app.RequestContext) {
    	c.String(consts.StatusOK, "get")
    })
    h.POST("/post", func(ctx context.Context, c *app.RequestContext) {
    	c.String(consts.StatusOK, "post")
    })
    h.PUT("/put", func(ctx context.Context, c *app.RequestContext) {
    	c.String(consts.StatusOK, "put")
    })
    h.DELETE("/delete", func(ctx context.Context, c *app.RequestContext) {
    	c.String(consts.StatusOK, "delete")
    })
    h.PATCH("/patch", func(ctx context.Context, c *app.RequestContext) {
    	c.String(consts.StatusOK, "patch")
    })
    h.HEAD("/head", func(ctx context.Context, c *app.RequestContext) {
    	c.String(consts.StatusOK, "head")
    })
    h.OPTIONS("/options", func(ctx context.Context, c *app.RequestContext) {
    	c.String(consts.StatusOK, "options")
    })
    h.Any("/ping_any", func(ctx context.Context, c *app.RequestContext) {
    	c.String(consts.StatusOK, "any")
    })
    h.Handle("LOAD","/load", func(ctx context.Context, c *app.RequestContext) {
    	c.String(consts.StatusOK, "load")
    })
    h.Spin()

}

路由组

Hertz 提供了路由组( Group )的能力,用于支持路由分组的功能,同时中间件也可以注册到路由组上。

示例代码:

package main

import (
"context"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
"github.com/cloudwego/hertz/pkg/protocol/consts"
)

funcmain(){
h := server.Default(server.WithHostPorts("127.0.0.1:8080"))
v1 := h.Group("/v1")
v1.GET("/get", func(ctx context.Context, c \*app.RequestContext) {
c.String(consts.StatusOK, "get")
})
v1.POST("/post", func(ctx context.Context, c \*app.RequestContext) {
c.String(consts.StatusOK, "post")
})
v2 := h.Group("/v2")
v2.PUT("/put", func(ctx context.Context, c \*app.RequestContext) {
c.String(consts.StatusOK, "put")
})
v2.DELETE("/delete", func(ctx context.Context, c \*app.RequestContext) {
c.String(consts.StatusOK, "delete")
})
h.Spin()
}

路由类型

Hertz 支持丰富的路由类型用于实现复杂的功能,包括静态路由、参数路由、通配路由。

路由的优先级:静态路由 > 命名路由 > 通配路由

  • 静态路由如上文

  • 参数路由:

    • Hertz 支持使用 :name 这样的命名参数设置路由,并且命名参数只匹配单个路径段。

    • 如果我们设置/user/:name路由,匹配情况如下

      路径 是否匹配
      /user/gordon 匹配
      /user/you 匹配
      /user/gordon/profile 不匹配
      /user/ 不匹配
    • 通过使用 RequestContext.Param 方法,我们可以获取路由中携带的参数。
    package main
    
    import (
    "context"
    "github.com/cloudwego/hertz/pkg/app"
    "github.com/cloudwego/hertz/pkg/app/server"
    "github.com/cloudwego/hertz/pkg/protocol/consts"
    )
    
    funcmain(){
    h := server.Default(server.WithHostPorts("127.0.0.1:8080"))
    // This handler will match: "/hertz/version", but will not match : "/hertz/" or "/hertz"
    h.GET("/hertz/:version", func(ctx context.Context, c \*app.RequestContext) {
    version := c.Param("version")
    c.String(consts.StatusOK, "Hello %s", version)
    })
    h.Spin()
    }
    

通配路由

  • Hertz 支持使用 *path 这样的通配参数设置路由,并且通配参数会匹配所有内容。

  • 如果我们设置/src/*path路由,匹配情况如下

路径 是否匹配
/src/ 匹配
/src/somefile.go 匹配
/src/subdir/somefile.go 匹配
  • 通过使用 RequestContext.Param 方法,我们可以获取路由中携带的参数。

    package main
    
    import (
    "context"
    "github.com/cloudwego/hertz/pkg/app"
    "github.com/cloudwego/hertz/pkg/app/server"
    "github.com/cloudwego/hertz/pkg/protocol/consts"
    )
    
    funcmain(){
    h := server.Default(server.WithHostPorts("127.0.0.1:8080"))
    // However, this one will match "/hertz/v1/" and "/hertz/v2/send"
    h.GET("/hertz/:version/\*action", func(ctx context.Context, c \*app.RequestContext) {
    version := c.Param("version")
    action := c.Param("action")
    message := version + " is " + action
    c.String(consts.StatusOK, message)
    })
    h.Spin()
    }
    

参数绑定

代码绑定是Hertz超级赞的一部分,可以非常优雅的完成请求参数映射到结构体与请求参数的验证

此处参数绑定使用了 github.com/bytedance/g…

上面分别介绍了http中常见的参数获取api,hertz出了提供api获取参数信息,还提供了参数绑定功能,帮助我们直接将请求参数绑定到结构体上并校验参数的合法性。

func PersonBind(ctx context.Context, c \*app.RequestContext) {
type person struct {
Age  int    `path:"age" json:"age"`    // 从路径中获取参数
Name string `query:"name" json:"name"` // 从query中获取参数
City string `json:"city"`              // 从body中获取参数
}
var p person
if err := c.BindAndValidate(\&p); err != nil {
panic(err)
}
c.JSON(200, utils.H{
"person": p,
})
}

curl:

curl --location --request POST '<http://localhost:8888/person_bind/12?name=erik>' \
\--header 'Content-Type: application/json' \
\--data-raw '{
"city":"BeiJing"
}'

返回结果:

{
    "person": {
    "age": 12,
    "name": "erik",
    "city": "BeiJing"
    }
}

中间件

中间件首尾相连最终形成一个过滤器链,用户可以在中间件中设定一些通用的处理规则,比如:统一错误处理,用户信息验证,跨域处理等 Hertz提供了两个通用的中间件,一个是JWT验证,一个是Cors跨域中间件,开箱即用,详情可以参考:www.cloudwego.io/zh/docs/her…

使用跨域中间件示例

func main() {
h := server.Default()
// CORS for <https://foo.com> and <https://github.com> origins, allowing:
// - PUT and PATCH methods
// - Origin header
// - Credentials share
// - Preflight requests cached for 12 hours
h.Use(cors.New(cors.Config{
AllowOrigins:     \[]string{"<https://foo.com"}>,
AllowMethods:     \[]string{"PUT", "PATCH"},
AllowHeaders:     \[]string{"Origin"},
ExposeHeaders:    \[]string{"Content-Length"},
AllowCredentials: true,
AllowOriginFunc: func(origin string) bool {
return origin == "<https://github.com>"
},
MaxAge: 12 \* time.Hour,
}))
h.Spin()
}

错误处理

我们可以借助Hertz提供的中间件的能力,统一对错误进行处理。即在最外层的中间件捕获错误,然后根据错误类型做对应的处理。 这里需要借助三方库errors来获取go的错误堆栈,方便我们排查问题

  • 引入errors
go get github.com/pkg/errors

hertz的app.RequestContext提供了c.Error(err)方法用于保存业务中产生的错误,c.Errors()获取业务中产生的错误。所以如果程序运行时产生错误,我们可以将错误保存到app.RequestContext中,并在中间件中获取这个错误,判断错误的类型进行对应的处理。
统一异常处理中间件代码如下:

package middleware

import (
"context"
"errors"
"fmt"

    "github.com/bytedance/gopkg/util/logger"
    "github.com/cloudwego/hertz/pkg/app"
    "github.com/cloudwego/hertz/pkg/common/utils"

)

func GlobalErrorHandler(ctx context.Context, c \*app.RequestContext) {
c.Next(ctx)

    if len(c.Errors) == 0 {
    	// 没有收集到异常直接返回
    	fmt.Println("retun")
    	return
    }
    hertzErr := c.Errors[0]
    // 获取errors包装的err
    err := hertzErr.Unwrap()
    // 打印异常堆栈
    logger.CtxErrorf(ctx, "%+v", err)
    // 获取原始err
    err = errors.Unwrap(err)
    // todo 进行错误类型判断
    c.JSON(400, utils.H{
    	"code":    400,
    	"message": err.Error(),
    })

}

配置中间件

package main

import (
"hertz\_demo/biz/middleware"

    "github.com/cloudwego/hertz/pkg/app/server"

)

func main() {
h := server.Default()
h.Use(middleware.GlobalErrorHandler)
register(h)
h.Spin()
}

业务代码中将错误存放到app.RequestContext中直接退出

err = c.BindAndValidate(&req)
if err != nil {
    fmt.Printf("%v", err.Error())
    _ = c.Error(errors.WithStack(err))
    return
}

参数校验异常时,异常堆栈信息如下:

validating: expr\_path=Name, cause=invalid
2022/07/25 23:41:47.977087 logger.go:190: \[Error] \[validating: expr\_path=Name, cause=invalid
hertz\_demo/biz/handler/person.PersonInfo
/Users/xxx/gopath/src/hertz\_demo/biz/handler/person/person\_service.go:23
github.com/cloudwego/hertz/pkg/app.(\*RequestContext).Next
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/hertz\@v0.2.0/pkg/app/context.go:611
hertz\_demo/biz/middleware.GlobalErrorHandler
/Users/xxx/gopath/src/hertz\_demo/biz/middleware/global\_error\_handler.go:14
github.com/cloudwego/hertz/pkg/app.(\*RequestContext).Next
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/hertz\@v0.2.0/pkg/app/context.go:611
github.com/cloudwego/hertz/pkg/app/middlewares/server/recovery.Recovery.func1
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/hertz\@v0.2.0/pkg/app/middlewares/server/recovery/recovery.go:51
github.com/cloudwego/hertz/pkg/app.(\*RequestContext).Next
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/hertz\@v0.2.0/pkg/app/context.go:611
github.com/cloudwego/hertz/pkg/route.(\*Engine).ServeHTTP
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/hertz\@v0.2.0/pkg/route/engine.go:607
github.com/cloudwego/hertz/pkg/protocol/http1.Server.Serve
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/hertz\@v0.2.0/pkg/protocol/http1/server.go:244
github.com/cloudwego/hertz/pkg/route.(\*Engine).Serve
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/hertz\@v0.2.0/pkg/route/engine.go:456
github.com/cloudwego/hertz/pkg/route.(\*Engine).onData
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/hertz\@v0.2.0/pkg/route/engine.go:353
github.com/cloudwego/hertz/pkg/network/netpoll.(\*transporter).ListenAndServe.func2
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/hertz\@v0.2.0/pkg/network/netpoll/transport.go:83
github.com/cloudwego/netpoll.(\*connection).onRequest.func2
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/netpoll\@v0.2.4/connection\_onevent.go:153
github.com/cloudwego/netpoll.(\*connection).onProcess.func1
/Users/xxx/gopath/pkg/mod/github.com/cloudwego/netpoll\@v0.2.4/connection\_onevent.go:176
github.com/bytedance/gopkg/util/gopool.(\*worker).run.func1.1
/Users/xxx/gopath/pkg/mod/github.com/bytedance/gopkg\@v0.0.0-20220623074550-9d6d3df70991/util/gopool/worker.go:69
github.com/bytedance/gopkg/util/gopool.(\*worker).run.func1
/Users/xxx/gopath/pkg/mod/github.com/bytedance/gopkg\@v0.0.0-20220623074550-9d6d3df70991/util/gopool/worker.go:70
runtime.goexit
/usr/local/go/src/runtime/asm\_amd64.s:1571]

响应结果:

{
    "code": 400,
    "message": "validating: expr\_path=Name, cause=invalid"
}

Hertz 代码生成工具 hz

与 Hertz 一并开源的还有一个易用的命令行工具 Hz,用户只需提供一个 IDL,根据定义好的接口信息,Hz 便可以一键生成项目脚手架,让 Hertz 达到开箱即用的状态;Hz 也支持基于 IDL 的更新能力,能够基于 IDL 变动智能地更新项目代码。目前 Hz 支持了 Thrift 和 Protobuf 两种 IDL 定义。命令行工具内置丰富的选项,可以根据自己的需求使用。同时它底层依赖 Protobuf 官方的编译器和自研的 Thriftgo 的编译器,两者都支持自定义的生成代码插件。如果默认模板不能够满足需求,完全能够按需定义。

基本使用:

  • 创建一个 Hertz 新项目
// GOPATH 下执行,go mod 名字默认为当前路径相对GOPATH的路径,也可自己指定
hz new

// 非GOPATH 下执行,需要指定 go mod 名
hz new -mod hertz/demo

// 整理 & 拉取依赖
go mod tidy

  • 编译项目
go build
  • 运行项目
./{{your binary}}
  • 测试
curl 127.0.0.1:8888/ping

如果返回{“message”:“pong”},说明接口调通。

总结

本文主要介绍了 Golang 微服务 HTTP 框架 Hertz,只是简单的入门练习,如果想了解更多内容还是需要仔细研究官方文档,官方文档的内容很清晰全面,后续还需要深入学习。

引用

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。