第 6 章:运用后端服务
后端服务是云原生架构的基础,通过合理设计服务系统、测试驱动开发和服务发现机制,能够实现高效、可靠的微服务生态。本章将系统介绍服务系统设计、依赖服务的 TDD 实践、数据共享模式与服务发现等关键技术。
服务系统设计
在真实场景中,服务往往不是孤立存在的,而是彼此依赖、相互协作,形成完整的服务系统。服务需要授权、认证、数据持久化、缓存、文档生成等多种组件,开发时应始终以系统化思维进行架构设计。
下图展示了常见但不合理的服务依赖关系:

这种线性层次结构容易导致下游服务异常被忽略,测试覆盖不足。更合理的服务架构如图所示:

为避免系统混乱,建议提前设计 API,严格遵守协议和版本控制,确保服务间协作有序。
测试驱动开发与依赖服务实践
测试驱动开发(TDD)不仅提升代码质量,更能增强开发者信心。本节将以 catalog 服务和 fulfillment 服务为例,展示如何用 TDD 构建依赖服务。
构建 Fulfillment 服务
Fulfillment 服务作为仓库服务,负责商品库存和发货时间。采用 TDD,首先编写测试用例:
package service
import (
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/codegangsta/negroni"
"github.com/gorilla/mux"
"github.com/unrolled/render"
)
var (
formatter = render.New(render.Options{
IndentJSON: true,
})
)
func TestGetFullfillmentStatusReturns200ForExistingSKU(t *testing.T) {
var (
request *http.Request
recorder *httptest.ResponseRecorder
)
server := NewServer()
targetSKU := "THINGAMAJIG12"
recorder = httptest.NewRecorder()
request, _ = http.NewRequest("GET", "/skus/"+targetSKU, nil)
server.ServeHTTP(recorder, request)
var detail fulfillmentStatus
if recorder.Code != http.StatusOK {
t.Errorf("Expected %v; received %v", http.StatusOK, recorder.Code)
}
payload, err := ioutil.ReadAll(recorder.Body)
if err != nil {
t.Errorf("Error parsing response body: %v", err)
}
err = json.Unmarshal(payload, &detail)
if err != nil {
t.Errorf("Error unmarshaling response to fullfillment status: %v", err)
}
if detail.QuantityInStock != 100 {
t.Errorf("Expected 100 qty in stock, got %d", detail.QuantityInStock)
}
if detail.ShipsWithin != 14 {
t.Errorf("Expected shipswithin 14 days, got %d", detail.ShipsWithin)
}
if detail.SKU != "THINGAMAJIG12" {
t.Errorf("Expected SKU THINGAMAJIG12, got %s", detail.SKU)
}
}
本例中有以下几个测试断言。
- 从资源/skus/*{sku}*中接收一个200状态码。
- 可以解析这个响应体。
- 响应体可以被解析成 fulfillmentStatus 结构体。
- 构造一个假的响应体,因为还没有构建出一个功能完善的服务。
回顾上一章,像这样的测试并不是一气呵成的。而是要编写一个运行失败的测试断言后,通过代码实现让它运行通过,然后再编写另一个测试断言,再通过代码实现让它运行通过,这样不断重复才构建完成的。
依照这种方式构建测试,可以让我们从繁重的代码迭代检查中解脱出来。
fulfillment 服务只包含一个请求处理器,它的代码如代码清单 6.2 所示。不要忘记我们现在测试的是故意用伪造的虚假数据作为返回值的服务。如果想将其变成更符合生产的功能性服务,首先要做的就是编写测试断言,证明此服务没有使用虚假数据作为返回值。如果是虚假的数据,那么这些测试将会运行失败。所以,如果想让测试运行通过,就不得不编写功能代码让服务显得更“真实”。
代码清单 6.2 handlers.go
package service
import (
"net/http"
"github.com/gorilla/mux"
"github.com/unrolled/render"
)
// getFullfillmentStatusHandler simulates actual fulfillment by supplying
// bogus values for QuantityInStock
// and ShipsWithin for any given SKU. Used to demonstrate a backing service
// supporting a primary service.
func getFullfillmentStatusHandler(formatter *render.Render) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
vars := mux.Vars(req)
sku := vars["sku"]
formatter.JSON(w, http.StatusOK, fulfillmentStatus{
SKU: sku,
ShipsWithin: 14,
QuantityInStock: 100,
})
}
}
func rootHandler(formatter *render.Render) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
formatter.Text(w,
http.StatusOK,
"Fulfillment Service, see (url) for API.")
}
}
这个函数通过返回仅包含一个变量值 SKU 的虚假值来使测试通过。这个返回值总是返回两周内的交易商品和 100 个商品库存。
最后,需要设置路由才能让浏览器或者 RESTful 客户端进行测试,请看如下的 initRoutes 方法。
func initRoutes(mx mux.Router, formatterrender.Render) {
mx.HandleFunc("/skus/{sku}",
getFullfillmentStatusHandler(formatter)).Methods("GET")
}
要相信 fulfillment 模拟器可以按照预期正常工作,因为我们已经运行过测试用例了。如果想要运行出如下结果(发送一个 SKU 参数值为 WIDGET42 的 GET 请求),就要采用如下命令(假设目前已经从 Git 上获取了最新的代码并用 glide install 命令安装了相应的第三方包)。
$ go build
$ ./fulfillment-service
[negroni] listening on :3001
[negroni] Started GET /skus/WIDGET42
[negroni] Completed 200 OK in 163.614μs
构建 Catalog 服务
现在已经有了一个 fulfillment 模拟服务,这个服务已经通过了手动单元测试并有很好的代码覆盖率,接下来就可以继续构建 catalog 服务了。
catalog 服务也是一个返回伃造虚假值的模拟器,但是有一个例外:一些数据从 catalog 服务返回后需要再去请求 fulfillment 服务。
发送一个 GET 请求到 catalog 服务并获得 SKU 的值,然后 catalog 服务还会再去请求 fulfillment 服务进而获得这个 SKU 的值并返回相应的伪造数据。
代码清单 6.3 显示了 catalog 服务的测试用例,它和 fulfillment 的测试用例相似,这些测试都是基于期望的伪造值编写的。
代码清单 6.3 handlers_test.go
package service
import (
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/codegangsta/negroni"
"github.com/unrolled/render"
)
var (
formatter = render.New(render.Options{
IndentJSON: true
})
)
func TestGetDetailsForCatalogItemReturnsProperData(t *testing.T) {
var (
request *http.Request
recorder *httptest.ResponseRecorder
)
server := MakeTestServer()
targetSKU := "THINGAMAJIG12"
recorder = httptest.NewRecorder()
request, _ = http.NewRequest("GET", "/catalog/"+targetSKU, nil)
server.ServeHTTP(recorder, request)
var detail catalogItem
if recorder.Code != http.StatusOK {
t.Errorf("Expected %v; received %v", http.StatusOK, recorder.Code)
}
payload, err := ioutil.ReadAll(recorder.Body)
if err != nil {
t.Errorf("Error parsing response body: %v", err)
}
err = json.Unarshal(payload, &detail)
if err != nil {
t.Errorf("Error unmarshaling response to catalog item: %v", err)
}
if detail.QuantityInStock != 1000 {
t.Errorf("Expected 100 qty in stock, got %d", detail.QuantityInStock)
}
if detail.ShipsWithin != 99 {
t.Errorf("Expected shipswithin 14 days, got %d", detail.ShipsWithin)
}
if detail.SKU != "THINGAMAJIG12" {
t.Errorf("Expected SKU THINGAMAJIG12, got %s", detail.SKU)
}
if detail.ProductID != 1 {
t.Errorf("Expected product ID of 1, got %d", detail.ProductID)
}
}
func MakeTestServer() *negroni.Negroni {
fakeClient := fakeWebClient{}
return NewServerFromClient(fakeClient)
}
type fakeWebClient struct{}
func (client fakeWebClient) getFulfillmentStatus(sku string)
(status fulfillmentStatus, err error) {
status = fulfillmentStatus{
SKU: sku,
ShipsWithin: 99,
QuantityInStock: 1000,
}
return status, err
}
若要让上面的测试通过,需要创建请求处理器,该处理器中使用一个真实的客户端去消费 fulfillment 服务,或者使用一个假的客户端仅仅返回伪造的数据。
代码清单 6.4 显示了具体的实现代码。
代码清单 6.4 handlers.go
package service
import (
"fmt"
"net/http"
"github.com/gorilla/mux"
"github.com/unrolled/render"
)
// getAllCatalogItemsHandler returns a fake list of catalog items
func getAllCatalogItemsHandler(formatter *render.Render) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
catalog := make([]catalogItem, 2)
catalog[0] = fakeItem("ABC1234")
catalog[1] = fakeItem("STAPLER99")
formatter.JSON(w, http.StatusOK, catalog)
}
}
// getCatalogItemDetailsHandler returns a fake catalog item. The key takeaway here
// is that we're using a backing service to get fulfillment status for the individual
// item.
func getCatalogItemDetailsHandler(formatter *render.Render,
serviceClient fulfillmentClient) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
vars := mux.Vars(req)
sku := vars["sku"]
status, err := serviceClient.getFulfillmentStatus(sku)
if err == nil {
formatter.JSON(w, http.StatusOK, catalogItem{
ProductID: 1,
SKU: sku,
Description: "This is a fake product",
Price: 1599, //$15.99
ShipsWithin: status.ShipsWithin,
QuantityInStock: status.QuantityInStock,
})
} else {
formatter.JSON(w, http.StatusInternalServerError,
fmt.Sprintf("Fulfillment Client error: %s", err.Error()))
}
}
}
func rootHandler(formatter *render.Render) http.HandlerFuxnc {
return func(w http.ResponseWriter, req *http.Request) {
formatter.Text(w, http.StatusOK,
"Catalog Service, see http://github.com/cloudnativego/backing-catalog for API.")
}
}
func fakeItem(sku string) (item catalogItem) {
item.SKU = sku
item.Description = "This is a fake product"
item.Price = 1599
item.QuantityInStock = 75
item.ShipsWithin = 14
return
}
如上所示,数据封装中的两个值 ShipsWithin 和 QuantityInStock 明确地被后端服务赋值。这个测试通过后,就可以继续下一步测试。
提到测试模式,可以采用许多测试方法。比如使用注入库和魔法库去创建虚假对象,然后虚假对象记录被调用和没被调用的内容,并且可以在给定的环境下指定虚假对象返回什么值。
上面所说的方法没有错误,但是实现这样的方法可以有很多种方式,如果利用 Go 语言接口的天生优势来做这件事,那么只需要一个接口就够了,这个称之为will-it-blend typing
接下来要做的是创建 fulfillment 客户端,这个客户端将会发送一个 HTTP 请求到后端服务。如果是创建方便测试的假客户端,那么只需要返回合适的虚假值即可。
代码清单 6.6 展示了一个已被实现的 HTTP 客户端。这里定义了一个 fulfillmentClient 接口,该接口只有一个方法供真客户端或假客户端来实现。
Apiary 和客户端代码生成器
调用 HTTP 方法并保存返回的结果是需要经常进行的繁重工作。使用 Apiary 可以使编写 API 代码变得更简单,可以预览并执行样例代码来访问特定的 API。这样的话用任何语言(包括 Go)来生成客户端包装器都是非常方便的。
代码清单 6.6 中的代码展示了创建一个 HTTP 请求并将这个请求的返回值反序列化成 fulfillmentStatus 结构体的过程。
代码清单 6.6 fulfillment-client.go
package service
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
)
type fulfillmentClient interface {
getFulfillmentStatus(sku string) (status fulfillmentStatus, err error)
}
type fulfillmentWebClient struct {
rootURL string
}
func (client fulfillmentWebClient) getFulfillmentStatus(sku string)
(status fulfillmentStatus, err error) {
httpclient := &http.Client{}
skuURL := fmt.Sprintf("%s/%s", client.rootURL, sku)
fmt.Printf("About to request SKU details from backing service: %s\n", skuURL)
req, _ := http.NewRequest("GET", skuURL, nil)
resp, err := httpclient.Do(req)
if err != nil {
fmt.Printf("Errored when sending request to the server: %s\n",
err.Error())
return
}
defer resp.Body.Close()
payload, _ := ioutil.ReadAll(resp.Body)
err = json.Unmarshal(payload, &status)
if err != nil {
fmt.Println("Failed to unmarshal server response.")
return
}
return status, err
}
如下面这段代码所示,后端服务的 URL 都是硬编码的。
func NewServer() *negroni.Negroni {
formatter := render.New(render.Options{
IndentJSON: true
})
n := negroni.Classic()
mx := mux.NewRouter()
webClient := fulfillmentWebClient{
rootURL: "http://localhost:3001/skus",
}
initRoutes(mx, formatter, webClient)
n.UseHandler(mx)
return n
}
func initRoutes(mx *mux.Router, formatter *render.Render, webClient fulfillmentClient) {
mx.HandleFunc("/catalog",
getAllCatalogItemsHandler(formatter)).Methods("GET")
mx.HandleFunc("/catalog/{sku}",
getCatalogItemDetailsHandler(formatter, webClient)).Methods("GET")
}
若要修复这个硬编码,可以通过读取环境变量的方式在端口 3001 上启动 fulfillment 服务,然后再启动 catalog 服务。在命令终端访问 catalog 服务将会返回如下输出结果。
[negroni] Started GET /catalog/THINGY
[negroni] Completed 200 OK in 2.122473ms
如果在终端上同样可以看到 fulfillment 服务的输出,那么就证明 catalog 服务的确调用了 fulfillment 服务。
[negroni] Started GET /skus/THINGY
[negroni] Completed 200 OK in 79.226μs
仔细思考一下,到目前为止我们已经完成了以下步骤。
- 运用 TDD 方式构建了一个依赖于其他服务的服务。
- 构建了另一个测试优先的服务,此服务返回虚假值,并模仿后端服务的功能。
测试通过后就可以创建抽象服务客户端。在代码中采用真实的 HTTP 客户端,在测试代码中使用假数据,以上操作若没有代码生成器、模拟器等工具都是无法完成的。
启动所有的服务,每个服务绑定不同端口,确保在调用 catalog 服务时会再访问调用 fullfillment 服务。
在服务之间共享结构化数据
本章已经介绍了一些服务,这些服务访问其他服务暴露出来的数据。在示例中,fulfillment 服务对外暴露一个很小的结构体,这个结构体通过变量 SKU 来表示 fulfillment 服务的库存状态。库存状态信息包括商品的具体交易时间和库存余量。
仔细观察会注意到一些细节,如果不能很好地处理这些细节将直接导致优雅的代码变成肮脏的代码,甚至可能会变成可怕的代码。
fulfillment 服务需要维护一个结构体,这个结构体代表了服务的状态,可以被服务操作并序列化成 JSON 数据。catalog 服务也要维护一个结构体,这个结构体代表了同样的服务状态,所以需要读取 fulfillment 服务暴露出来的 JSON 数据,并将其填充到 catalog 商品的详细结果中。
目前存在一个核心问题,那就是数据模型的共享问题。通常可以用三种方法来解决这个问题,下面将会逐个探讨每一种方法。
客户端引用服务端包
这个解决方案将结构体 fulfillmentStatus 变成了 FulfillmentStatus(由此变成可导出类型),这样就可以在 catalog 服务中直接读取 fulfillment 服务的状态数据了。
表面上看来这是一个好主意,可以最大化代码复用,没有一行重复的代码,这样就不会因为在 fulfillment 与 catalog 中定义了相同的实例而产生偏差。
这看起来像是一个不错的解决方案,但实际上却存在着危险的副作用。最大的副作用产生在读取 fulfillment 服务暴露出来的所有数据这一环节。现在 catalog 服务与 fulfillment 服务紧密耦合在一起,如果 fulfillment 服务发生某些更改,虽然没有更改公共约定,但也很可能会导致 catalog 服务的编译或运行失败。
所以要尽可能避免使用这种方法。作为作者与开发者,我们一起经历了整个服务系统的开发,让客户端直接引用服务端的代码不会带来任何益处,有时候会导致严重后果。
客户端复制服务端结构
本章代码示例中已经采用了这个方法。在这种情况下,fulfillment 与 catalog 服务都会自定义 fulfillmentStatus 结构体并附带 JSON 序列化标签。
许多的开发者和架构师看到这种做法后,估计要产生杀人的冲动了。怎么敢复制粘贴同样的代码两次呢?超过 10 行的重复代码是不可能被允许的,并且会被其他人厌恶。
重复代码
信不信由你,我们曾经讨论过很多次关于重复代码行数的问题,到底是 10 行还是 3 行。很多人通常会在忘记某种特定模式真正意图的情况下而严格遵守这个行数要求。
若不想改变 fulfillment 服务的内部代码,那就必须改变 catalog 服务的内部代码。即使 catalog 服务采用最新版本的 fulfillment 服务中的结构体(取决于是否关注相关依赖的更新),但是只要 fulfillment 服务改变了自己的内部代码,那么 catalog 服务也只能跟着改变。
概括一下,服务端与客户端可以有完全不一样的内部结构定义,它们都是 API 返回数据的组成部分。这样服务端与客户端可以随意改变自己的内部代码并维持自己的版本发布节奏。举个例子,可以用不同的结构体来组成相同的 JSON 结构。想要获得什么样子的 JSON 数据结构取决于这个数据结构被如何处理,以及这个数据结构是否会被其他服务影响。
为了加强理解,这里引用 Sam Newman 所著的Building Microservices中的一句话:
各服务之间的重复代码远远比服务内部的重复代码糟糕得多。 —— Sam Newman
客户端与服务端引用共享包
现在来看第三种方法。客户端直接引用服务端代码包的这种方式是不理想的,这将会导致丑陋的代码片段产生。通过下面这种方式可以解决这个问题:从客户端和服务端中提取共享数据结构,将它们移动到共享和中立的地方供所有的服务引用。这是一个不错的解决办法,对吗?
不,这种方法具有第一种方法的所有缺点,并成功地伪装成了一种优雅的解决方案。这种方法只是玩了一个 shell 游戏,聪明地结合公有结构体与私有结构体,将坏代码的气味隐藏起来,让多个服务间共享依赖。经验告诉我们,这种方法是不可取的。
应该避免使用这种方法。创建一个真正的敏捷和协作的服务生态系统,唯一安全的方法就是保证它们尽可能地松耦合,这意味着不能在它们之间共享任何结构体。
康威定律中声称,团队组织结构和微服务结构之间是有直接关联的,所以必须要考虑到客户端与服务端不是由同一个人来维护的可能性,这样会增加对共享结构的维护难度。
在过去的项目中,我们企图在 RESTful 服务与安卓客户端之间共享简单的 Java 类,这导致了无休止的噩梦——运行时故障和周期性的数据损坏。我们应该引以为鉴,避免这种噩梦再次发生。
服务绑定与外部配置
硬编码服务地址不利于云原生部署,推荐通过环境变量或服务绑定实现外部化配置。以 Cloud Foundry 为例,可通过 user-provided service 绑定 fulfillment 服务地址,catalog 服务启动时自动读取并配置:
package service
import (
"fmt"
"github.com/cloudfoundry-community/go-cfenv"
"github.com/cloudnativego/cf-tools"
"github.com/codegangsta/negroni"
"github.com/gorilla/mux"
"github.com/unrolled/render"
)
// NewServerFromCFEnv decides the URL to use for a webclient
func NewServerFromCFEnv(appEnv *cfenv.App) *negroni.Negroni {
webClient := fulfillmentWebClient{
rootURL: "http://localhost:3001/skus"
}
val, err := cftools.GetVCAPServiceProperty("backing-fulfill", "url", appEnv)
if err == nil {
webClient.rootURL = val
} else {
fmt.Printf("Failed to get URL property from bound service: %v\n", err)
}
fmt.Printf("Using the following URL for fulfillment backing service: %s\n",
webClient.rootURL)
return NewServerFromClient(webClient)
}
// NewServerFromClient configures and returns a Server.
func NewServerFromClient(webClient fulfillmentClient) *negroni.Negroni {
formatter := render.New(render.Options{
IndentJSON: true
})
n := negroni.Classic()
mx := mux.NewRouter()
initRoutes(mx, formatter, webClient)
n.UseHandler(mx)
return n
}
func initRoutes(mx *mux.Router, formatter *render.Render, webClient fulfillmentClient) {
mx.HandleFunc("/", rootHandler(formatter)).Methods("GET")
mx.HandleFunc("/catalog", getAllCatalogItemsHandler(formatter)).Methods("GET")
mx.HandleFunc("/catalog/{sku}",
getCatalogItemDetailsHandler(formatter, webClient)).Methods("GET")
}
Cloud Foundry CLI 示例:
cf create-user-provided-service backing-fulfill -p "url"
cf push -o cloudnativego/backing-catalog
cf bind-service backing-catalog backing-fulfill
服务发现机制
服务发现分为静态绑定和动态发现。动态服务发现通过注册中心实现服务自动注册与发现,提升系统弹性和可维护性。
Netflix Eureka 服务发现
Eureka 是 Netflix 开源的服务发现系统,支持服务注册、心跳检测和实例管理。可通过 Docker 快速启动:
docker run -p 8080:8080 netflixoss/eureka:1.3.1
Go 客户端示例(使用 fargo 库):
package main
import (
"fmt"
"github.com/hudl/fargo"
)
func main() {
// For a real app, you'd bind a user-provided service with eureka
// credentials and URL.
c := fargo.NewConn("http://192.168.99.100:8080/eureka/v2")
i := fargo.Instance{
HostName: "i-6543",
Port: 9090,
App: "TESTAPP",
IPAddr: "127.0.0.10",
VipAddress: "127.0.0.10",
SecureVipAddress: "127.0.0.10",
DataCenterInfo: fargo.DataCenterInfo{Name: fargo.MyOwn},
Status: fargo.UP,
}
c.RegisterInstance(&i)
f, _ := c.GetApps()
for key, theApp := range f {
fmt.Println("App:", key, " First Host Name:", theApp.Instances[0].HostName)
}
app, _ := c.GetApp("TESTAPP")
fmt.Printf("%v\n", app)
}
读者练习
建议读者实践以下步骤,巩固服务注册与发现能力:
- fork backing-catalog 和 backing-fulfillment 仓库
- 修改 fulfillment 服务,启动时自动注册到 Eureka
- 修改 catalog 服务,启动时自动注册到 Eureka
- 修改 catalog 服务,使用 fargo 向 Eureka 查询 fulfillment 服务主机信息
进阶操作
- 注册主机信息为非硬编码,使用 cf-env 库自动查询应用路由,实现自动注册和发现。
总结
本章系统介绍了后端服务系统设计、测试驱动开发、服务间数据共享模式、服务绑定与外部配置、服务发现机制等关键技术。通过实践这些模式,能够构建高效、可靠、可扩展的云原生微服务系统。建议读者结合实际项目,深入理解服务协作与发现机制,为后续复杂系统开发打下坚实基础。
参考文献
- Building Microservices - oreilly.com
- Cloud Foundry 官方文档 - pivotal.io
- Go 官方网站 - golang.org
- Netflix Eureka - github.com
- Fargo Go 客户端 - github.com
- cf-env 项目文档 - github.com