前言
Gin
是一个用Go语言编写的web框架。它是一个类似于martini
但拥有更好性能的API框架, 由于使用了httprouter
,速度提高了近40倍。
如果你是性能和高效的追求者, 你会爱上Gin
。
Go语言里最流行的Web框架,Github上有32K+
star。 基于httprouter开发的Web框架。 中文文档齐全,简单易用的轻量级框架。
安装
D:\learning-gin>set GOPROXY=https://goproxy.cn
-------------------------------------------------------
D:\learning-gin>go get -u github.com/gin-gonic/gin
go: google.golang.org/protobuf upgrade => v1.25.0 go: gopkg.in/yaml.v2 upgrade => v2.4.0 go: github.com/golang/protobuf upgrade => v1.4.3 go: github.com/ugorji/go/codec upgrade => v1.2.1 go: golang.org/x/sys upgrade => v0.0.0-20201211090839-8ad439b19e0f go: github.com/json-iterator/go upgrade => v1.1.10 go: github.com/modern-go/reflect2 upgrade => v1.0.1 go: github.com/go-playground/validator/v10 upgrade => v10.4.1 go: github.com/modern-go/concurrent upgrade => v0.0.0-20180306012644-bacd9c7ef1dd go: golang.org/x/crypto upgrade => v0.0.0-20201208171446-5f87f3452ae9
Gin简单示例
package main import "github.com/gin-gonic/gin" func index(c *gin.Context) { //返回json类型的数据,h=type H map[string]interface{} c.JSON(200, gin.H{"msg": "您好呀!"}, ) } func main() { //定义1个默认路由(基于httprouter的) router := gin.Default() //增加url router.GET("/index", index) //server段开始linsten运行 router.Run("127.0.0.1:8000") }
request&response Header
vue设置请求头中的token
this.$http.defaults.headers.common.X-token = 'sidhlmajldhbd-vue'
Gin获取请求头中的Token
token := c.Request.Header.Get("X-token")
Gin响应头设置Token
c.Header("X-token","sidhlmajldhbd-gin")
Gin request
我们可以通过gin的context获取到客户端请求携带的url参数、form表单、json数据、文件等。
package main import ( "fmt" "github.com/gin-gonic/gin" ) type user struct { Name string `json:"name"` City string `json:"city"` } var person=&user{} //从url获取参数 func urlData(c *gin.Context) { //name:=c.Query("name") //获取url参数,获取不到获取空字符串 name:=c.DefaultQuery("name","zhanggen") ////获取url参数,获取不到获取默认! city:=c.DefaultQuery("city","bj") person.Name=name person.City=city c.JSON(200,person) } //从form表单中获取数据 func formData(c *gin.Context) { c.PostForm("name") person.Name=c.DefaultPostForm("name","Martin") person.City=c.DefaultPostForm("city","London") c.JSON(200,person) } //获取url地址参数 func pathData(c *gin.Context){ person.City=c.Param("city") person.Name=c.Param("name") c.JSON(200,*person) } //获取json数据 func jsonData(c *gin.Context){ c.Bind(person) fmt.Println("-----------------",*person) c.JSON(200,person) } func main() { r:=gin.Default() //http://127.0.0.1:8001/user?name=zhanggen&city=beijing r.GET("/user",urlData) r.POST("/user",formData) //http://127.0.0.1:8001/user/bj/zhanggen r.GET("/user/:city/:name",pathData) r.POST("/user/json/",jsonData) r.Run(":8001") }
Gin shouldBind
默认情况下,我们需要根据客户端请求的content-type,在后端使用不同的方式获取客户端请求参数。
获取请求参还需要c.Query、c.PostForm、c.Bind、C.Param,这也太麻烦了~
shouldBind可帮助我们根据客户端request的content-type,把requestBody中数据自动绑定到后台Struct的字段,但不支持重复绑定。
BindXXX:会自动返回信息,输入无效时,在header写入状态码=400。
ShouldBindXXX:返回消息,输入无效时,不会在header写入400状态码,这时候可以自定义返回信息,在使用上相对来说更加灵活。
ShouldBindWith:
在gin 1.4 之前,重复使用ShouldBind绑定会报错EOF。
gin 1.4 之后官方提供了一个 ShouldBindBodyWith 的方法,可以支持重复绑定,原理就是将body的数据缓存了下来,但是二次取数据的时候还是得用 ShouldBindBodyWith 才行,直接用 ShouldBind 还是会报错的。
package main import ( "fmt" "github.com/gin-gonic/gin" ) //0.contentType对应ShouldBind对应的结构体 type UserInfo struct { Username string `form: "username" json: "username" ` Password string `form: "password" json: "password" ` } func index(c *gin.Context) { requestMethod := c.Request.Method //1.声明1个值类型uerinfo类型的变量u var user UserInfo //2.把客户端request请求的参数和后端的结合体字段进行绑定 err := c.ShouldBind(&user) if err != nil { c.JSON(400, gin.H{ "err" : err.Error()}) return } //3.可以通过反射的方式,根据客户端request的contentType自动获取数据了 if requestMethod == "GET" { fmt.Println(user) c.HTML(200, "index.html" , gin.H{}) } if requestMethod == "POST" { fmt.Println(user) c.JSON(200, gin.H{ "data" : "postOkay" }) } } func main() { router := gin.Default() router.Static( "/static" , "./static" ) router.LoadHTMLGlob( "templates/*" ) router.GET( "/user" , index) router.POST( "/user" , index) router.Run( ":8002" ) } |
自定义验证的错误信息
对前端传入的参数进行校验,当验证不通过时,会给出错误的信息。
package main import ( "fmt" "reflect" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" ) type LoginDto struct { Username string `json:"username" binding:"required" msg:"用户名不能为空"` Password string `json:"password" binding:"min=3,max=6" msg:"密码长度不能小于3大于6"` Email string `json:"email" binding:"email" msg:"邮箱地址格式不正确"` } func GetValidMsg(err error, obj interface{}) string { fmt.Println("sherlock") getObj := reflect.TypeOf(obj) if errs, ok := err.(validator.ValidationErrors); ok { for _, e := range errs { if f, exist := getObj.Elem().FieldByName(e.Field()); exist { return f.Tag.Get("msg") } } } return err.Error() } func main() { r := gin.Default() r.POST("/login", func(c *gin.Context) { userDto := &LoginDto{} if err := c.ShouldBindJSON(userDto); err != nil { c.JSON(200, gin.H{ "status": "err", "message": GetValidMsg(err, userDto), }) } else { c.JSON(200, gin.H{ "status": "change", "message": userDto, }) } }) r.Run(":8080") }
Gin response
我们web开发过程中,大型项目会采用MVVM(前后端分离)的架构,小型项目会采用MTV(模板渲染)的架构。
疏通同归其目的都是完成数据驱动视图,不同的是数据驱动视图的地方不一样。
貌似web开发玩得就是这6个字,数据----》 驱动-----》视图。空谈误国,怎么才能更好的驱动视图才是关键。
MTV模式(模板渲染):后端使用模板语法也就是字符串替换的方式,在后端直接完成数据和HTML的渲染,直接返回给客户端。
MVVM(前后端分离架构):后端返回json数据,前端使用axios/ajax的方式获取到数据,使用vue等前端框架完成数据到HTML的渲染。
1.RESTful API
只要API程序遵循了REST风格,那就可以称其为RESTful API。
其实核心思想是1个资源对应1个URL,客户端对这1资源操作时(不同的request.get/post/put/delete方法)对应后端的增/删/改/查操作。
例如,我们现在要编写一个管理书籍的系统,我们可以查询对一本书进行查询、创建、更新和删除等操作。
我们在编写程序的时候就要设计客户端浏览器与我们Web服务端交互的方式和路径。按照经验我们通常会设计成如下模式:
请求方法 | URL | 含义 |
---|---|---|
GET | /book | 查询书籍信息 |
POST | /create_book | 创建书籍记录 |
POST | /update_book | 更新书籍信息 |
POST | /delete_book | 删除书籍信息 |
我们按照RESTful API设计如下:
请求方法 | URL | 含义 |
---|---|---|
GET | /book | 查询书籍信息 |
POST | /book | 创建书籍记录 |
PUT | /book | 更新书籍信息 |
DELETE | /book | 删除书籍信息 |
c.JSON响应json数据
package main import "github.com/gin-gonic/gin" //结构体 type user struct { Name string `json:"name"` Age int `json:"age"` } //视图函数 func perosn(c *gin.Context) { var userInfor=user{Name: "张根",Age:18} c.JSON(200,userInfor) } func main(){ r:=gin.Default() r.GET("/person/",perosn) r.Run(":8002") }
2.MVC模板渲染
如果是小型项目、历史原因、SEO优化我们使用模板渲染,Gin也是支持MTV模式的。
package main import ( "fmt" "github.com/gin-gonic/gin" ) func index(c *gin.Context) { //3.gin 模板渲染 c.HTML(200, "index.html", gin.H{"title": "首页", "body": "hello"}) } func main() { //1.创建1个默认的路由引擎 router := gin.Default() router.GET("/", index) //2.gin模板解析 router.LoadHTMLGlob("templates/*") //正则匹配templates/所有文件 router.LoadHTMLGlob("templates/**/*") //正则匹配template/目录/所有文件 err := router.Run(":9001") if err != nil { fmt.Println("gin启动失败", err) } }
3.文件上传
http请求也可以传输文件,有时候我们可以使用gin搭建1个ftp服务器。
单个文件上传
package main import ( "fmt" "github.com/gin-gonic/gin" "path" ) func handleFile(c *gin.Context) { method := c.Request.Method if method == "GET" { c.HTML(200, "file.html" , gin.H{}) } if method == "POST" { //1.从客户端请求中获取文件 fileObj, err := c.FormFile( "localFile" ) if err != nil { c.JSON(400, gin.H{ "err" : err.Error()}) return } //2.保存到服务端 fileStorePath := path.Join( "./upload/" , fileObj.Filename) err = c.SaveUploadedFile(fileObj, fileStorePath) if err != nil { errMsg := fmt.Sprintf( "文件保存失败:%s\n" , err.Error()) c.JSON(200, gin.H{ "err" : errMsg}) } c.JSON(200, gin.H{ "data" : "上传成功" }) } } func main() { router := gin.Default() router.Static( "/static" , "./static" ) router.LoadHTMLGlob( "templates/*" ) router.GET( "/file/" , handleFile) router.POST( "/file/" , handleFile) err := router.Run( ":8002" ) if err != nil { fmt.Println( "gin启动失败" , err) return } } |
多个文件上传
func main() { router := gin.Default() // 处理multipart forms提交文件时默认的内存限制是32 MiB // 可以通过下面的方式修改 // router.MaxMultipartMemory = 8 << 20 // 8 MiB router.POST( "/upload" , func (c *gin.Context) { // Multipart form form, _ := c.MultipartForm() files := form.File[ "file" ] for index, file := range files { log.Println(file.Filename) dst := fmt.Sprintf( "C:/tmp/%s_%d" , file.Filename, index) // 上传文件到指定的目录 c.SaveUploadedFile(file, dst) } c.JSON(http.StatusOK, gin.H{ "message" : fmt.Sprintf( "%d files uploaded!" , len(files)), }) }) router.Run() } |
Gin模板渲染
现在大部分都是前后端分离的架构,除了seo优化我们基本不会使用gin做模板渲染。
1.扩展gin模板函数
package main import ( "fmt" "github.com/gin-gonic/gin" "html/template" ) func index(c *gin.Context) { //3.gin 模板渲染 c.HTML(200, "index.html", gin.H{"title": "首页", "name": "Martin", "age": "hello", "url": `<a href="https://www.cnblogs.com/sss4/">主页</a>`}) } func main() { //1.创建1个默认的路由引擎 router := gin.Default() router.GET("/", index) //1.5 gin框架模板自定义模板函数 router.SetFuncMap(template.FuncMap{ "safe": func(safeString string) template.HTML { return template.HTML(safeString) }, }) //2.gin模板解析 //router.LoadHTMLGlob("templates/*") //正则匹配templates/所有文件 router.LoadHTMLGlob("templates/**/*") //正则匹配template/目录/所有文件 err := router.Run(":9001") if err != nil { fmt.Println("gin启动失败", err) } }
模板
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>{{.title}}</title> </head> <body> <ul> <li>{{.name}}</li> <li>{{.age}}</li> <li>{{.url | safe}}</li> </ul> </body> </html>
2.加载静态文件路径
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | package main import ( "fmt" "github.com/gin-gonic/gin" "html/template" ) func index(c *gin.Context) { //5.gin 模板渲染 c.HTML(200, "index.html" , gin.H{ "title" : "首页" , "name" : "Martin" , "age" : "hello" , "url" : `<a href= "https://www.cnblogs.com/sss4/" >主页</a>`}) } func main() { //1.创建1个默认的路由引擎 router := gin.Default() router.GET( "/" , index) //2.加载静态文件路径 .css router.Static( "/static" , "./static" ) //3. 扩展gin框架模板自定义模板函数 router.SetFuncMap(template.FuncMap{ "safe" : func (safeString string) template.HTML { return template.HTML(safeString) }, }) //4.gin模板解析 //router.LoadHTMLGlob("templates/*") //正则匹配templates/所有文件 router.LoadHTMLGlob( "templates/**/*" ) //正则匹配template/目录/所有文件 err := router.Run( ":9001" ) if err != nil { fmt.Println( "gin启动失败" , err) } } |
模板
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>{{.title}}</title> <link rel="stylesheet" href="/static/dist/css/bootstrap.min.css"> <script src="/static/jquery-3.2.1.min.js"></script> <script src="/static/dist/js/bootstrap.min.js"></script> </head> <body> <div class="container"> <table class="table table-hover"> <theader> <tr> <td>姓名</td> <td>年龄</td> <td>主页</td> </tr> </theader> <tbody> <tr> <td>{{.name}}</td> <td>{{.age}}</td> <td>{{.url|safe}}</td> </tr> </tbody> </table> </div> </body> </html>
3.Gin模板继承
html/template实现了模板的嵌套和继承,但是gin不包含此功能。但是我们使用第第三方包。github.com/gin-contrib/multitemplate
Gin 路由组
URL路由太多了就需要分组管理,类似Flask的蓝图、Django里面的include URL。这些都是基于反射实现的。
但是Gin框架中的路由使用的是httprouter这个库,其基本原理就是构造一个路由地址的前缀树。
1.单支路由
//所有请求方式都汇聚到handleBook router.Any("/book/", handleBook) //处理404错误 router.NoRoute(handle404)
2.路由组
//cmdb路由组 cmdbRouter := router.Group("/cmdb") { cmdbRouter.GET("/list/") cmdbRouter.GET("/hosts/") cmdbRouter.GET("/option/") } //工单路由组 workOrder := router.Group("/works") { workOrder.GET("/daily/") cmdbRouter.GET("/momthly") cmdbRouter.GET("/quarterly") }
3.路由嵌套
虽然gin的路由支持嵌套,但是出于对查询性能的考虑我们一般都会不会嵌套很多层路由。
//cmdb路由组 cmdbRouter := router.Group("/cmdb") { cmdbRouter.GET("/list/") cmdbRouter.GET("/hosts/") //1.cmdb的主机 hostRouter := cmdbRouter.Group("/host") { //1.1主机的cpu hostRouter.GET("/cup/") //1.2主机的内存 hostRouter.GET("/memory/") //1.3主机的硬盘 hostRouter.GET("/disks/") //1.4主机运行的服务 hostRouter.GET("/process/") //1.5网络流量 hostRouter.GET("/networks/") } cmdbRouter.GET("/option/") } //2.工单路由组 workOrder := router.Group("/works") { workOrder.GET("/daily/") cmdbRouter.GET("/momthly") cmdbRouter.GET("/quarterly") }
Gin中间件
我们可以在不修改视图函数的前提下,利用Web框架中携带的钩子函数也就是中间件 做权限控制、登录认证、权限校验、数据分页、记录日志、耗时统计.........
注意我们的中间件不仅可以设置1个,也根据我们的业务逻辑设置N个,相当于对用户请求增加了多层过滤。
就像Python里面的多层装饰器。
1.中间件执行流程
由于http请求包含request、response 2个动作所以中间件是双行线,中间件的执行流程就像1个递归函数的执行过程。
1 2 3 | 压栈: 用户---------> 认证中间件---------> 用户权限中间件---------> 错误处理中间件---------> 视图函数执行 出栈: 视图函数执行完毕---------> 错误处理中间件---------> 用户权限中间件---------> 认证中间件---------> 用户 |
2.控制中间件执行流程
所为的控制流程我感觉就是设计中间件这个栈里面包含的层层栈针。
我们在弹匣里装了什么样的子弹,扣动扳机时就会发射出什么子弹,这样想会更简单一些否则很容易被绕进去。
在中间件执行的过程中我们可以控制进栈和出栈流程。
以上代码执行结果:
m1 in
m2 in
m1 out
调用context.Next(),继续调用下一个视图函数进行压栈。(子弹装满弹匣)
调用context.Abort() 阻止继续调用后续的函数,执行完当前栈针(函数)之后出栈。(1发子弹就够了)
调用context.Abort() + return,当前位置返回,当前位置之后的代码都不需要不执行了。(1发哑弹)
package main import ( "fmt" "github.com/gin-gonic/gin" ) //中间件1 func middleWare1(c *gin.Context) { fmt.Println( "middleWare1开始----------" ) c.Next() //调用后续的处理函数 fmt.Println( "middleWare1结束----------" ) } //中间件2 func middleWare2(c *gin.Context) { fmt.Println( "middleWare2开始========" ) c.Abort() //终止后续处理函数的调用,执行完本函数返回 return //更极端一些 到这就结束!(本函数也不需要执行完毕了)。 fmt.Println( "middleWare2结束========" ) } //index视图函数 func index(c *gin.Context) { fmt.Println( "index开始+++++++++" ) c.JSON(200, gin.H{ "data" : "ok" }) fmt.Println( "index结束+++++++++" ) } func main() { router := gin.Default() //全局注册中间件:middleWare1, middleWare2 router.Use(middleWare1, middleWare2) router.GET( "/index/" , index) err := router.Run( ":9001" ) if err != nil { fmt.Println( "Gin启动失败" , err) } } |
3.给单个路由(url)设置中间件
当我们需要对特定的视图函数增加新功能时,可以给它增加1个中间件。
package main import ( "fmt" "github.com/gin-gonic/gin" "time" ) //中间件1 func middleWare1(c *gin.Context) { fmt.Println("--------------I`m going through middleWare1----------") start := time.Now() c.Next() //调用后续的处理函数 cost := time.Since(start) fmt.Printf("耗时----------%v\n", cost) c.Abort() //终止请求 } //index handlerfunc类型的函数 func index(c *gin.Context) { fmt.Println("--------------I`m going through handlerfunc----------") c.JSON(200, gin.H{"data": "ok"}) c.Next() } //中间件2 func middleWare2(c *gin.Context) { fmt.Println("--------------I`m going through middleWare2----------") c.Next() c.Abort() //请求终止 } func main() { router := gin.Default() //设置中间件流程:middleWare1-----》index----》middleWare2 router.GET("/index/", middleWare1, index, middleWare2) err := router.Run(":9001") if err != nil { fmt.Println("Gin启动失败", err) } }
4.全局注册中间件
如果我们需要每个视图函数都设置1个中间件,把这一中间件写到每个视图函数前面会非常不方便,我们可以使用use进行全局注册。
package main import ( "fmt" "github.com/gin-gonic/gin" ) //中间件1 func middleWare1(c *gin.Context) { fmt.Println("middleWare1开始----------") c.Next() //调用后续的处理函数 fmt.Println("middleWare1结束----------") //c.Abort() //终止请求 } //中间件2 func middleWare2(c *gin.Context) { fmt.Println("middleWare2开始========") c.Next() fmt.Println("middleWare2结束========") } //index handlerfunc类型的函数 func index(c *gin.Context) { fmt.Println("index开始+++++++++") c.JSON(200, gin.H{"data": "ok"}) fmt.Println("index结束+++++++++") } func main() { router := gin.Default() //全局注册中间件:middleWare1, middleWare2 router.Use(middleWare1, middleWare2) router.GET("/index/", index) err := router.Run(":9001") if err != nil { fmt.Println("Gin启动失败", err) } }
输出:
验证web框架里中间件设计思想是的递归思想。
middleWare1开始---------- middleWare2开始======== index开始+++++++++ index结束+++++++++ middleWare2结束======== middleWare1结束----------
5.路由组注册中间件
给路由组注册中间件有2种写法
写法1:
shopGroup := r.Group( "/shop" , StatCost()) { shopGroup.GET( "/index" , func (c *gin.Context) {...}) ... } |
写法2:
shopGroup := r.Group( "/shop" ) shopGroup.Use(StatCost()) { shopGroup.GET( "/index" , func (c *gin.Context) {...}) ... } |
6.闭包的中间件
以上我们得知:Gin的中间件是以1种gin.HandlerFunc类型存在,在路由和路由组里进行注册。
router.GET( "/index/" , authMiddleWare(false), index) |
那我们可以使用闭包将1个开关参数和这个handlerFunc一起包起来。实现对中间进行开关控制比较灵活。
package main import ( "fmt" "github.com/gin-gonic/gin" ) //使用闭包函数返回,gin.HandlerFunc。可以实现对中间进行开关控制,比较灵活。 func authMiddleWare(work bool) gin.HandlerFunc { if work{ //连接数据库 //其他准备工作 dbDB := "Martin" return func (c *gin.Context) { username := c.Query( "username" ) if username == dbDB { c.Next() } else { c.Abort() c.JSON(403, gin.H{ "data" : "没有访问权限" }) } } } return func (context *gin.Context) { } } //index视图函数 func index(c *gin.Context) { fmt.Println( "index视图函数开始" ) c.JSON(200, gin.H{ "data" : "ok" }) fmt.Println( "index视图函数结束" ) } func main() { router := gin.Default() router.GET( "/index/" , authMiddleWare(false), index) err := router.Run( ":9001" ) if err != nil { fmt.Println( "Gin启动失败" , err) } } |
7.夸中间件进行传值
中间件可以有多层,假如我们上游的中间得出的值,如何传递到下游中间件呢?。通过上下文content。
当在中间件或handler
中启动新的goroutine
时,不能使用原始的上下文(c *gin.Context),必须使用其只读副本(c.Copy()
)。以保证我们传递的值是一致的。
c.Set( "username" , username) currentUser, ok := c.Get( "username" ) |
8.gin默认中间件
gin.Defaut生成的路由引擎,默认使用了Logger(), Recovery()的中间件。
//生成的路由引擎,默认使用了Logger(), Recovery()的中间件 gin.Default()
router := gin.New()
Logger:用于记录日志
Recovery:用于保证在gin 发生错误时进程不会终止。
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
package main import ( "fmt" "github.com/gin-gonic/gin" ) //使用闭包函数返回,gin.HandlerFunc。可以实现对中间进行开关控制,比较灵活。 func authMiddleWare(work bool) gin.HandlerFunc { if work { //连接数据库 //其他准备工作 dbDB := "Martin" return func(c *gin.Context) { username := c.Query("username") if username == dbDB { //1.在中间中设置值进行传递 c.Set("username", username) fmt.Println("-----------", username) c.Next() } else { c.Abort() c.JSON(403, gin.H{"data": "没有访问权限"}) } } } return func(context *gin.Context) { } } //index视图函数 func index(c *gin.Context) { //2.获取上流传递的值 currentUser, ok := c.Get("username") if !ok { currentUser = "anonymous" } fmt.Println("index视图函数开始") c.JSON(200, gin.H{"data": currentUser}) fmt.Println("index视图函数结束") } func main() { //生成的路由引擎,默认使用了Logger(), Recovery()的中间件 gin.Default() //router := gin.Default() router := gin.New() router.GET("/index/", authMiddleWare(true), index) err := router.Run(":9001") if err != nil { fmt.Println("Gin启动失败", err) } }
9.自定义日志中间件
- 记录正常请求信息
- 捕捉到错误信记录特定格式的日志
Gin设置cookie
1.cookie是server端保存在browser中的用户信息
2.每客户端次访问server端时都会携带该域下的cooki信息到server端。
3.不同域名之间的cookie是不共享的。(无法跨域,所以前后端分离的项目只能使用token)
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步