『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阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容

  • 关于Mongodb的全面总结 MongoDB的内部构造《MongoDB The Definitive Guide》...
    中v中阅读 31,898评论 2 89
  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    阳明先生_x阅读 15,968评论 3 119
  • 贝爸-西土瓦大神作品 三国之小军师(37)贾诩 曹洪最后没死,在被刘备军救醒之后他便借了一匹马去寻曹操了。这只...
    白色冰菊阅读 581评论 6 4
  • 借用林语堂散文中的一句话,人生不过如此而已,内省宁静而又充满激情,深味虚无却仍坚守信念。人生,不过是一段来了又走的...
    浅烟_老刚阅读 597评论 0 1
  • 今晚是新青椒的成员听课,来了许许多多的新面孔,新成员,新同学。我也忍不住进来蹭课,当然也收获满满,我想告诉大家,这...
    藤县069黎献清阅读 360评论 2 3