Golang工程经验

Golang工程经验 做为一个C/C++的开发者而言,开启Golang语言开发之路是很容易的,从语法、语义上的理解到工程开发,都可以快速熟悉起来;相比C、C++,Golang语言更简洁,更容易写出高并发的服务后台系统html

转战Golang一年有余,经历了两个线上项目的洗礼,总结出一些工程经验,一个是总结出一些实战经验,一个是用来发现自我不足之处java

Golang语言简介 Go语言是谷歌推出的一种全新的编程语言,能够在不损失应用程序性能的状况降低低代码的复杂性。Go语言专门针对多处理器系统应用程序的编程进行了优化,使用Go编译的程序能够媲美C或C++代码的速度,并且更加安全、支持并行进程。mysql

基于Golang的IM系统架构 我基于Golang的两个实际线上项目都是IM系统,本文基于现有线上系统作一些总结性、引导性的经验输出。linux

Golang TCP长链接 & 并发 既然是IM系统,那么必然须要TCP长链接来维持,因为Golang自己的基础库和外部依赖库很是之多,咱们能够简单引用基础net网络库,来创建TCP server。通常的TCP Server端的模型,能够有一个协程【或者线程】去独立执行accept,而且是for循环一直accept新的链接,若是有新链接过来,那么创建链接而且执行Connect,因为Golang里面协程的开销很是之小,所以,TCP server端还能够一个链接一个goroutine去循环读取各自链接链路上的数据并处理。固然, 这个在C++语言的TCP Server模型中,通常会经过EPoll模型来创建server端,这个是和C++的区别之处。nginx

关于读取数据,Linux系统有recv和send函数来读取发送数据,在Golang中,自带有io库,里面封装了各类读写方法,如io.ReadFull,它会读取指定字节长度的数据git

为了维护链接和用户,而且一个链接一个用户的一一对应的,须要根据链接可以找到用户,同时也须要可以根据用户找到对应的链接,那么就须要设计一个很好结构来维护。咱们最初采用map来管理,可是发现Map里面的数据太大,查找的性能不高,为此,优化了数据结构,conn里面包含user,user里面包含conn,结构以下【只包括重要字段】。github

// 一个用户对应一个链接 type User struct { uid int64 conn *MsgConn BKicked bool // 被另外登录的一方踢下线 BHeartBeatTimeout bool // 心跳超时 。。。 }golang

type MsgConn struct { conn net.Conn lastTick time.Time // 上次接收到包时间 remoteAddr string // 为每一个链接建立一个惟一标识符 user *User // MsgConn与User一一映射 。。。 } 创建TCP server 代码片断以下正则表达式

func ListenAndServe(network, address string) { tcpAddr, err := net.ResolveTCPAddr(network, address) if err != nil { logger.Fatalf(nil, "ResolveTcpAddr err:%v", err) } listener, err = net.ListenTCP(network, tcpAddr) if err != nil { logger.Fatalf(nil, "ListenTCP err:%v", err) } go accept() }redis

func accept() { for { conn, err := listener.AcceptTCP() if err == nil {

// 包计数,用来限制频率
        
        //anti-attack, 黑白名单
      ...
      
        // 新建一个链接
        imconn := NewMsgConn(conn)
        
        // run
        imconn.Run()
    } 
}
复制代码

}

func (conn *MsgConn) Run() {

//on connect
conn.onConnect()

go func() {
    tickerRecv := time.NewTicker(time.Second * time.Duration(rateStatInterval))
    for {
        select {
        case <-conn.stopChan:
            tickerRecv.Stop()
            return
        case <-tickerRecv.C:
            conn.packetsRecv = 0
        default:
        
           // 在 conn.parseAndHandlePdu 里面经过Golang自己的io库里面提供的方法读取数据,如io.ReadFull
            conn_closed := conn.parseAndHandlePdu()
            if conn_closed {
                tickerRecv.Stop()
                return
            }
        }
    }
}()
复制代码

}

// 将 user 和 conn 一一对应起来 func (conn *MsgConn) onConnect() *User { user := &User{conn: conn, durationLevel: 0, startTime: time.Now(), ackWaitMsgIdSet: make(map[int64]struct{})} conn.user = user return user }

TCP Server的一个特色在于一个链接一个goroutine去处理,这样的话,每一个链接独立,不会相互影响阻塞,保证可以及时读取到client端的数据。若是是C、C++程序,若是一个链接一个线程的话,若是上万个或者十万个线程,那么性能会极低甚至于没法工做,cpu会所有消耗在线程之间的调度上了,所以C、C++程序没法这样玩。Golang的话,goroutine能够几十万、几百万的在一个系统中良好运行。同时对于TCP长链接而言,一个节点上的链接数要有限制策略。

链接超时 每一个链接须要有心跳来维持,在心跳间隔时间内没有收到,服务端要检测超时并断开链接释放资源,golang能够很方便的引用须要的数据结构,同时对变量的赋值(包括指针)很是easy

var timeoutMonitorTree *rbtree.Rbtree var timeoutMonitorTreeMutex sync.Mutex var heartBeatTimeout time.Duration //心跳超时时间, 配置了默认值ssss var loginTimeout time.Duration //登录超时, 配置了默认值ssss

type TimeoutCheckInfo struct { conn *MsgConn dueTime time.Time }

func AddTimeoutCheckInfo(conn *MsgConn) { timeoutMonitorTreeMutex.Lock() timeoutMonitorTree.Insert(&TimeoutCheckInfo{conn: conn, dueTime: time.Now().Add(loginTimeout)}) timeoutMonitorTreeMutex.Unlock() }

如 &TimeoutCheckInfo{},赋值一个指针对象

Golang 基础数据结构 Golang中,不少基础数据都经过库来引用,咱们能够方便引用咱们所须要的库,经过import包含就能直接使用,如源码里面提供了sync库,里面有mutex锁,在须要锁的时候能够包含进来

经常使用的如list,mutex,once,singleton等都已包含在内

list链表结构,当咱们须要相似队列的结构的时候,能够采用,针对IM系统而言,在长链接层处理的消息id的列表,能够经过list来维护,若是用户有了回应则从list里面移除,不然在超时时间到后尚未回应,则入offline处理 mutex锁,当须要并发读写某个数据的时候使用,包含互斥锁和读写锁

var ackWaitListMutex sync.RWMutex var ackWaitListMutex sync.Mutex once表示任什么时候刻都只会调用一次,通常的用法是初始化实例的时候使用,代码片断以下

var initRedisOnce sync.Once

func GetRedisCluster(name string) (*redis.Cluster, error) { initRedisOnce.Do(setupRedis) if redisClient, inMap := redisClusterMap[name]; inMap { return redisClient, nil } else { } }

func setupRedis() { redisClusterMap = make(map[string]*redis.Cluster) commonsOpts := []redis.Option{ redis.ConnectionTimeout(conf.RedisConnTimeout), redis.ReadTimeout(conf.RedisReadTimeout), redis.WriteTimeout(conf.RedisWriteTimeout), redis.IdleTimeout(conf.RedisIdleTimeout), redis.MaxActiveConnections(conf.RedisMaxConn), redis.MaxIdleConnections(conf.RedisMaxIdle), }), ... } } 这样咱们能够在任何须要的地方调用GetRedisCluster,而且不用担忧实例会被初始化屡次,once会保证必定只执行一次

singleton单例模式,这个在C++里面是一个经常使用的模式,通常须要开发者本身经过类来实现,类的定义决定单例模式设计的好坏;在Golang中,已经有成熟的库实现了,开发者无须重复造轮子,关于何时该使用单例模式请自行Google。一个简单的例子以下

import  "github.com/dropbox/godropbox/singleton"

var SingleMsgProxyService = singleton.NewSingleton(func() (interface{}, error) {
cluster, _ := cache.GetRedisCluster("singlecache")
return &singleMsgProxy{
    Cluster:  cluster,
    MsgModel: msg.MsgModelImpl,
}, nil
复制代码

})

Golang interface 接口 若是说goroutine和channel是Go并发的两大基石,那么接口interface是Go语言编程中数据类型的关键。在Go语言的实际编程中,几乎全部的数据结构都围绕接口展开,接口是Go语言中全部数据结构的核心。

interface - 泛型编程 严格来讲,在 Golang 中并不支持泛型编程。在 C++ 等高级语言中使用泛型编程很是的简单,因此泛型编程一直是 Golang 诟病最多的地方。可是使用 interface 咱们能够实现泛型编程,以下是一个参考示例

package sort

// A type, typically a collection, that satisfies sort.Interface can be // sorted by the routines in this package. The methods require that the // elements of the collection be enumerated by an integer index. type Interface interface { // Len is the number of elements in the collection. Len() int // Less reports whether the element with // index i should sort before the element with index j. Less(i, j int) bool // Swap swaps the elements with indexes i and j. Swap(i, j int) }

...

// Sort sorts data. // It makes one call to data.Len to determine n, and O(nlog(n)) calls to // data.Less and data.Swap. The sort is not guaranteed to be stable. func Sort(data Interface) { // Switch to heapsort if depth of 2ceil(lg(n+1)) is reached. n := data.Len() maxDepth := 0 for i := n; i > 0; i >>= 1 { maxDepth++ } maxDepth *= 2 quickSort(data, 0, n, maxDepth) }

Sort 函数的形参是一个 interface,包含了三个方法:Len(),Less(i,j int),Swap(i, j int)。使用的时候无论数组的元素类型是什么类型(int, float, string…),只要咱们实现了这三个方法就可使用 Sort 函数,这样就实现了“泛型编程”。

这种方式,我在项目里面也有实际应用过,具体案例就是对消息排序。

下面给一个具体示例,代码可以说明一切,一看就懂:

type Person struct { Name string Age int }

func (p Person) String() string { return fmt.Sprintf("%s: %d", p.Name, p.Age) }

// ByAge implements sort.Interface for []Person based on // the Age field. type ByAge []Person //自定义

func (a ByAge) Len() int { return len(a) } func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }

func main() { people := []Person{ {"Bob", 31}, {"John", 42}, {"Michael", 17}, {"Jenny", 26}, }

fmt.Println(people)
sort.Sort(ByAge(people))
fmt.Println(people)
复制代码

}

interface - 隐藏具体实现 隐藏具体实现,这个很好理解。好比我设计一个函数给你返回一个 interface,那么你只能经过 interface 里面的方法来作一些操做,可是内部的具体实现是彻底不知道的。

例如咱们经常使用的context包,就是这样的,context 最早由 google 提供,如今已经归入了标准库,并且在原有 context 的基础上增长了:cancelCtx,timerCtx,valueCtx。

若是函数参数是interface或者返回值是interface,这样就能够接受任何类型的参数

基于Golang的model service 模型【类MVC模型】 在一个项目工程中,为了使得代码更优雅,须要抽象出一些模型出来,同时基于C++面向对象编程的思想,须要考虑到一些类、继承相关。在Golang中,没有类、继承的概念,可是咱们彻底能够经过struct和interface来创建咱们想要的任何模型。在咱们的工程中,抽象出一种我自认为是相似MVC的模型,可是不彻底同样,我的以为这个模型抽象的比较好,容易扩展,模块清晰。对于使用java和PHP编程的同窗对这个模型应该是再熟悉不过了,我这边经过代码来讲明下这个模型

首先一个model包,经过interface来实现,包含一些基础方法,须要被外部引用者来具体实现

package model

// 定义一个基础model type MsgModel interface { Persist(context context.Context, msg interface{}) bool UpdateDbContent(context context.Context, msgIface interface{}) bool ... } 再定义一个msg包,用来具体实现model包中MsgModel模型的全部方法

package msg

type msgModelImpl struct{}

var MsgModelImpl = msgModelImpl{}

func (m msgModelImpl) Persist(context context.Context, msgIface interface{}) bool { // 具体实现 }

func (m msgModelImpl) UpdateDbContent(context context.Context, msgIface interface{}) bool { // 具体实现

}

...

model 和 具体实现方定义并实现ok后,那么就还须要一个service来统筹管理

package service

// 定义一个msgService struct包含了model里面的UserModel和MsgModel两个model type msgService struct { msgModel model.MsgModel }

// 定义一个MsgService的变量,并初始化,这样经过MsgService,就能引用并访问model的全部方法 var ( MsgService = msgService{ msgModel: msg.MsgModelImpl, } )

调用访问

import service

service.MsgService.Persist(ctx, xxx)

总结一下,model对应MVC的M,service 对应 MVC的C, 调用访问的地方对应MVC的V

Golang 基础资源的封装 在MVC模型的基础下,咱们还须要考虑另一点,就是基础资源的封装,服务端操做必然会和mysql、redis、memcache等交互,一些经常使用的底层基础资源,咱们有必要进行封装,这是基础架构部门所须要承担的,也是一个好的项目工程所须要的

redis redis,咱们在github.com/garyburd/redigo/redis的库的基础上,作了一层封装,实现了一些更为贴合工程的机制和接口,redis cluster封装,支持分片、读写分离

// NewCluster creates a client-side cluster for callers. Callers use this structure to interact with Redis database func NewCluster(config ClusterConfig, instrumentOpts *instrument.Options) *Cluster { cluster := new(Cluster) cluster.pool = make([]*client, len(config.Configs)) masters := make([]string, 0, len(config.Configs))

for i, sharding := range config.Configs {
    master, slaves := sharding.Master, sharding.Slaves
    masters = append(masters, master)

    masterAddr, masterDb := parseServer(master)

    cli := new(client)
    cli.master = &redisNode{
        server: master,
        Pool: func() *redis.Pool {
            pool := &redis.Pool{
                MaxIdle:     config.MaxIdle,
                IdleTimeout: config.IdleTimeout,
                Dial: func() (redis.Conn, error) {
                    c, err := redis.Dial(
                        "tcp",
                        masterAddr,
                        redis.DialDatabase(masterDb),
                        redis.DialPassword(config.Password),
                        redis.DialConnectTimeout(config.ConnTimeout),
                        redis.DialReadTimeout(config.ReadTimeout),
                        redis.DialWriteTimeout(config.WriteTimeout),
                    )
                    if err != nil {
                        return nil, err
                    }
                    return c, err
                },
                TestOnBorrow: func(c redis.Conn, t time.Time) error {
                    if time.Since(t) < time.Minute {
                        return nil
                    }
                    _, err := c.Do("PING")
                    return err
                },
                MaxActive: config.MaxActives,
            }

            if instrumentOpts == nil {
                return pool
            }

            return instrument.NewRedisPool(pool, instrumentOpts)
        }(),
    }

    // allow nil slaves
    if slaves != nil {
        cli.slaves = make([]*redisNode, 0)
        for _, slave := range slaves {
            addr, db := parseServer(slave)

            cli.slaves = append(cli.slaves, &redisNode{
                server: slave,
                Pool: func() *redis.Pool {
                    pool := &redis.Pool{
                        MaxIdle:     config.MaxIdle,
                        IdleTimeout: config.IdleTimeout,
                        Dial: func() (redis.Conn, error) {
                            c, err := redis.Dial(
                                "tcp",
                                addr,
                                redis.DialDatabase(db),
                                redis.DialPassword(config.Password),
                                redis.DialConnectTimeout(config.ConnTimeout),
                                redis.DialReadTimeout(config.ReadTimeout),
                                redis.DialWriteTimeout(config.WriteTimeout),
                            )
                            if err != nil {
                                return nil, err
                            }
                            return c, err
                        },
                        TestOnBorrow: func(c redis.Conn, t time.Time) error {
                            if time.Since(t) < time.Minute {
                                return nil
                            }
                            _, err := c.Do("PING")
                            return err
                        },
                        MaxActive: config.MaxActives,
                    }

                    if instrumentOpts == nil {
                        return pool
                    }

                    return instrument.NewRedisPool(pool, instrumentOpts)
                }(),
            })
        }
    }

    // call init
    cli.init()

    cluster.pool[i] = cli
}

if config.Hashing == sharding.Ketama {
    cluster.sharding, _ = sharding.NewKetamaSharding(sharding.GetShardServers(masters), true, 6379)
} else {
    cluster.sharding, _ = sharding.NewCompatSharding(sharding.GetShardServers(masters))
}

return cluster
复制代码

}

总结一下:

使用链接池提升性能,每次都从链接池里面取链接而不是每次都从新创建链接 设置最大链接数和最大活跃链接(同一时刻可以提供的链接),设置合理的读写超时时间 实现主从读写分离,提升性能,须要注意若是没有从库则只读主库 TestOnBorrow用来进行健康检测 单独开一个goroutine协程用来按期保活【ping-pong】 hash分片算法的选择,一致性hash仍是hash取模,hash取模在扩缩容的时候比较方便,一致性hash并无带来明显的优点,咱们公司内部统一建议采用hash取模 考虑如何支持双写策略 memcache memcached客户端代码封装,依赖 github.com/dropbox/god…, 实现其ShardManager接口,支持Connection Timeout,支持Fail Fast和Rehash

goroutine & chann 实际开发过程当中,常常会有这样场景,每一个请求经过一个goroutine协程去作,如批量获取消息,可是,为了防止后端资源链接数太多等,或者防止goroutine太多,每每须要限制并发数。给出以下示例供参考

package main

import ( "fmt" "sync" "time" )

var over = make(chan bool)

const MAXConCurrency = 3

//var sem = make(chan int, 4) //控制并发任务数 var sem = make(chan bool, MAXConCurrency) //控制并发任务数

var maxCount = 6

func Worker(i int) bool {

sem <- true
defer func() {
    <-sem
}()

// 模拟出错处理
if i == 5 {
    return false
}
fmt.Printf("now:%v num:%v\n", time.Now().Format("04:05"), i)
time.Sleep(1 * time.Second)
return true
复制代码

}

func main() { //wg := &sync.WaitGroup{} var wg sync.WaitGroup for i := 1; i <= maxCount; i++ { wg.Add(1) fmt.Printf("for num:%v\n", i) go func(i int) { defer wg.Done() for x := 1; x <= 3; x++ { if Worker(i) { break } else { fmt.Printf("retry :%v\n", x) } } }(i) } wg.Wait() //等待全部goroutine退出 } goroutine & context.cancel Golang 的 context很是强大,详细的能够参考个人另一篇文章 Golang Context分析

这里想要说明的是,在项目工程中,咱们常常会用到这样的一个场景,经过goroutine并发去处理某些批量任务,当某个条件触发的时候,这些goroutine要可以控制中止执行。若是有这样的场景,那么我们就须要用到context的With 系列函数了,context.WithCancel生成了一个withCancel的实例以及一个cancelFuc,这个函数就是用来关闭ctxWithCancel中的 Done channel 函数。

示例代码片断以下

func Example(){

// context.WithCancel 用来生成一个新的Context,能够接受cancel方法用来随时中止执行 newCtx, cancel := context.WithCancel(context.Background())

for peerIdVal, lastId := range lastIdMap {
    wg.Add(1)

    go func(peerId, minId int64) {
        defer wg.Done()

        msgInfo := Get(newCtx, uid, peerId, minId, count).([]*pb.MsgInfo)
        if msgInfo != nil && len(msgInfo) > 0 {
            if singleMsgCounts >= maxCount {
                cancel()  // 当条件触发,则调用cancel中止
                mutex.Unlock()
                return
            }
        }
        mutex.Unlock()
    }(peerIdVal, lastId)
}

wg.Wait()   
复制代码

}

func Get(ctx context.Context, uid, peerId, sinceId int64, count int) interface{} { for { select { // 若是收到Done的chan,则立马return case <-ctx.Done(): msgs := make([]*pb.MsgInfo, 0) return msgs

default:
        // 处理逻辑
    }
}
复制代码

}

traceid & context 在大型项目工程中,为了更好的排查定位问题,咱们须要有必定的技巧,Context上下文存在于一整条调用链路中,在服务端并发场景下,n多个请求里面,咱们如何可以快速准确的找到一条请求的前因后果,专业用语就是指调用链路,经过调用链咱们可以知道这条请求通过了哪些服务、哪些模块、哪些方法,这样能够很是方便咱们定位问题

traceid就是咱们抽象出来的这样一个调用链的惟一标识,再经过Context进行传递,在任何代码模块[函数、方法]里面都包含Context参数,咱们就能造成一个完整的调用链。那么如何实现呢 ?在咱们的工程中,有RPC模块,有HTTP模块,两个模块的请求来源确定不同,所以,要实现全部服务和模块的完整调用链,须要考虑http和rpc两个不一样的网络请求的调用链

traceid的实现 const TraceKey = "traceId"

func NewTraceId(tag string) string { now := time.Now() return fmt.Sprintf("%d.%d.%s", now.Unix(), now.Nanosecond(), tag) }

func GetTraceId(ctx context.Context) string { if ctx == nil { return "" }

// 从Context里面取
traceInfo := GetTraceIdFromContext(ctx)
if traceInfo == "" {
    traceInfo = GetTraceIdFromGRPCMeta(ctx)
}

return traceInfo
复制代码

}

func GetTraceIdFromGRPCMeta(ctx context.Context) string { if ctx == nil { return "" } if md, ok := metadata.FromIncomingContext(ctx); ok { if traceHeader, inMap := md[meta.TraceIdKey]; inMap { return traceHeader[0] } } if md, ok := metadata.FromOutgoingContext(ctx); ok { if traceHeader, inMap := md[meta.TraceIdKey]; inMap { return traceHeader[0] } } return "" }

func GetTraceIdFromContext(ctx context.Context) string { if ctx == nil { return "" } traceId, ok := ctx.Value(TraceKey).(string) if !ok { return "" } return traceId }

func SetTraceIdToContext(ctx context.Context, traceId string) context.Context { return context.WithValue(ctx, TraceKey, traceId) }

http的traceid 对于http的服务,请求方多是客户端,也能是其余服务端,http的入口里面就须要增长上traceid,而后打印日志的时候,将TraceID打印出来造成完整链路。若是http server采用gin来实现的话,代码片断以下,其余http server的库的实现方式相似便可

import "github.com/gin-gonic/g…"

func recoveryLoggerFunc() gin.HandlerFunc { return func(c *gin.Context) { c.Set(trace.TraceKey, trace.NewTraceId(c.ClientIP())) defer func() { ...... func 省略实现 } }() c.Next() } }

engine := gin.New() engine.Use(OpenTracingFunc(), httpInstrumentFunc(), recoveryLoggerFunc())

session := engine.Group("/sessions")
session.Use(sdkChecker)
{
    session.POST("/recent", httpsrv.MakeHandler(RecentSessions))
}
复制代码

这样,在RecentSessions接口里面若是打印日志,就可以经过Context取到traceid

access log access log是针对http的请求来的,记录http请求的API,响应时间,ip,响应码,用来记录并能够统计服务的响应状况,固然,也有其余辅助系统如SLA来专门记录http的响应状况

Golang语言实现这个也很是简单,并且这个是个通用功能,建议能够抽象为一个基础模块,全部业务都能import后使用

大体格式以下:

http_log_pattern='%{2006-01-02T15:04:05.999-0700}t %a - %{Host}i "%r" %s - %T "%{X-Real-IP}i" "%{X-Forwarded-For}i" %{Content-Length}i - %{Content-Length}o %b %{CDN}i'

"%a", "${RemoteIP}",
    "%b", "${BytesSent|-}",
    "%B", "${BytesSent|0}",
    "%H", "${Proto}",
    "%m", "${Method}",
    "%q", "${QueryString}",
    "%r", "${Method} ${RequestURI} ${Proto}",
    "%s", "${StatusCode}",
    "%t", "${ReceivedAt|02/Jan/2006:15:04:05 -0700}",
    "%U", "${URLPath}",
    "%D", "${Latency|ms}",
    "%T", "${Latency|s}",
复制代码

具体实现省略

最终获得的日志以下:

2017-12-20T20:32:58.787+0800 192.168.199.15 - www.demo.com:50001 "POST /arcp/unregister HTTP/1.1" 200 - 0.035 "-" "-" 14 - - 13 - 2017-12-20T20:33:27.741+0800 192.168.199.15 - www.demo.com:50001 "POST /arcp/register HTTP/1.1" 200 - 0.104 "-" "-" 68 - - 13 - 2017-12-20T20:42:01.803+0800 192.168.199.15 - www.demo.com:50001 "POST /arcp/unregister HTTP/1.1" 200 - 0.035 "-" "-" 14 - - 13 - 开关策略、降级策略 线上服务端系统,必需要有降级机制,也最好可以有开关机制。降级机制在于出现异常状况可以舍弃某部分服务保证其余主线服务正常;开关也有着一样的功效,在某些状况下打开开关,则可以执行某些功能或者说某套功能,关闭开关则执行另一套功能或者不执行某个功能。

这不是Golang的语言特性,可是是工程项目里面必要的,在Golang项目中的具体实现代码片断以下:

package switches

var ( xxxSwitchManager = SwitchManager{switches: make(map[string]*Switch)}

AsyncProcedure = &Switch{Name: "xxx.msg.procedure.async", On: true}

// 使能音视频
EnableRealTimeVideo = &Switch{Name: "xxx.real.time.video", On: true}
复制代码

)

func init() { xxxSwitchManager.Register(AsyncProcedure, EnableRealTimeVideo) }

// 具体实现结构和实现方法 type Switch struct { Name string On bool listeners []ChangeListener }

func (s *Switch) TurnOn() { s.On = true s.notifyListeners() }

func (s *Switch) notifyListeners() { if len(s.listeners) > 0 { for _, l := range s.listeners { l.OnChange(s.Name, s.On) } } }

func (s *Switch) TurnOff() { s.On = false s.notifyListeners() }

func (s *Switch) IsOn() bool { return s.On }

func (s *Switch) IsOff() bool { return !s.On }

func (s *Switch) AddChangeListener(l ChangeListener) { if l == nil { return } s.listeners = append(s.listeners, l) }

type SwitchManager struct { switches map[string]*Switch }

func (m SwitchManager) Register(switches ...*Switch) { for _, s := range switches { m.switches[s.Name] = s } }

func (m SwitchManager) Unregister(name string) { delete(m.switches, name) }

func (m SwitchManager) TurnOn(name string) (bool, error) { if s, ok := m.switches[name]; ok { s.TurnOn() return true, nil } else { return false, errors.New("switch " + name + " is not registered") } }

func (m SwitchManager) TurnOff(name string) (bool, error) { if s, ok := m.switches[name]; ok { s.TurnOff() return true, nil } else { return false, errors.New("switch " + name + " is not registered") } }

func (m SwitchManager) IsOn(name string) (bool, error) { if s, ok := m.switches[name]; ok { return s.IsOn(), nil } else { return false, errors.New("switch " + name + " is not registered") } }

func (m SwitchManager) List() map[string]bool { switches := make(map[string]bool) for name, switcher := range m.switches { switches[name] = switcher.On } return switches }

type ChangeListener interface { OnChange(name string, isOn bool) }

// 这里开始调用 if switches.AsyncProcedure.IsOn() { // do sth }else{ // do other sth }

prometheus + grafana prometheus + grafana 是业界经常使用的监控方案,prometheus进行数据采集,grafana进行图表展现。

Golang里面prometheus进行数据采集很是简单,有对应client库,应用程序只需暴露出http接口便可,这样,prometheus server端就能够按期采集数据,而且还能够根据这个接口来监控服务端是否异常【如挂掉的状况】。

import "github.com/prometheus/…"

engine.GET("/metrics", gin.WrapH(prometheus.Handler()))

这样就实现了数据采集,可是具体采集什么样的数据,数据从哪里生成的,还须要进入下一步:

package prometheus

import "github.com/prometheus/…"

var DefaultBuckets = []float64{10, 50, 100, 200, 500, 1000, 3000}

var MySQLHistogramVec = prometheus.NewHistogramVec( prometheus.HistogramOpts{ Namespace: "allen.wu", Subsystem: "xxx", Name: "mysql_op_milliseconds", Help: "The mysql database operation duration in milliseconds", Buckets: DefaultBuckets, }, []string{"db"}, )

var RedisHistogramVec = prometheus.NewHistogramVec( prometheus.HistogramOpts{ Namespace: "allen.wu", Subsystem: "xxx", Name: "redis_op_milliseconds", Help: "The redis operation duration in milliseconds", Buckets: DefaultBuckets, }, []string{"redis"}, )

func init() { prometheus.MustRegister(MySQLHistogramVec) prometheus.MustRegister(RedisHistogramVec) ... }

// 使用,在对应的位置调用prometheus接口生成数据 instanceOpts := []redis.Option{ redis.Shards(shards...), redis.Password(viper.GetString(conf.RedisPrefix + name + ".password")), redis.ClusterName(name), redis.LatencyObserver(func(name string, latency time.Duration) { prometheus.RedisHistogramVec.WithLabelValues(name).Observe(float64(latency.Nanoseconds()) * 1e-6) }), }

捕获异常 和 错误处理 panic 异常 捕获异常是否有存在的必要,根据各自不一样的项目自行决定,可是通常出现panic,若是没有异常,那么服务就会直接挂掉,若是可以捕获异常,那么出现panic的时候,服务不会挂掉,只是当前致使panic的某个功能,没法正常使用,我的建议仍是在某些有必要的条件和入口处进行异常捕获。

常见抛出异常的状况:数组越界、空指针空对象,类型断言失败等;Golang里面捕获异常经过 defer + recover来实现

C++有try。。。catch来进行代码片断的异常捕获,Golang里面有recover来进行异常捕获,这个是Golang语言的基本功,是一个比较简单的功能,很少说,看代码

func consumeSingle(kafkaMsg *sarama.ConsumerMessage) { var err error defer func() { if r := recover(); r != nil { if e, ok := r.(error); ok { // 异常捕获的处理 } } }() }

在请求来源入口处的函数或者某个方法里面实现这么一段代码进行捕获,这样,只要经过这个入口出现的异常都能被捕获,并打印详细日志

error 错误 error错误,能够自定义返回,通常工程应用中的作法,会在方法的返回值上增长一个error返回值,Golang容许每一个函数返回多个返回值,增长一个error的做用在于,获取函数返回值的时候,根据error参数进行判断,若是是nil表示没有错误,正常处理,不然处理错误逻辑。这样减小代码出现异常状况

panic 抛出的堆栈信息排查 若是某些状况下,没有捕获异常,程序在运行过程当中出现panic,通常都会有一些堆栈信息,咱们如何根据这些堆栈信息快速定位并解决呢 ?

通常信息里面都会代表是哪一种相似的panic,如是空指针异常仍是数组越界,仍是xxx;

而后会打印一堆信息出来包括出现异常的代码调用块及其文件位置,须要定位到最后的位置而后反推上去

分析示例以下

{"date":"2017-11-22 19:33:20.921","pid":17,"level":"ERROR","file":"recovery.go","line":16,"func":"1","msg":"panic in /Message.MessageService/Proces s: runtime error: invalid memory address or nil pointer dereference github.com.xxx/demo/biz/ve… /www/jenkins_home/.jenkins/jobs/demo/jobs/demo--biz/workspace/src/github.com.xxx/demo/biz/vendor/github.com.xxx/demo/commons/ interceptor/recovery.go:17 runtime.call64 /www/jenkins_home/.jenkins/tools/org.jenkinsci.plugins.golang.GolangInstallation/go1.9/go/src/runtime/asm_amd64.s:510 runtime.gopanic /www/jenkins_home/.jenkins/tools/org.jenkinsci.plugins.golang.GolangInstallation/go1.9/go/src/runtime/panic.go:491 runtime.panicmem /www/jenkins_home/.jenkins/tools/org.jenkinsci.plugins.golang.GolangInstallation/go1.9/go/src/runtime/panic.go:63 runtime.sigpanic /www/jenkins_home/.jenkins/tools/org.jenkinsci.plugins.golang.GolangInstallation/go1.9/go/src/runtime/signal_unix.go:367 github.com.xxx/demo/biz/ve… /www/jenkins_home/.jenkins/jobs/demo/jobs/demo--biz/workspace/src/github.com.xxx/demo/biz/vendor/github.com.xxx/demo/m trace-middleware-go/grpc/client.go:49 github.com.xxx/demo/biz/ve… /www/jenkins_home/.jenkins/jobs/demo/jobs/demo--biz/workspace/src/github.com.xxx/demo/biz/vendor/github.com/grpc-ecosystem/go-gr pc-middleware/chain.go:90 github.com.xxx/demo/biz/ve…

问题分析

经过报错的堆栈信息,能够看到具体错误是“runtime error: invalid memory address or nil pointer dereference”,也就是空指针异常,而后逐步定位日志,能够发现最终致使出现异常的函数在这个,以下:

github.com.xxx/demo/biz/ve…

/www/jenkins_home/.jenkins/jobs/demo/jobs/demo--biz/workspace/src/github.com.xxx/demo/biz/vendor/github.com.xxx/demo/m
复制代码

trace-middleware-go/grpc/client.go:49 通常panic,都会有上述错误日志,而后经过日志,能够追踪到具体函数,而后看到OpenTracingClientInterceptor后,是在client.go的49行,而后开始反推,经过代码能够看到,多是trace指针为空。而后一步一步看是从哪里开始调用的

最终发现代码以下:

ucConn, err := grpcclient.NewClientConn(conf.Discovery.UserCenter, newBalancer, time.Second*3, conf.Tracer)
if err != nil {
    logger.Fatalf(nil, "init user center client connection failed: %v", err)
    return
}
UserCenterClient = pb.NewUserCenterServiceClient(ucConn)
复制代码

那么开始排查,conf.Tracer是否是可能为空,在哪里初始化,初始化有没有错,而后发现这个函数是在init里面,而后conf.Tracer确实在main函数里面显示调用的,main函数里面会引用或者间接引用全部包,那么init就必定在main以前执行。

这样的话,init执行的时候,conf.Tracer尚未被赋值,所以就是nil,就会致使panic了

项目工程级别接口 项目中若是可以有一些调试debug接口,有一些pprof性能分析接口,有探测、健康检查接口的话,会给整个项目在线上稳定运行带来很大的做用。 除了pprof性能分析接口属于Golang特有,其余的接口在任何语言都有,这里只是代表在一个工程中,须要有这类型的接口

上下线接口 咱们的工程是经过etcd进行服务发现和注册的,同时还提供http服务,那么就须要有个机制来上下线,这样上线过程当中,若是服务自己尚未所有启动完成准备就绪,那么就暂时不要在etcd里面注册,不要上线,以避免有请求过来,等到就绪后再注册;下线过程当中,先从etcd里面移除,这样流量再也不导入过来,而后再等待一段时间用来处理还未完成的任务

咱们的作法是,start 和 stop 服务的时候,调用API接口,而后再在服务的API接口里面注册和反注册到etcd

var OnlineHook = func() error {
    return nil
}

var OfflineHook = func() error {
    return nil
}
复制代码

// 初始化两个函数,注册和反注册到etcd的函数 api.OnlineHook = func() error { return registry.Register(conf.Discovery.RegisterAddress) }

api.OfflineHook = func() error {
    return registry.Deregister()
}


// 设置在线的函数里面分别调用上述两个函数,用来上下线
func SetOnline(isOnline bool) (err error) {
    if conf.Discovery.RegisterEnabled {
        if !isServerOnline && isOnline {
            err = OnlineHook()
        } else if isServerOnline && !isOnline {
            err = OfflineHook()
        }
    }

    if err != nil {
        return
    }

    isServerOnline = isOnline
    return
}


SetOnline 为Http API接口调用的函数
复制代码

nginx 探测接口,健康检查接口 对于http的服务,通常访问都经过域名访问,nginx配置代理,这样保证服务能够随意扩缩容,可是nginx既然配置了代码,后端节点的状况,就必需要可以有接口能够探测,这样才能保证流量导入到的节点必定的在健康运行中的节点;为此,服务必需要提供健康检测的接口,这样才能方便nginx代理可以实时更新节点。

这个接口如何实现?nginx代理通常经过http code来处理,若是返回code=200,认为节点正常,若是是非200,认为节点异常,若是连续采样屡次都返回异常,那么nginx将节点下掉

如提供一个/devops/status 的接口,用来检测,接口对应的具体实现为:

func CheckHealth(c *gin.Context) { // 首先状态码设置为非200,如503 httpStatus := http.StatusServiceUnavailable // 若是当前服务正常,并服务没有下线,则更新code if isServerOnline { httpStatus = http.StatusOK }

// 不然返回code为503
c.IndentedJSON(httpStatus, gin.H{
    onlineParameter: isServerOnline,
})
复制代码

}

PProf性能排查接口

// PProf
profGroup := debugGroup.Group("/pprof")
profGroup.GET("/", func(c *gin.Context) {
    pprof.Index(c.Writer, c.Request)
})
profGroup.GET("/goroutine", gin.WrapH(pprof.Handler("goroutine")))
profGroup.GET("/block", gin.WrapH(pprof.Handler("block")))
profGroup.GET("/heap", gin.WrapH(pprof.Handler("heap")))
profGroup.GET("/threadcreate", gin.WrapH(pprof.Handler("threadcreate")))

profGroup.GET("/cmdline", func(c *gin.Context) {
    pprof.Cmdline(c.Writer, c.Request)
})
profGroup.GET("/profile", func(c *gin.Context) {
    pprof.Profile(c.Writer, c.Request)
})
profGroup.GET("/symbol", func(c *gin.Context) {
    pprof.Symbol(c.Writer, c.Request)
})
profGroup.GET("/trace", func(c *gin.Context) {
    pprof.Trace(c.Writer, c.Request)
})
复制代码

debug调试接口 // Debug debugGroup := engine.Group("/debug") debugGroup.GET("/requests", func(c *gin.Context) { c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") trace.Render(c.Writer, c.Request, true) }) debugGroup.GET("/events", func(c *gin.Context) { c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") trace.RenderEvents(c.Writer, c.Request, true) }) 开关【降级】实时调整接口 前面有讲到过,在代码里面须要有开关和降级机制,并讲了实现示例,那么若是须要可以实时改变开关状态,而且实时生效,咱们就能够提供一下http的API接口,供运维人员或者开发人员使用。

// Switch
console := engine.Group("/switch")
{
    console.GET("/list", httpsrv.MakeHandler(ListSwitches))
    console.GET("/status", httpsrv.MakeHandler(CheckSwitchStatus))
    console.POST("/turnOn", httpsrv.MakeHandler(TurnSwitchOn))
    console.POST("/turnOff", httpsrv.MakeHandler(TurnSwitchOff))
}
复制代码

go test 单元测试用例 单元测试用例是必须,是自测的一个必要手段,Golang里面单元测试很是简单,import testing 包,而后执行go test,就可以测试某个模块代码

如,在某个user文件夹下有个user包,包文件为user.go,里面有个Func UpdateThemesCounts,若是想要进行test,那么在同级目录下,创建一个user_test.go的文件,包含testing包,编写test用例,而后调用go test便可

通常的规范有:

每一个测试函数必须导入testing包 测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头 将测试文件和源码放在相同目录下,并将名字命名为{source_filename}_test.go 一般状况下,将测试文件和源码放在同一个包内。 以下:

// user.go func UpdateThemesCounts(ctx context.Context, themes []int, count int) error { redisClient := model.GetRedisClusterForTheme(ctx) key := themeKeyPattern for _, theme := range themes { if redisClient == nil { return errors.New("now redis client") }

total, err := redisClient.HIncrBy(ctx, key, theme, count)
    if err != nil {
        logger.Errorf(ctx, "add key:%v for theme:%v count:%v failed:%v", key, theme, count, err)
        return err
    } else {
        logger.Infof(ctx, "now key:%v theme:%v total:%v", key, theme, total)
    }
}

return nil
复制代码

}

//user_test.go package user

import ( "fmt" "testing" "Golang.org/x/net/conte…" )

func TestUpdateThemeCount(t *testing.T) { ctx := context.Background() theme := 1 count := 123 total, err := UpdateThemeCount(ctx, theme, count) fmt.Printf("update theme:%v counts:%v err:%v \n", theme, total, err) }

在此目录下执行 go test便可出结果

测试单个文件 or 测试单个包 一般,一个包里面会有多个方法,多个文件,所以也有多个test用例,假如咱们只想测试某一个方法的时候,那么咱们须要指定某个文件的某个方案

以下:

allen.wu@allen.wudeMacBook-Pro-4:~/Documents/work_allen.wu/goDev/Applications/src/github.com.xxx/avatar/app_server/service/centralhub$tree . . ├── msghub.go ├── msghub_test.go ├── pushhub.go ├── rtvhub.go ├── rtvhub_test.go ├── userhub.go └── userhub_test.go

0 directories, 7 files

总共有7个文件,其中有三个test文件,假如咱们只想要测试rtvhub.go里面的某个方法,若是直接运行go test,就会测试全部test.go文件了。

所以咱们须要在go test 后面再指定咱们须要测试的test.go 文件和 它的源文件,以下:

go test -v msghub.go msghub_test.go

测试单个文件下的单个方法 在测试单个文件之下,假如咱们单个文件下,有多个方法,咱们还想只是测试单个文件下的单个方法,要如何实现?咱们须要再在此基础上,用 -run 参数指定具体方法或者使用正则表达式。

假如test文件以下:

package centralhub

import ( "context" "testing" )

func TestSendTimerInviteToServer(t *testing.T) { ctx := context.Background()

err := sendTimerInviteToServer(ctx, 1461410596, 1561445452, 2)
if err != nil {
    t.Errorf("send to server friendship build failed. %v", err)
}
复制代码

}

func TestSendTimerInvite(t *testing.T) { ctx := context.Background() err := sendTimerInvite(ctx, "test", 1461410596, 1561445452) if err != nil { t.Errorf("send timeinvite to client failed:%v", err) } } go test -v msghub.go msghub_test.go -run TestSendTimerInvite

go test -v msghub.go msghub_test.go -run "SendTimerInvite" 测试全部方法 指定目录便可 go test

测试覆盖度 go test工具给咱们提供了测试覆盖度的参数,

go test -v -cover

go test -cover -coverprofile=cover.out -covermode=count

go tool cover -func=cover.out

goalng GC 、编译运行 服务端开发者若是在mac上开发,那么Golang工程的代码能够直接在mac上编译运行,而后若是须要部署在Linux系统的时候,在编译参数里面指定GOOS便可,这样能够本地调试ok后再部署到Linux服务器。

若是要部署到Linux服务,编译参数的指定为

ldflags=" -X {repo}/version.version={version} -X {repo}/version.branch={branch} -X {repo}/version.goVersion={go_version} -X {repo}/version.buildTime={build_time} -X {repo}/version.buildUser={build_user} "

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "{ldflags}" -obinary_dir/binary_name{repo}/ 对于GC,咱们要收集起来,记录到日志文件中,这样方便后续排查和定位,启动的时候指定一下便可执行gc,收集gc日志能够重定向

export GIN_MODE=release
GODEBUG=gctrace=1 $SERVER_ENTRY 1>/dev/null 2>$LOGDIR/gc.log.`date "+%Y%m%d%H%M%S"` &
复制代码

Golang包管理 目录代码管理 目录代码管理 整个项目包括两大类,一个是本身编写的代码模块,一个是依赖的代码,依赖包须要有进行包管理,本身的编写的代码工程须要有一个合适的目录进行管理 main.go :入口 doc : 文档 conf : 配置相关 ops : 运维操做相关【http接口】 api : API接口【http交互接口】 daemon : 后台daemon相关 model : model模块,操做底层资源 service : model的service grpcclient : rpc client registry : etcd 注册 processor : 异步kafka消费

. ├── README.md ├── api ├── conf ├── daemon ├── dist ├── doc ├── grpcclient ├── main.go ├── misc ├── model ├── ops ├── processor ├── registry ├── service ├── tools ├── vendor └── version 包管理 go容许import不一样代码库的代码,例如github.com, golang.org等等;对于须要import的代码,可使用 go get 命令取下来放到GOPATH对应的目录中去。

对于go来讲,其实并不care你的代码是内部仍是外部的,总之都在GOPATH里,任何import包的路径都是从GOPATH开始的;惟一的区别,就是内部依赖的包是开发者本身写的,外部依赖的包是go get下来的。

依赖GOPATH来解决go import有个很严重的问题:若是项目依赖的包作了修改,或者干脆删掉了,会影响到其余现有的项目。为了解决这个问题,go在1.5版本引入了vendor属性(默认关闭,须要设置go环境变量GO15VENDOREXPERIMENT=1),并在1.6版本以后都默认开启了vendor属性。 这样的话,全部的依赖包都在项目工程的vendor中了,每一个项目都有各自的vendor,互不影响;可是vendor里面的包没有版本信息,不方便进行版本管理。

目前市场上经常使用的包管理工具主要有godep、glide、dep

godep godep的使用者众多,如docker,kubernetes, coreos等go项目不少都是使用godep来管理其依赖,固然缘由多是早期也没的工具可选,早期咱们也是使用godep进行包管理。

使用比较简单,godep save;godep restore;godep update;

可是后面随着咱们使用和项目的进一步增强,咱们发现godep有诸多痛点,目前已经逐步开始弃用godep,新项目都开始采用dep进行管理了。

godep的痛点:

godep若是遇到依赖项目里有vendor的时候就可能会致使编译不过,vendor下再嵌套vendor,就会致使编译的时候出现版本不一致的错误,会提示某个方法接口不对,所有放在当前项目的vendor下

godep锁定版本太麻烦了,在项目进一步发展过程当中,咱们依赖的项目(包)多是早期的,后面因为升级更新,某些API接口可能有变;可是咱们项目若是已经上线稳定运行,咱们不想用新版,那么就须要锁定某个特定版本。可是这个对于godep而言,操做着实不方便。

godep的时候,常常会有一些包须要特定版本,而后包依赖编译不过,尤为是在多人协做的时候,本地gopath和vendor下的版本不同,而后本地gopath和别人的gopath的版本不同,致使编译会遇到各类依赖致使的问题

glide glide也是在vendor以后出来的。glide的依赖包信息在glide.yaml和glide.lock中,前者记录了全部依赖的包,后者记录了依赖包的版本信息

glide create # 建立glide工程,生成glide.yaml glide install # 生成glide.lock,并拷贝依赖包 glide update # 更新依赖包信息,更新glide.lock

由于glide官方说咱们不更新功能了,只bugfix,请你们开始使用dep吧,因此鉴于此,咱们在选择中就放弃了。同时,glide若是遇到依赖项目里有vendor的时候就直接跪了,dep的话,就会滤掉,不会再vendor下出现嵌套的,所有放在当前项目的vendor下

dep golang官方出品,dep最近的版本已经作好了从其余依赖工具的vendor迁移过来的功能,功能很强大,是咱们目前的最佳选择。不过目前尚未release1.0 ,可是已经能够用在生成环境中,对于新项目,我建议采用dep进行管理,不会有历史问题,并且当新项目上线的时候,dep也会进一步优化而且可能先于你的项目上线。

dep默认从github上拉取最新代码,若是想优先使用本地gopath,那么3.x版本的dep须要显式参数注明,以下

dep init -gopath -v 总结 godep是最初使用最多的,可以知足大部分需求,也比较稳定,可是有一些不太好的体验;

glide 有版本管理,相对强大,可是官方表示再也不进行开发;

dep是官方出品,目前没有release,功能一样强大,是目前最佳选择;

看官方的对比

Golang容易出现的问题 包引用缺乏致使panic go vendor 缺失致使import屡次致使panic

本工程下没有vendor目录,然而,引入了这个包“github.com.xxx/demo/biz/mo… 这个biz包里面包含了vendor目录。

这样,编译此工程的时候,会致使一部分import是从oracle下的vendor,另外一部分是从gopath,这样就会出现一个包被两种不一样方式import,致使出现重复注册而panic

并发 致使 panic fatal error: concurrent map read and map write

并发编程中最容易出现资源竞争,之前玩C++的时候,资源出现竞争只会致使数据异常,不会致使程序异常panic,Golang里面会直接抛错,这个是比较好的作法,由于异常数据最终致使用户的数据异常,影响很大,甚至没法恢复,直接抛错后交给开发者去修复代码bug,通常在测试过程当中或者代码review过程当中就可以发现并发问题。

并发的处理方案有二:

经过chann 串行处理 经过加锁控制 相互依赖引用致使编译不过 Golang不容许包直接相互import,会致使编译不过。可是有个项目里面,A同窗负责A模块,B同窗负责B模块,因为产品需求致使,A模块要调用B模块中提供的方法,B模块要调用A模块中提供的方法,这样就致使了相互引用了

咱们的解决方案是: 将其中一个相互引用的模块中的方法提炼出来,独立为另一个模块,也就是另一个包,这样就不至于相互引用

Golang json类型转换异常 Golang进行json转换的时候,经常使用作法是一个定义struct,成员变量使用tag标签,而后经过自带的json包进行处理,容易出现的问题主要有:

成员变量的首字母没有大写,致使json后生成不了对应字段 json string的类型不对,致使json Unmarshal 的时候抛错 Golang 总结 golang使用一年多以来,我的认为golang有以下优势:

学习入门快;让开发者开发更为简洁 不用关心内存分配和释放,gc会帮咱们处理; 效率性能高; 不用本身去实现一些基础数据结构,官方或者开源库能够直接import引用; struct 和 interface 能够实现类、继承等面向对象的操做模式; 初始化和赋值变量简洁; 并发编程goroutine很是容易,结合chann能够很好的实现; Context可以自我控制开始、中止,传递上下文信息

相关文章
相关标签/搜索