本篇博客主要介绍了如何从零开始,使用Go Module做为依赖管理,基于Gin来一步一步搭建Go的Web服务器。并使用Endless来使服务器平滑重启,使用Swagger来自动生成Api文档。html
源码在此处:项目源码前端
你们能够先查看源码,而后再根据本篇文章,来了解搭建过程当中服务器的一些细节。java
如下全部的步骤都基于MacOS。node
在这里推荐使用homebrew进行安装。固然你也可使用源码安装。mysql
brew install go
复制代码
跑完命令以后,在命令行输入go
。若是在命令行看到以下输出,则表明安装成功。git
Go is a tool for managing Go source code.
Usage:
go <command> [arguments]
The commands are:
...
...
复制代码
须要注意的是,go的版本须要在1.11
之上,不然没法使用go module。如下是个人go的版本。github
go version
# go version go1.12.5 darwin/amd64
复制代码
推荐使用GoLandgolang
打开GoLand,在GoLand的设置中找到Global GOPATH,将其设置为$HOME/go
。$HOME
目录就是你的电脑的用户目录,若是该目录下没有go
目录的话,也不须要新建,当咱们在后面的操做中初始化模块的时候,会自动的在用户目录下新建go目录。spring
一样,在GoLand中设置中找到Go Modules (vgo)。勾选Enable Go Modules (vgo) integration前的选择框来启用Go Moudlesql
在你经常使用的工做区新建一个目录,若是你有github的项目,能够直接clone下来。
go mod init $MODULE_NAME
复制代码
在刚刚新建的项目的根目录下,使用上述命令来初始化go module。该命令会在项目根目录下新建一个go.mod的文件。
若是你的项目是从github上clone下来的,$MODULE_NAME
这个参数就不须要了。它会默认为github.com/$GITHUB_USER_NAME/$PROJECT_NAME
。
例如本项目就是github.com/detectiveHLH/go-backend-starter
;若是是在本地新建的项目,则必需要加上最后一个参数。不然就会遇到以下的错误。
go: cannot determine module path for source directory /Users/hulunhao/Projects/go/test/src (outside GOPATH, no import comments)
复制代码
初始化完成以后的go.mod
文件内容以下。
module github.com/detectiveHLH/go-backend-starter
go 1.12
复制代码
在项目的根目录下新建main.go。代码以下。
package main
import (
"fmt"
)
func main() {
fmt.Println("This works")
}
复制代码
在根目录下使用go run main.go
,若是看到命令行中输出This works
则表明基础的框架已经搭建完成。接下来咱们开始将Gin引入框架。
Gin是一个用Go实现的HTTP Web框架,咱们使用Gin来做为starter的Base Framework。
直接经过go get命令来安装
go get github.com/gin-gonic/gin
复制代码
安装成功以后,咱们能够看到go.mod文件中的内容发生了变化。
而且,咱们在设定的GOPATH下,并无看到刚刚安装的依赖。实际上,依赖安装到了$GOPATH/pkg/mod下。
module github.com/detectiveHLH/go-backend-starter
go 1.12
require github.com/gin-gonic/gin v1.4.0 // indirect
复制代码
同时,也生成了一个go.sum文件。内容以下。
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3 h1:t8FVkw33L+wilf2QiWkw0UV77qRpcH/JHPKGpKa2E8g=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-gonic/gin v1.4.0 h1:3tMoCCfM7ppqsR0ptz/wi1impNpT7/9wQtMZ8lr1mCQ=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/ugorji/go v1.1.4 h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ=
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
复制代码
用过Node的人都知道,在安装完依赖以后会生成一个package-lock.json文件,来锁定依赖的版本。以防止后面从新安装依赖时,安装了新的版本,可是与现有的代码不兼容,这会带来一些没必要要的BUG。
可是这个go.sum文件并非这个做用。咱们能够看到go.mod中只记录了一个Gin的依赖,而go.sum中则有很是多。是由于go.mod中只记录了最顶层,就是咱们直接使用命令行安装的依赖。可是要知道,一个开源的包一般都会依赖不少其余的依赖包。
而go.sum就是记录全部顶层和其中间接依赖的依赖包的特定版本的文件,为每个依赖版本生成一个特定的哈希值,从而在一个新环境启用该项目时,能够作到对项目依赖的100%还原。go.sum还会保留一些过去使用过的版本的信息。
在go module下,不须要vendor目录来保证可重现的构建,而是经过go.mod文件来对项目中的每个依赖进行精确的版本管理。
若是以前的项目用的是vendor,那么从新用go.mod从新编写不太现实。咱们可使用go mod vendor
命令将以前项目全部的依赖拷贝到vendor目录下,为了保证兼容性,在vendor目录下的依赖并不像go.mod同样。拷贝以后的目录不包含版本号。
并且经过上面安装gin能够看出,一般状况下,go.mod文件是不须要咱们手动编辑的,当咱们执行完命令以后,go.mod也会自动的更新相应的依赖和版本号。
下面咱们来了解一下go mod的相关命令。
还有一个命令值得提一下,go list -m all
能够列出当前项目的构建列表。
修改main.go的代码以下。
package main
import (
"fmt"
"github.com/gin-gonic/gin"
)
func main() {
fmt.Println("This works.")
r := gin.Default()
r.GET("/hello", func(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"code": 200,
"message": "This works",
"data": nil,
})
})
r.Run()
}
复制代码
上述的代码引入了路由,熟悉Node的应该能够看出,这个与koa-router的用法十分类似。
照着上述运行main.go的步骤,运行main.go。就能够在控制台看到以下的输出。
This works.
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET /hello --> main.main.func1 (3 handlers)
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080
复制代码
此时,服务器已经在8080端口启动了。而后在浏览器中访问http://localhost:8080/hello,就能够看到服务器的正常返回。同时,服务器这边也会打印相应的日志。
[GIN] 2019/06/08 - 17:41:34 | 200 | 214.213µs | ::1 | GET /hello
复制代码
在根目录下新建router目录。在router下,新建router.go文件,代码以下。
package router
import "github.com/gin-gonic/gin"
func InitRouter() *gin.Engine {
router := gin.New()
apiVersionOne := router.Group("/api/v1/")
apiVersionOne.GET("hello", func(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"code": 200,
"message": "This works",
"data": nil,
})
})
return router
}
复制代码
在这个文件中,导出了一个InitRouter函数,该函数返回gin.Engine类型。该函数还定义了一个路由为/api/v1/hello的GET请求。
将main.go的代码改成以下。
package main
import (
"fmt"
"github.com/detectiveHLH/go-backend-starter/router"
)
func main() {
r := router.InitRouter()
r.Run()
}
复制代码
而后运行main.go,启动以后,访问http://localhost:8080/api/v1/hello,能够看到,与以前访问/hello路由的结果是同样的。
到此为止,咱们已经拥有了一个拥有简单功能的Web服务器。那么问题来了,这样的一个开放的服务器,只要知道了地址,你的服务器就知道暴露给其余人了。这样会带来一些安全隐患。因此咱们须要给接口加上鉴权,只有经过认证的调用方,才有权限调用服务器接口。因此接下来,咱们须要引入JWT。
使用go get命令安装jwt-go依赖。
go get github.com/dgrijalva/jwt-go
复制代码
在根目录下新建middleware/jwt目录,在jwt目录下新建jwt.go文件,代码以下。
package jwt
import (
"github.com/detectiveHLH/go-backend-starter/consts"
"github.com/gin-gonic/gin"
"net/http"
"time"
)
func Jwt() gin.HandlerFunc {
return func(c *gin.Context) {
var code int
var data interface{}
code = consts.SUCCESS
token := c.Query("token")
if token == "" {
code = consts.INVALID_PARAMS
} else {
claims, err := util.ParseToken(token)
if err != nil {
code = consts.ERROR_AUTH_CHECK_TOKEN_FAIL
} else if time.Now().Unix() > claims.ExpiresAt {
code = consts.ERROR_AUTH_CHECK_TOKEN_TIMEOUT
}
}
if code != consts.SUCCESS {
c.JSON(http.StatusUnauthorized, gin.H{
"code": code,
"msg": consts.GetMsg(code),
"data": data,
})
c.Abort()
return
}
c.Next()
}
}
复制代码
此时,代码中会有错误,是由于咱们没有声明consts这个包,其中的变量SUCCESS、INVALID_PARAMS和ERROR_AUTH_CHECK_TOKEN_FAIL是未定义的。根据code获取服务器返回信息的函数GetMsg也没定义。一样没有定义的还有util.ParseToken(token)和claims.ExpiresAt。因此咱们要新建consts包。咱们在根目录下新建consts目录,而且在consts目录下新建code.go,将定义好的一些常量引进去,代码以下。
const (
SUCCESS = 200
ERROR = 500
INVALID_PARAMS = 400
)
复制代码
再新建message.go文件,代码以下。
var MsgFlags = map[int]string{
SUCCESS: "ok",
ERROR: "fail",
INVALID_PARAMS: "请求参数错误",
}
func GetMsg(code int) string {
msg, ok := MsgFlags[code]
if ok {
return msg
}
return MsgFlags[ERROR]
}
复制代码
在根目录下新建util,而且在util下新建jwt.go,代码以下。
package util
import (
"github.com/dgrijalva/jwt-go"
"time"
)
var jwtSecret = []byte(setting.AppSetting.JwtSecret)
type Claims struct {
Username string `json:"username"`
Password string `json:"password"`
jwt.StandardClaims
}
func GenerateToken(username, password string) (string, error) {
nowTime := time.Now()
expireTime := nowTime.Add(3 * time.Hour)
claims := Claims{
username,
password,
jwt.StandardClaims {
ExpiresAt : expireTime.Unix(),
Issuer : "go-backend-starter",
},
}
tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token, err := tokenClaims.SignedString(jwtSecret)
return token, err
}
func ParseToken(token string) (*Claims, error) {
tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if tokenClaims != nil {
if claims, ok := tokenClaims.Claims.(*Claims); ok && tokenClaims.Valid {
return claims, nil
}
}
return nil, err
}
复制代码
在上面的util中,setting包并无定义,因此在这个步骤中咱们须要定义setting包。
使用go get命令安装依赖。
go get gopkg.in/ini.v1
复制代码
在项目根目录下新建setting目录,并在setting目录下新建setting.go文件,代码以下。
package setting
import (
"gopkg.in/ini.v1"
"log"
)
type App struct {
JwtSecret string
}
type Server struct {
Ip string
Port string
}
type Database struct {
Type string
User string
Password string
Host string
Name string
TablePrefix string
}
var AppSetting = &App{}
var ServerSetting = &Server{}
var DatabaseSetting = &Database{}
var config *ini.File
func Setup() {
var err error
config, err = ini.Load("config/app.ini")
if err != nil {
log.Fatal("Fail to parse 'config/app.ini': %v", err)
}
mapTo("app", AppSetting)
mapTo("server", ServerSetting)
mapTo("database", DatabaseSetting)
}
func mapTo(section string, v interface{}) {
err := config.Section(section).MapTo(v)
if err != nil {
log.Fatalf("Cfg.MapTo RedisSetting err: %v", err)
}
}
复制代码
在项目根目录下新建config目录,并新建app.ini文件,内容以下。
[app]
JwtSecret = 233
[server]
Ip : localhost
Port : 8000
Url : 127.0.0.1:27017
[database]
Type = mysql
User = $YOUR_USERNAME
Password = $YOUR_PASSWORD
Host = 127.0.0.1:3306
Name = golang_test
TablePrefix = golang_test_
复制代码
到此为止,经过jwt token进行鉴权的逻辑已经所有完成,剩下的就须要实现登陆接口来将token在用户登陆成功以后返回给用户。
使用go get命令安装依赖。
go get github.com/astaxie/beego/validation
复制代码
在router下新建login.go,代码以下。
package router
import (
"github.com/astaxie/beego/validation"
"github.com/detectiveHLH/go-backend-starter/consts"
"github.com/detectiveHLH/go-backend-starter/util"
"github.com/gin-gonic/gin"
"net/http"
)
type auth struct {
Username string `valid:"Required; MaxSize(50)"`
Password string `valid:"Required; MaxSize(50)"`
}
func Login(c *gin.Context) {
appG := util.Gin{C: c}
valid := validation.Validation{}
username := c.Query("username")
password := c.Query("password")
a := auth{Username: username, Password: password}
ok, _ := valid.Valid(&a)
if !ok {
appG.Response(http.StatusOK, consts.INVALID_PARAMS, nil)
return
}
authService := authentication.Auth{Username: username, Password: password}
isExist, err := authService.Check()
if err != nil {
appG.Response(http.StatusOK, consts.ERROR_AUTH_CHECK_TOKEN_FAIL, nil)
return
}
if !isExist {
appG.Response(http.StatusOK, consts.ERROR_AUTH, nil)
return
}
token, err := util.GenerateToken(username, password)
if err != nil {
appG.Response(http.StatusOK, consts.ERROR_AUTH_TOKEN, nil)
return
}
appG.Response(http.StatusOK, consts.SUCCESS, map[string]string{
"token": token,
})
}
复制代码
在util包下新增response.go文件,代码以下。
package util
import (
"github.com/detectiveHLH/go-backend-starter/consts"
"github.com/gin-gonic/gin"
)
type Gin struct {
C *gin.Context
}
func (g *Gin) Response(httpCode, errCode int, data interface{}) {
g.C.JSON(httpCode, gin.H{
"code": httpCode,
"msg": consts.GetMsg(errCode),
"data": data,
})
return
}
复制代码
除了返回类,login.go中还有关键的鉴权逻辑尚未实现。在根目录下新建service/authentication目录,在该目录下新建auth.go文件,代码以下。
package authentication
import "fmt"
type Auth struct {
Username string
Password string
}
func (a *Auth) Check() (bool, error) {
userName := a.Username
passWord := a.Password
// todo:实现本身的鉴权逻辑
fmt.Println(userName, passWord)
return true, nil
}
复制代码
在此处,须要本身真正的根据业务去实现对用户调用接口的合法性校验。例如,能够根据用户的用户名和密码去数据库作验证。
修改router.go中的代码以下。
package router
import (
"github.com/detectiveHLH/go-backend-starter/middleware/jwt"
"github.com/gin-gonic/gin"
)
func InitRouter() *gin.Engine {
router := gin.New()
router.GET("/login", Login)
apiVersionOne := router.Group("/api/v1/")
apiVersionOne.Use(jwt.Jwt())
apiVersionOne.GET("hello", func(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"code": 200,
"message": "This works",
"data": nil,
})
})
return router
}
复制代码
能够看到,咱们在路由文件中加入了/login接口,并使用了咱们自定义的jwt鉴权的中间件。只要是在v1下的路由,请求以前都会先进入jwt中进行鉴权,鉴权经过以后才能继续往下执行。
到此,咱们使用go run main.go
启动服务器,访问http://localhost:8080/api/v1/hello会遇到以下错误。
{
"code": 400,
"data": null,
"msg": "请求参数错误"
}
复制代码
这是由于咱们加入了鉴权,凡是须要鉴权的接口,都须要带上参数token。而要获取token则必需要先要登陆,假设咱们的用户名是Tom,密码是123。以此来调用登陆接口。
http://localhost:8080/login?username=Tom&password=123
复制代码
在浏览器中访问如上的url以后,能够看到返回以下。
{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlRvbSIsInBhc3N3b3JkIjoiMTIzIiwiZXhwIjoxNTYwMTM5MTE3LCJpc3MiOiJnby1iYWNrZW5kLXN0YXJ0ZXIifQ.I-RSi-xVV1Tk_2iBWolF1u94Y7oVBQXnHh6OI2YKJ6U"
},
"msg": "ok"
}
复制代码
有了token以后,咱们再调用hello接口,能够看到数据正常的返回了。
http://localhost:8080/api/v1/hello?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IlRvbSIsInBhc3N3b3JkIjoiMTIzIiwiZXhwIjoxNTYwMTM5MTE3LCJpc3MiOiJnby1iYWNrZW5kLXN0YXJ0ZXIifQ.I-RSi-xVV1Tk_2iBWolF1u94Y7oVBQXnHh6OI2YKJ6U
复制代码
通常的处理方法是,前端拿到这个token,利用持久化存储存下来,而后以后的每次请求都将token写在header中发给后端。后端先经过header中的token来校验调用接口的合法性,验证经过以后才进行真正的接口调用。
而在这我将token写在了request param中,只是为了作一个例子来展现。
完成了基本的框架以后,咱们就开始为接口引入swagger文档。写过java的同窗应该对swagger不陌生。往常写API文档,都是手写。即每一个接口的每个参数,都须要手打。
而swagger不同,swagger只须要你在接口上打上几个注解(Java中的操做),就能够自动为你生成swagger文档。而在go中,咱们是经过注释的方式来实现的,接下来咱们安装gin-swagger。
go get github.com/swaggo/gin-swagger
go get -u github.com/swaggo/gin-swagger/swaggerFiles
go get -u github.com/swaggo/swag/cmd/swag
go get github.com/ugorji/go/codec
go get github.com/alecthomas/template
复制代码
引入依赖以后,咱们须要在router/router.go中注入swagger。在import中加入_ "github.com/detectiveHLH/go-backend-starter/docs"
。
并在router := gin.New()
以后加入以下代码。
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
复制代码
在router/login.go中的Login函数上方加上以下注释。
// @Summary 登陆
// @Produce json
// @Param username query string true "username"
// @Param password query string true "password"
// @Success 200 {string} json "{"code":200,"data":{},"msg":"ok"}"
// @Router /login [get]
复制代码
在项目根目录下使用swag init
命令来初始化swagger文档。该命令将会在项目根目录生成docs目,内容以下。
.
├── docs.go
├── swagger.json
└── swagger.yaml
复制代码
运行main.go,而后在浏览器访问http://localhost:8080/swagger/index.html就能够看到swagger根据注释自动生成的API文档了。
go get github.com/fvbock/endless
复制代码
package main
import (
"fmt"
"github.com/detectiveHLH/go-backend-starter/router"
"github.com/fvbock/endless"
"log"
"syscall"
)
func main() {
r := router.InitRouter()
address := fmt.Sprintf("%s:%s", setting.ServerSetting.Ip, setting.ServerSetting.Port)
server := endless.NewServer(address, r)
server.BeforeBegin = func(add string) {
log.Printf("Actual pid is %d", syscall.Getpid())
}
err := server.ListenAndServe()
if err != nil {
log.Printf("Server err: %v", err)
}
}
复制代码
对比起没有go module的依赖管理,如今的go module更像是Node.js中的package.json,也像是Java中的pom.xml,惟一不一样的是pom.xml须要手动更新。
当咱们拿到有go module项目的时候,不用担忧下来依赖时,由于版本问题可能致使的一些兼容问题。直接使用go mod中的命令就能够将制定了版本的依赖所有安装,其效果相似于Node.js中的npm install
。
go module定位module的方式,与Node.js寻找依赖的逻辑同样,Node会从当前命令执行的目录开始,依次向上查找node_modules中是否有这个依赖,直到找到。go则是依次向上查找go.mod文件,来定位一个模块。
相信以后go以后的依赖管理,会愈来愈好。
Happy hacking.
参考:
往期文章:
相关:
- 我的网站: Lunhao Hu
- 微信公众号: SH的全栈笔记(或直接在添加公众号界面搜索微信号LunhaoHu)
![]()