[TOC]
JWT(Json Web Token)驗證(附帶源碼講解)
一天,正是午休時段
兵長路過胖sir座位炼七,大吃一驚,今天胖sir居然沒有打呼嚕蓖康,而是在低著頭聚精會神盯著一本書
兵長湊近一看筐眷,胖sir居然在看史書...
兵長:(輕聲道)黎烈,你在看~~ 什 ~~ 么 ~~
胖sir:我在想我要是穿越到清朝,我會是啥身份匀谣?
what照棋??~~~ 振定, 能是啥身份必怜,肯定是重量級人物唄
胖sir: 我呸, 今天我倒要給你講講啥叫身份
講到身份后频,不得不說一下cookie梳庆、session、Token的區(qū)別卑惜,come on
1 cookie膏执、session、Token的區(qū)別
Cookie
Cookie總是保存在客戶端中露久,按在客戶端中的存儲位置更米,可分為 內(nèi)存Cookie
和 硬盤Cookie
。
內(nèi)存Cookie由瀏覽器維護毫痕,保存在內(nèi)存中征峦,瀏覽器關閉后就消失了,其存在時間是短暫的消请。
硬盤Cookie保存在硬盤?栏笆,有?個過期時間,除??戶??清理或到了過期時間臊泰,硬盤Cookie不會被刪除蛉加,其存在時間 是?期的。
所以,按存在時間针饥,可分為 ?持久Cookie
和持久Cookie
厂抽。
那么cookies到底是什么呢?
cookie 是?個?常具體的東?丁眼,指的就是瀏覽器??能永久存儲的?種數(shù)據(jù)筷凤,僅僅是瀏覽器實現(xiàn)的?種數(shù)
據(jù)存儲功能。
cookie由服務器?成户盯,發(fā)送給瀏覽器 嵌施,瀏覽器把cookie以key-value形式保存到某個?錄下的?本?件
內(nèi),下?次請求同??站時會把該cookie發(fā)送給服務器莽鸭。由于cookie是存在客戶端上的吗伤,所以瀏覽器加?
了?些限制確保cookie不會被惡意使?,同時不會占據(jù)太多磁盤空間硫眨,所以每個域的cookie數(shù)量是有限的足淆。
Session
Session字?意思是會話,主要?來標識??的身份礁阁。
?如在?狀態(tài)的api服務在多次請求數(shù)據(jù)庫時巧号,如何 知道是同?個?戶,這個就可以通過session的機制姥闭,服務器要知道當前發(fā)請求給??的是誰丹鸿,為了區(qū)分客戶端請求, 服務端會給具體的客戶端?成身份標識session 棚品,然后客戶端每次向服務器發(fā)請求 的時候靠欢,都帶上這個“身份標識”,服務器就知道這個請求來?于誰了铜跑。
?于客戶端如何保存該標識门怪,可以有很多?式,對于瀏覽器??锅纺,?般都是使? cookie 的?式 掷空,服務器使?session把?戶信息臨時保存了服務器上,?戶離開?站就會銷毀囤锉,這種憑證存儲?式相對于 坦弟,cookie來說更加安全。
但是session會有?個缺陷: 如果web服務器做了負載均衡官地,那么下?個操作請求到 了另?臺服務器的時候session會丟失酿傍。
因此厢汹,通常企業(yè)?會使? redis,memcached 緩存中間件來實現(xiàn)session的共享,此時web服務器就是? 個完全?狀態(tài)的存在凄鼻,所有的?戶憑證可以通過共享session的?式存取命辖,當前session的過期和銷毀機制 需要?戶做控制耳幢。
Token
token的意思是“令牌”爸邢,是?戶身份的驗證?式科乎,最簡單的token組成: uid(?戶唯?標識) + time(當前 時間戳) + sign(簽名,由token的前?位+鹽以哈希算法壓縮成?定?度的?六進制字符串) 密强,同時還可 以將不變的參數(shù)也放進token
這里說的token只的是 JWT(Json Web Token)
2 JWT是個啥宴杀?
?般??癣朗,?戶注冊登陸后會?成?個jwt token返回給瀏覽器,瀏覽器向服務端請求數(shù)據(jù)時攜帶 token 旺罢,服務器端使? signature 中定義的?式進?解碼旷余,進?對token進?解析和驗證。
jwt token 的組成部分
-
header: ?來指定使?的算法(HMAC SHA256 RSA)和token類型(如JWT)
官網(wǎng)上可以找到各種語言的jwt庫扁达,例如我們下面使用這個庫進行編碼正卧,因為這個庫使用的人是最多的,值得信賴
go get github.com/dgrijalva/jwt-go
image payload: 包含聲明(要求)跪解,聲明通常是?戶信息或其他數(shù)據(jù)的聲明炉旷,?如?戶id,名稱叉讥,郵箱等. 聲明窘行。可分為三種: registered,public,private
signature: ?來保證JWT的真實性图仓,可以使?不同的算法
header
token的第一部分罐盔,如
{
"alg": "HS256",
"typ": "JWT"
}
對上?的json進?base64編碼即可得到JWT的第?個部分
payload
token第二部分如
registered claims: 預定義的聲明,通常會放置?些預定義字段救崔,?如過期時間惶看,主題等(iss:issuer,exp:expiration time,sub:subject,aud:audience)
public claims: 可以設置公開定義的字段
private claims: ?于統(tǒng)?使?他們的各?之間的共享信息
不要在header和payload中放置敏感信息,除?信息本身已經(jīng)做過脫敏處理帚豪,因為payload部分的具體數(shù)據(jù)是可以通過token來獲取到的
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
Signature
token的第三部分
為了得到簽名部分碳竟,必須有編碼過的header和payload,以及?個秘鑰狸臣,簽名算法使?header中指定的那 個莹桅,然后對其進?簽名即可
HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload),secret)
簽名是 ?于驗證消息在傳遞過程中有沒有被更改 ,并且烛亦,對于使?私鑰簽名的token诈泼,它還可以驗證JWT
的發(fā)送?是否為它所稱的發(fā)送?。
簽名的?的
最后?步簽名的過程煤禽,實際上是對頭部以及載荷內(nèi)容進?簽名铐达。
?般??,加密算法對于不同的輸? 產(chǎn)?的輸出總是不?樣的檬果。對于兩個不同的輸?瓮孙,產(chǎn)?同樣的輸出的概率極其地?唐断。所以,我們就把“不?樣的輸?產(chǎn)?不?樣的輸出”當做必然事件來看待杭抠。
所以脸甘,如果有?對頭部以及載荷的內(nèi)容解碼之后進?修改,再進?編碼的話偏灿,那么新的頭部和載荷的 簽名和之前的簽名就將是不?樣的丹诀。?且,如果不知道服務器加密的時候?的密鑰的話翁垂,得出來的簽名也 ?定會是不?樣的铆遭。
服務器應?在接受到JWT后,會?先對頭部和載荷的內(nèi)容?同?算法再次簽名沿猜。那么服務器應?是怎 么知道我們?的是哪?種算法呢枚荣?
在JWT的頭部中已經(jīng)?alg字段指明了我們的加密算法了。
如果服務器應?對頭部和載荷再次以同樣?法簽名之后發(fā)現(xiàn)邢疙,??計算出來的簽名和接受到的簽名不 ?樣棍弄,那么就說明這個Token的內(nèi)容被別?動過的,我們應該拒絕這個Token疟游,
注意:在JWT中呼畸,不應該在載荷??加?任何敏感的數(shù)據(jù),?如?戶的密碼颁虐。具體原因上文已經(jīng)給過答案了
jwt.io?站
在jwt.io(https://jwt.io/#debugger-io
)?站中蛮原,提供了?些JWT token的編碼,驗證以及?成jwt的?具另绩。
下圖就是?個典型的jwt-token的組成部分儒陨。
啥時候使用JWT呢?
我們要明白的時候笋籽,JWT是用作認證的蹦漠,而不是用來做授權的。明白他的功能车海,那么對應JWT的應用場景就不言而喻了
Authorization(授權): 典型場景笛园,?戶請求的token中包含了該令牌允許的路由,服務和資源侍芝。單點登錄其實就是現(xiàn)在?泛使?JWT的?個特性
-
Information Exchange(信息交換): 對于安全的在各?之間傳輸信息??研铆,JSON Web Tokens?疑 是?種很好的?式.因為JWTs可以被簽名
例如,?公鑰/私鑰對州叠,你可以確定發(fā)送?就是它們所說的 那個?棵红。另外,由于簽名是使?頭和有效負載計算的咧栗,您還可以驗證內(nèi)容沒有被篡改
JWT工作方式是怎樣的逆甜?
JWT認證過程基本上整個過程分為兩個階段
- 第?個階段虱肄,客戶端向服務端獲取token
- 第?階段,客戶端帶著該token去請求相關的資源
通常?較重要的是交煞,服務端如何根據(jù)指定的規(guī)則進?token的?成浩峡。
在認證的時候,當?戶?他們的憑證成功登錄以后错敢,?個JSON Web Token將會被返回。 此后缕粹,token就是?戶憑證了稚茅,你必須?常??以防?出現(xiàn)安全問題。 ?般??平斩,你保存令牌的時間不應該超過你所需要它的時間亚享。
?論何時?戶想要訪問受保護的路由或者資源的時候,?戶代理(通常是瀏覽器)都應該帶上JWT绘面,典型 的欺税,通常放在Authorization header中,?Bearer schema: Authorization: Bearer <token> 服務器上的受保護的路由將會檢查Authorization header中的JWT是否有效揭璃,如果有效晚凿,則?戶可以訪問 受保護的資源。
如果JWT包含?夠多的必需的數(shù)據(jù)瘦馍,那么就可以減少對某些操作的數(shù)據(jù)庫查詢的需要歼秽,盡管可能并不總是如此。 如果token是在授權頭(Authorization header)中發(fā)送的情组,那么跨源資源共享(CORS)將不會成為問題燥筷,因為它不使?cookie。
來感受一張官方的圖
獲取JWT以及訪問APIs以及資源
客戶端向授權接?請求授權
服務端授權后返回?個access token給客戶端
客戶端使?access token訪問受保護的資源
3 基于Token的身份認證和基于服務器的身份認證
1院崇、給予服務器的身份認證肆氓,通常是基于服務器上的session來做用戶認證,使用session會有如下幾個問題
- Sessions:認證通過后需要將?戶的session數(shù)據(jù)保存在內(nèi)存中底瓣,隨著認證?戶的增加谢揪,內(nèi)存開銷會?
- 擴展性問題: 由于session存儲在內(nèi)存中,擴展性會受限濒持,雖然后期可以使?redis,memcached來緩存數(shù)據(jù)
- CORS: 當多個終端訪問同?份數(shù)據(jù)時键耕,可能會遇到禁?請求的問題
- CSRF: ?戶容易受到CSRF攻擊(Cross Site Request Forgery, 跨站域請求偽造)
2、基于Token的身份認證證是?狀態(tài)的柑营,服務器或者session中不會存儲任何?戶信息.(很好的解決了共享 session的問題)
?戶攜帶?戶名和密碼請求獲取token(接?數(shù)據(jù)中可使?appId,appKey屈雄,或是自己協(xié)商好的某類數(shù)據(jù))
服務端校驗?戶憑證,并返回?戶或客戶端?個Token
客戶端存儲token,并在請求頭中攜帶Token
服務端校驗token并返回相應數(shù)據(jù)
需要注意幾點:
- 客戶端請求服務器的時候官套,必須將token放到header中
- 客戶端請求服務器每一次都需要帶上token
- 服務器需要設置 為接收所有域的請求:
Access-Control-Allow-Origin: *
3酒奶、Session和JWT Token的有啥不一樣的蚁孔?
- 他倆都可以存儲用戶相關的信息
- session 存儲在服務器, JWT存儲在客戶端
4 ?Token有什么好處呢惋嚎?
- 他是
無狀態(tài)
的 且可擴展性好
- 他相對安全:防?CSRF攻擊杠氢,token過期重新認證
上文有說說,JWT是用于做身份認證的而不是做授權的另伍,那么在這里列舉一下 做認證和做授權分別用在哪里呢鼻百?
- 例如
OAuth2
是?種授權框架,是用于授權摆尝,主要用在 使?第三?賬號登錄的情況 (?如使?weibo, qq, github登錄某個app) - JWT是?種認證協(xié)議 温艇,?在 前后端分離 , 需要簡單的對后臺API進?保護時使?
- 無論是授權還是認證,都需要記住使用HTTPS來保護數(shù)據(jù)的安全性
5 實際看看JWT如何做身份驗證
- jwt做身份驗證堕汞,這里主要講如何根據(jù)header勺爱,payload,signature生成token
- 客戶端帶著token來服務器做請求讯检,如何校驗琐鲁?
下面實例代碼,主要做了2個接口
用到的技術點:
- gin
- 路由分組
- 中間件的使用
- gorm
- 簡單操作mysql數(shù)據(jù)庫人灼,插入围段,查詢
- jwt
- 生成token
- 解析token
登錄接口
訪問url : http://127.0.0.1:9999/v1/login
功能:
- 用戶登錄
- 生成jwt,并返回給到客戶端
- gorm對數(shù)據(jù)庫的操作
認證后Hello接口
訪問url : http://127.0.0.1:9999/v1/auth/hello
功能:
- 校驗 客戶端請求服務器攜帶token
- 返回客戶端所請求的數(shù)據(jù)
代碼結構如下圖
main.go
package main
import (
"github.com/gin-gonic/gin"
"my/controller"
"my/myauth"
)
func main() {
//連接數(shù)據(jù)庫
conErr := controller.InitMySQLCon()
if conErr != nil {
panic(conErr)
}
//需要使用到gorm投放,因此需要先做一個初始化
controller.InitModel()
defer controller.DB.Close()
route := gin.Default()
//路由分組
v1 := route.Group("/v1/")
{
//登錄(為了方便蒜撮,將注冊和登錄功能寫在了一起)
v1.POST("/login", controller.Login)
}
v2 := route.Group("/v1/auth/")
//一個身份驗證的中間件
v2.Use(myauth.JWTAuth())
{
//帶著token請求服務器
v2.POST("/hello", controller.Hello)
}
//監(jiān)聽9999端口
route.Run(":9999")
}
controller.go
文件中基本的數(shù)據(jù)結構定義為:
文件中涉及的處理函數(shù):
實際源碼:
package controller
import (
"errors"
"fmt"
jwtgo "github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"log"
"net/http"
"time"
_ "github.com/jinzhu/gorm/dialects/mysql"
)
//登錄請求信息
type ReqInfo struct {
Name string `json:"name"`
Passwd string `json:"passwd"`
}
// 構造用戶表
type MyInfo struct {
Id int32 `gorm:"AUTO_INCREMENT"`
Name string `json:"name"`
Passwd string `json:"passwd"`
CreatedAt *time.Time
UpdateTAt *time.Time
}
//Myclaims
// 定義載荷
type Myclaims struct {
Name string `json:"userName"`
// StandardClaims結構體實現(xiàn)了Claims接口(Valid()函數(shù))
jwtgo.StandardClaims
}
//密鑰
type JWT struct {
SigningKey []byte
}
//hello 接口
func Hello(c *gin.Context) {
claims, _ := c.MustGet("claims").(*Myclaims)
if claims != nil {
c.JSON(http.StatusOK, gin.H{
"status": 0,
"msg": "Hello wrold",
"data": claims,
})
}
}
var (
DB *gorm.DB
secret = "iamsecret"
TokenExpired error = errors.New("Token is expired")
TokenNotValidYet error = errors.New("Token not active yet")
TokenMalformed error = errors.New("That's not even a token")
TokenInvalid error = errors.New("Couldn't handle this token:")
)
//數(shù)據(jù)庫連接
func InitMySQLCon() (err error) {
// 可以在api包里設置成init函數(shù)
connStr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", "root", "123456", "127.0.0.1", 3306, "mygorm")
fmt.Println(connStr)
DB, err = gorm.Open("mysql", connStr)
if err != nil {
return err
}
return DB.DB().Ping()
}
//初始化gorm對象映射
func InitModel() {
DB.AutoMigrate(&MyInfo{})
}
func NewJWT() *JWT {
return &JWT{
[]byte(secret),
}
}
// 登陸結果
type LoginResult struct {
Token string `json:"token"`
Name string `json:"name"`
}
// 創(chuàng)建Token(基于用戶的基本信息claims)
// 使用HS256算法進行token生成
// 使用用戶基本信息claims以及簽名key(signkey)生成token
func (j *JWT) CreateToken(claims Myclaims) (string, error) {
// 返回一個token的結構體指針
token := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, claims)
return token.SignedString(j.SigningKey)
}
//生成token
func generateToken(c *gin.Context, info ReqInfo) {
// 構造SignKey: 簽名和解簽名需要使用一個值
j := NewJWT()
// 構造用戶claims信息(負荷)
claims := Myclaims{
info.Name,
jwtgo.StandardClaims{
NotBefore: int64(time.Now().Unix() - 1000), // 簽名生效時間
ExpiresAt: int64(time.Now().Unix() + 3600), // 簽名過期時間
Issuer: "pangsir", // 簽名頒發(fā)者
},
}
// 根據(jù)claims生成token對象
token, err := j.CreateToken(claims)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": err.Error(),
"data": nil,
})
}
log.Println(token)
// 返回用戶相關數(shù)據(jù)
data := LoginResult{
Name: info.Name,
Token: token,
}
c.JSON(http.StatusOK, gin.H{
"status": 0,
"msg": "登陸成功",
"data": data,
})
return
}
//解析token
func (j *JWT) ParserToken(tokenstr string) (*Myclaims, error) {
// 輸入token
// 輸出自定義函數(shù)來解析token字符串為jwt的Token結構體指針
// Keyfunc是匿名函數(shù)類型: type Keyfunc func(*Token) (interface{}, error)
// func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc) (*Token, error) {}
token, err := jwtgo.ParseWithClaims(tokenstr, &Myclaims{}, func(token *jwtgo.Token) (interface{}, error) {
return j.SigningKey, nil
})
fmt.Println(token, err)
if err != nil {
// jwt.ValidationError 是一個無效token的錯誤結構
if ve, ok := err.(*jwtgo.ValidationError); ok {
// ValidationErrorMalformed是一個uint常量,表示token不可用
if ve.Errors&jwtgo.ValidationErrorMalformed != 0 {
return nil, TokenMalformed
// ValidationErrorExpired表示Token過期
} else if ve.Errors&jwtgo.ValidationErrorExpired != 0 {
return nil, TokenExpired
// ValidationErrorNotValidYet表示無效token
} else if ve.Errors&jwtgo.ValidationErrorNotValidYet != 0 {
return nil, TokenNotValidYet
} else {
return nil, TokenInvalid
}
}
}
// 將token中的claims信息解析出來和用戶原始數(shù)據(jù)進行校驗
// 做以下類型斷言跪呈,將token.Claims轉換成具體用戶自定義的Claims結構體
if claims, ok := token.Claims.(*Myclaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("token NotValid")
}
//登錄
func Login(c *gin.Context) {
var reqinfo ReqInfo
var userInfo MyInfo
err := c.BindJSON(&reqinfo)
if err == nil {
fmt.Println(reqinfo)
if reqinfo.Name == "" || reqinfo.Passwd == ""{
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": "賬號密碼不能為空",
"data": nil,
})
c.Abort()
return
}
//校驗數(shù)據(jù)庫中是否有該用戶
err := DB.Where("name = ?", reqinfo.Name).Find(&userInfo)
if err != nil {
fmt.Println("數(shù)據(jù)庫中沒有該用戶 段磨,可以進行添加用戶數(shù)據(jù)")
//添加用戶到數(shù)據(jù)庫中
info := MyInfo{
Name: reqinfo.Name,
Passwd: reqinfo.Passwd,
}
dberr := DB.Model(&MyInfo{}).Create(&info).Error
if dberr != nil {
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": "登錄失敗,數(shù)據(jù)庫操作錯誤",
"data": nil,
})
c.Abort()
return
}
}else{
if userInfo.Name != reqinfo.Name || userInfo.Passwd != reqinfo.Passwd{
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": "賬號密碼錯誤",
"data": nil,
})
c.Abort()
return
}
}
//創(chuàng)建token
generateToken(c, reqinfo)
} else {
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": "登錄失敗耗绿,數(shù)據(jù)請求錯誤",
"data": nil,
})
}
}
myauth.go
package myauth
import (
"fmt"
"github.com/gin-gonic/gin"
"my/controller"
"net/http"
)
//身份認證
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
//拿到token
token := c.Request.Header.Get("token")
if token == "" {
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": "token為空苹支,請攜帶token",
"data": nil,
})
c.Abort()
return
}
fmt.Println("token = ", token)
//解析出實際的載荷
j := controller.NewJWT()
claims, err := j.ParserToken(token)
if err != nil {
// token過期
if err == controller.TokenExpired {
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": "token授權已過期,請重新申請授權",
"data": nil,
})
c.Abort()
return
}
// 其他錯誤
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": err.Error(),
"data": nil,
})
c.Abort()
return
}
// 解析到具體的claims相關信息
c.Set("claims", claims)
}
}
myauth.go
package myauth
import (
"fmt"
"github.com/gin-gonic/gin"
"my/controller"
"net/http"
)
//身份認證
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
//拿到token
token := c.Request.Header.Get("token")
if token == "" {
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": "token為空误阻,請攜帶token",
"data": nil,
})
c.Abort()
return
}
fmt.Println("token = ", token)
//解析出實際的載荷
j := controller.NewJWT()
claims, err := j.ParserToken(token)
if err != nil {
// token過期
if err == controller.TokenExpired {
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": "token授權已過期债蜜,請重新申請授權",
"data": nil,
})
c.Abort()
return
}
// 其他錯誤
c.JSON(http.StatusOK, gin.H{
"status": -1,
"msg": err.Error(),
"data": nil,
})
c.Abort()
return
}
// 解析到具體的claims相關信息
c.Set("claims", claims)
}
}
6 jwt是如何將header,paylaod,signature組裝在一起的?
1> 我們從創(chuàng)建token的函數(shù)開始看起
CreateToken用JWT對象綁定究反,對象中包含密鑰寻定,函數(shù)的參數(shù)是載荷
2> NewWithClaims 函數(shù)參數(shù)是加密算法,載荷
NewWithClaims的具體作用是是初始化一個Token對象
3> SignedString函數(shù)精耐,參數(shù)為密鑰
主要是得到一個完整的token
SigningString 將header 與 載荷 處理后拼接在一起
Sign 將密鑰計算一個hash值狼速,與header,載荷拼接在一起卦停,進而制作成token
此處的Sign 方法具體是調(diào)用哪一個實現(xiàn)向胡,請繼續(xù)往下看
4> SigningString
將header通過json序列化之后使用base64加密
同樣的也將載荷通過json序列化之后使用base64加密
將這倆加密后的字符串拼接在一起
5> 回到創(chuàng)建token函數(shù)的位置
func (j *JWT) CreateToken(claims Myclaims) (string, error) {
// 返回一個token的結構體指針
token := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, claims)
return token.SignedString(j.SigningKey)
}
SigningMethodHS256 對應這一個結構SigningMethodHMAC
恼蓬,如下
看到這里,便解開了上述第4點 Sign方法具體在哪里實現(xiàn)的問題
7> 效果查看
登錄&注冊接口
數(shù)據(jù)庫展示(若對編碼中的gorm有疑問僵芹,可以看小魔童哪吒的上一期gorm的整理)
Hello接口
以上為本期全部內(nèi)容处硬,如有疑問可以在評論區(qū)或后臺提出你的疑問,我們一起交流拇派,一起成長荷辕。
好家伙要是文章對你還有點作用的話,請幫忙點個關注件豌,分享到你的朋友圈桐腌,分享技術,分享快樂
技術是開放的苟径,我們的心態(tài),更應是開放的躬审。擁抱變化棘街,向陽而生,努力向前行承边。
作者:小魔童哪吒