『No18: Go 實現世界杯后臺管理系統』

182.png

大家好,我叫謝偉,是一名程序員。

最近沒時間更新文章,抱歉。

趁著周末更新一期,上一期講到 如何快速熟悉一個項目, 文章的最后講到,最好的方法是借用相同的技術棧重新實現一個項目。

本文就是借用相同技術棧實現了 2018世界杯后臺管理系統 。

主要使用到的技術是:

  • gin 快速搭建 web server
  • gin-swagger 自動化構建API 文檔
  • gorm 操作數據庫
  • fresh 實現 web server 監聽
  • viper 實現讀取用戶配置
  • 數據庫 使用 postgre
  • goquery 實現網頁解析

主要的思路是:

第一步:

既然是 2018 屆世界杯后臺管理系統,那么肯定需要本屆世界杯的數據,那么數據從哪里來?

目標網站 2018屆俄羅斯世界杯

既然已經知道目標網站,那么下一步的動作是什么?

網頁爬蟲。

  • matches
  • teams
  • groups
  • players
  • statistics
  • awards
  • classic

主要需要的信息是這些。

第二步:

分析網頁源代碼。網頁爬蟲。在 go 中用來網頁解析的一個比較好庫的是 goquery

對需要的目標數據一個個分析。

第三步:

數據存到哪?

你當然肯定按照你的意愿來,存文本,或者存數據庫。一般企業級的應用,會存本地嗎?

那么我還是老老實實存數據庫。數據庫的選擇,按自己來,我這邊選擇 postgre.

既然使用到數據庫,必然需要操作數據庫,如果你希望代碼中充斥著SQL 語句,那么你可以選擇寫SQL 語句,當然我覺得更好的維護方式是使用 ORM, go 內使用orm 技術,一個比較好的庫是 gorm .

使用 gorm 你可以很方便的實現 數據庫的增刪改查。

第四步:

既然數據有了,那么如何實現后臺管理系統?

應該是要使用 restful API 實現 資源的增刪改查。

推薦使用 gin 。 當然你喜歡其他框架也是OK的,甚至你喜歡原生的,那也是OK的。

只不過,我覺得 gin 的速度快,輕量,學習成本低。你可以很容易的實現 web server.

使用中間件可以實現對 gin 的擴展。

第五步:

假如數據不想讓任何人都可以隨意訪問到,那么如何限制呢?對應前端的效果就是,需要登入才能實現訪問資源,那么后端是如何實現的?

jwt: json web token 使用 json 來傳遞數據,用于判定用戶是否登陸狀態。

具體的做法:

  • 登陸,取到 jwt
  • 訪問時,請求時 Header 中需掛載 jwt

下文只講述核心代碼:

1. 項目結構

├── configs
├── docs
│   └── swagger
├── domain
├── infra
│   ├── adapter
│   ├── config
│   ├── crypt
│   ├── download
│   ├── init
│   └── model
├── ui
│   └── api-server
│       ├── admins
│       ├── awards
│       ├── classic
│       ├── coaches
│       ├── controller
│       ├── groups
│       ├── matches
│       ├── players
│       ├── statistics
│       └── teams
└── vendor

  • configs 配置信息,主要是數據的配置信息,主機地址,端口,用戶名和密碼等
  • docs API 文檔,gin-swagger 自動構建的,不是手動創建的
  • domain 領域層,主要是網頁信息的分析和爬取和入庫
  • infra 基礎設施層,主要是字符串處理、加密算法、獲取網頁源代碼、數據庫模型定義
  • UI 用戶可視化層, 主要是 gin構建的API 的操作,包括路由、響應和swagger 文檔注釋
  • vendor 第三方庫

2. 獲取網頁源代碼

使用內置的net/http 即可實現

func Downloader(url string) (*goquery.Document, error) {
    request, err := http.NewRequest("GET", url, nil)
    if err != nil {
        return nil, ErrDownloader
    }

    request.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36")
    client := http.DefaultClient

    response, err := client.Do(request)
    if err != nil {
        return nil, ErrDownloader
    }

    defer response.Body.Close()
    return goquery.NewDocumentFromReader(response.Body)
}


假如你遇到動態加載數據,不想費勁分析網頁,對速度要求也不高,你可以使用 selenium

func DownloaderBySelenium(url string) (string, error) {
    caps := selenium.Capabilities{
        "browserName": "chrome",
    }

    imageCaps := map[string]interface{}{
        "profile.managed_default_content_settings.images": 2,
    }
    chromeCaps := chrome.Capabilities{
        Prefs: imageCaps,
        Path:  "",
        Args: []string{
            "--headless",
            "--no-sandbox",
            "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) AppleWebKit/604.4.7 (KHTML, like Gecko) Version/11.0.2 Safari/604.4.7",
        },
    }
    caps.AddChrome(chromeCaps)

    service, err := selenium.NewChromeDriverService(
        config.ChromeDriverPath, 9515,
    )
    defer service.Stop()

    if err != nil {
        fmt.Println(ErrSeleniumService)
        return "", ErrSeleniumService
    }
    webDriver, err := selenium.NewRemote(caps, fmt.Sprintf("http://localhost:%d/wd/hub", 9515))

    if err != nil {
        fmt.Println(ErrWebDriver)
        return "", ErrWebDriver
    }

    err = webDriver.Get(url)

    if err != nil {
        fmt.Println(ErrWebDriverGet)
        return "", ErrWebDriverGet
    }
    return webDriver.PageSource()

}

3. 數據庫表定義和響應信息定義

數據庫表定義操控 gorm model 的定義,類型,非空,默認值等使用 tag 實現


// awards 表定義

type Award struct {
    ID        uint   `gorm:"primary_key;column:id"`
    AwardName string `gorm:"type:varchar(64);not null;column:award_name"`
    URL       string `gorm:"type:varchar(128);not null;column:url"`
    Info      string `gorm:"type:varchar(128);not null;column:info"`
}


// API 響應信息定義
type AwardSerializer struct {
    ID        uint   `json:"id"`
    AwardName string `json:"award_name"`
    Info      string `json:"info"`
    URL       string `json:"url"`
}

func (a *Award) Serializer() AwardSerializer {
    return AwardSerializer{
        ID:        a.ID,
        AwardName: a.AwardName,
        Info:      a.Info,
        URL:       a.URL,
    }
}


4. 信息爬取入庫


func Awards(doc *goquery.Document) error {
    var err error
    count := 0
    urlList := make([]string, 0, 0)
    urlList = append(urlList, "/worldcup/awards/golden-boot/")
    urlList = append(urlList, "/worldcup/awards/golden-glove/")
    urlList = append(urlList, "/worldcup/awards/golden-ball/")
    for _, url := range urlList {
        completeAwardURl := config.RootURL + url
        doc, err := download.Downloader(completeAwardURl)
        if err != nil {
            err = ErrorAwardDownloader
            break
        }
        // db save
        awards := callBack(completeAwardURl, doc)
        fmt.Println(completeAwardURl)
        for _, award := range awards {
            fmt.Println(award)
            count++
            // push data into db
            initiator.POSTGRES.Save(&award)

        }
    }
    fmt.Println(count)

    return err
}

func callBack(url string, doc *goquery.Document) []model.Award {

    allAwardInfo := make([]model.Award, 0, 0)

    awardName := doc.Find("h1").Eq(2).Text()

    doc.Find("div p").Each(func(i int, selection *goquery.Selection) {

        if i > 6 {

            awardInfo := selection.Text()
            if strings.HasPrefix(awardInfo, "*") {
                return
            }
            oneAward := model.Award{}
            oneAward.URL = url
            oneAward.AwardName = awardName
            oneAward.Info = awardInfo
            allAwardInfo = append(allAwardInfo, oneAward)
        }

    })
    return allAwardInfo
}

5. 構建 restful API

func awardsRegistry(r *gin.RouterGroup) {
    r.GET("/awards", awards.ShowAllAwardHandler)
    r.GET("/awards/:awardID", awards.ShowAwardHandler)
}
package awards

import (
    "FIFA-World-Cup/infra/init"
    "FIFA-World-Cup/infra/model"
    "fmt"
    "github.com/gin-gonic/gin"
    "github.com/pkg/errors"
    "net/http"
)

var (
    ErrorAwardParam = errors.New("award param is not correct")
)

// ShowAwardHandler will list Awards
// @Summary List Awards
// @Accept json
// @Tags Awards
// @Security Bearer
// @Produce  json
// @Param awardID path string true "award id"
// @Resource Awards
// @Router /awards/{id} [get]
// @Success 200 {object} model.AwardSerializer
func ShowAwardHandler(c *gin.Context) {

    id := c.Param("awardID")

    var award model.Award
    if dbError := initiator.POSTGRES.Where("info LIKE ?", fmt.Sprintf("%%%s%%", id)).First(&award).Error; dbError != nil {
        c.AbortWithError(400, dbError)
        return
    }
    c.JSON(http.StatusOK, award.Serializer())

}

type ListAwardParam struct {
    Search string `form:"search"`
    Return string `form:"return"`
}

// ShowAllAwardHandler will list Awards
// @Summary List Awards
// @Accept json
// @Tags Awards
// @Security Bearer
// @Produce  json
// @Param search path string false "award_name"
// @param return path string false "return = all_list"
// @Resource Awards
// @Router /awards [get]
// @Success 200 {array} model.AwardSerializer
func ShowAllAwardHandler(c *gin.Context) {

    var param ListAwardParam

    if err := c.ShouldBindQuery(&param); err != nil {
        c.AbortWithError(400, ErrorAwardParam)
        return
    }

    var awards []model.Award

    if param.Search != "" {
        if dbError := initiator.POSTGRES.Where("award_name LIKE ?", fmt.Sprintf("%%%s%%", param.Search)).Find(&awards).Error; dbError != nil {
            c.AbortWithError(400, dbError)
            return
        }
    }

    if param.Return == "all_list" {
        if dbError := initiator.POSTGRES.Find(&awards).Error; dbError != nil {
            c.AbortWithError(400, dbError)
            return
        }
    }

    var result = make([]model.AwardSerializer, len(awards))

    for index, award := range awards {
        result[index] = award.Serializer()
    }
    c.JSON(http.StatusOK, result)
}

具體響應函數上方的注釋是構建自動化文檔需要的。

6. jwt 認證


package controller

import (
    "FIFA-World-Cup/infra/init"
    "FIFA-World-Cup/infra/model"
    "errors"
    "fmt"
    "github.com/gin-gonic/gin"
    "strings"
)

var (
    ErrorAuth      = errors.New("please add token: 'Authorization: Bearer xxxx'")
    ErrorAuthWrong = errors.New("token is not right,example: Bearer xxxx")
)

func AuthRequired() gin.HandlerFunc {
    return func(c *gin.Context) {
        if vendor := c.Request.Header.Get("X-Requested-With"); vendor != "" {
            c.Set("X-Requested-With", vendor)
        }

        header := c.Request.Header.Get("Authorization")
        if header == "" {
            c.AbortWithError(400, ErrorAuth)
            return
        }

        authHeader := strings.Split(header, " ")

        if len(authHeader) != 2 {
            c.AbortWithError(400, ErrorAuthWrong)
            return
        }

        token := authHeader[1]

        var admin model.Admin
        fmt.Println(token)
        if dbError := initiator.POSTGRES.Where("auth_token = ?", token).First(&admin).Error; dbError != nil {
            c.AbortWithError(400, dbError)
        } else {
            c.Set("current_admin", admin)
            c.Next()
        }
    }
}

什么意思呢?

  1. 用戶需注冊或者登陸,后臺生成對應的 auth_token

select * from admins;

 id |          created_at           |          updated_at           | deleted_at |      name      |                auth_token                |                     encrypted_password'                      |    phone     | state
----+-------------------------------+-------------------------------+------------+----------------+------------------------------------------+--------------------------------------------------------------+--------------+-------
2   2018-07-20 16:10:11.099085  2018-07-20 16:10:11.099085      FIFA-World-Cup  c6d81d35bc598ddedf3e0b798cd5d463139ab6c9    $2a$04$wKHmdGixgrISJM7wV3rKn.6HX5Bjg8.JbelGYl/443ber3aXI/K8K    110120119   admin


每個用戶會生成對應的 auth_token

訪問資源 HEADER 需要帶上這個 token. 達到認證的目的。

7. 效果

Swagger-API 文檔

Swagger-API.png

API 列表

PostMan-API.png

視頻版講解

BiliBili

8. 源代碼

FIFA-World-Cup-2018


全文完,我是謝偉,再會,謝謝。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,748評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,165評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,595評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,633評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,435評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,943評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,035評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,175評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,713評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,599評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,788評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,303評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,034評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,412評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,664評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,408評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,747評論 2 370

推薦閱讀更多精彩內容

  • 關于Mongodb的全面總結 MongoDB的內部構造《MongoDB The Definitive Guide》...
    中v中閱讀 31,985評論 2 89
  • 1、通過CocoaPods安裝項目名稱項目信息 AFNetworking網絡請求組件 FMDB本地數據庫組件 SD...
    陽明先生_X自主閱讀 16,000評論 3 119
  • 貝爸-西土瓦大神作品 三國之小軍師(37)賈詡 曹洪最后沒死,在被劉備軍救醒之后他便借了一匹馬去尋曹操了。這只...
    白色冰菊閱讀 600評論 6 4
  • 借用林語堂散文中的一句話,人生不過如此而已,內省寧靜而又充滿激情,深味虛無卻仍堅守信念。人生,不過是一段來了又走的...
    淺煙_老剛閱讀 620評論 0 1
  • 今晚是新青椒的成員聽課,來了許許多多的新面孔,新成員,新同學。我也忍不住進來蹭課,當然也收獲滿滿,我想告訴大家,這...
    藤縣069黎獻清閱讀 368評論 2 3