Skip to content

19微服务网关如何作为服务端统一入口点?

在单体架构中,客户端在向服务端发起请求时,会通过类似 Nginx 的负载均衡组件获取到多个相同的应用程序实例中的一个。请求由该服务实例进行处理,服务端处理完之后返回响应给客户端。

而在微服务架构下,原来的单体应用拆分成了多个业务微服务。此时,直接对外暴露这些业务微服务,必然会存在一些问题。客户端直接向每个微服务发送请求,其问题主要如下:

  • API 粒度的问题,客户端需求和每个微服务暴露的细粒度可能存在 API 不匹配的情况。

  • 微服务之间的调用可能不仅仅基于 HTTP 的方式,还有可能使用 Thrift、gRPC 和 AMQP 消息传递协议,这些 API 无法暴露出去。

  • 直接对外暴露接口,使得微服务难以重构,特别是服务数量达到一个量级,这类重构就非常困难了。

如上问题,解决的方案是使用微服务网关。网关在一个 API 架构中的作用是保护、增强和控制外部请求对于 API 服务的访问

什么是微服务网关

在微服务架构中,网关位于接入层之下和业务服务层之上。微服务网关是微服务架构中的一个基础服务,从面向对象设计的角度看,它与外观模式类似。

微服务架构图

微服务网关封装了系统内部架构,为每个客户端提供一个定制的 API,用来保护、增强和控制对于微服务的访问。换句话来讲,微服务网关就是一个处于应用程序或服务之前的系统,用来管理授权、访问控制和流量限制等,这样微服务就会被微服务网关保护起来,对所有的调用者透明。因此,隐藏在微服务网关后面的业务系统就可以更加专注于业务本身。

微服务网关的功能特性

作为连接服务消费方和服务提供方的中间件系统,微服务网关将各自业务系统的演进和发展做了天然的隔离,使业务系统更加专注于业务服务本身,同时微服务网关还可以为服务提供和沉淀更多附加功能。

微服务网关的主要功能特性如下图所示:

网关的功能特性示意图

结合该图,我们就来具体介绍下这四类功能。

  • 请求接入。管理所有接入请求,作为所有 API 接口的请求入口。在生产环境中,为了保护内部系统的安全性,往往内网与外网都是隔离的,服务端应用都是运行在内网环境中,为了安全,一般不允许外部直接访问。网关可以通过校验规则和配置白名单,对外部请求进行初步过滤,这种方式更加动态灵活。

  • 统一管理。可以提供统一的监控工具、配置管理和接口的 API 文档管理等基础设施。例如,统一配置日志切面,并记录对应的日志文件。

  • 解耦。可以使得微服务系统的各方能够独立、自由、高效、灵活地调整,而不用担心给其他方面带来影响。软件系统的整个过程中包括不同的角色,有服务的开发提供方、服务的用户、运维人员、安全管理人员等,每个角色的职责和关注点都不同。微服务网关可以很好地解耦各方的相互依赖关系,让各个角色的用户更加专注自己的目标。

  • 拦截插件。服务网关层除了处理请求的路由转发外,还需要负责认证鉴权、限流熔断、监控和安全防范等,这些功能的实现方式,往往随着业务的变化不断调整。这就要求网关层提供一套机制,可以很好地支持这种动态扩展。拦截策略提供了一个扩展点,方便通过扩展机制对请求进行一系列加工和处理。同时还可以提供统一的安全、路由和流控等公共服务组件。

实战案例:自己动手实现一个网关

API 网关最基础的功能是对请求进行路由转发,根据配置的转发规则将请求动态地转发到指定的服务实例。动态是指与服务发现结合,如 Consul、ZooKeeper 等组件,我们在前面的"服务注册与发现"模块已详细讲解。本课时我们将会使用 Go 实现一个简易的 API 网关。

API 网关根据客户端 HTTP 请求,动态查询注册中心的服务实例,通过反向代理实现对后台服务的调用。

API 网关将符合规则的请求路由调用对应的后端服务。这里的规则可以有很多种,如 HTTP 请求的资源路径、方法、头部和参数等。这里我们以最简单的请求路径为例,规则为 :/{serviceName}/#。即:路径第一部分为注册中心服务实例名称,其余部分为服务实例的 REST 路径。如:

js
/cargo-service/cargos/
/cargo-service/locations

其中:

  • /cargo-service 为服务名称;

  • /locations 为 cargo-service 服务提供的接口。

1. 实现思路

客户端向网关发起请求,网关解析请求资源路径中的信息,根据服务名称查询注册中心的服务实例;然后使用反向代理技术把客户端请求转发至后端真实的服务实例,请求执行完毕后,再把响应信息返回客户端。

自定义网关的调用请求示意图

我们设计实现的网关的功能主要包含如下几点:

  • HTTP请求的规则遵循 /{serviceName}/#,否则不予通过。

  • 使用 Go 提供的反向代理包 httputil.ReverseProxy 实现一个简单的反向代理,它能够对请求实现负载均衡,随机地把请求发送给服务实例。

  • 使用 Consul 客户端 API 动态查询服务实例。

2. 编写反向代理方法

创建目录 gateway,然后新建 main.go 文件。NewReverseProxy 方法接受两个参数:Consul 客户端对象 api.Client 和日志记录工具 log.Logger,返回反向代理对象。该方法的实现过程如下:

  • 获取请求路径,检查是否符合规则,不符合规则直接返回;

  • 解析请求路径,获取服务名称(请求路径的第一部分);

  • 使用 Consul 客户端查询服务实例,若查询到结果,则随机选择一个作为目标实例;

  • 根据选定的目标实例,设置反向代理参数 Schema、Host 和 Path。

java
// 位于 section19/gateway/main.go
// NewReverseProxy 创建反向代理处理方法
func NewReverseProxy(client *api.Client, logger log.Logger) *httputil.ReverseProxy {
    //创建 Director
    director := func(req *http.Request) {
        //查询原始请求路径,如:/arithmetic/calculate
        reqPath := req.URL.Path
        if reqPath == "" {
            return
        }
        //按照分隔符'/'对路径进行分解,获取服务名称serviceName
        pathArray := strings.Split(reqPath, "/")
        serviceName := pathArray[1]
        //调用consul api查询serviceName的服务实例列表
        result, _, err := client.Catalog().Service(serviceName, "", nil)
        if err != nil {
            logger.Log("ReverseProxy failed", "query service instace error", err.Error())
            return
        }
        if len(result) == 0 {
            logger.Log("ReverseProxy failed", "no such service instance", serviceName)
            return
        }
        //重新组织请求路径,去掉服务名称部分
        destPath := strings.Join(pathArray[2:], "/")
        //随机选择一个服务实例
        tgt := result[rand.Int()%len(result)]
        logger.Log("service id", tgt.ServiceID)
        //设置代理服务地址信息
        req.URL.Scheme = "http"
        req.URL.Host = fmt.Sprintf("%s:%d", tgt.ServiceAddress, tgt.ServicePort)
        req.URL.Path = "/" + destPath
    }
    return &httputil.ReverseProxy{Director: director}
}

在反向转发处理的时候,我们只是根据请求中的服务名直接转发,如果需要对外屏蔽服务名的话,这样的路由转发规则显然是不够的。为了增加路由配置的多样性,我们可以抽出路由配置层,根据指定的规则进行路由转发,如根据配置名称、头部的信息、请求的参数、请求的 body 等规则转发到指定的服务。

3. 编写入口方法

main 方法的主要任务是创建 Consul 连接对象、创建日志记录对象和开启反向代理 HTTP 服务。整个过程与前面课时创建用户服务类似,代码如下(为了测试方便,直接指定了 Consul 服务地址信息):

java
// 位于 section19/gateway/main.go:65
func main() {
    // 创建环境变量
    var (
        consulHost = flag.String("consul.host", "127.0.0.1", "consul server ip address")
        consulPort = flag.String("consul.port", "8500", "consul server port")
    )
    flag.Parse()
    //创建日志组件
    var logger log.Logger
    {
        logger = log.NewLogfmtLogger(os.Stderr)
        logger = log.With(logger, "ts", log.DefaultTimestampUTC)
        logger = log.With(logger, "caller", log.DefaultCaller)
    }
    // 创建consul api客户端
    consulConfig := api.DefaultConfig()
    consulConfig.Address = "http://" + *consulHost + ":" + *consulPort
    consulClient, err := api.NewClient(consulConfig)
    if err != nil {
        logger.Log("err", err)
        os.Exit(1)
    }
    //创建反向代理
    proxy := NewReverseProxy(consulClient, logger)
    errc := make(chan error)
    go func() {
        c := make(chan os.Signal)
        signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
        errc <- fmt.Errorf("%s", <-c)
    }()
    //开始监听
    go func() {
        logger.Log("transport", "HTTP", "addr", "9099")
        errc <- http.ListenAndServe(":9099", proxy)
    }()
    // 开始运行,等待结束
    logger.Log("exit", <-errc)
}

如上的代码实现,为了创建反向代理,需要先创建日志组件和 Consul 连接对象。反向代理处理器一般还可以使用装饰者模式封装,如增加中间件 Hystrix 断路器、链路追踪 Tracer(Zipkin、Jaeger)组件等。

4. 运行货运与网关服务

做好如上的准备步骤之后,我们开始运行货运服务。为了测试负载均衡效果,启动两个实例。这里我们是在一台主机上测试,所以需要使用不同的端口。首先编译货运服务:

yaml
$  go build -o cmd/cargo cmd/main.go

在 cmd 目录下生成了 cargo 可执行文件,下面我们就分别来启动两个货运服务实例:

java
./cargo/cmd/cargo -consul.host localhost -consul.port 8500 -service.host 127.0.0.1 -service.port 8000
./cargo/cmd/cargo -consul.host localhost -consul.port 8500 -service.host 127.0.0.1 -service.port 8002

启动成功并注册到 Consul,控制台输出如下:

java
ts=2020-07-28T10:11:12.974789Z transport=http address=8000 msg=listening
ts=2020-07-28T10:11:13.006241Z service=cargo-service tags="[cargo-service aoho]" address=localhost action=register

再切换至目录 gateway,执行 go build 完成编译,最后启动网关服务。

java
> ./gateway -consul.host localhost -consul.port 8500
> ts=2020-07-28T10:11:37.662124Z caller=main.go:56 transport=HTTP addr=9099

5. 测试

网关服务和两个货运服务实例启动好之后,我们通过命令行请求货运服务的接口 /cargos,以获取指定 Id 的货运信息,请求如下:

yaml
$ curl -X POST \
  http://localhost:9099/cargo-service/cargos/ \
  -H 'Content-Type: application/json' \
  -d '{
  "Id": "ABC123"
}'
{
    "cargo": {
        "arrival_deadline": "2020-08-11T18:56:44.627+08:00",
        "destination": "CNHKG",
        "misrouted": false,
        "origin": "SESTO",
        "routed": false,
        "tracking_id": "ABC123"
    }
}

同时,在终端可以看到如下输出,说明多次请求访问了不同的服务实例:

java
ts=2020-07-28T10:11:51.108611Z caller=main.go:96 serviceid=cargo-service64ffdd53-9c66-43cb-9ada-0d48ebddc632
ts=2020-07-28T10:12:00.215364Z caller=main.go:96 serviceid=cargo-servicee8c53e6f-e4ff-4737-a3bd-f1b11b0b2e95

本案例我们使用反向代理技术,并结合注册中心 Consul 实现了简单的 API 网关。Go 提供了反向代理工具包,使得整个实现过程变得比较简单。实际项目中使用的产品,如 Zuul、Nginx 等,还包含了限流、请求过滤、身份认证等功能。该网关虽然仅仅实现了请求的代理,但重点在于帮助你了解了网关实现的基本原理,从而为后续网关功能的扩增打下基础。

小结

本课时我们首先介绍了微服务网关产生的背景及其相关概念,然后还介绍了微服务网关在微服务架构中的职能。作为服务端的统一入口点,微服务网关主要用来实现接入请求、统一管理、解耦和配置拦截策略等功能。最后,为便于你更加详细地了解网关组件相关功能的实现原理,我们还自己动手实现了一个 Go 微服务网关,你可以跟着上手实操下。

学完本课时,你可以结合自己的实践经验,思考下我们实现的简易网关还需要承担哪些微服务架构中的职责。欢迎你在留言区积极发言和讨论。