這篇文章更進一步,會結合電商前後臺API系統,把Go語言的知識點應用到商業項目中,讓大家結合實際的場景去理解,這樣應該對大家更有幫助! ...
這篇文章比較硬核,爆肝5千字,把之前整理的知識點都串起來了。建議先收藏,慢慢看。
前言
上一篇文章 #【Go WEB進階實戰】開源的電商前後臺API系統 很受大家歡迎,有好多小伙伴私信我問題:“gtoken真不錯,能不能再多講講?”、“介面怎麼設計Cache好?”、“代碼規範講一下吧”、“這個系統有沒有前端頁面?”,等等....
那我就再寫一篇作為補充嘍,小伙伴們還有什麼問題歡迎在評論區留言。
之前整理過一篇可能是全網最用心的「Go學習建議和資料彙總」,也深受大家好評,大家可以先收藏慢慢學。
這篇文章更進一步,會結合電商前後臺API系統,把Go語言的知識點應用到商業項目中,讓大家結合實際的場景去理解,這樣應該對大家更有幫助!
小提示:這篇文章的重點不是把各個知識點講透,而是為了讓大家理解各個知識點在商業項目中的應用。如果你的基礎比較薄弱,每個知識點的最後也都附上了詳解鏈接,方便大家去查漏補缺。
下麵就開始和我進階實戰吧:
登錄鑒權
我們在上一篇文章中有介紹,系統的登錄鑒權是通過gtoken實現的,有的小伙伴沒有搞清楚登錄信息存儲在哪裡?我們是如何獲得當前登錄用戶的信息?
首先gtoken的數據預設使用記憶體緩存gcache,這種緩存會隨著服務的終止而銷毀,當重啟服務時,之前緩存的數據就丟失了;gtoken也支持使用redis,比如我們的項目中就是使用了gredis,將登錄信息存儲在redis中進行管理。
更多關於gtoken的知識點可以看這篇專題文章:# 通過閱讀源碼解決項目難題:GToken替換JWT實現SSO單點登錄
如果你基礎比較弱的話,我還錄製了視頻教程:# 【視頻】登錄鑒權的三種方式:token、jwt、session實戰分享
下麵聊聊如何獲得登錄用戶信息的問題:
我們使用Go語言無論開發http項目還是rpc項目,上下文都是很重要的概念,用於共用變數和鏈路跟蹤。
我們通過Context上下文對象在一次請求中設置用戶信息,共用變數,進而實現在後續鏈路中都能獲得當前登錄用戶的信息:
Context上下文
以修改密碼舉例:
我們通過ghttp.Request的實例r,調用GetCtxVar() 方法。
比如:r.GetCtxVar(middleware.CtxAccountId),通過這種方式我們就可以獲得登錄用戶信息了
小提示:為了行文清晰,讓大家更直觀的看到和知識點相關的代碼,不重要的代碼會用三個豎著的.省略。完整的代碼可以fork文末的GitHub,已把這個項目開源。
調用示例代碼
func (s *rotationService) UpdateMyPassword(r *ghttp.Request, req *UpdateMyPasswordReq) (res sql.Result, err error) {
.
.
.
//獲得當前登錄用戶
req.Id = gconv.Int(r.GetCtxVar(middleware.CtxAccountId))
ctx := r.GetCtx()
res, err = dao.AdminInfo.Ctx(ctx).WherePri(req.Id).Update(req)
if err != nil {
return nil, err
}
return
}
賦值示例代碼
賦值的核心代碼也很簡單,就是通過 r.SetCtxVar(key, value) 方法,就能把變數賦值到context中了
package middleware
import (
"github.com/goflyfox/gtoken/gtoken"
"github.com/gogf/gf/net/ghttp"
"github.com/gogf/gf/util/gconv"
"malu/library/response"
)
const (
CtxAccountId = "account_id" //token獲取
.
.
.
)
type TokenInfo struct {
Id int
.
.
.
}
var GToken *gtoken.GfToken
var MiddlewareGToken = tokenMiddleware{}
type tokenMiddleware struct{}
func (s *tokenMiddleware) GetToken(r *ghttp.Request) {
var tokenInfo TokenInfo
token := GToken.GetTokenData(r)
err := gconv.Struct(token.GetString("data"), &tokenInfo)
if err != nil {
response.Auth(r)
return
}
r.SetCtxVar(CtxAccountId, tokenInfo.Id)
.
.
.
r.Middleware.Next()
}
小技巧
- 在架構設計中,在哪個場景下設置Context是非常重要的:上下文的變數必須在請求一開始便註入到請求流程中,以便於其他方法調用,所以我們在中間件中來實現是比較優雅的選擇。
- 結合實際場景,我們設置到Context中的變數可以是指針類型,因為任何地方獲取到這個指針,不僅可以獲取到裡面的數據,而且能夠直接修改裡面的數據。
- 建議養成好習慣:在service層的方法中,第一個參數必傳
context.Context
對象或者*ghttp.Request
對象。這樣有利於我們後續擴展,能夠方便的通過context共用數據,而且還能進行鏈路追蹤
更詳細的介紹看這裡:# GoFrame 如何優雅的共用變數 | Context的使用
介面緩存
關於介面緩存,有小伙伴提出這樣的疑問?
當然要設計介面數據緩存了,而且在GoFrame中還有比較優雅的實踐方式:鏈式操作設置緩存。
我們給查詢介面添加緩存的思路是這樣的:
常規操作
- 定義緩存key
- 根據緩存key查詢是否有值
- 有值返回緩存中的值,不查詢DB
- 無值,查詢DB,寫入緩存
- 返回數據
func (s *rotationService) Detail(r *ghttp.Request, req *DetailReq) (res model.ArticleInfo, err error) {
cacheKey := ArticleDetailCacheKey + gconv.String(req.Id)
res := Cache::get(cacheKey)
if(!res){
err = dao.ArticleInfo.Ctx(r.GetCtx()).WherePri(req.Id).Scan(&res)
if err != nil {
return res, err
}
Cache::set(cacheKey,res,time.Hour)
}
return
}
GoFrame為我們提供了非常優雅的鏈接操作:
鏈式操作
我們只需要在鏈式查詢中使用Cache()方法,設置緩存時間和緩存key就可以了,GoFrame為我們實現了上述常規操作中的繁瑣操作:
鏈式操作:取值
func (s *rotationService) Detail(r *ghttp.Request, req *DetailReq) (res model.ArticleInfo, err error) {
//查詢時優先查詢緩存
cacheKey := ArticleDetailCacheKey + gconv.String(req.Id)
err = dao.ArticleInfo.Ctx(r.GetCtx()).Cache(time.Hour, cacheKey).WherePri(req.Id).Scan(&res)
if err != nil {
return res, err
}
return
}
鏈式操作:更新值
更新操作只需要將Cache()方法的第一個參數過期時間設置為負數,就會清空緩存:
func (s *rotationService) Update(r *ghttp.Request, req *UpdateArticleReq) (res sql.Result, err error) {
ctx := r.GetCtx()
.
.
.
//更新緩存
cacheKey := ArticleDetailCacheKey + gconv.String(req.Id)
res, err = dao.ArticleInfo.Ctx(ctx).Cache(-1, cacheKey).WherePri(req.Id).Update(req)
if err != nil {
return nil, err
}
return
}
除了這個典型的場景,我們項目的熱門商品是通過LRU緩存淘汰策略實現的,小伙伴們可以看這篇詳解一探究竟:# GoFrame gcache使用實踐 | 緩存控制 淘汰策略
介面相容處理
需求場景
我們電商系統的文章和商品都支持收藏和取消收藏
取消收藏有2種情況:一種是根據收藏id
刪除;另一種是根據收藏類型和文章id(或者商品id)刪除
思考題
我們根據上述的需求是設計兩個介面分別實現呢?還是只設計一個介面相容實現呢?
我傾向於只使用一種介面,相容實現:這樣不僅減少代碼量,而且後期有邏輯調整時,只修改一處代碼就可以了。
看下我們是如何實現的:
結構體
首先定義我們的請求結構體,允許通過收藏id刪除;
或者根據類型和對象id刪除(收藏類型:1商品 2文章)
type DeleteReq struct {
Id int `json:"id"`
Type int `json:"type"`
ObjectId int `json:"object_id"`
}
api層
然後我們編寫api層,這部分代碼很簡單,所有的api層代碼都是這種規範:
- 定義請求參數結構體
- 解析請求參數,做數據校驗,有問題直接返回錯誤;正常則繼續向下執行
- 調用service層對應的方法,傳入上下文context和請求體
- 根據service層的返回結果決定是返回錯誤碼,還是返回數據。
小技巧:所有的api層都是這樣的思路,我們的邏輯處理一般寫在service中
func (*collectionApi) Delete(r *ghttp.Request) {
var req *DeleteReq
if err := r.Parse(&req); err != nil {
response.ParamErr(r, err)
}
if res, err := service.Delete(r.Context(), req); err != nil {
response.Code(r, err)
} else {
response.SuccessWithData(r, res)
}
}
service層
最後我們編寫service層代碼,實現取消收藏介面相容的重點也在這裡了:
我們根據傳入的id做判斷,如果id不為0,根據收藏id刪除;否則的話就根據傳入的type類型區別是文章還是商品,根據ObjectId確定要刪除對象的id。
func (s *collectionService) Delete(ctx context.Context, req *DeleteReq) (res sql.Result, err error) {
if req.Id != 0 {
//根據收藏id刪除
res, err = dao.CollectionInfo.Ctx(ctx).WherePri(req.Id).Delete()
} else {
//根據類型和對象id刪除
res, err = dao.CollectionInfo.Ctx(ctx).
Where(dao.CollectionInfo.Columns.Type, req.Type).
Where(dao.CollectionInfo.Columns.ObjectId, req.ObjectId).
Delete()
}
if err != nil {
return nil, err
}
return
}
小技巧:我們查詢條件的欄位都是通過這種方式取值的:dao.CollectionInfo.Columns.Type,而不會寫死字元串type,原因是如果我們的欄位有修改,前者這種寫法可以一改全改;而後者寫死字元串的方式很難找全要修改的地方,維護成本比較高。
統計查詢
咱們想一個複雜點的場景,進階實戰一下GoFrame ORM的使用:
我們需要查詢最近7天每天的訂單量,如果當天沒有訂單就返回0。期望的數據結構是這樣的:
"order_total": [10, 0, 10, 20, 10, 0, 7],
我們如何實現呢?
service層
重點看這段查詢語句
err := dao.OrderInfo.Ctx(ctx).Where(dao.OrderInfo.Columns.CreatedAt+" >= ", shared.GetBefore7Date()).Fields("count(id) total,date_format(created_at, '%Y-%m-%d') today").Group("today").Scan(&TodayTotals)
在GoFrame中 where的第二個參數如果傳數組,預設就是where in查詢;
我們在Fields()方法中除了可以指定查詢欄位,還可以使用查詢函數,也可以指定別名:
func OrderTotal(ctx context.Context) (counts []int) {
counts = []int{0, 0, 0, 0, 0, 0, 0}
recent7Dates := shared.GetRecent7Date()
TodayTotals := []TodayTotal{}
//只取最近7天
err := dao.OrderInfo.Ctx(ctx).Where(dao.OrderInfo.Columns.CreatedAt+" >= ", shared.GetBefore7Date()).Fields("count(*) total,date_format(created_at, '%Y-%m-%d') today").Group("today").Scan(&TodayTotals)
fmt.Printf("result:%v", TodayTotals)
for i, date := range recent7Dates {
for _, todayTotal := range TodayTotals {
if date == todayTotal.Today {
counts[i] = todayTotal.Total
}
}
}
if err != nil {
return counts
}
return
}
工具類
受某位知乎大神的啟發,生成最近一周的日期我是這麼實現的:
從性能角度考慮可能不是最優寫法,但是理解成本肯定非常低:
//生成最近一周的日期
func GetRecent7Date() (dates []string) {
gt := gtime.New(time.Now())
dates = []string{
gt.Format("Y-m-d"), //今天
gt.Add(-gtime.D * 1).Format("Y-m-d"), //1天前
gt.Add(-gtime.D * 2).Format("Y-m-d"),
gt.Add(-gtime.D * 3).Format("Y-m-d"),
gt.Add(-gtime.D * 4).Format("Y-m-d"),
gt.Add(-gtime.D * 5).Format("Y-m-d"),
gt.Add(-gtime.D * 6).Format("Y-m-d"), //6天前
}
return
}
事務處理
事務的應用場景很清晰:當我們提供的某個服務,需要操作多次DB,並且這些操作要具有原子性,要麼都成功,要麼都失敗。這種情況就需要事務處理。
事務處理的特點是:只要其中有一個環節失敗了,之前成功的DB操作也會回滾到之前的狀態。
事務處理實戰
比如我們創建訂單時就需要做事務處理,我們一個訂單可以添加多個商品,創建訂單時除了添加主訂單表,也會添加商品訂單表。
GoFrame的事務處理非常簡單:
- 只需要我們通過g.DB().Begin()開啟事務
- 在鏈式操作中通過.TX(tx)方法添加事務
- 在最後判斷是否有錯誤發生,有錯誤則通過Rollback()回滾事務,沒錯誤則通過Commit()方法提交事務。
func (s *orderService) Add(r *ghttp.Request, req *AddOrderReq) (res sql.Result, err error) {
req.OrderInfo.UserId = gconv.Int(r.GetCtxVar(middleware.CtxAccountId))
req.OrderInfo.Number = shared.GetOrderNum()
tx, err := g.DB().Begin()
if err != nil {
return nil, errors.New("啟動事務失敗")
}
//defer方法最後執行 如果有報錯則回滾 如果沒有報錯,則提交事務
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
//生成主訂單
lastInsertId, err := dao.OrderInfo.Ctx(r.GetCtx()).TX(tx).InsertAndGetId(req.OrderInfo)
if err != nil {
return nil, err
}
//生成商品訂單
for _, info := range req.OrderGoodsInfos {
info.OrderId = gconv.Int(lastInsertId)
_, err := dao.OrderGoodsInfo.Ctx(r.GetCtx()).TX(tx).Insert(info)
if err != nil {
return nil, err
}
}
return
}
更多關於事務的知識點可以閱讀這篇文章:
# Go語言中比較優雅的寫法
靈活應用
需求
我們需要根據多個查詢條件去查詢商品,比如根據商品名稱和商品分類去查詢。
需求分析
我們來分析一下,客戶端會有幾種查詢場景?
- 根據商品名稱和商品分類2個條件查詢
- 只根據商品名稱查詢
- 只根據商品分類查詢
- 都沒有傳值,不命中查詢條件,返回全部商品
常規實現
做了需求分析之後,正常的思路就是寫if...else...判斷了:
whereCondition := gmap.New()
if req.Keyword != "" && req.CategoryId != 0 {
whereCondition = g.Map{
"name like": "%" + req.Keyword + "%",
"level1_category_id =? OR level2_category_id =? OR level3_category_id =? ": g.Slice{req.CategoryId, req.CategoryId, req.CategoryId},
}
} else if req.Keyword != "" {
whereCondition = g.Map{
"name like": "%" + req.Keyword + "%",
}
} else if req.CategoryId != 0 {
whereCondition = g.Map{
"level1_category_id =? OR level2_category_id =? OR level3_category_id =? ": g.Slice{req.CategoryId, req.CategoryId, req.CategoryId},
}
} else {
whereCondition = g.Map{}
}
但是這種寫法太亂了,而且不容易擴展:如果我們再加一個查詢條件,不僅要新增一個else,就要改已經存在的if...else判斷,後面維護起來簡直是噩夢啊。
優化之後
在經過思考之後,使用map的set方法靈活賦值是個很好的選擇,優化後的代碼如下:
whereCondition := gmap.New()
if req.Keyword != "" {
whereCondition.Set(dao.GoodsInfo.Columns.Name+" like ", "%"+req.Keyword+"%")
}
if req.CategoryId != 0 {
whereCondition.Set("level1_category_id =? OR level2_category_id =? OR level3_category_id =? ", g.Slice{req.CategoryId, req.CategoryId, req.CategoryId})
}
優化後的代碼異常清晰,如果我們再加新的查詢條件,只需要在代碼中再加一個if判斷就可以了。
我的感悟
我在學習map基礎用法的時候,並不能想到這種應用場景,這是很正常的。只有當真正開發商業項目,在具體需求的驅動之下,督促我們做優化,這時候會刺激我們回顧之前學到的知識點。結合實際需求幫助大家將之前學到的Go知識靈活運用,這是我開源這個項目的目的,也是我寫這篇文章的目的。
瞭解更多set知識
# GoFrame gset使用技巧總結 | 天然支持排序和有序遍歷、出棧、子集判斷、序列化、遍歷修改
好了,擴展的知識點就聊到這裡,下麵是對你學Go有幫助的學習資料,歡迎和我一起學習Go,實踐Go。
GitHub
本項目的GitHub地址,歡迎star、fork、復刻:
抱團取暖
公眾號:程式員升職加薪之旅
微信:wangzhongyang1993