遇到的问题
连接池。由于PHP没有连接池,当高并发时就会有大量的数据库连接直接冲击到MySQL上,最终导致数据库挂掉。虽然Swoole有连接池,但是Swoole只是PHP的一个扩展,之前使用Swoole过程中就踩过很多的坑。经过我们的讨论还是觉得使用Golang更加可控一些。
框架的选择
在PHP中一直用的是Yaf,所以在Go中自然而言就选择了Gin。因为我们一直以来的原则是:尽量接近底层代码。
封装过于完善的框架不利于对整个系统的掌控及理解。我不需要你告诉我这个目录是干嘛的,这个配置怎么写,这个函数怎么用等等。
Gin是一个轻路由框架,很符合我们的需求。为了更好地开发,我们也做了几个中间件。
中间件——input
每个接口都需要获取GET或POST的参数,但是gin自带的方法只能返回string,所以我们进行了简单的封装。封装过后我们就可以根据所需直接转换成想要的数据类型。
1package input
2
3import (
4 "strconv"
5)
6
7type I struct {
8 body string
9}
10
11func (input *I) get(p string) *I {
12 d, e := Context.GetQuery(p)
13 input.body = d
14 if e == false {
15 return input
16 }
17
18 return input
19}
20
21func (input *I) post(p string) *I {
22 d, e := Context.GetPostForm(p)
23 input.body = d
24 if e == false {
25 return input
26 }
27
28 return input
29}
30
31func (input *I) String() string {
32 return input.body
33}
34
35func (input *I) Atoi() int {
36 body, _ := strconv.Atoi(input.body)
37 return body
38}
1package input
2
3//获取GET参数
4func Get(p string) *I {
5 i := new(I)
6 return i.get(p)
7}
8
9//获取POST参数
10func Post(p string) *I {
11 i := new(I)
12 return i.get(p)
13}
封装之前
1pid, _ := strconv.Atoi(c.Query("product_id"))
2alias := c.Query("product_alias")
封装之后
1pid := input.Get("product_id").Atoi()
2alias := input.Get("product_alias").String()
中间件——logger
gin自身的logger比较简单,一般我们都需要将日志按日期分文件写到某个目录下。所以我们自己重写了一个logger,这个logger可以实现将日志按日期分文件并将错误信息发送给Sentry。
1package ginx
2
3import (
4 "fmt"
5 "io"
6 "os"
7 "time"
8
9 "github.com/gin-gonic/gin"
10 "sao.cn/configs"
11)
12
13var (
14 logPath string
15 lastDay int
16)
17
18func init() {
19 logPath = configs.Load().Get("SYS_LOG_PATH").(string)
20 _, err := os.Stat(logPath)
21 if err != nil {
22 os.Mkdir(logPath, 0755)
23 }
24}
25
26func defaultWriter() io.Writer {
27 writerCheck()
28 return gin.DefaultWriter
29}
30
31func defaultErrorWriter() io.Writer {
32 writerCheck()
33 return gin.DefaultErrorWriter
34}
35
36func writerCheck() {
37 nowDay := time.Now().Day()
38 if nowDay != lastDay {
39 var file *os.File
40 filename := time.Now().Format("2006-01-02")
41 logFile := fmt.Sprintf("%s/%s-%s.log", logPath, "gosapi", filename)
42
43 file, _ = os.Create(logFile)
44 if file != nil {
45 gin.DefaultWriter = file
46 gin.DefaultErrorWriter = file
47 }
48 }
49
50 lastDay = nowDay
51}
1package ginx
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/url"
10 "time"
11
12 "github.com/gin-gonic/gin"
13 "gosapi/application/library/output"
14 "sao.cn/sentry"
15)
16
17func Logger() gin.HandlerFunc {
18 return LoggerWithWriter(defaultWriter())
19}
20
21func LoggerWithWriter(outWrite io.Writer) gin.HandlerFunc {
22 return func(c *gin.Context) {
23 NewLog(c).CaptureOutput().Write(outWrite).Report()
24 }
25}
26
27const (
28 LEVEL_INFO = "info"
29 LEVEL_WARN = "warning"
30 LEVEL_ERROR = "error"
31 LEVEL_FATAL = "fatal"
32)
33
34type Log struct {
35 startAt time.Time
36 conText *gin.Context
37 writer responseWriter
38 error error
39
40 Level string
41 Time string
42 ClientIp string
43 Uri string
44 ParamGet url.Values `json:"pGet"`
45 ParamPost url.Values `json:"pPost"`
46 RespBody string
47 TimeUse string
48}
49
50func NewLog(c *gin.Context) *Log {
51 bw := responseWriter{buffer: bytes.NewBufferString(""), ResponseWriter: c.Writer}
52 c.Writer = &bw
53
54 clientIP := c.ClientIP()
55 path := c.Request.URL.Path
56 method := c.Request.Method
57 pGet := c.Request.URL.Query()
58 var pPost url.Values
59 if method == "POST" {
60 c.Request.ParseForm()
61 pPost = c.Request.PostForm
62 }
63 return &Log{startAt: time.Now(), conText: c, writer: bw, Time: time.Now().Format(time.RFC850), ClientIp: clientIP, Uri: path, ParamGet: pGet, ParamPost: pPost}
64}
65
66func (l *Log) CaptureOutput() *Log {
67 l.conText.Next()
68 o := new(output.O)
69 json.Unmarshal(l.writer.buffer.Bytes(), o)
70 switch {
71 case o.Status_code != 0 && o.Status_code < 20000:
72 l.Level = LEVEL_ERROR
73 break
74 case o.Status_code > 20000:
75 l.Level = LEVEL_WARN
76 break
77 default:
78 l.Level = LEVEL_INFO
79 break
80 }
81
82 l.RespBody = l.writer.buffer.String()
83 return l
84}
85
86func (l *Log) CaptureError(err interface{}) *Log {
87 l.Level = LEVEL_FATAL
88 switch rVal := err.(type) {
89 case error:
90 l.RespBody = rVal.Error()
91 l.error = rVal
92 break
93 default:
94 l.RespBody = fmt.Sprint(rVal)
95 l.error = errors.New(l.RespBody)
96 break
97 }
98
99 return l
100}
101
102func (l *Log) Write(outWriter io.Writer) *Log {
103 l.TimeUse = time.Now().Sub(l.startAt).String()
104 oJson, _ := json.Marshal(l)
105 fmt.Fprintln(outWriter, string(oJson))
106 return l
107}
108
109func (l *Log) Report() {
110 if l.Level == LEVEL_INFO || l.Level == LEVEL_WARN {
111 return
112 }
113
114 client := sentry.Client()
115 client.SetHttpContext(l.conText.Request)
116 client.SetExtraContext(map[string]interface{}{"timeuse": l.TimeUse})
117 switch {
118 case l.Level == LEVEL_FATAL:
119 client.CaptureError(l.Level, l.error)
120 break
121 case l.Level == LEVEL_ERROR:
122 client.CaptureMessage(l.Level, l.RespBody)
123 break
124 }
125}
由于Gin是一个轻路由框架,所以类似数据库操作和Redis操作并没有相应的包。这就需要我们自己去选择好用的包。
Package - 数据库操作
最初学习阶段使用了datbase/sql,但是这个包有个用起来很不爽的问题。
1pid := 10021
2rows, err := db.Query("SELECT title FROM `product` WHERE id=?", pid)
3if err != nil {
4 log.Fatal(err)
5}
6defer rows.Close()
7for rows.Next() {
8 var title string
9 if err := rows.Scan(&title); err != nil {
10 log.Fatal(err)
11 }
12 fmt.Printf("%s is %d\n", title, pid)
13}
14if err := rows.Err(); err != nil {
15 log.Fatal(err)
16}
上述代码,如果select的不是title,而是*,这时就需要提前把表结构中的所有字段都定义成一个变量,然后传给Scan方法。
这样,如果一张表中有十个以上字段的话,开发过程就会异常麻烦。那么我们期望的是什么呢。提前定义字段是必须的,但是正常来说应该是定义成一个结构体吧? 我们期望的是查询后可以直接将查询结果转换成结构化数据。
花了点时间寻找,终于找到了这么一个包——github.com/jmoiron/sqlx。
1// You can also get a single result, a la QueryRow
2 jason = Person{}
3 err = db.Get(&jason, "SELECT * FROM person WHERE first_name=$1", "Jason")
4 fmt.Printf("%#v\n", jason)
5 // Person{FirstName:"Jason", LastName:"Moiron", Email:"jmoiron@jmoiron.net"}
6
7 // if you have null fields and use SELECT *, you must use sql.Null* in your struct
8 places := []Place{}
9 err = db.Select(&places, "SELECT * FROM place ORDER BY telcode ASC")
10 if err != nil {
11 fmt.Println(err)
12 return
13 }
sqlx其实是对database/sql的扩展,这样一来开发起来是不是就爽多了,嘎嘎~
为什么不用ORM? 还是上一节说过的,尽量不用过度封装的包。
Package - Redis操作
最初我们使用了redigo【github.com/garyburd/redigo/redis】,使用上倒是没有什么不爽的,但是在压测的时候发现一个问题,即连接池的使用。
1func factory(name string) *redis.Pool {
2 conf := config.Get("redis." + name).(*toml.TomlTree)
3 host := conf.Get("host").(string)
4 port := conf.Get("port").(string)
5 password := conf.GetDefault("passwd", "").(string)
6 fmt.Printf("conf-redis: %s:%s - %s\r\n", host, port, password)
7
8 pool := &redis.Pool{
9 IdleTimeout: idleTimeout,
10 MaxIdle: maxIdle,
11 MaxActive: maxActive,
12 Dial: func() (redis.Conn, error) {
13 address := fmt.Sprintf("%s:%s", host, port)
14 c, err := redis.Dial("tcp", address,
15 redis.DialPassword(password),
16 )
17 if err != nil {
18 exception.Catch(err)
19 return nil, err
20 }
21
22 return c, nil
23 },
24 }
25 return pool
26}
27
28/**
29 * 获取连接
30 */
31func getRedis(name string) redis.Conn {
32 return redisPool[name].Get()
33}
34
35/**
36 * 获取master连接
37 */
38func Master(db int) RedisClient {
39 client := RedisClient{"master", db}
40 return client
41}
42
43/**
44 * 获取slave连接
45 */
46func Slave(db int) RedisClient {
47 client := RedisClient{"slave", db}
48 return client
49}
以上是定义了一个连接池,这里就产生了一个问题,在redigo中执行redis命令时是需要自行从连接池中获取连接,而在使用后还需要自己将连接放回连接池。最初我们就是没有将连接放回去,导致压测的时候一直压不上去。
那么有没有更好的包呢,答案当然是肯定的 —— gopkg.in/redis.v5
1func factory(name string) *redis.Client {
2 conf := config.Get("redis." + name).(*toml.TomlTree)
3 host := conf.Get("host").(string)
4 port := conf.Get("port").(string)
5 password := conf.GetDefault("passwd", "").(string)
6 fmt.Printf("conf-redis: %s:%s - %s\r\n", host, port, password)
7
8 address := fmt.Sprintf("%s:%s", host, port)
9 return redis.NewClient(&redis.Options{
10 Addr: address,
11 Password: password,
12 DB: 0,
13 PoolSize: maxActive,
14 })
15}
16
17/**
18 * 获取连接
19 */
20func getRedis(name string) *redis.Client {
21 return factory(name)
22}
23
24/**
25 * 获取master连接
26 */
27func Master() *redis.Client {
28 return getRedis("master")
29}
30
31/**
32 * 获取slave连接
33 */
34func Slave() *redis.Client {
35 return getRedis("slave")
36}
可以看到,这个包就是直接返回需要的连接了。
那么我们去看一下他的源码,连接有没有放回去呢。
1func (c *baseClient) conn() (*pool.Conn, bool, error) {
2 cn, isNew, err := c.connPool.Get()
3 if err != nil {
4 return nil, false, err
5 }
6 if !cn.Inited {
7 if err := c.initConn(cn); err != nil {
8 _ = c.connPool.Remove(cn, err)
9 return nil, false, err
10 }
11 }
12 return cn, isNew, nil
13}
14
15func (c *baseClient) putConn(cn *pool.Conn, err error, allowTimeout bool) bool {
16 if internal.IsBadConn(err, allowTimeout) {
17 _ = c.connPool.Remove(cn, err)
18 return false
19 }
20
21 _ = c.connPool.Put(cn)
22 return true
23}
24
25func (c *baseClient) defaultProcess(cmd Cmder) error {
26 for i := 0; i <= c.opt.MaxRetries; i++ {
27 cn, _, err := c.conn()
28 if err != nil {
29 cmd.setErr(err)
30 return err
31 }
32
33 cn.SetWriteTimeout(c.opt.WriteTimeout)
34 if err := writeCmd(cn, cmd); err != nil {
35 c.putConn(cn, err, false)
36 cmd.setErr(err)
37 if err != nil && internal.IsRetryableError(err) {
38 continue
39 }
40 return err
41 }
42
43 cn.SetReadTimeout(c.cmdTimeout(cmd))
44 err = cmd.readReply(cn)
45 c.putConn(cn, err, false)
46 if err != nil && internal.IsRetryableError(err) {
47 continue
48 }
49
50 return err
51 }
52
53 return cmd.Err()
54}
可以看到,在这个包中的底层操作会先去connPool中Get一个连接,用完之后又执行了putConn方法将连接放回connPool。
结束语
1package main
2
3import (
4 "github.com/gin-gonic/gin"
5
6 "gosapi/application/library/initd"
7 "gosapi/application/routers"
8)
9
10func main() {
11 env := initd.ConfTree.Get("ENVIRONMENT").(string)
12 gin.SetMode(env)
13
14 router := gin.New()
15 routers.Register(router)
16
17 router.Run(":7321") // listen and serve on 0.0.0.0:7321
18}
3月21日开始写main,现在已经上线一个星期了,暂时还没发现什么问题。
经过压测对比,在性能上提升了大概四倍左右。原先响应时间在70毫秒左右,现在是10毫秒左右。原先的吞吐量大概在1200左右,现在是3300左右。
原文发布时间为:2018-08-29
本文来自云栖社区合作伙伴“Golang语言社区”,了解相关信息可以关注“Golang语言社区”。