服务发布和引用
服务发布者如何发布一个服务?(接口文档,服务接口地址等)
服务调用者如何引用这个服务?(引用地址,接口文档参数和返回数据等)
最常见的服务发布和引用有大概三种形式:
RESTful API
XML 配置
IDL 文件
1. RESTful API
主要用作 HTTP 或者 HTTPS 协议的接口定义。在单体服务中也是广泛应用的。
来看一个 微博开源服务化项目 Motan go语言版本 的 RESTful API的例子:
server 使用实例:
通过yaml配置需要导出的服务,来指定导出的服务配置等参数:
#config of registries
motan-registry: #motan-registry 区用来配置不同的注册中心,多个注册中心以id进行区分
direct-registry: # 注册中心id,service引用时需与此id相同
protocol: direct # 注册中心协议,也即注册中心类型。此处为便于测试使用直连注册中心,实际注册时不发生注册行为。
#conf of services
motan-service:
mytest-motan2:
path: com.weibo.motan.demo.service.MotanDemoService # 服务名称
group: motan-demo-rpc #服务所属group
protocol: motan2
registry: direct-registry
serialization: simple #目前golang版本仅支持simple序列化方式,其他序列化方式会逐步提供
ref : "main.MotanDemoService" #golang中对service的具体实现类引用。此处为`包名.类名`方式引用,也可以使用自定义的id,需要与注册服务实现类时的id参数一致
export: "motan2:8100" #对外提供服务的端口。不同service可以使用相同export端口,前提条件为协议与序列化等配置必须相同。
然后,实现一个service并对外导出服务代码如下:
package main
import (
"fmt"
"time"
motan "github.com/weibocom/motan-go"
)
func main() {
runServerDemo()
}
func runServerDemo() {
mscontext := motan.GetMotanServerContext("serverdemo.yaml") //通过配置文件获取配置信息。所有的导出服务推荐只使用一个配置文件进行配置。
mscontext.RegisterService(&MotanDemoService{}, "") // 注册具体service实现类,可以在注册时指定别名,配置中可以通过别名进行引用。如果不使用别名,则通过`包名.类名`进行引用。
mscontext.Start(nil) // 注册完所有服务实现类后,通过start启动所有服务,完成服务注册
time.Sleep(time.Second * 50000000)
}
// service 具体实现类
type MotanDemoService struct{}
func (m *MotanDemoService) Hello(name string) string {
fmt.Printf("MotanDemoService hello:%s\n", name)
return "hello " + name
}
Client调用示例
同样也有个配置文件:
#config of registries
motan-registry:
direct-registry: # registry id
protocol: direct # registry type.
host: 127.0.0.1
port: 9981
#conf of refers
motan-refer:
mytest-motan2:
path: com.weibo.motan.demo.service.MotanDemoService # e.g. service name for subscribe
group: motan-demo-rpc # group name
protocol: motan2 # rpc protocol
registry: direct-registry
requestTimeout: 1000
serialization: simple
haStrategy: failover
loadbalance: roundrobin
再来看看client实现的代码:
同步调用:
package main
import (
"fmt"
motan "github.com/weibocom/motan-go"
motancore "github.com/weibocom/motan-go/core"
)
func main() {
runClientDemo()
}
func runClientDemo() {
mccontext := motan.GetClientContext("clientdemo.yaml")
mccontext.Start(nil)
mclient := mccontext.GetClient("mytest-motan2")
var reply string
err := mclient.Call("hello", "Ray", &reply) // sync call
if err != nil {
fmt.Printf("motan call fail! err:%v\n", err)
} else {
fmt.Printf("motan call success! reply:%s\n", reply)
}
}
异步调用:
func runClientDemo() {
mccontext := motan.GetClientContext("clientdemo.yaml")
mccontext.Start(nil)
mclient := mccontext.GetClient("mytest-motan2")
var reply string
// async call
result := mclient.Go("hello", "Ray", &reply, make(chan *motancore.AsyncResult, 1))
res := <-result.Done
if res.Error != nil {
fmt.Printf("motan async call fail! err:%v\n", res.Error)
} else {
fmt.Printf("motan async call success! reply:%+v\n", reply)
}
}
以上就是服务消费者就可以通过 HTTP 协议调用服务了,因为 HTTP 协议本身是一个公开的协议,对于服务消费者来说几乎没有学习成本,所以比较适合用作跨业务平台之间的服务协议。
比如你有一个服务,不仅需要在业务部门内部提供服务,还需要向其他业务部门提供服务,甚至开放给外网提供服务,这时候采用 HTTP 协议就比较合适,也省去了沟通服务协议的成本。
2. XML 配置
接下来讲下 XML 配置方式,这种方式的服务发布和引用主要分三个步骤:
服务提供者定义接口,并实现接口。
服务提供者进程启动时,通过加载 server.xml 配置文件将接口暴露出去。
服务消费者进程启动时,通过加载 client.xml 配置文件来引入要调用的接口。
主要是对server.xml 和 client.xml 进行加载调用接口即可,这里不再写代码实例了.
通过在服务提供者和服务消费者之间维持一份对等的 XML 配置文件,来保证服务消费者按照服务提供者的约定来进行服务调用。
在这种方式下,如果服务提供者变更了接口定义,不仅需要更新服务提供者加载的接口描述文件 server.xml,还需要同时更新服务消费者加载的接口描述文件 client.xml。
一般是私有 RPC 框架会选择 XML 配置这种方式来描述接口,因为私有 RPC 协议的性能要比 HTTP 协议高,所以在对性能要求比较高的场景下,采用 XML 配置的方式比较合适。
但这种方式对业务代码侵入性比较高,XML 配置有变更的时候,服务消费者和服务提供者都要更新,所以适合公司内部联系比较紧密的业务之间采用。如果要应用到跨部门之间的业务调用,一旦有 XML 配置变更,需要花费大量精力去协调不同部门做升级工作。
比如,一次底层服务的接口升级,需要所有相关的调用方都升级,为此花费了大量时间去协调沟通不同部门之间的升级工作,最后经历了大半年才最终完成。
所以对于 XML 配置方式的服务描述,一旦应用到多个部门之间的接口格式约定,如果有变更,最好是新增接口,不到万不得已不要对原有的接口格式做变更。
3. IDL 文件
IDL 就是接口描述语言(interface description language)的缩写,通过一种中立的方式来描述接口,使得在不同的平台上运行的对象和不同语言编写的程序可以相互通信交流。
比如你用 go 语言实现提供的一个服务,也能被 PHP/java 等语言调用。
也就是说 IDL 主要是用作跨语言平台的服务之间的调用,有两种最常用的 IDL:
一个是 Facebook 开源的 Thrift 协议
另一个是 Google 开源的 gRPC 协议
由于项目中使用过grpc,所有就以grpc为例看看demo:
syntax = "proto3";
package pb;
// 声明请求参数
message HelloRq {
string name = 1;
}
// 声明返回数据参数
message HelloRp {
string message = 1;
}
// 声明定义的 服务名称
service Test {
// 声明 调用方法函数名称结合出入参数
rpc SayHello (HelloRq) returns (HelloRp) {}
rpc SayHelloAgain (HelloRq) returns (HelloRp) {}
}
利用生成protoc 插件 行脚本命令:
rm -rf pb
mkdir pb
cd proto
protoc --go_out=plugins=grpc:../pb *
cd ../
于是就会在你的项目目录中看见如下:
需要注意的是,该过程需要在server端 和client 端 都要进行操作。
由此可见,gRPC 协议的服务描述是通过 proto 文件来定义接口的,然后再使用 protoc 来生成不同语言平台的客户端和服务端代码,从而具备跨语言服务调用能力。
有一点特别需要注意的是,在描述接口定义时,IDL 文件需要对接口返回值进行详细定义。
如果接口返回值的字段比较多,并且经常变化时,采用 IDL 文件方式的接口定义就不太合适了。
- 一方面可能会造成 IDL 文件过大难以维护,
- 另一方面只要 IDL 文件中定义的接口返回值有变更,都需要同步所有的服务消费者都更新,管理成本就太高了。
比如你的业务项目中某些接口返回字段就有好几十上百个,并且有些字段还不固定,返回字段是业务自定义的,这种情况下采用 Protobuf 文件来描述的话就很麻烦了,也就在不在合适。
总结:
我们介绍了服务描述最常见的三种方式:RESTful API、XML 配置以及 IDL 文件。
具体采用哪种服务描述方式是根据实际情况决定的,通常情况下,如果只是企业内部之间的服务调用,并且都是 Java 语言的话(Dubbo),选择 XML 配置方式是最简单的。
如果企业内部存在多个服务,并且服务采用的是不同语言平台,建议使用 IDL 文件方式进行描述服务。
如果还存在对外开放服务调用的情形的话,使用 RESTful API 方式则更加通用。
服务描述方式 | 使用场景 | 缺点 |
---|---|---|
RESTful API | 跨语言平台,组织内外皆可 | HTTP作为通信协议相比TCP,性能较差 |
XML 配置 | Java平台,一般作用内部 | 不支持跨语言 |
IDL 文件 | 跨语言平台,组织内外皆可 | 修改或者删除PB字段不能向前兼容(增加字段可以) |
服务注册和发现
经过上面的过程,假设你已经发布了个服务了,并且还部署到了服务器上,请问 在你不告诉我地址Ip和接口的情况下我该如何调用你的服务和接口呢?或者说如何知道你部署的这台机器的地址呢?
我们假设一个场景吧,比如是拆迁户配了很多套房子,然后你想把你某些房子租出来换点资源,但是你自己贴公告就显得有点效率低了,如果我离你的区域比较远又想租你们那边区域的房子而且还不想走过来自己找房源。
那么这个时候,你就需要找个中介公司,你把你的那些房子都挂在他那里,中介公司会帮你挂出来,然后我们才能通过这网上中介发布的房源信息找到你的房源,完事儿。
那么同理,你把你的发布的服务需要挂在一个类似“中介”的地方,我们称之为 注册中心。
下面聊聊 注册中心的原理和实现方式。
注册中心原理:
在微服务架构下,主要有三种角色:
- 服务提供者(RPC Server)、
- 服务消费者(RPC Client)、
- 服务注册中心(Registry)
三者的交互关系请看下面这张图,我来简单解释一下。
- RPC Server 提供服务,在启动时,根据服务发布文件 server.xml 中的配置的信息,向 Registry 注册自身服务,并向 Registry 定期发送心跳汇报存活状态。(注册自己,发送心跳)
- RPC Client 调用服务,在启动时,根据服务引用文件 client.xml 中配置的信息,向 Registry 订阅服务,把 Registry 返回的服务节点列表缓存在本地内存中,并与 RPC Sever 建立连接。(订阅服务,缓存服务节点列表)
- 当 RPC Server 节点发生变更时,Registry 会同步变更,RPC Client 感知后会刷新本地内存中缓存的服务节点列表。(同步变更,更新缓存列表)
- RPC Client 从本地缓存的服务节点列表中,基于负载均衡算法选择一台 RPC Sever 发起调用。(负载调用服务)
注册中心实现方式:
注册中心的实现主要涉及几个问题:注册中心需要提供哪些接口,该如何部署;如何存储服务信息;如何监控服务提供者节点的存活;如果服务提供者节点有变化如何通知服务消费者,以及如何控制注册中心的访问权限。
1. 注册中心 API
注册中心必须实现几个最基本的 API 接口:
- 服务注册接口:用于服务的注册调用 (服务提供者)
- 服务反注册接口:用于服务的注销删除(服务提供者)
- 心跳汇报接口:用于服务汇报心跳存活(服务提供者)
- 服务订阅接口:获取可用的服务提供者节点列表 (服务消费者)
- 服务变更查询接口:重新获取最新可用的服务节点列表 (服务消费者
- 服务查询接口:查询注册中心当前注册了哪些服务信息 (服务注册中心)
- 服务修改接口:修改注册中心某一服务信息(服务注册中心)
2. 集群部署
注册中心作为服务提供者和服务消费者之间沟通的桥梁,它的重要性不言而喻。所以注册中心一般都是采用集群部署来保证高可用性,并通过分布式一致性协议来确保集群中不同节点之间的数据保持一致。
以开源注册中心 ZooKeeper 为例,ZooKeeper 集群中包含多个节点,服务提供者和服务消费者可以同任意一个节点通信,因为它们的数据一定是相同的,这是为什么呢?这就要从 ZooKeeper 的工作原理说起:
- 每个 Server 在内存中存储了一份数据,Client 的读请求可以请求任意一个 Server。
- ZooKeeper 启动时,将从实例中选举一个 leader(Paxos 协议)。
- Leader 负责处理数据更新等操作(ZAB 协议)。
- 一个更新操作成功,当且仅当大多数 Server 在内存中成功修改 。
通过上面这种方式,ZooKeeper 保证了高可用性以及数据一致性。
当然开源的注册中心类似还有 consul、Etcd
3. 目录存储
ZooKeeper,注册中心存储服务信息一般采用层次化的目录结构:
4. 服务健康状态检测
注册中心除了要支持最基本的服务注册和服务订阅功能以外,还必须具备对服务提供者节点的健康状态检测功能,这样才能保证注册中心里保存的服务节点都是可用的。
5. 服务状态变更通知
一旦注册中心探测到有服务提供者节点新加入或者被剔除,就必须立刻通知所有订阅该服务的服务消费者,刷新本地缓存的服务节点信息,确保服务调用不会请求不可用的服务提供者节点。
6. 白名单机制
在实际的微服务测试和部署时,通常包含多套环境,比如生产环境一套、测试环境一套。开发在进行业务自测、测试在进行回归测试时,一般都是用测试环境,部署的 RPC Server 节点注册到测试的注册中心集群。但经常会出现开发或者测试在部署时,错误的把测试环境下的服务节点注册到了线上注册中心集群,这样的话线上流量就会调用到测试环境下的 RPC Server 节点,可能会造成意想不到的后果。
为了防止这种情况发生,注册中心需要提供一个保护机制,你可以把注册中心想象成一个带有门禁的房间,只有拥有门禁卡的 RPC Server 才能进入。在实际应用中,注册中心可以提供一个白名单机制,只有添加到注册中心白名单内的 RPC Server,才能够调用注册中心的注册接口,这样的话可以避免测试环境中的节点意外跑到线上环境中去。
总结:
注册中心可以说是实现服务化的关键,因为服务化之后,服务提供者和服务消费者不在同一个进程中运行,实现了解耦,这就需要一个纽带去连接服务提供者和服务消费者,而注册中心就正好承担了这一角色。
此外,服务提供者可以任意伸缩即增加节点或者减少节点,通过服务健康状态检测,注册中心可以保持最新的服务节点信息,并将变化通知给订阅服务的服务消费者。
注册中心一般采用分布式集群部署,来保证高可用性,并且为了实现异地多活,有的注册中心还采用多 IDC 部署,这就对数据一致性产生了很高的要求,这些都是注册中心在实现时必须要解决的问题。
服务注册中心选型
当下主流的服务注册与发现的解决方案,主要有两种:
应用内注册与发现:注册中心提供服务端和客户端的 SDK,业务应用通过引入注册中心提供的 SDK,通过 SDK 与注册中心交互,来实现服务的注册和发现。(Netflix 开源的 Eureka)
应用外注册与发现:业务应用本身不需要通过 SDK 与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。(Consul、zookeeper、etcd)
由于篇幅问题,关于注册中心 会另外开文章进行介绍。