译文连接:wuYin/blog
原文连接:ewanvalentine.io,翻译已获做者 Ewan Valentine 受权。node
本节未细致介绍 Docker,更多可参考:《第一本Docker书 修订版》linux
在上一篇中,咱们使用 gRPC 初步实现了咱们的微服务,本节将 Docker 化该微服务并引入 go-micro 框架代替 gRPC 简化服务的实现。git
占据着云计算的优点,微服务架构愈来愈流行,同时它的云端分布式的运行环境也对咱们的开发、测试和部署提出了很高的要求,容器(container)即是一项解决方案。github
在传统软件开发中,应用直接部署在环境和依赖都准备好的系统上,或在一台物理服务器上部署在由 Chef 或 Puppet 管理的虚拟集群里。这种部署方案不利于横向扩展,好比要部署多台物理服务器,须要都安装相同的依赖,再部署,非常麻烦。golang
vagrant 这类管理多个虚拟机的工具,虽然使项目的部署更为遍历,但每一个虚拟机都运行有一个完整的操做系统,十分耗费宿主主机的资源,并不适合微服务的开发和部署。web
容器 是精简版的操做系统,但并不运行一个 kernel 或系统底层相关的驱动,它只包含一些 run-time 必需的库,多个容器共享宿主主机的 kernel,多个容器之间相互隔离,互补影响。可参考:Redhat topicdocker
容器的运行环境只包含代码所须要的依赖,而不是使用完整的操做系统包含一大堆不须要的组件。此外,容器自己的体积相比虚拟机是比较小的,好比对比 ubuntu 16.04 优点不言而喻:shell
通常人会认为容器技术就是 Docker,实则否则,Docker 只是容器技术的一种实现,由于其操做简便且学习门槛低,因此如此流行。数据库
建立微服务部署的 Dockerfilenpm
# 若运行环境是 Linux 则需把 alpine 换成 debian # 使用最新版 alpine 做为基础镜像 FROM alpine:latest # 在容器的根目录下建立 app 目录 RUN mkdir /app # 将工做目录切换到 /app 下 WORKDIR /app # 将微服务的服务端运行文件拷贝到 /app 下 ADD consignment-service /app/consignment-service # 运行服务端 CMD ["./consignment-service"]
alpine 是一个超轻量级 Linux 发行版本,专为 Docker 中 Web 应用而生。它能保证绝大多数 web 应用能够正常运行,即便它只包含必要的 run-time 文件和依赖,镜像大小只有 4 MB,相比上边 Ubuntu16.4 节约了 99.7% 的空间:
因为 docker 镜像的超轻量级,在上边部署和运行微服务耗费的资源是很小的。
为了在 alpine 上运行咱们的微服务,向 Makefile 追加命令:
build: ... # 告知 Go 编译器生成二进制文件的目标环境:amd64 CPU 的 Linux 系统 GOOS=linux GOARCH=amd64 go build # 根据当前目录下的 Dockerfile 生成名为 consignment-service 的镜像 docker build -t consignment-service .
需手动指定 GOOS
和 GOARCH
的值,不然在 macOS 上编译出的文件是没法在 alpine 容器中运行的。
其中 docker build
将程序的执行文件 consignment-service 及其所需的 run-time 环境打包成了一个镜像,之后在 docker 中直接 run
镜像便可启动该微服务。
你能够把你的镜像分享到 DockerHub,两者的关系类比 npm 与 nodejs、composer 与 PHP,去 DockerHub 瞧一瞧,会发现不少优秀的开源软件都已 Docker 化,参考演讲:Willy Wonka of Containers
关于 Docker 构建镜像的细节,请参考书籍《第一本 Docker 书》第四章
继续在 Makefile 中追加命令:
build: ... run: # 在 Docker alpine 容器的 50001 端口上运行 consignment-service 服务 # 可添加 -d 参数将微服务放到后台运行 docker run -p 50051:50051 consignment-service
因为 Docker 有本身独立的网络层,因此须要指定将容器的端口映射到本机的那个端口,使用 -p
参数便可指定,好比 -p 8080:50051
是将容器 50051端口映射到本机 8080 端口,注意顺序是反的。更多参考:Docker 文档
如今运行 make build && make run
便可在 docker 中运行咱们的微服务,此时在本机执行微服务的客户端代码,将成功调用 docker 中的微服务:
在客户端代码(consignment-cli/cli.go)中,咱们手动指定了服务端的地址和端口,在本地修改不是很麻烦。但在生产环境中,各服务可能不在同一台主机上(分布式独立运行),其中任一服务从新部署后 IP 或运行的端口发生变化,其余服务将没法再调用它。若是你有不少个服务,彼此指定 IP 和端口来相互调用,那管理起来很麻烦
为解决服务间的调用问题,服务发现(service discovery)出现了,它做为一个注册中心会记录每一个微服务的 IP 和端口,各微服务上线时会在它那注册,下线时会注销,其余服务可经过名字或 ID 找到该服务类比门面模式。
为不重复造轮子,咱们直接使用实现了服务注册的 go-micro 框架。
go get -u github.com/micro/protobuf/proto go get -u github.com/micro/protobuf/protoc-gen-go
使用 go-micro 本身的编译器插件,在 Makefile 中修改 protoc 命令:
build: # 再也不使用 grpc 插件 protoc -I. --go_out=plugins=micro:$(GOPATH)/src/shippy/consignment-service proto/consignment/consignment.proto
你会发现从新生成的 consignment.pb.go 大有不一样。修改服务端代码 main.go 使用 go-micro
package main import ( pb "shippy/consignment-service/proto/consignment" "context" "log" "github.com/micro/go-micro" ) // // 仓库接口 // type IRepository interface { Create(consignment *pb.Consignment) (*pb.Consignment, error) // 存放新货物 GetAll() []*pb.Consignment // 获取仓库中全部的货物 } // // 咱们存放多批货物的仓库,实现了 IRepository 接口 // type Repository struct { consignments []*pb.Consignment } func (repo *Repository) Create(consignment *pb.Consignment) (*pb.Consignment, error) { repo.consignments = append(repo.consignments, consignment) return consignment, nil } func (repo *Repository) GetAll() []*pb.Consignment { return repo.consignments } // // 定义微服务 // type service struct { repo Repository } // // 实现 consignment.pb.go 中的 ShippingServiceHandler 接口 // 使 service 做为 gRPC 的服务端 // // 托运新的货物 // func (s *service) CreateConsignment(ctx context.Context, req *pb.Consignment) (*pb.Response, error) { func (s *service) CreateConsignment(ctx context.Context, req *pb.Consignment, resp *pb.Response) error { // 接收承运的货物 consignment, err := s.repo.Create(req) if err != nil { return err } resp = &pb.Response{Created: true, Consignment: consignment} return nil } // 获取目前全部托运的货物 // func (s *service) GetConsignments(ctx context.Context, req *pb.GetRequest) (*pb.Response, error) { func (s *service) GetConsignments(ctx context.Context, req *pb.GetRequest, resp *pb.Response) error { allConsignments := s.repo.GetAll() resp = &pb.Response{Consignments: allConsignments} return nil } func main() { server := micro.NewService( // 必须和 consignment.proto 中的 package 一致 micro.Name("go.micro.srv.consignment"), micro.Version("latest"), ) // 解析命令行参数 server.Init() repo := Repository{} pb.RegisterShippingServiceHandler(server.Server(), &service{repo}) if err := server.Run(); err != nil { log.Fatalf("failed to serve: %v", err) } }
go-micro 的实现相比 gRPC 有 3 个主要的变化:
micro.NewService(...Option)
简化了微服务的注册流程, micro.Run()
也简化了 gRPCServer.Serve()
,再也不须要手动建立 TCP 链接并监听。
注意看代码中第 4七、59 行,会发现 go-micro 将响应参数 Response 提到了入参,只返回 error,整合了 gRPC 的 [四种运行模式]()
服务的监听端口没有在代码中写死,go-mirco 会自动使用系统或命令行中变量 MICRO_SERVER_ADDRESS
的地址
对应更新一下 Makefile
run: docker run -p 50051:50051 \ -e MICRO_SERVER_ADDRESS=:50051 \ -e MICRO_REGISTRY=mdns \ consignment-service
-e 选项用于设置镜像中的环境变量,其中 MICRO_REGISTRY=mdns
会使 go-micro 在本地使用 mdns 多播做为服务发现的中间层。在生产环境通常会使用 Consul 或 Etcd 代替 mdns 作服务发现,在本地开发先一切从简。
如今执行 make build && make run
,你的 consignment-service 就有服务发现的功能了。
咱们须要更新一下客户端的代码,使用 go-micro 来调用微服务:
func main() { cmd.Init() // 建立微服务的客户端,简化了手动 Dial 链接服务端的步骤 client := pb.NewShippingServiceClient("go.micro.srv.consignment", microclient.DefaultClient) ... }
如今运行 go run cli.go
会报错:
由于服务端运行在 Docker 中,而 Docker 有本身独立的 mdns,与宿主主机 Mac 的 mdns 不一致。把客户端也 Docker 化,这样服务端与客户端就在同一个网络层下,顺利使用 mdns 作服务发现。
建立客户端的 Dokerfile
FROM alpine:latest RUN mkdir -p /app WORKDIR /app # 将当前目录下的货物信息文件 consignment.json 拷贝到 /app 目录下 ADD consignment.json /app/consignment.json ADD consignment-cli /app/consignment-cli CMD ["./consignment-cli"]
建立文件 consignment-cli/Makefile
build: GOOS=linux GOARCH=amd64 go build docker build -t consignment-cli . run: docker run -e MICRO_REGISTRY=mdns consignment-cli
执行 make build && make run
,便可看到客户端成功调用 RPC:
注明:译者的代码暂时未把 Golang 集成到 Dockerfile 中,读者有兴趣可参考原文。
上边的 consignment-service 负责记录货物的托运信息,如今建立第二个微服务 vessel-service 来选择合适的货轮来运送货物,关系以下:
consignment.json 文件中的三个集装箱组成的货物,目前能够经过 consignment-service 管理货物的信息,如今用 vessel-service 去检查货轮是否能装得下这批货物。
// vessel-service/proto/vessel/vessel.proto syntax = "proto3"; package go.micro.srv.vessel; service VesselService { // 检查是否有能运送货物的轮船 rpc FindAvailable (Specification) returns (Response) { } } // 每条货轮的熟悉 message Vessel { string id = 1; // 编号 int32 capacity = 2; // 最大容量(船体容量便是集装箱的个数) int32 max_weight = 3; // 最大载重 string name = 4; // 名字 bool available = 5; // 是否可用 string ower_id = 6; // 归属 } // 等待运送的货物 message Specification { int32 capacity = 1; // 容量(内部集装箱的个数) int32 max_weight = 2; // 重量 } // 货轮装得下的话 // 返回的多条货轮信息 message Response { Vessel vessel = 1; repeated Vessel vessels = 2; }
如今建立 vessel-service/Makefile
来编译项目:
build: protoc -I. --go_out=plugins=micro:$(GOPATH)/src/shippy/vessel-service proto/vessel/vessel.proto # dep 工具暂不可用,直接手动编译 GOOS=linux GOARCH=amd64 go build docker build -t vessel-service . run: docker run -p 50052:50051 -e MICRO_SERVER_ADDRESS=:50051 -e MICRO_REGISTRY=mdns vessel-service
注意第二个微服务运行在宿主主机(macOS)的 50052 端口,50051 已被第一个占用。
如今建立 Dockerfile 来容器化 vessel-service:
# 暂未将 Golang 集成到 docker 中 FROM alpine:latest RUN mkdir /app WORKDIR /app ADD vessel-service /app/vessel-service CMD ["./vessel-service"]
package main import ( pb "shippy/vessel-service/proto/vessel" "github.com/pkg/errors" "context" "github.com/micro/go-micro" "log" ) type Repository interface { FindAvailable(*pb.Specification) (*pb.Vessel, error) } type VesselRepository struct { vessels []*pb.Vessel } // 接口实现 func (repo *VesselRepository) FindAvailable(spec *pb.Specification) (*pb.Vessel, error) { // 选择最近一条容量、载重都符合的货轮 for _, v := range repo.vessels { if v.Capacity >= spec.Capacity && v.MaxWeight >= spec.MaxWeight { return v, nil } } return nil, errors.New("No vessel can't be use") } // 定义货船服务 type service struct { repo Repository } // 实现服务端 func (s *service) FindAvailable(ctx context.Context, spec *pb.Specification, resp *pb.Response) error { // 调用内部方法查找 v, err := s.repo.FindAvailable(spec) if err != nil { return err } resp.Vessel = v return nil } func main() { // 停留在港口的货船,先写死 vessels := []*pb.Vessel{ {Id: "vessel001", Name: "Boaty McBoatface", MaxWeight: 200000, Capacity: 500}, } repo := &VesselRepository{vessels} server := micro.NewService( micro.Name("go.micro.srv.vessel"), micro.Version("latest"), ) server.Init() // 将实现服务端的 API 注册到服务端 pb.RegisterVesselServiceHandler(server.Server(), &service{repo}) if err := server.Run(); err != nil { log.Fatalf("failed to serve: %v", err) } }
如今须要修改 consignent-service/main.go
,使其做为客户端去调用 vessel-service,查看有没有合适的轮船来运输这批货物。
// consignent-service/main.go package main import (...) // 定义微服务 type service struct { repo Repository // consignment-service 做为客户端调用 vessel-service 的函数 vesselClient vesselPb.VesselServiceClient } // 实现 consignment.pb.go 中的 ShippingServiceHandler 接口,使 service 做为 gRPC 的服务端 func (s *service) CreateConsignment(ctx context.Context, req *pb.Consignment, resp *pb.Response) error { // 检查是否有适合的货轮 vReq := &vesselPb.Specification{ Capacity: int32(len(req.Containers)), MaxWeight: req.Weight, } vResp, err := s.vesselClient.FindAvailable(context.Background(), vReq) if err != nil { return err } // 货物被承运 log.Printf("found vessel: %s\n", vResp.Vessel.Name) req.VesselId = vResp.Vessel.Id consignment, err := s.repo.Create(req) if err != nil { return err } resp.Created = true resp.Consignment = consignment return nil } // ... func main() { // ... // 解析命令行参数 server.Init() repo := Repository{} // 做为 vessel-service 的客户端 vClient := vesselPb.NewVesselServiceClient("go.micro.srv.vessel", server.Client()) pb.RegisterShippingServiceHandler(server.Server(), &service{repo, vClient}) if err := server.Run(); err != nil { log.Fatalf("failed to serve: %v", err) } }
更新 consignment-cli/consignment.json
中的货物,塞入三个集装箱,重量和容量都变大。
{ "description": "This is a test consignment", "weight": 55000, "containers": [ { "customer_id": "cust001", "user_id": "user001", "origin": "Manchester, United Kingdom" }, { "customer_id": "cust002", "user_id": "user001", "origin": "Derby, United Kingdom" }, { "customer_id": "cust005", "user_id": "user001", "origin": "Sheffield, United Kingdom" } ] }
至此,咱们完整的将 consignment-cli,consignment-service,vessel-service 三者流程跑通了。
客户端用户请求托运货物,货运服务向货船服务检查容量、重量是否超标,再运送:
本节中将更为易用的 go-micro 替代了 gRPC 同时进行了微服务的 Docker 化。最后建立了 vessel-service 货轮微服务来运送货物,并成功与货轮微服务进行了通讯。
不过货物数据都是存放在文件 consignment.json 中的,第三节咱们将这些数据存储到 MongoDB 数据库中,并在代码中使用 ORM 对数据进行操做,同时使用 docker-compose 来统一 Docker 化后的两个微服务。