Go语言非常适合日益流行的面向服务的体系结构。
在过去的几年中,出现了许多好的实践来帮助解决微服务所带来的问题。 如果您不想以难以维护的,难以操作的基础架构告终,那么这些做法非常重要。 通过将它们与Go的一些更容易被忽略的功能结合起来,您可以使服务的开发和操作变得更加容易。
在这篇博客文章中,我将解释Go的这些功能,并提供专注于开发,构建和配置服务的最佳实践。 这些做法易于应用,因此,新开发人员可以更快地加入并更好地合作。 您可以更快地部署,理解和调试服务; 通常,开发会更容易。
- GitHub上提供了示例服务的源代码: https : //github.com/gulyasm/goservice
把事情简单化
在enbrite.ly ,我们揭示了在线广告中的欺诈活动。 结果,我们开发并运营了由服务组成的基础架构。 这些服务大多数都作为Go服务实现。
我们选择Go是因为它易于学习,简单且富有成效。 去年,我们与六个开发团队一起编写了20多个服务。
最初,我们使用魔术框架和库通过使用其他人编写的内容简化了开发过程。 我们开始使用Martini,后来使用Negroni添加中间件和日志记录。
我读过许多很棒的Go开发人员使用标准的HTTP库,并且我想,“男孩,这是核心。” 但是,随着您对标准HTTP库的了解越来越多,您会越发意识到它是纯粹的天才,非常适合服务。
查看有关如何创建可通过域调用的最小服务并返回IP地址的示例。
type IPMessage struct {
IPs []net.IP
}
type ErrorMessage struct {
Error string
}
func IPHandler(rw http.ResponseWriter, r *http.Request) {
domain := r.URL.Query().Get("domain")
if len(domain) == 0 {
json.NewEncoder(rw).Encode(ErrorMessage{"No domain parameter"})
return
}
ips, err := net.LookupIP(domain)
if err != nil {
json.NewEncoder(rw).Encode(ErrorMessage{"Invalid domain address."})
return
}
json.NewEncoder(rw).Encode(IPMessage{ips})
}
func main() {
address := ":8090"
r := http.NewServeMux()
r.HandleFunc("/service/ip", IPHandler)
log.Println("IP service request at: " + address)
log.Println(http.ListenAndServe(address, r))
}
我们定义了IPHandler
函数,它是一个http.HandlerFunc
。 在主要功能中,我们将此添加到路由器中。
始终总是显式获得路由器比隐式使用默认值更好。 这样,我们可以随时与其他路由器交换。 如果我们决定我们需要的路径参数,我们可以方便地切换到大猩猩通过改变路由器http.NewServeMux()
来mux.NewRouter()
只有一行更改。 实际上,Gorilla路由器是我们有时在服务中使用的唯一第三方库。 它写得很好,非常适合REST服务。
我们还开始了使用端口记录侦听地址的良好做法。 对于其他开发人员而言,更容易在日志中看到该服务实际上已启动以及正在侦听的地址。 还应记录ListenAndServe
函数的返回值,以便在出现任何错误时返回一条日志消息,并且该函数返回而不是阻塞。
我们可能需要的下一件事是中间件。 使用标准库本身即可轻松创建中间件。 我们所要做的就是定义一个http.Handler
调用我们的基本处理程序,并添加到ListenAndServe
功能,而不是基本处理程序。 同样,无需第三方库; 一个简单而优雅的解决方案。
中间件本身:
type M struct {
handler http.Handler
}
func (m M) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
start := time.Now()
m.handler.ServeHTTP(rw, r)
log.Printf("%s served in %s\n", r.URL, time.Since(start))
}
func NewM(h http.Handler) http.Handler {
return M{h}
}
如果我们想向中间件添加任何东西,可以在ServeHTTP
方法中完成。 为了使我们的代码更简洁,更易读,我们可以将基本处理程序的构建提取到一个单独的函数中。
func createBaseHandler() http.Handler {
r := http.NewServeMux()
r.HandleFunc("/service/ip", IPHandler)
return NewM(r)
}
定义基本处理程序后,我们将其添加到ListenAndServe
函数中。
log.Println("Service request at: " + address)
log.Println(http.ListenAndServe(address, createBaseHandler()))
日志记录是中间件之类的另一个领域,人们可以使用它来访问第三方库。 缺少标准库日志记录软件包的一项功能是日志级别。
的确,有时在冗长的记录和生产日志之间切换很方便,但是我们遵循一个简单的规则。 您应该记录重要的事件或状态并携带有价值的信息:
- 如果没有有价值的信息,请不要记录。
- 如果您将该日志消息用于调试目的,请在生产中将其删除。
- 如果是错误,请根据错误的性质使用致命或紧急记录。
- 其他任何日志都与
log.Println
一起使用。
有了这个小规则,除了标准日志库中的日志级别外,您实际上并不需要日志级别。
错误记录的另一种最佳做法是记录错误本身(如果存在)。 不仅要记录通用的“发生错误”,还要记录返回的错误内容以及尽可能多的信息。 当发生错误时,它可能确实令人沮丧,但是错误消息没有告诉您任何特定的信息。 一个很好的例子是模板错误。 当模板解析失败时,返回的错误实例将包含确切的问题。 没有它,很难调试问题。
通过提供更详细的错误消息,您可以帮助以后进行调试。 相信我,您的团队稍后会感谢您!
您还可以使用log.SetPrefix
设置日志记录前缀,以注意哪个日志消息来自哪个服务。 如果将主机信息添加到前缀,甚至可以确定哪个主机记录了该消息,这在分布式环境中非常有用。
使用此简单易行的规则,我们的日志仅包含有价值的信息。 这样可以减少噪音,但可以确保我们获得调试,遥测或验尸所需的每条有价值的信息。
要设置前缀,请将以下内容添加到您的主函数中的任何日志记录语句之前:
log.SetPrefix("[service] ")
构建过程
应用程序的构建和部署必须简单,自动化。 在尝试了不同的方法之后,我们最终使用了Makefile。
将Makefile与go命令提供的工具结合使用,可提供功能全面的构建工具。 构建,测试,测试范围,静态分析和格式化都是go命令的一部分。 通过使它们成为构建过程的一部分,开发人员不必考虑这些步骤。 如果每个服务都使用相同的构建方式,则每个开发人员都可以轻松构建其中的任何一个。
仅使用make
命令,我们就可以审核,格式化,测试和构建我们的服务。 让我们来看一个如何向我们的服务添加Makefile的示例:
default: build
build:
go fmt
go vet
go build
test: build
go test
coverage-test:
go test -coverprofile=coverage.out
go tool cover -func=coverage.out
go tool cover -html=coverage.out
rm coverage.out
使用此统一的Makefile,每个团队成员都可以以相同的方式检查,测试和格式化代码。 它对代码审查有很大帮助。 无需讨论格式。 Go的兽医已经检查了代码中的常见错误。 如果构建过程通过,则可以100%确保没有未使用的变量或导入。
我个人真的很喜欢遇到未使用的变量时会失败的编译器功能。 我们不必争论未使用的变量,也不保证没有多余的结果。
要分析测试覆盖率,请使用make coverage-test
命令。 它运行测试并在浏览器中以HTML格式显示结果,因此您可以查看测试涵盖的内容和缺少的内容。 由标准Go工具提供的超级简便的测试覆盖率。
没有例外
我有一个硬性规定:每个服务都必须在根URL /
上提供UI。 UI应该显示内部状态和特定服务所做的任何历史。
在http://<service address>/
上可以访问并检查每个服务的信任为开发人员提供了信心,并为他们调试,开发或只是了解新服务提供了起点。
使用Go,可以很容易地将UI添加到任何服务中。 模板提供了一种生成HTML的简便方法。 添加Bootstrap CSS,您将拥有一个可操作的UI。 使用goroutine启动它,您甚至不必处理并行性。 使用Go,与服务本身并行运行管理站点非常容易。
首先,使用一些自举CSS,一个查询表和datatables javascript模块创建一个基本HTML模板,该模块向普通HTML表中添加分页,搜索和排序等功能。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
<link rel="stylesheet" href="http://d2fq26gzgmfal8.cloudfront.net/bootstrap.min.css" media="screen">
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/r/bs/dt-1.10.9/datatables.min.css" />
</head>
<body>
<div class="container">
<div class="row">
<h1>Domain checker</h1>
</div>
<div class="row">
<table class="table table-bordered" id="queries">
<thead>
<tr>
<th>Domain</th>
<th>IPs</th>
</tr>
</thead>
<tbody>
{{ range .Queries }}
<tr>
<td><a href="http://{{ .Domain }}">{{ .Domain }} </a>
</td>
<td>{{ .IPs }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
<script src="http://d2fq26gzgmfal8.cloudfront.net/jquery-1.10.2.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/r/bs/dt-1.10.9/datatables.min.js"></script>
<script src="http://d2fq26gzgmfal8.cloudfront.net/bootstrap.min.js"></script>
<script>
$(document).ready(function()
{
$('#queries').DataTable();
});
</script>
</body>
</html>
我们必须在/
上公开另一个端点,该端点可以解析,执行和显示此模板。
func uiHandler(rw http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles("templates/index.html")
if err != nil {
log.Panic("Error occured parsing the template", err)
}
page := PageData{
Queries: queries,
}
if err = tmpl.Execute(rw, page); err != nil {
log.Panic("Failed to write template", err)
}
}
要进行连接,我们必须将UI端点添加到路由器中,定义我们使用的结构,并将所有传入的域查询添加到queries
列表中,以便可以在UI上显示它。
我们使用一种结构来注册查询及其结果。 另一个是用于描述UI的模板。
// The Query type represents a query against our service.
type Query struct {
Domain string
IPs []net.IP
}
// The data struct for our basic UI
type PageData struct {
Queries []Query
}
在createBaseHandler
函数中定义了先前处理程序的createBaseHandler
,可以添加UI端点。
r.HandleFunc("/", uiHandler)
最后,我们将在IPHandler
完成的每个查询附加到全局变量。 变量:
var queries = []Query{}
在IPHandler
函数中:
queries = append(queries, Query{domain, ips})
每个团队成员都喜欢我们为我们的服务提供UI。 当新的团队成员尝试了解特定服务的功能时,此策略会有所帮助。 UI为他们提供了服务用途的功能视图。 当您可以在浏览器中检查服务而不必查询数据库时,也更容易查看服务的情况。
令人惊讶的副作用是客户的运营和销售也开始使用它们。 易于访问的UI使我们的服务在开发团队之外可用。
均匀心跳
我们需要了解有关运行服务的一组简单指标:
- 它在运行吗?
- 如果正在运行,它正在运行哪个版本?
我们希望为我们编写和运行的每项服务提供此信息。 有了Go,再简单不过了。
我们提供了在给定端口上运行Web服务的库 。 调用时,它将返回用于构建二进制文件,正常运行时间和状态的提交的SHA-1代码。 要使用此库,我们go heartbeat.RunHeartbeatService(portnum)
就是将go heartbeat.RunHeartbeatService(portnum)
添加到我们的主要函数中。
仅用一条线路,我们就在我们的服务中添加了心跳信号,任何第三方都可以使用该信号来检查我们的服务状态。 我们使用Consul进行服务发现和配置管理。 我们的Consul群集使用这些检测信号来了解服务实例是否正常。
对于DNS,我们使用AWS Route53。 它还使用此心跳信号来查看哪些IP地址对于给定的DNS记录有效。
要在构建服务时设置提交SHA-1代码,我们将Makefile中的go build
命令更改为:
go build --ldflags="-X github.com/enbritely/heartbeat-golang.CommitHash`git rev-parse HEAD"
它将SHA-1哈希添加到心跳库,当调用http://<service-address/heartbeat
url时,它将返回它。
另一个简单的解决方案是,任何人都容易记住每个服务都具有这种心跳。 它可用于获取运行版本或检查状态。 这不是计划中的优势,但后来在自动化方面也大有帮助。 当我们引入自动故障转移功能时,如果发生故障,其中一项服务将替换为另一项服务,则我们使用此心跳功能来检查服务的运行状况。
后来的优势的另一个例子是当我们的一个队友创建了一个网站,其中列出了所有已部署的服务。 因为有了此心跳服务,所以我们只需查询所有服务并显示结果页面即可。
要启用心跳服务侦听HEARTBEAT_ADDRESS环境变量中配置的地址,请在主函数中添加以下两行。
hAddress := os.Getenv(“HEARTBEAT_ADDRESS”)
go heartbeat.RunHeartbeatService(hAddress)
要安装心跳服务,您必须先获取它。
go get github.com/enbritely/heartbeat-golang
组态
我也喜欢保持配置简单。 我们遵循12因素应用技术,该技术描述了配置应来自环境。 这意味着环境变量。
它的简单性使其非常灵活。 在生产中设置与测试,登台或开发不同的配置很容易。 我们使用服务启动脚本在生产中设置这些变量,因此在服务启动时它们就可用。
环境变量很容易在开发机器上设置:您可以在命令行上定义它们,也可以将它们导出到.bashrc
或.profile
文件中。 我们使它变得更加容易。
每个开发人员都在项目的根文件夹中定义一个.dotenv
文件。 它包含我们的环境变量; 采购它们将设置开发配置。 它快速,简单,每个开发人员都可以调整。
例如,如果您不想在计算机上运行数据库,则可以将数据库地址设置为指向所需的任何位置。 我们中的一些人使用Docker运行本地数据库,我们中的一些人使用AWS RDS进行测试。 使用环境变量和.dotenv文件,这不是问题。
结论
软件开发应该很有趣。 如果有部分事情可以取笑,这就是整个团队的最大利益。 使代码审查,部署,测试或监视更加容易是一个很好的起点。
Go对此提供了大力支持。 使我们的开发环境在每个项目中保持一致,有助于现有或新团队成员更轻松地入职。 使服务可访问有助于调试。 用户界面为我们的服务创建了易于阅读的界面。 所有这些做法可帮助我们的团队专注于重要的事情。
您对Go的体验是什么? 您还有其他最佳做法吗? 请在评论中分享!
翻译自: https://www.javacodegeeks.com/2015/11/utilizing-the-simplicity-of-go-for-easy-development.html