Vstars 2019-06-27
欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~
本文来自云+社区翻译社,由Tnecesoc编译。
微服务就是将应用程序的业务领域划分为具有明确范围的不同场景,并以分离的流程来运行这些场景,使得其中跨边界的任何持久化的关系必须依赖最终的一致性,而不是 ACID 类事务或外键约束。这些概念很多都来源于领域驱动设计(DDD),或受到了它的启发。不过 DDD 是个要花一整个博客系列来讲的话题,这里就先不提了。
在我们的 Go 微服务系列博客还有微服务架构的背景下,实现服务间松耦合的一种方式是引入消息传递机制来进行不需要遵循严格的请求 / 响应式的消息交换或类似机制的服务的通信。这里要强调一点,引入消息传递机制只是众多可以用来实现服务间松耦合的策略之一。
正如我们在博客系列的第 8 章看到的那样,在 Spring Cloud 里,Spring Cloud Config 服务器将 RabbitMQ 作为了运行时的一个依赖项目,因此 RabbitMQ 应该是一个不错的消息中继器(message broker)。
至于本系列博客的这一章的内容,我们会在读取特定的帐号对象的时候让我们的 "account service" 往 RabbitMQ 的交换器里面放一条消息。这一消息会被一个我们会在这篇博客里编写的,全新的微服务所处理。我们同时也会将一些 Go 语言代码放在一个 “common” 库里面来实现在跨微服务情景下的代码复用。
记住第 1 部分的系统图景吗?下面是完成这一章的部分之后的样子:
在完成这一部分之前我们要做很多工作,不过我们能够做到。
这部分会有很多新代码,我们不可能把它全放在博客文章里。若要取得完整的源代码,不妨用 git clone 命令下载下来,并切换到第 9 章的分支:
git checkout P9
我们将实施一个简单的仿真用例:当在 “account service” 中读取某些 “VIP” 账户时,我们希望触发 “VIP offer” 服务。这一服务在特定情况下会为账户持有者生成一个 “offer” 。在一个设计正确的领域模型里面,accounts 对象还有 VIP offer 对象都是两个独立的领域,而它们应尽可能少地了解彼此。
这个 account service 应当从不直接访问 VIP service 的存储空间(即 offers)。在这种情况下,我们应该把一条消息传递给 RabbitMQ 上的 “vip service”,并将业务逻辑和持久化存储完全委任给 “vip service”。
我们将使用 AMQP 协议来进行所有通信,AMQP 协议是一个作为 ISO 标准的应用层协议,其所实现的消息传递能为系统带来可互操作性。这里我们就延用在第 8 章我们处理配置更新(configuration update)时候的设置,选用 streadway / amqp 这一 AMQP 库。
让我们重述一下 AMQP 中的交换器(exchange)与发布者(publisher)、消费者(consumer)和队列(queue)的关系:
发布者会将一条消息发布到交换点,后者会根据一定的路由规则或登记了相应消费者的绑定信息来讲消息的副本发到队列(或分发到多个队列)里去。对此,Quora 上的这个回答有个很好的解释。
由于我们想要使用新的以及现有的代码来从我们现有的 account service 和新的 vip service 里面的 Spring Cloud 配置文件里面加载我们所需的配置,所以我们会在这里创建我们的第一个共享库。
首先在 /goblog 下创建新文件夹 common 来存放可重用的内容:
mkdir -p common/messaging mkdir -p common/config
我们将所有与 AMQP 相关的代码放在 messaging 文件夹里面,并将配置文件放在 config 文件夹里。你也可以将 /goblog/accountservice/config 的内容复制到 /goblog/common/config 中 - 请记住,这要求我们更新先前从 account service 中导入配置代码的 import 语句。不妨看看完整源代码来查阅这部分的写法。
跟消息传递有关的代码会被封装在一个文件中,该文件将定义应用程序用来连接,发布和订阅消息的接口还有实际实现。实际上,我们用的 streadway / amqp 已经提供了很多实现 AMQP 消息传递所需的模板代码,所以这部分的具体细节也便不深究了。
在 /goblog/common/messaging 中创建一个新的 .go 文件:messagingclient.go。
让我们看看里面主要应有什么:
// 定义用来连接、发布消息、消费消息的接口 type IMessagingClient interface { ConnectToBroker(connectionString string) Publish(msg []byte, exchangeName string, exchangeType string) error PublishOnQueue(msg []byte, queueName string) error Subscribe(exchangeName string, exchangeType string, consumerName string, handlerFunc func(amqp.Delivery)) error SubscribeToQueue(queueName string, consumerName string, handlerFunc func(amqp.Delivery)) error Close() }
上面这段代码定义了我们所用的消息接口。这就是我们的 “account service” 和 “vip service” 在消息传递时将要处理的问题,能通过抽象手段来消除系统的大部分复杂度。请注意,我选择了两个 “Produce” 和 “Consume” 的变体,以便与订阅/发布主题还有 direct / queue 消息传递模式合在一起使用。
接下来,我们将定义一个结构体,该结构体将持有指向 amqp.Connection
的指针,并且我们将再加上一些必要的方法,以便它能(隐式地,Go 一直以来都是这样)实现我们刚才声明的接口。
// 接口实现,封装了一个指向 amqp.Connection 的指针 type MessagingClient struct { conn *amqp.Connection }
接口的实现非常冗长,在此只给出其中两个 - ConnectToBroker()
和 PublishToQueue()
:
func (m *MessagingClient) ConnectToBroker(connectionString string) { if connectionString == "" { panic("Cannot initialize connection to broker, connectionString not set. Have you initialized?") } var err error m.conn, err = amqp.Dial(fmt.Sprintf("%s/", connectionString)) if err != nil { panic("Failed to connect to AMQP compatible broker at: " + connectionString) } }
这就是我们获得 connection 指针 (如 amqp.Dial
) 的方法。如果我们丢掉了配置文件,或者连接不上中继器,那么微服务就会抛出一个 panic 异常,并会让容器协调器重新创建一个新的实例。在这里传入的 connectionString 参数就如下例所示:
amqp://guest:guest@rabbitmq:5672/
注意,这里的 rabbitmq broker 是以 service 这一 Docker Swarm 的模式下运行的。
PublishOnQueue()
函数很长 - 它跟官方提供的 streadway 样例或多或少地有些不同,毕竟这里简化了它的一些参数。为了将消息发布到一个有名字的队列,我们需要传递这些参数:
若要了解交换器的更多详情,那请参阅 RabbitMQ 文档。
func (m *MessagingClient) PublishOnQueue(body []byte, queueName string) error { if m.conn == nil { panic("Tried to send message before connection was initialized. Don't do that.") } ch, err := m.conn.Channel() // 从 connection 里获得一个 channel 对象 defer ch.Close() // 提供一些参数声明一个队列,若相应的队列不存在,那就创建一个 queue, err := ch.QueueDeclare( queueName, // 队列名 false, // 是否持久存在 false, // 是否在不用时就删掉 false, // 是否排外 false, // 是否无等待 nil, // 其他参数 ) // 往队列发布消息 err = ch.Publish( "", // 目标为默认的交换器 queue.Name, // 路由关键字,例如队列名 false, // 必须发布 false, // 立即发布 amqp.Publishing{ ContentType: "application/json", Body: body, // JSON 正文, 以 byte[] 形式给出 }) fmt.Printf("A message was sent to queue %v: %v", queueName, body) return err }
这里的模板代码略多,但应该不难理解。这段代码会声明一个(如果不存在那就创建一个)队列,然后把我们的消息以字节数组的形式发布给它。
将消息发布到一个有名字的交换器的代码会更复杂,因为它需要一段模板代码来声明交换器,以及队列,并把它们绑定在一起。这里有一份完整的源代码示例。
接下来,由于我们的 “MessageClient” 的实际使用者会是 /goblog/accountservice/service/handlers.go ,我们会往里面再添加一个字段,并在请求的帐户有 ID “10000” 的时候往硬编码进程序中的 “is VIP” 检查方法中发送一条消息:
var DBClient dbclient.IBoltClient var MessagingClient messaging.IMessagingClient // NEW func GetAccount(w http.ResponseWriter, r *http.Request) { ...
然后:
... notifyVIP(account) // 并行地发送 VIP 消息 // 若有这样的 account, 那就把它弄成一个 JSON, 然后附上首部和其他内容来打包 data, _ := json.Marshal(account) writeJsonResponse(w, http.StatusOK, data) } // 如果这个 account 是我们硬编码进来的 account, 那就开个协程来发送消息 func notifyVIP(account model.Account) { if account.Id == "10000" { go func(account model.Account) { vipNotification := model.VipNotification{AccountId: account.Id, ReadAt: time.Now().UTC().String()} data, _ := json.Marshal(vipNotification) err := MessagingClient.PublishOnQueue(data, "vipQueue") if err != nil { fmt.Println(err.Error()) } }(account) } }
正好借此机会展示一下调用一个新的协程(goroutine)时所使用的内联匿名函数,即使用 go
关键字。我们不能因为要执行 HTTP 处理程序发送消息就把 “主” 协程阻塞起来,因此这也是增加一点并行性的好时机。
main.go 也需要有所更新,以便在启动的时候能使用加载并注入到 Viper 里面的配置信息来初始化 AMQ 连接。
// 在 main 方法里面调用这个函数 func initializeMessaging() { if !viper.IsSet("amqp_server_url") { panic("No 'amqp_server_url' set in configuration, cannot start") } service.MessagingClient = &messaging.MessagingClient{} service.MessagingClient.ConnectToBroker(viper.GetString("amqp_server_url")) service.MessagingClient.Subscribe(viper.GetString("config_event_bus"), "topic", appName, config.HandleRefreshEvent) }
这段没什么意思 - 我们通过创建一个空的消息传递结构,并使用从 Viper 获取的属性值来调用 ConnectToBroker
来得到 service.MessagingClient
实例。如果我们的配置没有 broker_url
,那就抛一个 panic 异常,毕竟在不可能连接到中继器的时候程序也没办法运行。
我们在第 8 部分中已将 amqp_broker_url
属性添加到了我们的 .yml 配置文件里面,所以这个步骤实际上已经做过了。
broker_url: amqp://guest:[email protected]:5672 _(dev)_ broker_url: amqp://guest:guest@rabbitmq:5672 _(test)_
注意,我们在 “test” 配置文件里面填入的是 Swarm 服务名 “rabbitmq”,而不是从我的电脑上看到的 Swarm 的 LAN IP 地址。(大家的实际 IP 地址应该会有所不同,不过运行 Docker Toolbox 时 192.168.99.100 似乎是标准配置)。
我们并不推荐在配置文件中填入用户名和密码的明文。在真实的使用环境中,我们通常可以使用在第 8 部分中看到的 Spring Cloud Config 服务器里面的内置加密功能。
当然,我们至少应该编写一个单元测试,以确保 handlers.go 中的 GetAccount
函数在有人请求由 “10000” 标识的非常特殊的帐户时会尝试去发送一条消息。
为此,我们需要在 handlers_test.go 中实现一个模拟的 IMessagingClient
还有一个新的测试用例。我们先从模拟开始。这里我们将使用第三方工具 mockery 生成一个 IMessagingClient
接口的模拟实现(在 shell 运行下面的命令的时候一定要先把 GOPATH 设置好):
> go get github.com/vektra/mockery/.../ > cd $GOPATH/src/github.com/callistaenterprise/goblog/common/messaging > ./$GOPATH/bin/mockery -all -output . Generating mock for: IMessagingClient
现在,在当前文件夹中就有了一个模拟实现文件 IMessagingClient.go。我看这个文件的名字不爽,也看不惯它的驼峰命名法,因此我们将它重命名一下,让它的名字能更明显的表示它是一个模拟的实现,并且遵循本系列博客的文件名的一贯风格:
mv IMessagingClient.go mockmessagingclient.go
我们可能需要在生成的文件中稍微调整一下 import 语句,并删除一些别名。除此之外,我们会对这个模拟实现采用一种黑盒方法 - 只假设它会在我们开始测试的时候起作用。
不妨也看一看这里生成的模拟实现的源码,这跟我们在第 4 章中手动编写的内容非常相似。
在 handlers_test.go 里添加一个新的测试用例:
// 声明一个模仿类来让测试更有可读性 var anyString = mock.AnythingOfType("string") var anyByteArray = mock.AnythingOfType("[]uint8") // == []byte func TestNotificationIsSentForVIPAccount(t *testing.T) { // 配置 DBClient 的模拟实现 mockRepo.On("QueryAccount", "10000").Return(model.Account{Id:"10000", Name:"Person_10000"}, nil) DBClient = mockRepo mockMessagingClient.On("PublishOnQueue", anyByteArray, anyString).Return(nil) MessagingClient = mockMessagingClient Convey("Given a HTTP req for a VIP account", t, func() { req := httptest.NewRequest("GET", "/accounts/10000", nil) resp := httptest.NewRecorder() Convey("When the request is handled by the Router", func() { NewRouter().ServeHTTP(resp, req) Convey("Then the response should be a 200 and the MessageClient should have been invoked", func() { So(resp.Code, ShouldEqual, 200) time.Sleep(time.Millisecond * 10) // Sleep since the Assert below occurs in goroutine So(mockMessagingClient.AssertNumberOfCalls(t, "PublishOnQueue", 1), ShouldBeTrue) }) })}) }
有关的详情都写在了注释里。在此,我也看不惯在断言 numberOfCalls 的后置状态之前人为地搞个 10 ms 的睡眠,但由于模拟是在与 “主线程” 分离的协程中调用的,我们需要让它稍微挂起一段时间等待主线程完成一些工作。在此也希望能对协程和管道(channel)有一个更好的惯用的单元测试方式。
我承认 - 使用这种测试方式的过程比在为 Java 应用程序编写单元测试用例时使用 Mockito 更加冗长。不过,我还是认为它的可读性不错,写起来也很简单。
接着运行测试,并确保测试通过:
go test ./...
首先要运行 springcloud.sh 脚本来更新配置服务器。然后运行 copyall.sh 并等待几秒钟,来让它完成对我们的 “account service” 的更新。然后我们再使用 curl 来获取我们的 “特殊” 帐户。
> curl http://$ManagerIP:6767/accounts/10000 {"id":"10000","name":"Person_0","servedBy":"10.255.0.11"}
若顺利的话,我们应该能够打开 RabbitMQ 的管理控制台。然后再看看我们是否在名为 vipQueue 的队列上收到了一条消息:
open http://192.168.99.100:15672/#/queues
在上图的最底部,我们看到 “vipQueue” 有 1 条消息。我们再调用一下 RabbitMQ 管理控制台中的 “Get Message” 函数,然后我们应该可以看到这条消息:
最后该从头开始写一个全新的微服务了,我们将用它来展示如何使用 RabbitMQ 的消息。我们会将迄今为止在本系列中学到的东西用到里面,其中包括:
如果你执行过了 git checkout P9
,那就应该可以在 root/goblog 文件夹中看到 “vipservice” 。
我不会在这里介绍每一行代码,毕竟它有些部分跟 “accountservice” 有所重复。我们会将重点放在 我们刚刚所发送的消息的 “消费方式” 上。有几点要注意:
我们将使用 /goblog/common/messaging 和 SubscribeToQueue
函数中的代码,例如:
SubscribeToQueue(queueName string, consumerName string, handlerFunc func(amqp.Delivery)) error
对此我们要提供这些参数:
将我们的回调函数绑定到队列的 SubscribeToQueue
函数的实现也没什么好说的。这是其源代码,如果需要也可以看一看。
接下来我们快速浏览一下 vip service 的 main.go 来看看我们设置这些东西的过程:
var messagingClient messaging.IMessagingConsumer func main() { fmt.Println("Starting " + appName + "...") config.LoadConfigurationFromBranch(viper.GetString("configServerUrl"), appName, viper.GetString("profile"), viper.GetString("configBranch")) initializeMessaging() // 确保在服务存在的时候关掉连接 handleSigterm(func() { if messagingClient != nil { messagingClient.Close() } }) service.StartWebServer(viper.GetString("server_port")) } // 在收到 "vipQueue" 发来的消息时会调用的回调函数 func onMessage(delivery amqp.Delivery) { fmt.Printf("Got a message: %v\n", string(delivery.Body)) } func initializeMessaging() { if !viper.IsSet("amqp_server_url") { panic("No 'broker_url' set in configuration, cannot start") } messagingClient = &messaging.MessagingClient{} messagingClient.ConnectToBroker(viper.GetString("amqp_server_url")) // Call the subscribe method with queue name and callback function err := messagingClient.SubscribeToQueue("vip_queue", appName, onMessage) failOnError(err, "Could not start subscribe to vip_queue") err = messagingClient.Subscribe(viper.GetString("config_event_bus"), "topic", appName, config.HandleRefreshEvent) failOnError(err, "Could not start subscribe to " + viper.GetString("config_event_bus") + " topic") }
很熟悉对吧?我们在后续的章节也很可能会反复提到设置并启动我们加进去的微服务的方法。这也是基础知识的一部分。
这个 onMessage
函数只记录了我们收到的任何 “VIP” 消息的正文。如果我们要实现更多的仿真用例,就得引入一些花哨的逻辑来确定账户持有者是否有资格获得 “super-awesome buy all our stuff (tm)” 的待遇,并且也可能要往 “VIP offer 数据库“ 里写入记录。有兴趣的话不妨也实施一下这一逻辑,然后交个 pull request。
最后再提一下这段代码。在这段代码的帮助下,我们可以按下 Ctrl + C 来杀掉一个服务的实例,或者我们也可以等待 Docker Swarm 来杀死一个服务实例。
func handleSigterm(handleExit func()) { c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt) signal.Notify(c, syscall.SIGTERM) go func() { <-c handleExit() os.Exit(1) }() }
这段代码在可读性上并不比别的代码好,它所做的只是将管道 “c” 注册为 os.Interrupt
和 syscall.SIGTERM
的监听器,并且会阻塞性地监听 “c” 上的消息,直到接到任意信号为止。这使我们能够确信,只要微服务的实例被杀了,这里的 handleExit()
函数就会被调用。若还是不能确信的话,可以用 Ctrl + C 或者 Docker Swarm scaling 来测试一下。kill
指令也可以,不过 kill -9
就不行。因此,除非必须,最好不要用 kill -9
来结束任何东西的运行。
handleExit()
函数将调用我们在 IMessageConsumer
接口上声明的 Close()
函数,该函数会确保 AMQP 连接的正常关闭。
这里的 copyall.sh 脚本已经更新过了。若有跟从上面的步骤,并且确保了合 Github 的 P9 分支的一致性,那就可以运行了。在完成部署之后,执行 docker service ls 就应该会打印这样的内容:
> docker service ls ID NAME REPLICAS IMAGE kpb1j3mus3tn accountservice 1/1 someprefix/accountservice n9xr7wm86do1 configserver 1/1 someprefix/configserver r6bhneq2u89c rabbitmq 1/1 someprefix/rabbitmq sy4t9cbf4upl vipservice 1/1 someprefix/vipservice u1qcvxm2iqlr viz 1/1 manomarks/visualizer:latest
或者也可以使用 dvizz Docker Swarm 服务渲染器查看状态:
由于 docker service logs 功能在 1.13.0 版本里面被标记成了实验性功能,因此我们必须用老一套的方式来查看 “vipservice” 的日志。
首先,执行 docker ps 找出 CONTAINER ID:
> docker ps CONTAINER ID IMAGE a39e6eca83b3 someprefix/vipservice:latest b66584ae73ba someprefix/accountservice:latest d0074e1553c7 someprefix/configserver:latest
记下 vipservice 的 CONTAINER ID,并执行 docker logs -f 检查其日志:
> docker logs -f a39e6eca83b3 Starting vipservice... 2017/06/06 19:27:22 Declaring Queue () 2017/06/06 19:27:22 declared Exchange, declaring Queue () 2017/06/06 19:27:22 declared Queue (0 messages, 0 consumers), binding to Exchange (key 'springCloudBus') Starting HTTP service at 6868
打开另一个命令行窗口,并 curl 一下我们的特殊账户对象。
> curl http://$ManagerIP:6767/accounts/10000
如果一切正常,我们应该在原始窗口的日志中看到响应队列的消息。
Got a message: {"accountId":"10000","readAt":"2017-02-15 20:06:27.033757223 +0000 UTC"}
用于跨服务实例分发工作的模式利用了工作队列的概念。每个 “vip 消息” 应该由一个“vipservice”实例处理。
所以让我们看看使用 docker service scale 命令将 “vipservice” 扩展成两个实例时的情形:
> docker service scale vipservice=2
新的 “vipservice” 实例应该能在几秒钟内完成部署。
由于我们在 AMQP 中使用了 direct / queue 的发送方法,我们应该会见到一种轮转调度式(round-robin)的分发情形。再用 curl 触发四个 VIP 帐户的查找:
> curl http://$ManagerIP:6767/accounts/10000 > curl http://$ManagerIP:6767/accounts/10000 > curl http://$ManagerIP:6767/accounts/10000 > curl http://$ManagerIP:6767/accounts/10000
再次检查我们原始的 “vipservice” 的日志:
> docker logs -f a39e6eca83b3 Got a message: {"accountId":"10000","readAt":"2017-02-15 20:06:27.033757223 +0000 UTC"} Got a message: {"accountId":"10000","readAt":"2017-02-15 20:06:29.073682324 +0000 UTC"}
正如我们所预期的那样,我们看到第一个实例处理了四个消息中的两个。如果我们对另一个 “vipservice” 实例也执行一次 docker logs,那么我们也应该会在那里看到两条消息。
实际上,我并没有真正想出一个好的方式来在避免花费大量的时间模拟一个 AMQP 库的前提下,对 AMQP 消费者进行单元测试。在 messagingclient_test.go 里面准备了一个测试,用于测试订阅者对传入消息的等待以及处理的一个循环。不过并没有值得一提的地方。
为了更全面地测试消息传递机制,我可能会在后续的博客文章中回顾关于集成测试的话题。使用 Docker Remote API 或 Docker Compose 进行 go 测试。测试将启动一些服务,比如能让我们在测试代码里面发送还有接收消息的 RabbitMQ。
我们这次不弄性能测试。在发送和接收一些消息之后快速浏览一下内存使用情况就足够了:
CONTAINER CPU % MEM USAGE / LIMIT vipservice.1.tt47bgnmhef82ajyd9s5hvzs1 0.00% 1.859MiB / 1.955GiB accountservice.1.w3l6okdqbqnqz62tg618szsoj 0.00% 3.434MiB / 1.955GiB rabbitmq.1.i2ixydimyleow0yivaw39xbom 0.51% 129.9MiB / 1.955GiB
上面便是在处理了几个请求之后的内存使用情况。新的 “vipservice” 并不像 “accountservice” 那么复杂,因此在启动后它应该会使用更少的内存。
这大概是这个系列中最长的一部分了!我们在这章完成了这些内容:
问答
微服务架构:跨服务数据共享如何实现?
相关阅读
在微服务之间进行通信
RabbitMQ与AMQP协议
使用Akka HTTP构建微服务:CDC方法
此文已由作者授权腾讯云+社区发布,原文链接:https://cloud.tencent.com/dev...
欢迎大家前往腾讯云+社区或关注云加社区微信公众号(QcloudCommunity),第一时间获取更多海量技术实践干货哦~