goweb框架gin学习总结 | go 技术论坛-江南app体育官方入口
[toc]
文章介绍
本文我们将从零开始介绍gin的安装,gin的简单入门,基于gin框架的登录/注册表单验证实例,gin中间件的原理分析,gin返回html,静态文件的挂载和gin优雅的退出
什么是gin?
官方:gin 是一个用 go (golang) 编写的 http web 框架。 它具有类似 martini 的 api,但性能比 martini 快 40 倍。如果你需要极好的性能,使用 gin 吧。
gin 是 go语言写的一个 web 框架,它具有运行速度快,分组的路由器,良好的崩溃捕获和错误处理,非常好的支持中间件和 json。总之在 go语言开发领域是一款值得好好研究的 web 框架,开源网址:
安装
go get -u github.com/gin-gonic/gin
快速入门
实例一:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
//handle方法
func pong(c *gin.context) {
c.json(http.statusok, gin.h{
"name": "ice_moss",
"age": 18,
"school": "家里蹲大学",
})
}
func main() {
//初始化一个gin的server对象
//default实例化对象具有日志和返回状态功能
r := gin.default()
//注册路由,并编写处理方法
r.get("/ping", pong)
//监听端口:默认端口listen and serve on 0.0.0.0:8080
r.run(":8083")
}
接下来我们在浏览器中访问:
可以访问到:
"name": "ice_moss",
"age": 18,
"school": "家里蹲大学",
实例二:gin的get和post方法
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func ginget(c *gin.context) {
c.json(http.statusok, map[string]interface{}{
"name": "ice_moss",
})
}
func ginpost(c *gin.context) {
c.json(http.statusok, gin.h{
"token": "您好",
})
}
func main() {
router := gin.default()
router.get("/ginget", ginget)
router.post("/ginpost", ginpost)
router.run(":8083")
}
我们看到ginget和ginpost这两个方法中的c.json()第二个参数不一样,原因:gin.h
{}本质就是一个map[string]interface{}
//h is a shortcut for map[string]interface{}
type h map[string]any
然后我们就可以访问:
这里需要注意我们不能直接在浏览器中访问:
因为他是post方法
所以我们可以使用postman,来发送post请求
路由分组
gin为我们做了很好的路由分组,这样我们可以方便,对路由进行管理
实例三:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func productlists(c *gin.context) {
c.json(http.statusok, gin.h{
"矿泉水": [5]string{"娃哈哈", "2元", "500"},
"功能饮料": [3]string{"红牛", "6元", "200"},
})
}
func prouduct1(c *gin.context) {
req := c.param("haha")
c.json(http.statusok, gin.h{
"矿泉水": [5]string{"娃哈哈矿泉水", "2元", "500"},
"token": req,
})
}
func createproduct(c *gin.context) {}
//路由分组
func main() {
router := gin.default()
//未使用路由分组
//获取商品列表
//router.get("/productlist", productlists)
//获取某一个具体商品信息
//router.get("/productlist/1", prouduct1)
//添加商品
//router.post("productlist/add", createproduct)
//路由分组
productlist := router.group("/produc")
{
productlist.get("/list", productlists)
productlist.get("/1", prouduct1)
productlist.post("/add", createproduct)
}
router.run(":8083")
}
url值的提取
很多时候我们需要对url中数据的提取,或者动态的url,我们不可能将url写固定
实例四:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func productlists(c *gin.context) {
c.json(http.statusok, gin.h{
"矿泉水": [5]string{"娃哈哈矿泉水", "2元", "500"},
"功能饮料": [3]string{"红牛", "6元", "200"},
})
}
func prouduct1(c *gin.context) {
//获取url中的参数
id := c.param("id")
action := c.param("action")
c.json(http.statusok, gin.h{
"矿泉水": [5]string{"娃哈哈矿泉水", "2元", "500"},
"id": id,
"action": action,
})
}
func createproduct(c *gin.context) {}
//url取值
func main() {
router := gin.default()
//路由分组
productlist := router.group("/product")
{
productlist.get("", productlists)
//使用"/:id"动态匹配参数
productlist.get("/:id/:action", prouduct1)
productlist.post("", createproduct)
}
router.run(":8083")
}
访问:
返回:
{"action":"product1","id":"01","矿泉水":["娃哈哈矿泉水","2元","500","",""]}
当我们访问:
返回:
{"action":"product2000","id":"100","矿泉水":["娃哈哈矿泉水","2元","500","",""]}
构体体声明并做约束
实例五:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
//结构体声明,并做一些约束
type porsen struct {
id int `uri:"id" binding:"required"` //uri指在client中的名字为id,binding:"required指必填
name string `uri:"name" binding:"required"` //同理
}
//url参数获取
func main() {
router := gin.default()
router.get("/:name/:id", func(c *gin.context) {
//使用porsen对数据进行解组
var porsen porsen
if err := c.shouldbinduri(&porsen); err != nil {
c.status(404)
return
}
c.json(http.statusok, gin.h{
"name": porsen.name,
"id": porsen.id,
})
})
router.run(":8083")
}
当我们访问:
返回:
{"id":2000,"name":"test100"}
但是我们这样访问:
返回:
找不到 localhost 的网页找不到与以下网址对应的网页:http://localhost:8083/100/test2000
http error 404
这和我们约束条件一致
url参数的提取
get请求参数获取
url参数的提取是get方法常用的方法,url中有需要的参数,例如我们访问百度图片:
以?
后的都是参数参数间用&
分隔:?ct=201326592&tn=baiduimage&word=图片壁纸&pn=&spn=&ie=utf-8&oe=utf-8&cl=2&lm=-1&fr=ala&se=&sme=&cs=&os=&objurl=&di=&gsm=1e&dytabstr=mcwzldysmsw0ldisnsw3ldgsoq==
实例六:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func welcom(c *gin.context) {
//defaultquery根据字段名获取client请求的数据,client未提供数据则可以设置默认值
first_name := c.defaultquery("first_name", "未知")
last_mame := c.defaultquery("last_name", "未知")
c.json(http.statusok, gin.h{
"firstname": first_name,
"lastname": last_mame,
"work": [...]string{"公司:tencent", "职位:go开发工程师", "工资:20000"},
})
}
//url参数获取
func main() {
//实例化server对象
router := gin.default()
router.get("/welcom", welcom)
router.run(":8083")
}
接着我们在浏览器中访问:
返回:这样我们的后台就拿到了client提供的参数,并做业务处理,然后返回client
{"firstname":"moss","lastname":"ice","work":["公司:tencent","职位:go开发工程师","工资:20000"]}
post表单提交及其数据获取
在很多时候我们都需要使用post方法来传输数据,列如,用户登录/注册都需要提交表单等
下面我们来看看简单的表单提交:
实例七:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func postform(c *gin.context) {
username := c.defaultpostform("username", "unkown")
password := c.defaultpostform("password", "unkown")
if username == "[email protected]" && password == "123456" {
c.json(http.statusok, gin.h{
"name": "ice_moss",
"username": username,
"tokenstatus": "认证通过",
})
} else {
c.json(http.statusinternalservererror, gin.h{
"tokenstatus": "认证未通过",
})
}
}
//url参数获取
func main() {
//实例化server对象
router := gin.default()
router.post("/postform", postform)
router.run(":8083")
}
由于是post请求我们使用postman提交表单:
后台输出:
username ice_moss password 18dfdf
[gin] 2022/06/23 - 19:24:24 | 500 | 108.029µs | ::1 | post "/postform"
get和post混合使用
直接实例,实例八:
package main
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
func getpost(c *gin.context) {
id := c.query("id")
page := c.defaultquery("page", "未知的")
name := c.defaultpostform("name", "未知的")
password := c.postform("password")
c.json(http.statusok, gin.h{
"id": id,
"page": page,
"name": name,
"password": password,
})
}
//url参数获取
func main() {
//实例化server对象
router := gin.default()
//get和post混合使用
router.post("/post", getpost)
router.run(":8083")
}
因为是post方法使用,访问:
返回:
{
"id": "1",
"name": "[email protected]",
"page": "2",
"password": "123456"
}
数据格式json和protobuf
我们知道前后端数据交互大多数都是以json的格式,go也同样满足,我们知道grpc的数据交互是以protobuf格式的
这里我们用到了proto文件夹下的代码,结构如下:
proto
├── user.pb.go
└── user.proto
可参考
下面我们来看看go是如何处理json的,如何处理protobuf的
实例九
ackage main
import (
"net/http"
"studygin/hollegin/ch07/proto"
"github.com/gin-gonic/gin"
)
func morejson(c *gin.context) {
var msg struct {
nmae string `json:"username"`
message string
number int
}
msg.nmae = "ice_moss"
msg.message = "this is a test of jsom"
msg.number = 101
c.json(http.statusok, msg)
}
//使用protobuf
func returnproto(c *gin.context) {
course := []string{"python", "golang", "java", "c "}
user := &proto.teacher{
name: "ice_moss",
course: course,
}
//返回protobuf
c.protobuf(http.statusok, user)
}
//使用结构体和json对结构体字段进行标签,使用protobuf返回值
func main() {
router := gin.default()
router.get("/morejson", morejson)
router.get("/someprotobuf", returnproto)
router.run(":8083")
}
访问:
返回:
{"username":"ice_moss","message":"this is a test of jsom","number":101}
访问:
返回:然后会将someprotobuf返回的数据下载,当然我们可以使用grpc中的方法将数据接收解析出来
gin解析特殊字符
我们很多时候需要处理特殊的字符,比如:json会将特殊的html字符替换为对应的unicode字符,比如<替换为\u003c,如果想原样输出html,则使用purejson
实例十:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
//通常情况下,json会将特殊的html字符替换为对应的unicode字符,比如<替换为\u003c,如果想原样输出html,则使用purejson
func main() {
router := gin.default()
router.get("/json", func(c *gin.context) {
c.json(http.statusok, gin.h{
"html": "您好,世界!",
})
})
router.get("/purejson", func(c *gin.context) {
c.purejson(http.statusok, gin.h{
"html": "您好,世界!",
})
})
router.run(":8083")
}
访问:
返回:
{"html":"\u003cb\u003e您好,世界!\u003c/b\u003e"}
访问:
返回:
{"html":"您好,世界!"}
gin翻译器的实现
在这段代码中,我们是将注册代码实现翻译功能
实例十一:
package main
import (
"fmt"
"net/http"
"reflect"
"strings"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/locales/en"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
entranslations "github.com/go-playground/validator/v10/translations/en"
zhtranslations "github.com/go-playground/validator/v10/translations/zh"
"github.com/gin-gonic/gin"
)
// 定义一个全局翻译器t
var trans ut.translator
//login登录业务,字段添加tag约束条件
type login struct {
user string `json:"user" binding:"required"` //必填
password string `json:"password" binding:"required"` //必填
}
//signup注册业务,字段添加tag约束条件
type signup struct {
age int `json:"age" binding:"gte=18"` //gte大于等于
name string `json:"name" binding:"required"` //必填
email string `json:"email" binding:"required,email"` //必填邮件
password string `json:"password" binding:"required"` //必填
repassword string `json:"re_password" binding:"required,eqfield=password"` //repassword和password值一致
}
//removetopstruct去除以"."及其左部分内容
func removetopstruct(fields map[string]string) map[string]string {
res := map[string]string{}
for field, value := range fields {
res[field[strings.index(field, ".")1:]] = value
}
return res
}
// inittrans 初始化翻译器
func inittrans(locale string) (err error) {
// 修改gin框架中的validator引擎属性,实现自定制
if v, ok := binding.validator.engine().(*validator.validate); ok {
//注册一个获取json的自定义方法
v.registertagnamefunc(func(field reflect.structfield) string {
name := strings.splitn(field.tag.get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
zht := zh.new() // 中文翻译器
ent := en.new() // 英文翻译器
// 第一个参数是备用(fallback)的语言环境
// 后面的参数是应该支持的语言环境(支持多个)
// uni := ut.new(zht, zht) 也是可以的
uni := ut.new(ent, zht, ent)
// locale 通常取决于 http 请求头的 'accept-language'
var ok bool
// 也可以使用 uni.findtranslator(...) 传入多个locale进行查找
trans, ok = uni.gettranslator(locale)
if !ok {
return fmt.errorf("uni.gettranslator(%s) failed", locale)
}
// 注册翻译器
switch locale {
case "en":
err = entranslations.registerdefaulttranslations(v, trans)
case "zh":
err = zhtranslations.registerdefaulttranslations(v, trans)
default:
err = entranslations.registerdefaulttranslations(v, trans)
}
return
}
return
}
func main() {
res := map[string]string{
"ice_moss.habbit": "打球",
"ice_moss.from": "贵州 中国",
}
fmt.println(removetopstruct(res))
//初始化翻译器, 翻译器代码看不懂不要紧,我们只需知道这样使用就行
if err := inittrans("zh"); err != nil {
fmt.println("初始化翻译器失败", err)
return
}
router := gin.default()
router.post("/loginjson", func(c *gin.context) {
var login login
if err := c.shouldbind(&login); err != nil {
fmt.println(err.error())
errs, ok := err.(validator.validationerrors)
if !ok {
c.json(http.statusok, gin.h{
"msg": err.error(),
})
}
c.json(http.statusinternalservererror, gin.h{
"error": errs.translate(trans),
})
return
}
c.json(http.statusok, gin.h{
"msg": "验证通过",
})
})
router.post("/signupjson", func(c *gin.context) {
var signup signup
//shouldbind()对数据进行绑定,解组
if err := c.shouldbind(&signup); err != nil {
fmt.println(err.error())
//获取validator.validationerrors类型的error
errs, ok := err.(validator.validationerrors)
if !ok {
c.json(http.statusok, gin.h{
"msg": err.error(),
})
}
//validator.validationerrors类型错误则进行翻译
c.json(http.statusinternalservererror, gin.h{
"error": removetopstruct(errs.translate(trans)),
})
return
}
c.json(http.statusok, gin.h{
"msg": "注册成功",
})
})
router.run(":8083")
}
当我们访问:
如果参数不满足tag中的条件,则会返回如下结果:
当我们输入满足tag中的条件,就成功返回了:
gin中间件原理及自定义中间件
在此之前我们先来看一下gin实例化server,我们在之前是使用router := gin.default()
,但其实我们是可以直接使用router := gin.new()
, 那么在之前是实例中我们为什么不使用gin.new()
呢?
别急,我们先来看看gin.default()
的源码:
// default returns an engine instance with the logger and recovery middleware already attached.
func default() *engine {
debugprintwarningdefault()
engine := new()
engine.use(logger(), recovery())
return engine
}
我们可以看到default()其实是对gin.new()做了一层封装,并且做了其他事情,这里的其他事情就有“中间件”
即:engine.use(logger(), recovery())
他调用两个中间件( logger()用来输出日志信息,recovery 中间件会恢复(recovers) 任何恐慌(panics) 如果存在恐慌,中间件将会写入500)
gin中间件原理
我们来看看:engine.use()
func (engine *engine) use(middleware ...handlerfunc) iroutes {
engine.routergroup.use(middleware...)
engine.rebuild404handlers()
engine.rebuild405handlers()
return engine
}
入参是handlerfunc
类型,那么我们接着往下看handlerfunc
// handlerfunc defines the handler used by gin middleware as return value.
type handlerfunc func(*context)
其实handlerfunc
是func(*context)
类型
到这里中间件我们就可以自定义了
自定义中间件
我们定义一个监控服务运行时间,运行状态的中间件:
实例十二:
//自定义中间件,这里我们以函数调用的形式,对中间件进一步封装
func mytimelogger() gin.handlerfunc {
return func(c *gin.context) { //真正的中间件类型
t := time.now()
c.set("msg", "this is a test of middleware")
//它执行调用处理程序内链中的待处理处理程序
//让原本执行的逻辑继续执行
c.next()
end := time.since(t)
fmt.printf("耗时:%d\n", end.seconds())
status := c.writer.status()
fmt.println("状态监控:", status)
}
}
我们在main函数中:
func main() {
router := gin.default()
router.use(mytimelogger()) //这里使用函数调用
router.get("/ping", func(c *gin.context) {
c.json(http.statusok, gin.h{
"msg": "pong",
})
})
router.run(":8083")
}
访问:
返回 :
{"msg":"pong"}
中间件实际应用
基于中间件模拟登录:
实例十三:
//自定义中间件
func tokenrequired() gin.handlerfunc {
return func(c *gin.context) {
var token string
//从请求头中获取数据
for k, v := range c.request.header {
if k == "x-token" {
token = v[0]
} else {
fmt.println(k, v)
}
}
fmt.println(token)
if token != "ice_moss" {
c.json(http.statusunauthorized, gin.h{
"msg": "认证未通过",
})
//return在这里不会有被执行
c.abort() //这里先不用理解,后面会讲解,这里先理解为return
}
//继续往下执行该执行的逻辑
c.next()
}
}
将中间件加入gin中:
func main() {
router := gin.default()
//中间件
router.use(tokenrequired())
router.get("/ping", func(c *gin.context) {
c.json(http.statusok, gin.h{
"msg": "pong",
})
})
router.run(":8083")
}
我们在postman中进行请求:
将headers,增加字段:
正确参数返回:
{
"msg": "pong"
}
不正确参数返回:
{
"msg": "认证未通过"
}
这里我们需要对中间件原理进一步剖析:
现在我们将围绕两个方法来解释c.abort() 和 c.next()
c.abort()
在实例十三中我们看到:
func tokenrequired() gin.handlerfunc {
return func(c *gin.context) {
var token string
for k, v := range c.request.header {
if k == "x-token" {
token = v[0]
} else {
fmt.println(k, v)
}
}
fmt.println(token)
if token != "ice_moss" {
c.json(http.statusunauthorized, gin.h{
"msg": "认证未通过",
})
//return 不会被执行,需要使用c.abort()来结束当前
c.abort()
}
//继续执行该执行的逻辑
c.next()
}
}
- 为什么return 不能直接返回,而是使用c.abort()
原因:当我们启动服务后,gin会有一个类似于任务队列将所有配置的中间件和在注册处理方法压入队列中:
在处理业务代码之前,会将所有注册路由中的中间件以队列的执行方式执行,比如上面我们:
当我们在实例十三中执行return
他只是将当前函数返回,但是后面的方法仍然是按逻辑执行的,很显然这不是我们想要的结果,不满足验证条件的情况,应该将对此时的client终止服务,如果要终止服务就应该将图中的箭头跳过所有方法:
这样整个服务才是真正的终止,下面再来看看abort()
:
func (c *context) abort() {
c.index = abortindex
}
当代码执行到abort()时,index被赋值为abortindex,abortindex
是什么?
const abortindex int8 = math.maxint8 >> 1
可以看到,最后index指向任务末端,这就是const abortindex int8 = math.maxint8 >> 1
作用的效果
c.next()
理解了abort()
,next()
自然就好理解了,我们来看看next()
定义
func (c *context) next() {
c.index
for c.index < int8(len(c.handlers)) {
c.handlers[c.index](c)
c.index
}
}
执行过程:
gin返回html模板
我们使用html模板,将后端获取到的数据,直接填充至html中
我们先来编写一个html(实例为tmpl,无影响)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ .title }}</title>
</head>
<body>
<h1>{{ .menu }}</h1>
</body>
</html>
其中数据以{{ .title }}
从web层填充进来
我们需要注意目录结构,程序的执行入口main
需要和模板 templates
放置同一目录下,这样保证main
能读取文件html
ch11
├── main.go
└── templates
└── index.tmpl
实例十四:
main:
package main
import (
"fmt"
"net/http" "os" "path/filepath"
"github.com/gin-gonic/gin")
func main() {
router := gin.default()
//读取文件
router.loadhtmlfiles("templates/index.tmpl")
router.get("/index", func(c *gin.context) {
//写入数据, key必须要tmpl一致
c.html(http.statusok, "index.tmpl", gin.h{
"title": "购物网",
"menu": "菜单栏",
})
})
router.run(":8085")
}
当我们在浏览器中访问:
获取到:
当然router.loadhtmlfiles()
方法可以加载多个html文件
实例十五:
package main
import (
"fmt"
"net/http" "os" "path/filepath"
"github.com/gin-gonic/gin")
func main() {
router := gin.default()
//读取模板文件,按指定个读取
router.loadhtmlfiles("templates/index.tmpl", "templates/goods.html")
router.get("/index", func(c *gin.context) {
c.html(http.statusok, "index.tmpl", gin.h{
"title": "shop",
"menu": "菜单栏",
})
})
router.get("goods", func(c *gin.context) {
c.html(http.statusok, "goods.html", gin.h{
"title": "goods",
"goods": [4]string{"矿泉水", "面包", "薯片", "冰淇淋"},
})
})
router.run(":8085")
}
这样就可以访问:
或者:
返回结果:略
当然如果html文件很多,gin还提供了```
func (engine *engine) loadhtmlglob(pattern string) {……}
我们只需要这样调用:
//将"templates文件夹下所有文件加载
router.loadhtmlglob("templates/*")
对应二级目录,我们又是如何处理的呢?
//加载templates目录下的目录中的所有文件
router.loadhtmlglob("templates/**/*")
实例十六:
package main
import (
"fmt"
"net/http" "os" "path/filepath"
"github.com/gin-gonic/gin")
func main() {
router := gin.default()
router.loadhtmlglob("templates/**/*")
router.get("user/list", func(c *gin.context) {
c.html(http.statusok, "list.html", gin.h{
"title": "shop",
"list": "用户列表",
})
})
router.get("goods/list", func(c *gin.context) {
c.html(http.statusok, "list.html", gin.h{
"title": "shop",
"list": "商品列表",
})
})
router.run(":8085")
}
这样我们访问: 或者
gin静态文件的挂载
在web开发中经常需要将js文件和css文件,进行挂载,来满足需求
目录结构:
ch11
├── main.go
├── static
│ └── style.css
└── templates
└── user
└── list.html
html文件:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ .title }}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>{{ .list }}</h1>
</body>
</html>
css文件:
*{
background-color: aquamarine;
}
静态文件挂载方法:
router.static("/static", "./static")
该方法会去在html文件中标签中找到以
static
开头的链接,然后去找在当前main
所在的目录下找到以第二个参数./static
名称的目录下找到静态文件,然后挂载
实例十七:
package main
import (
"fmt"
"net/http" "os" "path/filepath"
"github.com/gin-gonic/gin")
func main() {
router := gin.default()
//挂载静态文件
router.static("/static", "./static")
router.loadhtmlglob("templates/**/*")
router.get("user/list", func(c *gin.context) {
c.html(http.statusok, "list.html", gin.h{
"title": "shop",
"list": "用户列表",
})
})
router.run(":8085")
}
然后访问:
可以看到:
gin优雅退出
在业务中,我们很多时候涉及到服务的退出,如:各种订单处理中,用户突然退出,支付费用时,程序突然退出,这里我们是需要是江南app体育官方入口的服务合理的退出,进而不造成业务上的矛盾
实例十八:
package main
import (
"fmt"
"net/http" "os" "os/signal" "syscall"
"github.com/gin-gonic/gin")
func main() {
router := gin.default()
router.get("ping", func(c *gin.context) {
c.json(http.statusok, gin.h{
"msg": "ping",
})
})
go func() {
router.run(":8085")
}()
qiut := make(chan os.signal)
//接收control c
//当接收到退出指令时,我们向chan收数据
signal.notify(qiut, syscall.sigint, syscall.sigterm)
<-qiut
//服务退出前做处理
fmt.println("服务退出中")
fmt.println("服务已退出")
}
在terminal中运行:go run main.go
服务启动后在terminal中退出(control c)就可以看到:
[gin-debug] listening and serving http on :8085
服务退出中
服务已退出
本作品采用《cc 协议》,转载必须注明作者和本文链接
虽然很多示例江南app体育官方入口官网都能找到,也是辛苦作者整理了 : 1:
实例十六的代码首行package掉了个p
好文当赏
大佬大佬 \
那个
数据格式 json 和 protobuf
实例9
报错
package studygin/hollegin/ch07/proto is not in goroot
你为何如此牛c?
大佬,studygin/hollegin/ch07/proto,是从哪里下载的我下载的里面怎么没有proto.teacher方法,还是说他只是一个导入的结构体
很好的文章