作为一名web开发者,笔者使用Golang作为后端语言在开发Web服务的时候遇到过很多问题,
- Golang作为静态语言,在业务逻辑开发速度方面,相较于Ruby等动态语言并不占有太大优势。使用Golang开发Web系统难免会使得代码可读性,代码整洁不如Ruby等语言来的直观,简洁。但是当我们面对海量用户请求的时候,相同的硬件配置下,使用golang会让我们开发的服务的性能更加好。
- 相较于Ruby语言中的Rails框架,Golang生态里面的Web开发框架并没有集大成者,把Routing,ORM等帮我们处理好。也就没有Rails生态圈中best practice.很多时候,使用Golang开发,意味着我们需要自己写一些粘合代码,让Golang生态中的优秀第三方框架可以各司其职,协同工作。
- 相较于Rails生态圈,Golang提供的单元测试工具真可谓简陋至极。
grafana 作为一个典型的Web系统,为我们使用Golang开发web系统提供了良好的范例。本文旨在通过解读grafana 在Http service 方面的代码,帮助Web开发者“写一些粘合代码”。
首先,我们先看看granfana HTTP service 所使用的第三方开源框架。
- https://github.com/go-macaron/macaron https://go-macaron.com/
- https://github.com/go-macaron/binding
- github.com/go-xorm/xorm http://xorm.io/
- github.com/go-xorm/builder
- https://github.com/hashicorp/go-hclog
- github.com/inconshreveable/log15
- github.com/opentracing/opentracing-go
- github.com/patrickmn/go-cache
- github.com/smartystreets/goconvey
- github.com/smartystreets/assertions
1. 启动流程
http server 包含http service所有需要的基础组件。
// github.com/grafana/grafana/pkg/api/http_server.go:38
type HTTPServer struct {
log log.Logger
macaron *macaron.Macaron
context context.Context
streamManager *live.StreamManager
cache *gocache.Cache
httpSrv *http.Server
RouteRegister RouteRegister `inject:""`
Bus bus.Bus `inject:""`
RenderService rendering.Service `inject:""`
Cfg *setting.Cfg `inject:""`
}
granfana 启动的时候,会通过grafana 的registry机制自动注册http server. HTTPServer实现了registry中定义的Service interface,所以启动granfana的时候,会调用 HTTPServer 的Init 方法。Init方法主要初始化HTTPServer struct定义的log以及cache组件。 HTTPServer 实现了registry中定义的BackgroundService方法,所以启动granfana的时候,会调用HTTPServer 的Run方法。Run方法会创建Macaron实例,注册路由,监听http端口,并且处理shutdown逻辑。至此,http server启动完毕。
// github.com/grafana/grafana/pkg/api/http_server.go:59
func (hs *HTTPServer) Run(ctx context.Context) error {
var err error
hs.context = ctx
hs.streamManager = live.NewStreamManager()
hs.macaron = hs.newMacaron()
hs.registerRoutes()
hs.streamManager.Run(ctx)
listenAddr := fmt.Sprintf("%s:%s", setting.HttpAddr, setting.HttpPort)
hs.log.Info("HTTP Server Listen", "address", listenAddr, "protocol", setting.Protocol, "subUrl", setting.AppSubUrl, "socket", setting.SocketPath)
hs.httpSrv = &http.Server{Addr: listenAddr, Handler: hs.macaron}
// handle http shutdown on server context done
go func() {
<-ctx.Done()
// Hacky fix for race condition between ListenAndServe and Shutdown
time.Sleep(time.Millisecond * 100)
if err := hs.httpSrv.Shutdown(context.Background()); err != nil {
hs.log.Error("Failed to shutdown server", "error", err)
}
}()
switch setting.Protocol {
case setting.HTTP:
err = hs.httpSrv.ListenAndServe()
if err == http.ErrServerClosed {
hs.log.Debug("server was shutdown gracefully")
return nil
}
case setting.HTTPS:
err = hs.listenAndServeTLS(setting.CertFile, setting.KeyFile)
if err == http.ErrServerClosed {
hs.log.Debug("server was shutdown gracefully")
return nil
}
case setting.SOCKET:
ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: setting.SocketPath, Net: "unix"})
if err != nil {
hs.log.Debug("server was shutdown gracefully")
return nil
}
// Make socket writable by group
os.Chmod(setting.SocketPath, 0660)
err = hs.httpSrv.Serve(ln)
if err != nil {
hs.log.Debug("server was shutdown gracefully")
return nil
}
default:
hs.log.Error("Invalid protocol", "protocol", setting.Protocol)
err = errors.New("Invalid Protocol")
}
return err
}
// github.com/grafana/grafana/pkg/api/http_server.go:52
func (hs *HTTPServer) Init() error {
hs.log = log.New("http.server")
hs.cache = gocache.New(5*time.Minute, 10*time.Minute)
return nil
}
2. log
grafana 采用了github.com/inconshreveable/log15第三方log组件写log,并且做了简单的封装。主要针对config文件中logging相关的配置写了一些逻辑代码。有一点是和大部分系统不一样的, 在grafana中,不同的组件拥有不同的log对象,比如HTTPServer有自己的log对象,xorm(grafana 采用的ORM框架)也有自己的log对象。这些log对象产生的log会合成一个log,输出到stdout, log file。
3. grafana采用Macaron 作为Go Web 框架
作为一款具有高生产力和模块化设计的web框架, Macaron提供了功能丰富的中间件模块,能满足基本Web服务的需求。需要Macaron解决的第一个问题是web系统的routing问题,grafana针对Macaron的routing做了适当的封装。
首先pkg/api/route_register.go 文件中定义了注册路由的逻辑。grafana把处理request分成namedmiddleware, subfixHandlers,和handler三部分。其中namedmiddleware是一种特殊的middleware,在grafana中, 通过github.com/facebookgo/inject设置了两个namedmiddleware, 分别为ReqeustMetrics 和 RequestTracing.
// github.com/grafana/grafana/pkg/cmd/grafana-server/server.go:78
serviceGraph.Provide(&inject.Object{Value: api.NewRouteRegister(middleware.RequestMetrics, middleware.RequestTracing)})
subfixHandler则是应用于Group类型路由的一类handler。 最后的handler(s)才是处理request核心逻辑的函数。route_register.go 定义了注册路由的逻辑, 而pkg/api/api.go则主要定义了具体的路由。我们以api.go中的admin api为例解释这套逻辑。 reqGrafanaAdmin函数是说凡是访问/api/admin/*的request都必须要具有admin role。AdminGetSetting 是处理核心业务的函数,它接收一个ReqContext对象作为参数, ReqContext是grafana基于macaron.Context定义的request对象。 其中bind(dtos.AdminCreateUserFrom{})以及wrap(…)等也是handler,只是使用了一些bind 以及wrap方法做了一些处理。通过这种分门别类的路由, granfana减少了重复代码的产生,而且代码更简洁,易懂。
// github.com/grafana/grafana/pkg/api/api.go:368
// admin api
r.Group("/api/admin", func(adminRoute RouteRegister) {
adminRoute.Get("/settings", AdminGetSettings)
adminRoute.Post("/users", bind(dtos.AdminCreateUserForm{}), AdminCreateUser)
adminRoute.Put("/users/:id/password", bind(dtos.AdminUpdateUserPasswordForm{}), AdminUpdateUserPassword)
adminRoute.Put("/users/:id/permissions", bind(dtos.AdminUpdateUserPermissionsForm{}), AdminUpdateUserPermissions)
adminRoute.Delete("/users/:id", AdminDeleteUser)
adminRoute.Get("/users/:id/quotas", wrap(GetUserQuotas))
adminRoute.Put("/users/:id/quotas/:target", bind(m.UpdateUserQuotaCmd{}), wrap(UpdateUserQuota))
adminRoute.Get("/stats", AdminGetStats)
adminRoute.Post("/pause-all-alerts", bind(dtos.PauseAllAlertsCommand{}), wrap(PauseAllAlerts))
}, reqGrafanaAdmin)
// github.com/grafana/grafana/pkg/api/admin.go:12
func AdminGetSettings(c *m.ReqContext) {
settings := make(map[string]interface{})
for _, section := range setting.Raw.Sections() {
jsonSec := make(map[string]interface{})
settings[section.Name()] = jsonSec
for _, key := range section.Keys() {
keyName := key.Name()
value := key.Value()
if strings.Contains(keyName, "secret") || strings.Contains(keyName, "password") || (strings.Contains(keyName, "provider_config")) {
value = "************"
}
if strings.Contains(keyName, "url") {
var rgx = regexp.MustCompile(`.*:\/\/([^:]*):([^@]*)@.*?$`)
var subs = rgx.FindAllSubmatch([]byte(value), -1)
if subs != nil && len(subs[0]) == 3 {
value = strings.Replace(value, string(subs[0][1]), "******", 1)
value = strings.Replace(value, string(subs[0][2]), "******", 1)
}
}
jsonSec[keyName] = value
}
}
c.JSON(200, settings)
}
// github.com/grafana/grafana/pkg/models/context.go:14
type ReqContext struct {
*macaron.Context
*SignedInUser
Session session.SessionStore
IsSignedIn bool
IsRenderCall bool
AllowAnonymous bool
Logger log.Logger
}
4. bus机制
在pkg/bus/bus.go中,定义了bus机制。bus主要利用供refect解决了接口依赖问题。在web系统中,我们往往把系统进行分层, 在grafana中, pkg/api package 属于接入层, pkg/api层会调用service层提供的业务逻辑代码。在api layer,我们为了能够调用service提供的函数并且方便些Unit test,传统的做法是在service提供的业务逻辑函数抽象出interface,这种做法虽然可行,但是很啰嗦。而且在写unit test的时候,会更加的啰嗦。 grafana中,提供bus机制来解决这个问题。首先,系统启动或者运行时, 可以通过bus.AddHandler函数注册HandlerFunc处理函数到globalBus对象, 然后在api层调用bus.Dispatch的时候,我们会提供一个strcut 对象作为msg,Dispatch会调用注册的HandlerFunc函数处理strcut对象,并且将处理结果写入到strcut对象中。 我们以api/admin/stats 为例,在AdminGetStats handler函数中,首先生成GetAdminStatsQuery struct 实例,然后利用bus.Dispatch 方法将query dispatch出去。最后由init函数注册的GetAdminStats函数处理,并且更新GetAdminStatsQuery strcut的Result的值。最后,AdminGetStats 会把statsQuery.Result以json形式返回给客户端。 这种机制在我们写unit test的时候mock service 函数非常实用,用起来也非常方便。
// github.com/grafana/grafana/pkg/services/sqlstore/stats.go:11
func init() {
bus.AddHandler("sql", GetSystemStats)
bus.AddHandler("sql", GetDataSourceStats)
bus.AddHandler("sql", GetDataSourceAccessStats)
bus.AddHandler("sql", GetAdminStats)
bus.AddHandlerCtx("sql", GetSystemUserCountStats)
}
// github.com/grafana/grafana/pkg/api/api.go:376
adminRoute.Get("/stats", AdminGetStats)
// github.com/grafana/grafana/pkg/api/admin.go:12
func AdminGetSettings(c *m.ReqContext) {
settings := make(map[string]interface{})
for _, section := range setting.Raw.Sections() {
jsonSec := make(map[string]interface{})
settings[section.Name()] = jsonSec
for _, key := range section.Keys() {
keyName := key.Name()
value := key.Value()
if strings.Contains(keyName, "secret") || strings.Contains(keyName, "password") || (strings.Contains(keyName, "provider_config")) {
value = "************"
}
if strings.Contains(keyName, "url") {
var rgx = regexp.MustCompile(`.*:\/\/([^:]*):([^@]*)@.*?$`)
var subs = rgx.FindAllSubmatch([]byte(value), -1)
if subs != nil && len(subs[0]) == 3 {
value = strings.Replace(value, string(subs[0][1]), "******", 1)
value = strings.Replace(value, string(subs[0][2]), "******", 1)
}
}
jsonSec[keyName] = value
}
}
c.JSON(200, settings)
}
dispatch机制中,每个msg由一个HandlerFunc处理。 除了dispatch 模式外, bus还提供了Publish机制, 每个msg可以由多个handlerFunc处理。实现机制和dispatch类似,只是可以注册多个handlerFunc处理对应的event。(publish机制中,把msg成为event)。我们以signup 为例解释publish机制。在用户signup成功时, pkg/api/signup.go 会调用bus.Publish方法产生SignUpCompleted event。在用户注册完成后,我们往往会发送注册成功邮件,在grafana中,发送注册成功邮件是由NotificationService完成的。NotificationService 会监听SignUpCompleted event, 如果有SignUpCompleted event产生,NotificationService就会执行发送邮件的逻辑。
// github.com/grafana/grafana/pkg/services/notifications/notifications.go:52
ns.Bus.AddEventListener(ns.signUpCompletedHandler)
// github.com/grafana/grafana/pkg/api/signup.go:87
// publish signup event
user := &createUserCmd.Result
bus.Publish(&events.SignUpCompleted{
Email: user.Email,
Name: user.NameOrFallback(),
})
// mark temp user as completed
if ok, rsp := updateTempUserStatus(form.Code, m.TmpUserCompleted); !ok {
return rsp
}
需要注意的是,不论dispatch还是publish机制, handlerFunc 都是同步完成的,dispatch函数会调用对应的handlerFunc, 然后执行handlerFunc, handlerFunc处理过程中,产生错误的话,错误会作为dispatch的返回值返回。在publish机制中, 一个event会被多个HandlerFunc处理。假设有三个HandlerFunc,分别为HandlerFunc1, HandlerFunc2, HandlerFunc3. bus.Publish会按照AddEventListener的顺序执行三个HandlerFunc, 假设HandlerFunc1执行成功, HandlerFunc2由于种种原因执行失败了,这个时候Publish就会返回,HandlerFunc3就不会被执行。 这一点和MQ产品中的异步机制是完全不同的。
5. Unit test
在golang的世界里面,相对于ruby等语言来说,写单元测试的确是一个繁琐无趣的活。一般来说,我们会通过golang的interface来mock一些方法。这种mock方法又衍生出两种写法。 比如,我们有一个待测方法。 func1, 里面有一个已经被测试过无需写单元测试的方法func2, 那么我们有两种方式来写单元测试。 方法1, 利用“type func”为func2抽象出一个接口interface_for_func2, 然后这个接口作为func1的一个参数。在运行的代码中,把func2传入func1作为参数。 在单元测试中,则用一个实现了interface_for_func2的mock方法传入func1.这样就实现了mock方法的目的。 方法2, 原理上和方法1是类似的,只是写法上有一些不同。 我们不在使用type func 抽象接口,而是为func2专门定义一个接口, 然后用一个struct来实现这些接口,在运行的代码中,使用实现了接口的struct来调用func2, 而在单元测试中,则mock一个实现了接口的mock的struct来调用mock的func2. 这两种方法在https://github.com/DATA-DOG/go-sqlmock和https://github.com/vektra/mockery都有体现。sqlmock的缺点是太过于负责,对鞋单元测试来说,需要构造大量的sql返回值。 mockery则需要生成大量mock的函数。
然而在grafana中, 使用了bus机制来解耦不通层面的代码。 以达到方便些单元测试的目的。 以下图里面的一个case为例,我们只需要使用bus.AddHandler就可以构造mock函数了。 不需要生成代码,简单方便。
// github.com/grafana/grafana/pkg/api/dashboard_permission_test.go:15
func TestDashboardPermissionApiEndpoint(t *testing.T) {
Convey("Dashboard permissions test", t, func() {
Convey("Given dashboard not exists", func() {
bus.AddHandler("test", func(query *m.GetDashboardQuery) error {
return m.ErrDashboardNotFound
})
loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", m.ROLE_EDITOR, func(sc *scenarioContext) {
callGetDashboardPermissions(sc)
So(sc.resp.Code, ShouldEqual, 404)
})
cmd := dtos.UpdateDashboardAclCommand{
Items: []dtos.DashboardAclUpdateItem{
{UserId: 1000, Permission: m.PERMISSION_ADMIN},
},
}
updateDashboardPermissionScenario("When calling POST on", "/api/dashboards/id/1/permissions", "/api/dashboards/id/:id/permissions", cmd, func(sc *scenarioContext) {
callUpdateDashboardPermissions(sc)
So(sc.resp.Code, ShouldEqual, 404)
})
})
本文仅仅解析了grafana中的http相关的部分的代码,还有很多代码没有touch到。以后再予以解析。