遇到的問題
連接池。由于PHP沒有連接池,當高并發時就會有大量的數據庫連接直接沖擊到MySQL上,最終導致數據庫掛掉。雖然Swoole有連接池,但是Swoole只是PHP的一個擴展,之前使用Swoole過程中就踩過很多的坑。經過我們的討論還是覺得使用Golang更加可控一些。
框架的選擇
在PHP中一直用的是Yaf,所以在Go中自然而言就選擇了Gin。因為我們一直以來的原則是:盡量接近底層代碼。
封裝過于完善的框架不利于對整個系統的掌控及理解。我不需要你告訴我這個目錄是干嘛的,這個配置怎么寫,這個函數怎么用等等。
Gin是一個輕路由框架,很符合我們的需求。為了更好地開發,我們也做了幾個中間件。
中間件——input
每個接口都需要獲取GET或POST的參數,但是gin自帶的方法只能返回string,所以我們進行了簡單的封裝。封裝過后我們就可以根據所需直接轉換成想要的數據類型。
package input
import (
"strconv"
)
type I struct {
body string
}
func (input *I) get(p string) *I {
d, e := Context.GetQuery(p)
input.body = d
if e == false {
return input
}
return input
}
func (input *I) post(p string) *I {
d, e := Context.GetPostForm(p)
input.body = d
if e == false {
return input
}
return input
}
func (input *I) String() string {
return input.body
}
func (input *I) Atoi() int {
body, _ := strconv.Atoi(input.body)
return body
}
package input
//獲取GET參數
func Get(p string) *I {
i := new(I)
return i.get(p)
}
//獲取POST參數
func Post(p string) *I {
i := new(I)
return i.get(p)
}
封裝之前
pid, _ := strconv.Atoi(c.Query("product_id"))
alias := c.Query("product_alias")
封裝之后
pid := input.Get("product_id").Atoi()
alias := input.Get("product_alias").String()
中間件——logger
gin自身的logger比較簡單,一般我們都需要將日志按日期分文件寫到某個目錄下。所以我們自己重寫了一個logger,這個logger可以實現將日志按日期分文件并將錯誤信息發送給Sentry。
package ginx
import (
"fmt"
"io"
"os"
"time"
"github.com/gin-gonic/gin"
"sao.cn/configs"
)
var (
logPath string
lastDay int
)
func init() {
logPath = configs.Load().Get("SYS_LOG_PATH").(string)
_, err := os.Stat(logPath)
if err != nil {
os.Mkdir(logPath, 0755)
}
}
func defaultWriter() io.Writer {
writerCheck()
return gin.DefaultWriter
}
func defaultErrorWriter() io.Writer {
writerCheck()
return gin.DefaultErrorWriter
}
func writerCheck() {
nowDay := time.Now().Day()
if nowDay != lastDay {
var file *os.File
filename := time.Now().Format("2006-01-02")
logFile := fmt.Sprintf("%s/%s-%s.log", logPath, "gosapi", filename)
file, _ = os.Create(logFile)
if file != nil {
gin.DefaultWriter = file
gin.DefaultErrorWriter = file
}
}
lastDay = nowDay
}
package ginx
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"time"
"github.com/gin-gonic/gin"
"gosapi/application/library/output"
"sao.cn/sentry"
)
func Logger() gin.HandlerFunc {
return LoggerWithWriter(defaultWriter())
}
func LoggerWithWriter(outWrite io.Writer) gin.HandlerFunc {
return func(c *gin.Context) {
NewLog(c).CaptureOutput().Write(outWrite).Report()
}
}
const (
LEVEL_INFO = "info"
LEVEL_WARN = "warning"
LEVEL_ERROR = "error"
LEVEL_FATAL = "fatal"
)
type Log struct {
startAt time.Time
conText *gin.Context
writer responseWriter
error error
Level string
Time string
ClientIp string
Uri string
ParamGet url.Values `json:"pGet"`
ParamPost url.Values `json:"pPost"`
RespBody string
TimeUse string
}
func NewLog(c *gin.Context) *Log {
bw := responseWriter{buffer: bytes.NewBufferString(""), ResponseWriter: c.Writer}
c.Writer = &bw
clientIP := c.ClientIP()
path := c.Request.URL.Path
method := c.Request.Method
pGet := c.Request.URL.Query()
var pPost url.Values
if method == "POST" {
c.Request.ParseForm()
pPost = c.Request.PostForm
}
return &Log{startAt: time.Now(), conText: c, writer: bw, Time: time.Now().Format(time.RFC850), ClientIp: clientIP, Uri: path, ParamGet: pGet, ParamPost: pPost}
}
func (l *Log) CaptureOutput() *Log {
l.conText.Next()
o := new(output.O)
json.Unmarshal(l.writer.buffer.Bytes(), o)
switch {
case o.Status_code != 0 && o.Status_code < 20000:
l.Level = LEVEL_ERROR
break
case o.Status_code > 20000:
l.Level = LEVEL_WARN
break
default:
l.Level = LEVEL_INFO
break
}
l.RespBody = l.writer.buffer.String()
return l
}
func (l *Log) CaptureError(err interface{}) *Log {
l.Level = LEVEL_FATAL
switch rVal := err.(type) {
case error:
l.RespBody = rVal.Error()
l.error = rVal
break
default:
l.RespBody = fmt.Sprint(rVal)
l.error = errors.New(l.RespBody)
break
}
return l
}
func (l *Log) Write(outWriter io.Writer) *Log {
l.TimeUse = time.Now().Sub(l.startAt).String()
oJson, _ := json.Marshal(l)
fmt.Fprintln(outWriter, string(oJson))
return l
}
func (l *Log) Report() {
if l.Level == LEVEL_INFO || l.Level == LEVEL_WARN {
return
}
client := sentry.Client()
client.SetHttpContext(l.conText.Request)
client.SetExtraContext(map[string]interface{}{"timeuse": l.TimeUse})
switch {
case l.Level == LEVEL_FATAL:
client.CaptureError(l.Level, l.error)
break
case l.Level == LEVEL_ERROR:
client.CaptureMessage(l.Level, l.RespBody)
break
}
}
由于Gin是一個輕路由框架,所以類似數據庫操作和Redis操作并沒有相應的包。這就需要我們自己去選擇好用的包。
Package - 數據庫操作
最初學習階段使用了datbase/sql,但是這個包有個用起來很不爽的問題。
pid := 10021
rows, err := db.Query("SELECT title FROM `product` WHERE id=?", pid)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var title string
if err := rows.Scan(&title); err != nil {
log.Fatal(err)
}
fmt.Printf("%s is %d\n", title, pid)
}
if err := rows.Err(); err != nil {
log.Fatal(err)
}
上述代碼,如果select的不是title,而是*,這時就需要提前把表結構中的所有字段都定義成一個變量,然后傳給Scan方法。
這樣,如果一張表中有十個以上字段的話,開發過程就會異常麻煩。那么我們期望的是什么呢。提前定義字段是必須的,但是正常來說應該是定義成一個結構體吧? 我們期望的是查詢后可以直接將查詢結果轉換成結構化數據。
花了點時間尋找,終于找到了這么一個包——github.com/jmoiron/sqlx。
// You can also get a single result, a la QueryRow
jason = Person{}
err = db.Get(&jason, "SELECT * FROM person WHERE first_name=$1", "Jason")
fmt.Printf("%#v\n", jason)
// Person{FirstName:"Jason", LastName:"Moiron", Email:"jmoiron@jmoiron.net"}
// if you have null fields and use SELECT *, you must use sql.Null* in your struct
places := []Place{}
err = db.Select(&places, "SELECT * FROM place ORDER BY telcode ASC")
if err != nil {
fmt.Println(err)
return
}
sqlx其實是對database/sql的擴展,這樣一來開發起來是不是就爽多了,嘎嘎~
為什么不用ORM? 還是上一節說過的,盡量不用過度封裝的包。
Package - Redis操作
最初我們使用了redigo【github.com/garyburd/redigo/redis】,使用上倒是沒有什么不爽的,但是在壓測的時候發現一個問題,即連接池的使用。
func factory(name string) *redis.Pool {
conf := config.Get("redis." + name).(*toml.TomlTree)
host := conf.Get("host").(string)
port := conf.Get("port").(string)
password := conf.GetDefault("passwd", "").(string)
fmt.Printf("conf-redis: %s:%s - %s\r\n", host, port, password)
pool := &redis.Pool{
IdleTimeout: idleTimeout,
MaxIdle: maxIdle,
MaxActive: maxActive,
Dial: func() (redis.Conn, error) {
address := fmt.Sprintf("%s:%s", host, port)
c, err := redis.Dial("tcp", address,
redis.DialPassword(password),
)
if err != nil {
exception.Catch(err)
return nil, err
}
return c, nil
},
}
return pool
}
/**
* 獲取連接
*/
func getRedis(name string) redis.Conn {
return redisPool[name].Get()
}
/**
* 獲取master連接
*/
func Master(db int) RedisClient {
client := RedisClient{"master", db}
return client
}
/**
* 獲取slave連接
*/
func Slave(db int) RedisClient {
client := RedisClient{"slave", db}
return client
}
以上是定義了一個連接池,這里就產生了一個問題,在redigo中執行redis命令時是需要自行從連接池中獲取連接,而在使用后還需要自己將連接放回連接池。最初我們就是沒有將連接放回去,導致壓測的時候一直壓不上去。
那么有沒有更好的包呢,答案當然是肯定的 —— gopkg.in/redis.v5
func factory(name string) *redis.Client {
conf := config.Get("redis." + name).(*toml.TomlTree)
host := conf.Get("host").(string)
port := conf.Get("port").(string)
password := conf.GetDefault("passwd", "").(string)
fmt.Printf("conf-redis: %s:%s - %s\r\n", host, port, password)
address := fmt.Sprintf("%s:%s", host, port)
return redis.NewClient(&redis.Options{
Addr: address,
Password: password,
DB: 0,
PoolSize: maxActive,
})
}
/**
* 獲取連接
*/
func getRedis(name string) *redis.Client {
return factory(name)
}
/**
* 獲取master連接
*/
func Master() *redis.Client {
return getRedis("master")
}
/**
* 獲取slave連接
*/
func Slave() *redis.Client {
return getRedis("slave")
}
可以看到,這個包就是直接返回需要的連接了。
那么我們去看一下他的源碼,連接有沒有放回去呢。
func (c *baseClient) conn() (*pool.Conn, bool, error) {
cn, isNew, err := c.connPool.Get()
if err != nil {
return nil, false, err
}
if !cn.Inited {
if err := c.initConn(cn); err != nil {
_ = c.connPool.Remove(cn, err)
return nil, false, err
}
}
return cn, isNew, nil
}
func (c *baseClient) putConn(cn *pool.Conn, err error, allowTimeout bool) bool {
if internal.IsBadConn(err, allowTimeout) {
_ = c.connPool.Remove(cn, err)
return false
}
_ = c.connPool.Put(cn)
return true
}
func (c *baseClient) defaultProcess(cmd Cmder) error {
for i := 0; i <= c.opt.MaxRetries; i++ {
cn, _, err := c.conn()
if err != nil {
cmd.setErr(err)
return err
}
cn.SetWriteTimeout(c.opt.WriteTimeout)
if err := writeCmd(cn, cmd); err != nil {
c.putConn(cn, err, false)
cmd.setErr(err)
if err != nil && internal.IsRetryableError(err) {
continue
}
return err
}
cn.SetReadTimeout(c.cmdTimeout(cmd))
err = cmd.readReply(cn)
c.putConn(cn, err, false)
if err != nil && internal.IsRetryableError(err) {
continue
}
return err
}
return cmd.Err()
}
可以看到,在這個包中的底層操作會先去connPool中Get一個連接,用完之后又執行了putConn方法將連接放回connPool。
結束語
package main
import (
"github.com/gin-gonic/gin"
"gosapi/application/library/initd"
"gosapi/application/routers"
)
func main() {
env := initd.ConfTree.Get("ENVIRONMENT").(string)
gin.SetMode(env)
router := gin.New()
routers.Register(router)
router.Run(":7321") // listen and serve on 0.0.0.0:7321
}
3月21日開始寫main,現在已經上線一個星期了,暫時還沒發現什么問題。
經過壓測對比,在性能上提升了大概四倍左右。原先響應時間在70毫秒左右,現在是10毫秒左右。原先的吞吐量大概在1200左右,現在是3300左右。
雖然Go很棒,但是我還是想說:PHP是最好的語言!