Golang Web 实战

Golang Web 实战

1. 前言

在开发一个应用,也就是我们俗称 App 时,最低的配置是需要一个前端和一个后端。由前端技术人员为用户开发接触到的页面,由后端为前端的各类用户事件提供处理和数据响应。比较常见的,如手机 App 应用(QQ、微信),网页 Web 应用(GitChat、CSDN 页面),桌面应用(YY、QQ 游戏)…… 不管是什么应用,都需要有后端技术与之匹配。而我们今天介绍的,就是其中的 Web 后端技术及相关实战。

由于 Chat 篇幅不适合描述过多实战无关的细节,对一些需要深入了解的知识,作者会贴上一些链接给读者们参阅。

思维导图

代码托管仓库

https://github.com/fwhezfwhez/gitchat

1.1 为什么使用 Go 作 Web 后端

可操作多核,实现高负载

Golang 可以使用 Go 关键字轻松操作多协程,并指定 CPU 数。

runtime.GOMAXPROCS(runtime.NumCPU())
go func(){}()

可以跨平台编译,比如在 Windows 上开发,交叉编译成 Linux 的可执行文件。

编程友好

Golang 易学、性能好,并且它是为了降低 C++/C 开发维护复杂度而诞生的新型语言,处理业务相当简单。上手成本几乎是零(不需要了解 Spring 全家桶)。

1.2 环境依赖

  • 安装 Go(1.11、1.12 最佳)
  • 安装 Docker(使用免费的 CE 版本)
  • 安装 PostgreS(可镜像)
  • 安装 Mongo(可镜像)
  • 安装 Redis(可镜像)

大部分中间件(Redis、Mongo、Consul、etcd……) 服务仅仅只需要相应的 IP 和端口,没有严格意义上要求本机安装,而正好 Docker 提供隔离级别良好的镜像服务,可以达到本机安装的效果。Gopher 的开发者大部分使用 Windows 和 Mac,今天也是拿 Win10 举例。正确安装好 Go 和 Docker 后,需要达到一下效果:

go --version
docker --version

顺便贴一下,具备 Docker 环境下,如何准备 PostgreS、Redis 和 Mongo。

docker pull postgres:9.6
docker pull redis:latest
docker pull mongo:latest

开启(长期) 以上三个中间件服务:

**因为本机已包含 PostgreS,5432 已经被占用,使用本机 5433 映射进 docker5405);margin:0px 0px 1.1em;font-family:“Source Code Pro”, monospace;font-size:0.9em;text-align:start;padding:10px 20px;border:0px;border-radius:5px;outline:0px;">

docker run -itd --restart=on-failure:20 -p 6379:6379 redis:latest
docker run -itd --restart=on-failure:20 -p 5433:5432 postgres:9.6
docker run -itd --restart=on-failure:20 -p 27017:27017 mongo:latest

**


**

2. 概念与使用

为了让接下来的实战场景通俗易懂,需要为读者介绍一些概念。这些概念根据其定义并结合了作者的认知而得出,如果和大家的一些理念相悖,可以在作者的读者圈提出自己的看法。技术的领域里,希望能抱着求同存异的心态来对待分歧。

关于这些协议的描述,不需要做太深入地了解,只需要知道如下三点:

  1. HTTP 传输过程中,信息是明文的,HTTPS 传输时是被加密的,所以 HTTPS 协议下的传输时更安全的。
  2. HTTP 走的是 80 端口,在做域名解析时,HTTP 开头的域名请求,会进入服务器的 80 端口,HTTPS 则是 443 端口。
  3. HTTP 与 HTTPS 都是基于 TCP 实现的应答式模型,客户端发送一个请求,服务端响应该请求,通信的建立一定源自于客户端,并且极少有服务端推送的场景。

2.2 HTTP(S) 的请求与响应

浏览器与服务器之间是基于 HTTP(S) 的应答式通信,那么 HTTP 请求与响应长啥样呢,不妨按照如下操作看看(包括但不限于在 360 浏览器/Chrome 浏览器中尝试):

  1. 浏览器输入 www.baidu.com,按下 F12 进入调试窗;
  2. 点击 network,并过滤选择 xhr,F5 刷新页面;
  3. 随意选中一个文本,每个文本对应一个 HTTP 请求响应,可以看一下它的组成(Headers、Response)。

2.3 RESTful

POST or:rgb(188, 96, 96);outline:0px;"\>/user/  获取用户列表
GET /user/1/ 获取id为1的用户记录
PATCH  /user/1/ 修改id为1的用户的属性
DELETE /user/1/ 删除id为1的用户

RESTful 的路径里,没有下划线和驼峰,没有纯动词,需要连接时,使用 -

/user/add-money/
/user/order/amount/

2.4 JSON

HTTP 请求的结构序列化方式,大部分是 application/json、application/xml、application/x-www-form-urlencoded,其中 JSON 使用的最多,三者都是文本协议,JSON 大体长这样:

{
    "chat\_name":"golang gitchat",
    "readers": [
       {
           "username":"张三"
       },
       {
           "username":"李四"
       }
    ]
}

2.5 Gin

Gin 是 Golang 实现的 Web 框架,上手难度极低,使用时贴合了 RESTful 习惯。

仓库坐标:

https://github.com/gin-gonic/gin

对一个陌生的框架,我们不要求全部掌握,但可以通过功能点,来学习如何使用。

如何搭建 RESTful 样式的 HTTP 服务,监听 POST/GET/PATCH/DELETE 请求

restful.go

package main

import (
    "github.com/gin-gonic/gin"
    "strconv"
)

type User struct {
    Id       int    `json:"id"`
    Username string `json:"username"`
}

var users = []User{{1, "张三"}, {2, "李四"}, {3, "王五"}}

func main() {
    r := gin.Default()
    r.GET("/user/", get)
    r.GET("/user/:id/", getOne)
    r.POST("/user/", post)

    r.PATCH("/user/:id/", patch)
    r.DELETE("/user/:id/", delete)

    r.Run(":8080")
}

func get(c *gin.Context) {
    c.JSON(200, users)
}
func post(c *gin.Context) {
    var user User
    c.Bind(&user)
    users = append(users, User{Username: user.Username, Id: user.Id})
    c.JSON(200, users)
}
func patch(c *gin.Context) {
    var user User
    c.Bind(&user)
    id := c.Param("id")
    for i,v:=range users{
        if strconv.Itoa(v.Id) == id {
            users[i].Username = user.Username
            user.Id = v.Id
        }
    }
    c.JSON(200, user)
}
func deleteById(c *gin.Context) {
    id := c.Param("id")
    for i,v:=range users{
        if strconv.Itoa(v.Id) == id {
            users = append(users[:i], users[i+1:]...)
        }
    }
    c.JSON(200, users)
}
func getOne(c *gin.Context) {
    var user User
    id := c.Param("id")
    for _,v:=range users{
        if strconv.Itoa(v.Id) == id {
            user = v
            break
        }
    }
    c.JSON(200, user)
}

go run restful.go

如何使用中间件

使用中间件验证身份(是否带 token)

middleware.go

go run middleware.go

2.6 PostgreS

PostgreS 是一款免费的功能强大的开源数据库,端口号为 5432,安装时可以选择本机安装,也可以使用 Docker。这里使用 Docker。

在前面,我们已经通过 Docker 安装了 PostgreS,并且运行了,那么我要如何操作呢?

关系型数据库基本上都是通过 SQL 语句来完成数据的增删改查,具体的概念,可以参考:

关于 PostgreS、Redis、Mongo,数据存哪里的问题:

[

三者里,PostgreS 有事务支持,并且读写速度都很好,唯一的缺陷是和众多关系数据库一样,连接数的上限存在瓶颈,它是库表列形式的关系型数据库,可以通过规范的 SQL 语句操作。

Redis 和 Mongo 都是无事务的非关系型数据库,前者有众多模型(队列、键值、管道等),后者以集合、文档、字段的形式存放 JSON 字段。

Redis 和 Mongo 都是内存数据库,读写效率都比 PostgreS 快,前者在应用中经常作为缓存,降低数据库连接开销,后者可以存放弱事务的记录(日志、社区论坛的各种数据……)

现在,我们尝试将操作的对象 users 从程序内存中,转储进 PostgreS。以下为涉及到的命令语句,使用场景都凝结进 GIF 了。

进入 PostgreS 终端:

docker exec  -it <postgres容器id> sh

以超管 PostgreS 进入 PostgreS 数据库命令行:

psql -U postgres -d postgres 

创建 gitchat 用户,密码 123456:

create user gitchat with password '123456' 

创建 test 数据库,用来存放我们的数据表:

create database test  

将测试数据库 test 所有权限赋予用户 gitchat:

grant all privileges on database test to gitchat

退出命令行:

\q

以 gitchat 账户进入 test 账户:

psql -U gitchat -d test

create table user_info(
    id serial primary key,
    username varchar not null default ''
)

](https://www.runoob.com/postgresql/postgresql-tutorial.html)

restful-pg.go

package main

import (
    "fmt"
    "github.com/gin-gonic/gin"
    "github.com/jinzhu/gorm"
    _ "github.com/lib/pq"
    "time"
)

type User struct {
    Id       int    `json:"id"`
    Username string `json:"username"`
}

var db *gorm.DB

func init() {
    var err error
    db, err = gorm.Open("postgres",
        fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=%s password=%s",
            "localhost",
            "5433",
            "gitchat",
            "test",
            "disable",
            "123456",
        ),
    )

    db.SingularTable(true)
    db.LogMode(true)
    db.DB().SetConnMaxLifetime(10 * time.Second)
    db.DB().SetMaxIdleConns(30)
    if err != nil {
        panic(err)
    }
}

func main() {
    r := gin.Default()
    r.GET("/user/", get)
    r.GET("/user/:id/", getOne)
    r.POST("/user/", post)

    r.PATCH("/user/:id/", patch)
    r.DELETE("/user/:id/[](resources/)nd(&user);e!=nil{
        panic(e)
    }

    if e := db.Raw("insert into user_info(username) values(?) returning *", user.Username).Scan(&user).Error; e != nil {
        c.JSON(500, gin.H{"message": e.Error()})
        return
    }
    c.JSON(200, user)
}
func patch(c *gin.Context) {
    var user User
    c.Bind(&user)
    id := c.Param("id")
    db.Raw("update user_info set username=? where id=? returning *", user.Username, id).Scan(&user)
    c.JSON(200, user)
}
func deleteById(c *gin.Context) {
    id := c.Param("id")
    db.Exec("delete from user_info where id=?", id)
    c.JSON(200, gin.H{"message": "success"})
}
func getOne(c *gin.Context) {
    var user User
    id := c.Param("id")
    db.Raw("select * from user_info where id=?", id).Scan(&user)
    c.JSON(200, user)
}

go run restful-pg.go

2.7 Redis

Redis 是一款非关系型数据库,具备多种业务模型。前面我们通过 Docker 已经启动过了 Redis,并将主机 6379 端口和 Redis 容器 6379 映射到了一起。

Redis 的主要业务场景:为查询提供缓存,跨服务通信数据传递

后者我们不做演示,其原理是两个服务之间不进行收发,当需要通信时,一个服务把需要传递的消息以 Key-Value 的形式放入 Redis,另一个服务去取。Redis 的各种命令我们不去深入,这里用到了 SETEX 和 GET 来进行数据缓存的测试。

连接 Redis 需要有对应语言的客户端,下面我们测试 Go 客户端,并为上面的 user 查询制定缓存。

redis-example.go

将数据库 user_info 表按照 id 缓存进 Redis

restful-redis-pg.go

package main

import (
    "encoding/json"
    "fmt"
    "github.com/garyburd/redigo/redis"
    "github.com/gin-gonic/gin"
    "github.com/jinzhu/gorm"
    _ "github.com/lib/pq"
    "time"
)

type User struct {
    Id       int    `json:"id"`
    Username string `json:"username"`
}

var pool = getRedis("redis://localhost:6379")

func (u *User) SyncRedis(conn redis.Conn) {
    if conn == nil {
        conn = pool.Get()
        defer conn.Close()
    }
    buf, _ := json.Marshal(u)
    key := fmt.Sprintf("gitchat:user_info:%d", u.Id)
    _, e := conn.Do("SETEX", key, 60*60*24, buf)
    if e != nil {
        panic(e)
    }
}

var db *gorm.DB

func init() {
    var err error
    db, err = gorm.Open("postgres",
        fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=%s password=%s",
            "localhost",
            "5433",
            "gitchat",
            "test",
            "disable",
            "123456",
        ),
    )

    db.SingularTable(true)
    db.LogMode(true)
    db.DB().SetConnMaxLifetime(10 * time.Second)
    db.DB().SetMaxIdleConns(30)
    if err != nil {
      [](resources/)ser_info").Scan(&users).Error; e != nil {
        c.JSON(500, gin.H{"message": e.Error()})
        return
    }
    conn := pool.Get()
    defer conn.Close()
    for i, _ := range users {
        users[i].SyncRedis(conn)
    }
    c.JSON(200, users)
}
func post(c *gin.Context) {
    var user User
    if e := c.Bind(&user); e != nil {
        panic(e)
    }

    if e := db.Raw("insert into user_info(username) values(?) returning *", user.Username).Scan(&user).Error; e != nil {
        c.JSON(500, gin.H{"message": e.Error()})
        return
    }
    user.SyncRedis(nil)
    c.JSON(200, user)
}
func patch(c *gin.Context) {
    var user User
    c.Bind(&user)
    id := c.Param("id")
    db.Raw("update user_info set username=? where id=? returning *", user.Username, id).Scan(&user)
    user.SyncRedis(nil)
    c.JSON(200, user)
}
func deleteById(c *gin.Context) {
    id := c.Param("id")
    db.Exec("delete from user_info where id=?", id)
    c.JSON(200, gin.H{"message": "success"})
}
func getOne(c *gin.Context) {
    var user User
    id := c.Param("id")

    conn :=pool.Get()
    defer conn.Close()

    buf,e :=redis.Bytes(conn.Do("GET", fmt.Sprintf("gitchat:user_info:%s", id)))
    if e!=nil {
        panic(e)
    }

    if len(buf) !=0 {
        e= json.Unmarshal(buf, &user)
        if e!=nil {
            panic(e)
        }
    } else {
        db.Raw("select * from user_info where id=?", id).Scan(&user)
    }

    c.JSON(200, user)
}
func getRedis(url string) *redis.Pool {
    return &redis.Pool{
        MaxIdle: 200,
        //MaxActive:   0,
        IdleTimeout: 180 * time.Second,
        Dial: func() (redis.Conn, error) {
            c, err := redis.DialURL(url)
            if err != nil {
                fmt.Println(err)
                return nil, err
            }
            return c, err
        },
        TestOnBorrow: func(c redis.Conn, t time.Time) error {
            _, err := c.Do("PING")
            return err
        },
    }
}

缓存以前

缓存之后

调用 GET localhost:8082/user/1/ 时, 已经不再存在数据库连接和 SQL 语句了, 并且平均查询速度也更快了。

2.8 Mongo

Mongo 和 Redis 一样,是内存数据库,对高并发的支持比 Redis 可用性更高,因为 Redis 是单线程的。

Mongo 的业务场景面向一些量级很大(未来可能会变得很大)的数据,下面我们在前面的 restful-redis-pg 的基础上,增加一个访问日志记录,这些日志被记入 Mongo。

Mongo 除了快以外,还有一个好处,如果 DB 和 Collection 不存在时,会自动创建。

restful-redis-mongo-pg.go

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "github.com/garyburd/redigo/redis"
    "github.com/gin-gonic/gin"
    "github.com/jinzhu/gorm"
    _ "github.com/lib/pq"
    "gopkg.in/mgo.v2"
    "io/ioutil"
    "time"
)

type User struct {
 [](resources/)Id)
    _, e := conn.Do("SETEX", key, 60*60*24, buf)
    if e != nil {
        panic(e)
    }
}

var db *gorm.DB
var mgoSession *mgo.Session
var col *mgo.Collection

func init() {
    var err error
    db, err = gorm.Open("postgres",
        fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=%s password=%s",
            "localhost",
            "5433",
            "gitchat",
            "test",
            "disable",
            "123456",
        ),
    )

    db.SingularTable(true)
    db.LogMode(true)
    db.DB().SetConnMaxLifetime(10 * time.Second)
    db.DB().SetMaxIdleConns(30)
    if err != nil {
        panic(err)
    }
    mgoSession, err = mgo.Dial("localhost:27017")
    if err != nil {
        panic(err)
    }

    // Optional. Switch the session to a monotonic behavior.
    mgoSession.SetMode(mgo.Monotonic, true)
    col = mgoSession.DB("test").C("user_info")
}

func clear() {
    mgoSession.Close()
    db.Close()
}

type VisitLog struct {
    URL         string    `json:"url"`
    IP          string    `json:"ip"`
    ContentType string    `json:"content_type"`
    Body        []byte    `json:"body"`
    Query       string    `json:"query"`
    CreatedAt   time.Time `json:"created_at"`
}

func VisitLogMiddleware(c *gin.Context) {
    defer c.Next()
    var vl VisitLog
    vl.URL = c.Request.URL.String()
    vl.ContentType = c.ContentType()
    vl.IP = c.ClientIP()
    buf, _ := ioutil.ReadAll(c.Request.Body)
    if len(buf) != 0 {
        vl.Body = buf
        c.Request.Body = ioutil.NopCloser(bytes.NewReader(buf))
    }
    vl.Query = c.Request.URL.Query().Encode()
    vl.CreatedAt = time.Now()

    err := col.Insert(&vl)
    if err != nil {
        panic(err)
    }
}

func main() {
    defer clear()
    r := gin.Default()
    r.Use(VisitLogMiddleware)
    r.GET("/user/", get)
    r.GET("/user/:id/", getOne)
    r.POST("/user/", post)

    r.PATCH("/user/:id/", patch)
    r.DELETE("/user/:id/", deleteById)

    r.GET("/visit-log/", visitLogControl)

    r.Run(":8082")
}

func get(c *gin.Context) {
    var users []User
    if e := db.Raw("select * from user_info").Scan(&users).Error; e != nil {
        c.JSON(500, gin.H{"message": e.Error()})
        return
    }
    conn := pool.Get()
    defer conn.Close()
    for i, _ := range users {
        users[i].SyncRedis(conn)
    }
    c.JSON(200, users)
}
func post(c *gin.Context) {
    var user User
    if e := c.Bind(&user); e != nil {
        panic(e)
    }

    if e := db.Raw("insert into user_info(username) values(?) returning *", user.Username).Scan(&user).Error; e != nil {
        c.JSON(500, gin.H{"message": e.Error()})
        return
    }
    user.SyncRedis(nil)
    c.JSON(200, user)
}
func patch(c *gin.Context) {
    var user User
    c.Bind(&user)
    id := c.Param("id")
    db.Raw("update user_info set username=? where id=? returning *", user.Username, id).Scan(&user)
    user.SyncRedis(nil)
    c.JSON(200, user)
}
func deleteById(c *gin.Context) {
    id := c.Param("id")
    db.Exec("delete from user_info where id=?", id)
    c.JSON(200, gin.H{"message": "success"})
}
func getOne(c *gin.Context) {
    var user User
    id := c.Param("id")

    conn := pool.Get()
    defer conn.Close()

    buf, e := redis.Bytes(conn.Do("GET", fmt.Sprintf("gitchat:user_info:%s", id)))
    if e != nil {
        panic(e)
    }

    if len(buf) != 0 {
        e = json.Unmarshal(buf, &user)
        if e != nil {
            panic(e)
        }
    } else {
        db.Raw("select * from user_info where id=?", id).Scan(&user)
    }

    c.JSON(200, user)
}
func getRedis(url string) *redis.Pool {
    return &redis.Pool{
        MaxIdle: 200,
        //MaxActive:   0,
        IdleTimeout: 180 * time.Second,
        Dial: func() (redis.Conn, error) {
            c, err := redis.DialURL(url)
            if err != nil {
                fmt.Println(err)
                return nil, err
            }
            return c, err
        },
        TestOnBorrow: func(c redis.Conn, t time.Time) error {
            _, err := c.Do("PING")
            return err
        },
    }
}

func visitLogControl(c *gin.Context) {
    var results []VisitLog
    err := col.Find(nil).All(&results)
    if err != nil {
        c.JSON(500, gin.H{"message": err.Error()})
        panic(err)
    }
    c.JSON(200, results)
}

3. 实例

仓库:https://github.com/fwhezfwhez/gitchat

game 项目提供了简要的游戏后端架构模型,根据业务的集中和分布性,重新拆分成了(最少 3 个) 子 Broker,分别为 app-center、backend、activities。

共享出来的仅仅是这个后端项目的基础架构,并没有把所有的核心代码放进来,但这一部分代码也足以给我们提供启发。

3.1 app-center

app-center 是该后端项目里的核心 Broker,应对来自所有客户端的请求。它提供了以下四种协议供不同的游戏客户端选择,可以适用大多数的业务场景。

TCP

     srv := tcpx.NewTcpX(tcpx.ProtobufMarshaller{})
    srv.HeartBeatMode(true, 10*time.Second)
    srv.AddHandler(1, func(c *tcpx.Context) {
        // HeartBeat
        c.RecvHeartBeat()
    })
    fmt.Println("tcp listens on 7001")
    _ = srv.ListenAnd[](resources/)r:transparent;text-size-adjust:none;-webkit-font-smoothing:antialiased;background:0px 0px;box-sizing:border-box;transition:background-color 0.15s ease-in-out 0s, color 0.15s ease-in-out 0s, border-color 0.15s ease-in-out 0s;color:rgb(79, 161, 219);text-decoration:none;outline:0px;">https://github.com/fwhezfwhez/tcpx,解决了游戏内即聊、数据对发、服务端通知等场景。

上例为部分代码,TCP 连接使用的序列化协议为 Protobuf。

Protobuf 有别于 JSON,它是一个二进制协议,具备极高的传输效率以及很小的体积。并且,Protobuf 随着 gRPC 的普及,几乎为所有的客户端/服务端语言支持。

和基本的 TCP 使用相比,TCPX 解决了粘包、易用、路由、中间件、心跳等常见问题。

相关了解:

* [如何使用 Protobuf 序列化结构](https://blog.csdn.net/fwhezfwhez/article/details/92740978)
* [如何在 Golang 中使用 TCP](https://github.com/fwhezfwhez/TestX/tree/master/test_tcp/basic)

HTTP

r := gin.Default()

r.GET("/ping", func(c *gin.Context) {
    c.String(200, "pong")
})

// prop
propRouter.HTTPRouter(r)
// activity
activityRouter.HTTPRouter(r)

s := &http.Server{
    Addr:           "8001",
    Handler:        cors.AllowAll().Handler(r),
    ReadTimeout:    60 * time.Second,
    WriteTimeout:   60 * time.Second,
    MaxHeaderBytes: 1 << 21,
}
fmt.Println("http listens on 8001")
s.ListenAndServe()

app-center 里会对一些快速上线的需求使用 HTTP 协议,因为 HTTP 的开发效率是极快的, 尤其是和第三方或者其他团队接入的时候。因为 TCP 在解析时,需要处理粘包、拆包,提高了对接上的协议复杂度,而 HTTP 的协议约定几乎不需要磨合,统一使用 application/json,少数场景会使用 Form 和 XML。

比如,在对接 HTTP 时,前后端只需要协定:

{
“username”: “gitchat”
}


而对接 TCP 时,最少需要协定:

[4]byte length
[]byte payload


而根据组包的预设来看,有的协议会配置成诸如:

[4]byte length
[4]byte messageID
[4]byte headerLength
[4]byte bodyLength
[]byte header
[]byte body


这样复杂的样式。

gRPC

lis, err := net.Listen("tcp", ":6001")
if err != nil {
    fmt.Println(err.Error())
    return
}
s := grpc.NewServer()
// prop
propPb.RegisterPropServiceServer(s, &propService.PropService{})

fmt.Println("grpc listens on 6001")
s.Serve(lis)

gRPC 在内部服务间的使用十分频繁,本身并不是暴露给外界用的。可以把 gRPC 理解成 HTTP 和 TCP 的集成,统筹了二者的优势,既有 TCP 的效率,又和 HTTP 一样高度统一。统一体现在 Proto 文件上,协议的内部细节不像 TCP 一样需要很长的对接期(当然,Stream 模式的 gRPC,据说也是要拆组包滴)。

在服务中心,gRPC 为 Broker 之间的服务调用,起了很好的疏导作用。比如 **Broker** activity 就是通过 gRPC 来调用 app-center 中的“赠送道具”接口的。 因为活动是以插件的形式开发,新增一个活动,大概率会包给其他的团队,不会直接给他们提供数据库的操作权,而是通过把服务通过 gRPC 外放给他们用。

KCP

这部分代码其实在库中注释掉了,实际的游戏客户端也没有介入。

KCP 的载速是 TCP 的 1.3 到 1.5 倍,代价是一定程度的带宽。项目里原本考虑用 KCP 供给用户选择做提速方案,可是因为本来就很快了,没有速度上的瓶颈,所以才暂时没有启用!

但 KCP 在代码上确实是没有成本的,在 TCPX 里集成了如下:

srv := tcpx.NewTcpX(tcpx.ProtobufMarshaller{})
srv.HeartBeatMode(true, 10*time.Second)
srv.AddHandler(1, func(c *tcpx.Context) {
    // HeartBeat
    c.RecvHeartBeat()
})
+ go func() {
+   fmt.Println("kcp listens on 7002")
+   _ = srv.ListenAndServe("kcp","7002")
+ }()

fmt.Println("tcp listens on 7001")
_ = srv.ListenAndServe("tcp", ":7001")

7002 的 KCP 和 7001 的 TCP,共享了服务路由,所有进入 TCP 的请求,只需要接入 KCP 里,就能达到完全一样的业务效果,牺牲一部分带宽来提升速度,WiFi 中有惊喜。

#### 3.2 backend

backend 是很传统的 HTTP 后端业务。在刚开始时,服务仅有一个 app-center,业务和后端共用了一个服务。

可随之而来有个问题,后端的代码经常变动,影响到了游戏业务中的游戏体验。

为了解决**后端业务经常更新,造成服务重启频繁**问题,将它从 app-center 中分离是很有必要的。

和 app-center 相比,HTTP 的业务,在验证方式上也有不同。

backend HTTP 后端需要有登陆和人工管理,使用的是 JWT。

app-center HTTP 后端提供客户端调用,不存在传统的 JWT 场景,大部分是通过 `hash(app-secret, salt)` 来校验。

JWT

func JWTValidate(c *gin.Context) {
token := c.Request.Header.Get(“Authorization”)
if token == “” {
c.JSON(402, gin.H{“message”: “valid fail”})
c.Abort()
return
}

tk, info := jwt_util.JwtTool.ValidateJWT(token)
if !tk.Valid {
    c.JSON(402, gin.H{"message": info})
    c.Abort()
    return
}
r := tk.Claims.(jwt.MapClaims)
c.Set("user_id", r["user_id"])

}


r.POST("/login/", genToken)
r.Use(middleware.JWTValidate)
// activity
activityRouter.Router(r)
// prop
propRouter.HTTPRouter(r)

后台业务,可以说是后端里最基本了,因为仅仅使用 HTTP 协议,涉及到的概念,几乎只有几个标签 RESTful、Gin、PostgreS,是标准的 CRUD 业务领域。

#### 3.3 activity

activity 和 backend 一样,原本是 app-center 中的一个子模块,但和其他模块相比,它有一个很显著的特性:**经常更新,维护团队相对更大。**

和 backend 相比,游戏的活动变动更加剧烈:

* 新的活动推广,需要根据不同的统计需求,在各个地方埋点,每一次埋点,都是一次变动,需要更新;
* 每一个活动都需要运营关注,在多个业务联动的活动场景里,团队的组成远比后台复杂;
* 活动引流是极高的负载点之一,需要均衡。

上述列表的每一个点都说明了一件事,把 activity 单独拉出来做服务十分必要。单独拉子服务,可以有下列好处:

* 子业务更新,可以不对主业务重启。活动在上架、关闭、更新时,activity 不会要求 app-center 重启。
* 子业务可以由不同的团队单独运营。比方说,需要在两款游戏之间联动,斗地主和麻将集中推出合作业务,这样的活动可以临时开辟团队开发和维护。
* 方便均衡。收缩的子业务,可以小巧轻便地部署在多个服务器,也可以很方便做负载均衡,几乎可以算是微服务了,但是粒度上会更大一点。

这次的实践里,主要是以插件化的形式,开发一个子活动——邀请有礼。

整个开发的模块,只在 game/brokers/activities/inviteActivity 里,backend 和 app-center 作为预设,提供了活动的 CRUD、道具的 CRUD,以及一些关键的 gRPC 接口,[项目](https://github.com/fwhezfwhez/gitchat/tree/master/chat1-web%E5%90%8E%E7%AB%AF%E5%AE%9E%E6%88%98/project)的文档和指引正在慢慢补全。

**邀请活动的要求**:

* 邀请方为甲方,被邀请方为乙方;
* 乙方应邀进入游戏时,赠送甲方一枚“人缘好的证明”,每天最多赠送 5 枚;
* 每天凌晨 4 点重置甲方进度;
* 每个星期统计上周人缘好的证明最多的玩家前十名,并发放“人气少年王”。

**登陆活动的要求**:

* 登陆时,赠送“今日的太阳”一枚。

邀请活动在库里已经完成了,登陆活动留下来练手。


欢迎关注我的公众号,回复关键字“Golang” ,将会有大礼相送!!! 祝各位面试成功!!!

发布了112 篇原创文章 · 获赞 2 · 访问量 5557

猜你喜欢

转载自blog.csdn.net/weixin_41818794/article/details/104393704