0%

前言

Envoy 是一款 CNCF 旗下的开源项目,由 Lyft 开源。Envoy 采用 C++ 实现,是面向 Service Mesh 的高性能网络代理服务。它与应用程序并行运行,通过以平台无关的方式提供通用功能来抽象网络。当基础架构中的所有服务流量都通过 Envoy 网格时,通过一致的可观测性,很容易地查看问题区域,调整整体性能。

Envoy 也是 Istio Service Mesh 中默认的 Data Plane,本文我们将讲解 Envoy 的一些基本概念,并采用一些实例来介绍如何在本地环境中快速使用 Envoy 作为 Service Mesh 的数据平面,以帮助读者理解 IstioData Panel 层实现。

官网:https://www.envoyproxy.io

更新历史

2021 年 03月 05日 - 初稿

扩展阅读

  1. https://holajiawei.com/envoy/
  2. https://www.lijiaocn.com/soft/envoy/
  3. https://www.jianshu.com/p/90f9ee98ce70
  4. https://github.com/wellls/blog/issues/47
  5. https://jimmysong.io/posts/envoy-as-front-proxy/
  6. https://www.yangcs.net/posts/run-envoy-on-your-laptop/

Envoy 特性

  1. 整体架构

img

  1. 进程无关架构

Envoy 是一个自组织的模块,与应用 Server 并无直接依赖。所有的 Envoy 构建了一个透明的服务网格 Service Mesh,处于其中的应用只需要简单的与本地的 Envoy 进行收发信息,并不需要关注整个网络拓扑。这种架构对于应用通信有两大好处:

  • Envoy 可以让任何的编程语言编写的服务通信,协同工作,Envoy 帮你屏蔽了服务之间的沟壑。
  • 任何曾经在大型微服务开发中工作过的人都知道发布一个库更新是多么的痛苦。Envoy 可以以一种透明的方式快速的发布更新整个基础架构中的版本。
  1. 高级负载均衡

分布式系统中不同模块间的负载均衡是一个复杂的问题。因为 Envoy 是一个自组织的代理,所以它能在一个地方实现高级负载均衡技术并使他们可被访问。当前 Envoy 支持自动重试、断路器、全局限速、阻隔请求、异常检测,将来还会支持按计划进行请求速率控制。

  1. 动态配置

Envoy 提供了可选的一系列的分层的动态配置 API,使用这些 API 可以构建出复杂的集中式部署管理。

  1. 正向代理支持

虽然 Envoy 设计初衷是服务和服务之间通信系统,得益于其监视、管理、服务发现和负载均衡算法的实现,Enovy 包含了足够多的特性为绝大多数 Web 服务做正向代理。

除了这些之外还有对 HTTP/2 的支持,L3L4L7 代理,可以实现 TCP ProxyHTTP Proxy 等功能。

  1. 线程模型

Envoy 使用单进程多线程架构,其中一个扮演主线程的控制各种协调任务,而一些工作线程负责监听、过滤和转发。一旦某个链接被监听器 Listener 接受,那么这个链接将会剩余的生命周期绑定在这个 Woker 线程。这种架构会使得大部分工作工作在单线程的情况下,只有少量的工作会涉及到线程间通信,Envoy 代码是 100% 非阻塞的。

  1. Listener 监听器
  • 一个 Envoy 进程可以设置多个不同的 Listener,建议一台机器只使用一个 Envoy 实例。
  • 每一个 Listener 的网络层 L3/L4 过滤器是独立配置的。并且一个 Listener 是可以通过配置来完成多种任务的,比如:访问限制、TLS 客户端校验、HTTP 链接管理等。
  • Listener 也有自己的非网络层过滤器,它可以修改链接的 Metadata 信息,通常用来影响接下来链接是如何被网络层过滤器处理的。
  • 无论网络层过滤器还是 Listener 过滤器都可以提前终止后续的过滤器链的执行。
  1. HTTP 连接管理器
  • Envoy 是完整支持 HTTP/1.1WebsocketsHTTP/2,不支持 SPDY
  • 这层过滤器主要是将原始的传递数据转变成 HTTP 层级的信息和事件,如收到 Headers、收到 Body 数据,同样它也可以做接入日志、Request ID 生成和追踪、Req/Res 头部修改工作、路由表管理、统计分析。
  • 每一个 HTTP 链接管理器有一个相匹配的路由表,路由表可以静态指定,也可以动态地通过 RDS API 来设置 route-dynamic
  • 其内部还有 HTTP 过滤器,可以支持在 HTTP 层级。在无需关注使用什么协议 (HTTP/1.1HTTP/2) 实现的情况下进行操作 HTTP 内容,支持 EncodeDecodeEncode/Decode 三种不同类型过滤器。
  1. HTTP 路由器
  • 经常用在做边缘/反向代理和构建内部 Envoy Mesh 发挥巨大作用。
  • HTTP 路由器可以支持请求重试配置:最大重试次数和设置重试条件,比如某些 5XX 错误和具有幂等性操作的 4XX 错误。
  • Envoy 自己使用 HTTP/2 链接管理器实现了 gRPC 协议,将原来官方的 Google gRPC 内置的很多功能,比如重试、超时、Endpoint 发现、负载均衡、负载报告、健康检查等功能都实现了。将来除非特殊特性必须,都可以使用 Envoy gRPC 来实现。
  1. Cluster 管理器

Cluster 管理器暴露 API 给过滤器,并允许过滤器可以得到链接到上游集群的 L3/L4 链接或者维持一个抽象的 HTTP 连接池用来链接上游集群(上游主机支持 HTTP 1.1 还是 HTTP 2 都是被隐藏的)。过滤器决定是使用 L3/L4 链接还是 HTTP Stream 来链接上游集群。而对于集群管理器来说,它负责所有集群内主机的可用性、负载均衡、健康度、线程安全的上游链接数据,上游链接类型 TCP/UPUDS,上游可接受的协议 HTTP 1.1/2

Cluster 管理器既可以静态配置,也可以使用 CDS-Cluster-Discovery-Service API 来动态配置。 集群在正式使用之前有一个 “加热” Warming 的过程:先做服务发现必要的初始化,比如 DNS 记录更新、EDS 更新,然后进行健康检查,当进行完上述的过程,会进入Becoming available 状态,这个阶段 Envoy 不会把流量指向它们; 在更新集群时,也不会把正在处理流量的集群处理掉,而是用新的去替换老的那些还未进行任何流量的集群。

  1. 与 Nginx 的区别
  • EnvoyHTTP/2 的支持比 Nginx 更好,支持包括 upstreamdownstream 在内的双向通信,而 Nginx 只支持 downstream 的连接。
  • 高级负载均衡功能是免费的,Nginx 的高级负载均衡功能则需要付费的 Nginx Plus 支持。
  • Envoy 支持热更新,Nginx 配置更新之后需要 Reload
  • Envoy 更贴近 Service Mesh 的使用习惯,Nginx 更贴近传统服务的使用习惯。

Envoy 术语

要深入理解 Envoy,首先需要先了解一下 Envoy 中的一些术语。

img

  • Host:能够进行网络通信的实体(如服务器上的应用程序)。
  • Downstream:下游主机连接到 Envoy,发送请求并接收响应。
  • Upstream:上游主机接收来自 Envoy 连接和请求并返回响应。
  • Listener:可以被下游客户端连接的命名网络(如端口、Unix 套接字)。一般是每台主机运行一个 Envoy,使用单进程运行,但是每个进程中可以启动任意数量的 Listener(监听器),每个监听器都独立配置一定数量的( L3/L4 )网络过滤器。
  • ClusterEnvoy 连接到的一组逻辑上相似的上游主机。
  • Mesh:以提供一致的网络拓扑的一组主机。
  • Runtime Configuration:与 Envoy 一起部署的外置实时配置系统。
  • Listener FilterListener 使用 Listener Filter(监听器过滤器)来操作链接的元数据,它的作用是在不更改 Envoy 的核心功能的情况下添加更多的集成功能。
  • Http Route TableHTTP 的路由规则,例如请求的域名,Path 符合什么规则,转发给哪个 Cluster

部署 Envoy

官方提供了 EnvoyDocker 镜像,直接下载对应镜像即可使用。

1
$ docker pull envoyproxy/envoy:latest

镜像中已经将 Envoy 安装到 /usr/local/bin 目录下,可以先看看 Envoy 进程的帮助信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# /usr/local/bin/envoy --help
USAGE:
/usr/local/bin/envoy [--disable-hot-restart] [--max-obj-name-len
<uint64_t>] [--max-stats <uint64_t>] [--mode
<string>] [--parent-shutdown-time-s <uint32_t>]
[--drain-time-s <uint32_t>]
[--file-flush-interval-msec <uint32_t>]
[--service-zone <string>] [--service-node
<string>] [--service-cluster <string>]
[--hot-restart-version] [--restart-epoch
<uint32_t>] [--log-path <string>] [--log-format
<string>] [-l <string>]
[--local-address-ip-version <string>]
[--admin-address-path <string>] [--v2-config-only]
[--config-yaml <string>] [-c <string>]
[--concurrency <uint32_t>] [--base-id <uint32_t>]
[--] [--version] [-h]

Envoy 进程启动的时候需要指定一些参数,其中最重要的是 --config-yaml 参数,用于指定 Envoy 进程启动的时候需要读取的配置文件地址。Docker 中配置文件默认是放在 /etc/envoy 目录下,配置文件的文件名是 envoy.yaml。所以我们在启动容器的时候需要将自定义的 envoy.yaml 配置文件挂载到指定目录下替换掉默认的配置文件。

1
# /usr/local/bin/envoy -c <path to config>.{json,yaml,pb,pb_text} --v2-config-only

注意:Envoy 默认的日志级别是 info,对于开发阶段需要进行调试的话,调整日志级别到 Debug 是非常有用的。你可以在启动参数中添加 -l debug 来将日志级别进行切换。

编写 Envoy 配置文件

在介绍 Envoy 的配置文件之前,先介绍一下 EnvoyAPIEnvoy 提供了两个版本的 APIV1V2 版本 API。现阶段 V1 版本已经不建议使用了,通常都是使用 V2API

V2API 提供了两种方式的访问,一种是 HTTP Rest 的方式访问,还有一种 GRPC 的访问方式。关于 GRPC 的介绍可以参考官方文档,在后面的文章中只实现了 GRPCAPI

Envoy 的启动配置文件分为两种方式:静态配置和动态配置。

静态配置是将所有信息都放在配置文件中,启动的时候直接加载。

动态配置需要提供一个 Envoy 的服务端,用于动态生成 Envoy 需要的服务发现接口,这里叫 XDS ,通过发现服务来动态的调整配置信息,Istio 就是实现了 V2API

静态配置

以一个最简化的静态配置来做示例,体验一下 Envoy

下面是 envoy.yaml配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 127.0.0.1, port_value: 9901 }

static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { cluster: some_service }
http_filters:
- name: envoy.router
clusters:
- name: some_service
connect_timeout: 0.25s
type: STATIC
lb_policy: ROUND_ROBIN
hosts: [{ socket_address: { address: 127.0.0.1, port_value: 80 }}]

在此基础上启动两个容器,envoyproxy 容器和 nginx 容器,nginx 容器共享 envoyproxy 容器的网络,以此来模拟 Sidecar

1
2
$ docker run -d -p 10000:10000 -v `pwd`/envoy.yaml:/etc/envoy/envoy.yaml --name envoyproxy envoyproxy/envoy:latest
$ docker run -d --network=container:envoyproxy --name nginx nginx

根据配置文件的规则,Envoy 监听在 10000 端口,同时该端口也在宿主机的 10000 端口上暴露出来。当有请求到达监听上后,Envoy 会对所有请求路由到 some_service 这个 Cluster 上,而该 ClusterUpstream 指向本地的 80 端口,也就是 Nginx 服务上。

img

动态配置

动态配置可以实现全动态,即实现 LDS (Listener Discovery Service)、CDS (Cluster Discovery Service)、RDS (Route Discovery Service)、EDS (Endpoint Discovery Service),以及 ADS (Aggregated Discovery Service)。

ADS 不是一个实际意义上的 XDS,它提供了一个汇聚的功能,以实现需要多个同步 XDS 访问的时候可以在一个 Stream 中完成的作用。

下面的图通过在静态配置的基础上,比较直观的表示出各个发现服务所提供的信息。

img

由此,典型的动态配置文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 127.0.0.1, port_value: 9901 }

dynamic_resources:
cds_config:
ads: {}
lds_config:
ads: {}
ads_config:
api_type: GRPC
cluster_names: [xds_cluster]

static_resources:
clusters:
- name: xds_cluster
connect_timeout: 0.25s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
http2_protocol_options: {}
hosts: [{ socket_address: { address: envoy-server, port_value: 50051 }}]

注意:动态配置和静态配置最大的区别在于,启动的时候一定要指定 clusterid,这两个参数表示该 Envoy 进程属于哪个 Clusterid 要求在相同的 Cluster 下唯一,以表示不同的指向发现服务的连接信息。这两个参数可以在 Envoy 的启动命令中添加--service-cluster--service-node 来指定,也可以在 envoy.yaml 配置文件中指定 node.clusternode.id

Envoy 使用实例

入门实例

了解一个开源软件,从官方实例入手再好不过了,因此下面的例子将会围绕官方仓库中的实例展开。所以在开始之前,你需要安装并配置以下工具:

  • Docker
  • Docker Compose
  • Git
  • Curl

我们将会使用 DockerDocker Compose 来构建和运行几个 Envoy 示例服务,并用 Curl 来检测 Envoy 示例服务是否在运行。

运行 Envoy

首先克隆 Envoy 官方仓库到本地,并定位到 envoy/examples/front-proxy 文件夹。

1
2
$ git clone https://github.com/envoyproxy/envoy
$ cd envoy/examples/front-proxy

front-proxy 文件夹中的服务是一个用 Flask 实现的后端服务,入口文件在 service.py 文件里面。Envoy 作为一个 Sidecar 部件,将与 service.py 在同一个容器中运行,并由 docker-compose,.yaml 文件配置。

前端代理比后端服务更简单,它使用配置文件 front-envoy.yaml 来运行 EnvoyDockerfile-frontenvoy 文件则是 front-envoyDockerfile

如果你之前没有接触过 Docker 的话,你可以使用以下命令在本地构建并运行 front-proxyDocker 镜像:

1
2
$ cd /path/to/envoy/examples/front-proxy
$ docker-compose up --build -d

其中的 --build 表示构建镜像, -d 表示在后台运行所有 docker-compose 配置文件中定义的镜像,具体可参考 Docker 相关文档。

命令运行成功后,将会启动一个前端代理和两个服务实例:service1service2。你可以通过以下命令来验证容器是否正常运行:

1
$ docker-compose ps

正常的话会返回以下内容:

1
2
3
4
5
6
$ front-proxy git:(master) docker-compose ps
Name Command State Ports
----------------------------------------------------------------------------------------------------------------------------
front-proxy_front-envoy_1 /usr/bin/dumb-init -- /bin ... Up 10000/tcp, 0.0.0.0:8000->80/tcp, 0.0.0.0:8001->8001/tcp
front-proxy_service1_1 /bin/sh -c /usr/local/bin/ ... Up 10000/tcp, 80/tcp
front-proxy_service2_1 /bin/sh -c /usr/local/bin/ ... Up 10000/tcp, 80/tcp

测试服务是否连通

你可以使用 curl 或者浏览器来测试服务是否在正常运行

浏览器中输入 http://localhost:8000/service/1 或者使用以下命令:

1
$ curl http://localhost:8000/service/1

如果返回结果是像下面这样,则表示 service1Envoy 服务正常运行:

1
Hello from behind Envoy (service 1)! hostname: a841ffceafd0 resolvedhostname: 172.18.0.4

你也可以用同样的方法测试 service 2 的服务。

1
$ curl http://localhost:8000/service/2

返回的结果和 service 1 类似。

1
Hello from behind Envoy (service 2)! hostname: e83b35c6f4fe resolvedhostname: 172.18.0.3 。

Envoy 配置

下面我们先简单看一下 Envoy 的静态配置信息,之后再继续看 Demo 中的动态配置信息。

我们先从 front-envoy.yml 入手。打开文件之后,我们会发现这个 yaml 有两个最高的层级,分别是 static_resourcesadminadmin 的内容相对比较简单,总共只有六行:

1
2
3
4
5
6
admin:
access_log_path: "/dev/null"
address:
socket_address:
address: 0.0.0.0
port_value: 8001

其中 access_log_path 字段值是 /dev/null,其含义是 admin 服务的请求日志将不会被保存。生产环境中可自行将目标目录指定到需要的地方。addressport_value 字段分别表示 admin server 运行的 IP 端口。

static_resource 的内容定义了非动态管理的集群(Cluster)和监听器(Listener)相关配置。集群是 Envoy 连接到的一组逻辑上相似的上游主机,一个集群是一组被定义的 ip/port 集合,Envoy 将借此实现负载均衡。监听器是一组被定义的网络地址,它是可以由下游客户端连接的命名网络位置(例如,端口、Unix 域套接字等)。监听器是服务(程序)监听者,就是真正干活的,客户端可借此连接至服务。

front proxy 中只有一个监听器,监听器中除了 socket_address 之外还有一个字段是 filter_chainsEnvoy 通过此字段来管理 HTTP 的连接和过滤。

1
2
3
4
5
6
7
8
9
10
11
12
13
listeners:
- address:
socket_address:
address: 0.0.0.0
port_value: 80
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route

其中有个配置选项是 virtual_hosts,该选项在 HTTP 连接管理过滤器中用作定义虚拟主机,并通过正则过滤允许访问服务的域名。路由也在其中配置,例子中将 /service/1/service/2 的请求分别转发到了其相应的集群中。

1
2
3
4
5
6
7
8
9
10
11
12
13
virtual_hosts:
- name: backend
domains:
- "*"
routes:
- match:
prefix: "/service/1"
route:
cluster: service1
- match:
prefix: "/service/2"
route:
cluster: service2

接下来我们继续看静态集群的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
clusters:
- name: service1
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
http2_protocol_options: {}
hosts:
- socket_address:
address: service1
port_value: 80
- name: service2
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
http2_protocol_options: {}
hosts:
- socket_address:
address: service2
port_value: 80

在静态集群的配置内容中,我们可以配置超时时间、熔断器、服务发现等等内容。集群由一系列端点 (Endpoints) 组成,端点就是一组服务集群中可以响应访问请求的网络地址。在上面的例子中,端点标准定义成 DNS ,除此之外,端点可以直接被定义成 Socket 地址,或者是可动态读取的服务发现机制。

尝试动手修改配置

我们可以在本地尝试自己修改配置,重建镜像,测试修改后的配置。监听过滤器是 Envoy 为监听器提供的附加功能。比方说,想要增加访问日志到我们的 HTTP 过滤器中,只要增加 access_log 字段到配置文件中即可:

1
2
3
4
5
6
7
8
9
10
- filters:
- name: envoy.http_connection_manager
config:
codec_type: auto
stat_prefix: ingress_http
access_log:
- name: envoy.file_access_log
config:
path: "/var/log/access.log"
route_config:

修改之后,先通过 docker-compose down 命令关闭 docker-compose 容器组,然后使用 docker-compose up --build -d 命令重新构建镜像并运行容器组即可。

为了验证我们新增的 access_log 字段是否生效,我们可以模拟几次请求。然后通过命令 docker-compose exec front-envoy /bin/bash 手动进入容器内部查看访问日志是否在相应的目录中,你会看到 /var/log/access.log 文件记录着你的请求结果。

管理页面

Envoy 的一大特色是内置了管理页面,你可以通过 http://localhost:8001 访问。管理页面中 /cluster 菜单展示了上游 (Upstream) 集群端口的统计内容,stats 菜单则显示了更多端口的统计内容。

img

更多管理页面的内容你可以直接访问帮助页面 http://localhost:8001/help 来查看。

请求处理流程

Envoy 中对访问请求的处理流程大致如下,先将请求数据预处理,转成 Envoy 中的 Filter, 读写请求的 filter 分别是 ReadFilterWriteFiler,对每个网络层也有各自的 filterTCP 的是 TcpProxyFilter, HTTP 的是 ConnectionManager,都由读 filter ReadFilter 继承而来。各个 filter 预处理完成之后就会组织成上面示例配置文件中有提到的 FilterChain, 收到 FilterChain 之后会将其路由到指定的集群中,并根据负载均衡获取到相应的地址,然后将请求转发出去。

进阶实例

接下来的实验主要以动态配置的方式来实现一个简单的需求,首先描述一下需求场景:有两个微服务,一个是 envoy-web,一个 envoy-server

  • envoy-web 相当于下图中的 front-envoy 作为对外访问的入口。
  • envoy-server 相当于下图中的 service_1service_2,是内部的一个微服务,部署 2 个实例。

img

envoy-server 有 3 个 API,分别是 /envoy-server/hello/envoy-server/hi/envoy-server/self,目的是测试 Envoy 对于流入 envoy-server 的流量控制,对外只允许访问 /envoy-server/hello/envoy-server/hi 两个 API/envoy-server/self 不对外暴露服务。

envoy-web 也有 3 个 API,分别是 /envoy-web/hello/envoy-web/hi/envoy-web/self,目的是测试 Envoy 对于流出 envoy-web 的流量控制,出口流量只允许 /envoy-web/hello/envoy-web/self 两个访问出去。

最终的实验:外部只能访问 envoy-web 暴露的接口

  • 当访问 /envoy-web/hello 接口时返回 envoy-server/hello 接口的数据,表示 envoy-web 作为客户端访问 envoy-server 返回服务响应的结果。
  • 当访问 /envoy-web/hi 接口时,envoy-webenvoy 拦截住出口流量,限制 envoy-webenvoy-server 发送请求,对于前端用户返回 mock 数据。
  • 当访问 /envoy-web/self 接口时,envoy-web 出口流量可以到达 envoy-server 容器,但是 envoy-server 在入口流量处控制住了此次请求,拒绝访问 envoy-server服务,对于前端用户返回 mock 数据。

静态配置

首先,以静态配置的方式先实现功能。

编写服务代码

服务代码分为 envoy-webenvoy-server 两个服务,采用 SpringBoot 的方式,下面记录一些重要的代码片段。

  • envoy-server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
public class HelloRest {
private static final Logger LOGGER = LoggerFactory.getLogger(HelloRest.class);

@GetMapping("/envoy-server/hello")
public String hello() {
LOGGER.info("get request from remote, send response, say hello");
return "hello";
}

@GetMapping("/envoy-server/hi")
public String hi() {
LOGGER.info("get request from remote, send response, say hi");
return "hi";
}

@GetMapping("/envoy-server/self")
public String self() {
LOGGER.info("get request from remote, send response, say self");
return "self";
}
}
  • envoy-web
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@RestController
public class HelloController {
private static final Logger LOGGER = LoggerFactory.getLogger(HelloController.class);

@Autowired
private RestTemplate template;

@GetMapping("/envoy-web/local")
public String sayLocal() {
LOGGER.info("get request, send response");
return "local";
}

@GetMapping("/envoy-web/hello")
public String sayHello() {
String url = "http://127.0.0.1:10000/envoy-server/hello";
LOGGER.info("get request, send rest template to {}", url);
return getRemote(url, "mock value for hello");
}

@GetMapping("/envoy-web/hi")
public String sayHi() {
String url = "http://127.0.0.1:10000/envoy-server/hi";
LOGGER.info("get request, send rest template to {}", url);
return getRemote(url, "mock value for hi");
}

@GetMapping("/envoy-web/self")
public String saySelf() {
String url = "http://127.0.0.1:10000/envoy-server/self";
LOGGER.info("get request, send rest template to {}", url);
return getRemote(url, "mock value for self");
}

private String getRemote(String url, String mock) {
try {
ResponseEntity<String> response = template.getForEntity(url, String.class);
return response.getBody();
} catch (Exception e) {
LOGGER.error("error happens: {}", e);
return mock;
}
}
}

注:为简化起见,代码只是介绍对出入流量的控制,直接在 envoy-web 上访问了本地的 Envoy 端口进行转发流量,实际代码中可以用服务名:服务端口号访问,而此时为了使得 Envoy 仍然可以拦截入和出的流量,可以配置 IptablesIstio 的实现中也是使用了 Iptables)。

编写配置文件

针对不同的服务,也配置了两份 envoy.yaml 配置文件。

  • envoy-server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9900 }
static_resources:
listeners:
- name: listener_ingress
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/envoy-server/hello" }
route: { cluster: cluster_server }
- match: { prefix: "/envoy-server/hi" }
route: { cluster: cluster_server }
http_filters:
- name: envoy.router
clusters:
- name: cluster_server
connect_timeout: 0.5s
type: STATIC
lb_policy: ROUND_ROBIN
hosts:
- { socket_address: { address: 127.0.0.1, port_value: 8081 }}
  • envoy-web
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9900 }
static_resources:
listeners:
- name: listener_ingress
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/envoy-web/" }
route: { cluster: cluster_ingress }
- match: { prefix: "/envoy-server/hello" }
route: { cluster: cluster_egress }
- match: { prefix: "/envoy-server/self" }
route: { cluster: cluster_egress }
http_filters:
- name: envoy.router
clusters:
- name: cluster_ingress
connect_timeout: 0.5s
type: STATIC
lb_policy: ROUND_ROBIN
hosts:
- { socket_address: { address: 127.0.0.1, port_value: 8080 }}
- name: cluster_egress
connect_timeout: 0.5s
type: STATIC
lb_policy: ROUND_ROBIN
hosts:
- { socket_address: { address: 172.17.0.2, port_value: 10000 }}
- { socket_address: { address: 172.17.0.3, port_value: 10000 }}

启动测试

1
2
3
4
5
6
7
8
9
10
11
# envoy-server1
$ docker run -d -v `pwd`/envoy-server.yaml:/etc/envoy/envoy.yaml --name envoyproxy-server1 envoyproxy/envoy:latest /usr/local/bin/envoy --service-cluster envoy-server --service-node 1 -c /etc/envoy/envoy.yaml --v2-config-only
$ docker run -d --network=container:envoyproxy-server1 --name envoy-server1 envoy-server:1.1

# envoy-server2
$ docker run -d -v `pwd`/envoy-server.yaml:/etc/envoy/envoy.yaml --name envoyproxy-server2 envoyproxy/envoy:latest /usr/local/bin/envoy --service-cluster envoy-server --service-node 2 -c /etc/envoy/envoy.yaml --v2-config-only
$ docker run -d --network=container:envoyproxy-server2 --name envoy-server2 envoy-server:1.1

# envoy-web
$ docker run -d -p 10000:10000 -v `pwd`/envoy-web.yaml:/etc/envoy/envoy.yaml --name envoyproxy-web envoyproxy/envoy:latest /usr/local/bin/envoy --service-cluster envoy-web --service-node 1 -c /etc/envoy/envoy.yaml --v2-config-only
$ docker run -d --network=container:envoyproxy-web --name envoy-web envoy-web:1.1

当容器部署完毕之后,可以直接访问以下 3 个 URL ,其中 hi 和 self 的访问返回的是 mock 数据,虽然同为 mock 数据,但是这两个 URL 其实是不相同的,一个是在 Envoy 出口流量处做的控制,一个是在 Envoy 入口流量处做的控制,其中的细节可以再去品味品味。

img

动态配置

动态配置需要实现发现服务,通过 GRPC 的方式获取相应。

动态的配置文件在前面的内容中已经有过介绍,最重要的是需要提供一个发现服务,对外提供 XDS 服务,下面以其中的一个 LDS 作为介绍,其他 XDS 实现类似。

服务端:既然作为服务,就需要对外提供接口服务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class GrpcService {
private Server server;
private static final int PORT = 50051;

private void start() throws IOException {
server = ServerBuilder.forPort(PORT)
.addService(new LdsService())
.addService(new CdsService())
.addService(new RdsService())
.addService(new EdsService())
.addService(new AdsService())
.build()
.start();
System.err.println("Server started, listening on " + PORT);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.err.println("*** shutting down gRPC server since JVM is shutting down");
GrpcService.this.stop();
System.err.println("*** server shut down");
}));
}

private void stop() {
if (server != null) {
server.shutdown();
}
}

private void blockUntilShutdown() throws InterruptedException {
if (server != null) {
server.awaitTermination();
}
}

public static void main(String[] args) throws IOException, InterruptedException {
final GrpcService server = new GrpcService();
server.start();
server.blockUntilShutdown();
}
}

XDS 通过 GRPC 生成服务端的 stub 文件,实现 LdsServer 继承自 ListenerDiscoveryServiceGrpc.ListenerDiscoveryServiceImplBase,需要实现 streamListeners 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class LdsService extends ListenerDiscoveryServiceGrpc.ListenerDiscoveryServiceImplBase {
private static final Logger LOGGER = LogManager.getLogger();

@Override
public StreamObserver<Discovery.DiscoveryRequest> streamListeners(StreamObserver<Discovery.DiscoveryResponse> responseObserver) {
return new StreamObserver<Discovery.DiscoveryRequest>() {
@Override
public void onNext(Discovery.DiscoveryRequest request) {
XdsHelper.getInstance().buildAndSendResult(request, responseObserver);
}

@Override
public void onError(Throwable throwable) {
LOGGER.warn("Error happens", throwable);
}

@Override
public void onCompleted() {
LOGGER.info("LdsService completed");
}
};
}
}

总结

至此,我们就基本介绍完 Envoy 使用的一些常见的使用方法,在实现的时候也会有其他一些细节需要注意。比如,Envoy 作为一个服务之间网络请求的代理,如何拦截全部的入和出流量?

Istio 给了一个很好的解决方案,就是通过 Iptables。它会使用一个特定的 uid(默认 1337)用户运行 Envoy 进程,Iptables 对于 1337 用户的流量不做拦截。下面就是参考 Istioiptables.sh 做的一个实现:

1
2
3
4
5
6
7
8
9
uname=envoy
uid=1337
iptalbes -t nat -F
iptables -t nat -I PREROUTING -p tcp -j REDIRECT --to-ports 10000
iptables -t nat -N ENVOY_OUTPUT
iptables -t nat -A OUTPUT -p tcp -j ENVOY_OUTPUT
iptables -t nat -A ENVOY_OUTPUT -p tcp -d 127.0.0.1/32 -j RETURN
iptables -t nat -A ENVOY_OUTPUT -m owner --uid-owner ${uid} -j RETURN
iptables -t nat -A ENVOY_OUTPUT -p tcp -j REDIRECT --to-ports 10000

前言

今天 Go 团队很高兴地宣布 Go 1.16 的发布,新增的 embed 包使用新的 //go:embed 指令提供对编译时访问嵌入的文件。

Go 1.16 还添加了 macOS ARM64 的支持(也称为 Apple silicon)。对于苹果新发布的 M1 芯片的 Mac 感兴趣的读者,可以放心购买了。

请注意,Go 1.16 默认需要使用 Go Modules,现在,根据 Go 官方的 2020 Go 开发人员调查,96% 的 Go 开发人员已经切换到了 Go Modules。

Go 1.16 还有许多其他改进和错误修复,包括编译速度提升高达 25% 且内存减少 15%。

本文我们主要来介绍 embed 包的使用方式。

更新历史

2021 年 02月 18日 - 初稿


//go:embed 指令使用方式

使用 //go:embed 指令,需要导入 embed 包,嵌入单个文件,可以使用字符串类型的变量和字节类型切片的变量,并且可以使用 _ 空白导入 embed 包。

FS 类型允许嵌入文件树,例如静态 Web 服务的文件目录,FS 实现了 io/fs 包的 FS 接口,因此,它可以与任何文件系统相关的包一起使用,包括 net/httptext\template,和 html\template

字符串类型模式示例代码:

1
2
//go:embed hello.txt
var s string

字节类型切片模式示例代码:

1
2
//go:embed hello.txt
var b []byte

FS 类型模式示例代码:

1
2
//go:embed hello.txt
var f embed.FS

从以上三段代码中,我们可以看到,在变量声明上方的 //go:embed 指令,指定要嵌入的文件。该指令必须紧接在包含单个变量声明的行之前,变量的类型必须是字符串类型,或者是字节类型的切片,或者是 FS 类型(或 FS 的别名)。

需要注意的是,指令和变量声明之间仅允许空行和使用 // 注释的行。

//go:embed 指令进阶

关于 //go:embed 指令,还有一些值得注意的细节,总结如下:

  1. //go:embed 指令可用于导出变量和未导出变量,具体取决于包是否希望将数据提供给其它包。
  2. //go:embed 指令只能在包作用域中与全局变量一起使用,而不能与局部变量一起使用。
  3. FS 类型模式允许多个路径以空格分隔,字符串类型模式和字节类型的切片模式仅允许匹配单个文件路径。
  4. 路径分隔符是正斜杠(即使在 Windows 系统中)。
  5. 不能包含 ... 或空路径,也不能以斜杠开始或结束。
  6. 如果要匹配当前目录中的所有内容,使用 * 而不是 .

//go:embed 的 FS 类型

FS 是只读的文件集合,通常使用 //go:embed 指令初始化。如果声明时没有 //go:embed 指令,FS 则是一个空文件系统。

FS 是只读值,因此, 可以安全地同时从多个 go 协程使用, 并且安全地将 FS 类型的值分配给对方。

1
2
3
type FS struct {
// contains filtered or unexported fields
}

FS 类型包含三个方法,分别是 Open,ReadDir 和 ReadFile。

  • func (f FS) Open(name string) (fs.File, error) 打开要读取的命名文件,并作为 fs.File 返回它。
  • func (f FS) ReadDir(name string) ([]fs.DirEntry, error) 读取并返回整个命名目录。
  • func (f FS) ReadFile(name string) ([]byte, error) 读取并返回命名文件的内容。

ReadFile 方法的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"embed"
"fmt"
)

//go:embed hello.txt
var f embed.FS

func main () {
bs, err := f.ReadFile("hello.txt")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(bs)
}

总结

本文主要是介绍 golang v1.16 新增的 embed 包的使用方式,包括新增的 //go:embed 指令和 embed 包的 FS 类型的方法。重点是 //go:embed 指令允许在 Go 应用程序中包含任意文件和目录的内容。

关于 embed 包的更多内容,可以访问源码 /usr/local/go/src/embed/embed.go

前言

本篇文章主要想讨论一下,Kubernetes 的 kubectl 命令中的使用到到的一个编程模式 – Visitor(注:其实,kubectl 主要使用到了两个一个是Builder,另一个是Visitor)。本来,Visitor 是面向对象设计模英中一个很重要的设计模款(参看Wikipedia Visitor Pattern词条),这个模式是一种将算法与操作对象的结构分离的一种方法。这种分离的实际结果是能够在不修改结构的情况下向现有对象结构添加新操作,是遵循开放/封闭原则的一种方法。这篇文章我们重点看一下 kubelet 中是怎么使用函数式的方法来实现这个模式的。

更新历史

2020 年 12 月 27 日 - 初稿


一个简单示例

我们还是先来看一个简单设计模式的Visitor的示例。

  • 我们的代码中有一个Visitor的函数定义,还有一个Shape接口,其需要使用 Visitor函数做为参数。
  • 我们的实例的对象 CircleRectangle实现了 Shape 的接口的 accept() 方法,这个方法就是等外面给我传递一个Visitor。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"encoding/json"
"encoding/xml"
"fmt"
)

type Visitor func(shape Shape)

type Shape interface {
accept(Visitor)
}

type Circle struct {
Radius int
}

func (c Circle) accept(v Visitor) {
v(c)
}

type Rectangle struct {
Width, Heigh int
}

func (r Rectangle) accept(v Visitor) {
v(r)
}

然后,我们实现两个Visitor,一个是用来做JSON序列化的,另一个是用来做XML序列化的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func JsonVisitor(shape Shape) {
bytes, err := json.Marshal(shape)
if err != nil {
panic(err)
}
fmt.Println(string(bytes))
}

func XmlVisitor(shape Shape) {
bytes, err := xml.Marshal(shape)
if err != nil {
panic(err)
}
fmt.Println(string(bytes))
}

下面是我们的使用Visitor这个模式的代码

1
2
3
4
5
6
7
8
9
10
11
func main() {
c := Circle{10}
r := Rectangle{100, 200}
shapes := []Shape{c, r}

for _, s := range shapes {
s.accept(JsonVisitor)
s.accept(XmlVisitor)
}

}

其实,这段代码的目的就是想解耦 数据结构和 算法,使用 Strategy 模式也是可以完成的,而且会比较干净。但是在有些情况下,多个Visitor是来访问一个数据结构的不同部分,这种情况下,数据结构有点像一个数据库,而各个Visitor会成为一个个小应用。 kubectl就是这种情况。

k8s相关背景

接下来,我们再来了解一下相关的知识背景:

  • 对于Kubernetes,其抽象了很多种的Resource,比如:Pod, ReplicaSet, ConfigMap, Volumes, Namespace, Roles …. 种类非常繁多,这些东西构成为了Kubernetes的数据模型(点击 Kubernetes Resources 地图 查看其有多复杂)
  • kubectl 是Kubernetes中的一个客户端命令,操作人员用这个命令来操作Kubernetes。kubectl 会联系到 Kubernetes 的API Server,API Server会联系每个节点上的 kubelet ,从而达到控制每个结点。
  • kubectl 主要的工作是处理用户提交的东西(包括,命令行参数,yaml文件等),然后其会把用户提交的这些东西组织成一个数据结构体,然后把其发送给 API Server。
  • 相关的源代码在 src/k8s.io/cli-runtime/pkg/resource/visitor.go 中(源码链接

kubectl 的代码比较复杂,不过,其本原理简单来说,它从命令行和yaml文件中获取信息,通过Builder模式并把其转成一系列的资源,最后用 Visitor 模式模式来迭代处理这些Reources

下面我们来看看 kubectl 的实现,为了简化,我用一个小的示例来表明 ,而不是直接分析复杂的源码。

kubectl的实现方法

Visitor模式定义

首先,kubectl 主要是用来处理 Info结构体,下面是相关的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type VisitorFunc func(*Info, error) error

type Visitor interface {
Visit(VisitorFunc) error
}

type Info struct {
Namespace string
Name string
OtherThings string
}
func (info *Info) Visit(fn VisitorFunc) error {
return fn(info, nil)
}

我们可以看到,

  • 有一个 VisitorFunc 的函数类型的定义
  • 一个 Visitor 的接口,其中需要 Visit(VisitorFunc) error 的方法(这就像是我们上面那个例子的 Shape
  • 最后,为Info 实现 Visitor 接口中的 Visit() 方法,实现就是直接调用传进来的方法(与前面的例子相仿)

我们再来定义几种不同类型的 Visitor。

Name Visitor

这个Visitor 主要是用来访问 Info 结构中的 NameNameSpace 成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type NameVisitor struct {
visitor Visitor
}

func (v NameVisitor) Visit(fn VisitorFunc) error {
return v.visitor.Visit(func(info *Info, err error) error {
fmt.Println("NameVisitor() before call function")
err = fn(info, err)
if err == nil {
fmt.Printf("==> Name=%s, NameSpace=%s\n", info.Name, info.Namespace)
}
fmt.Println("NameVisitor() after call function")
return err
})
}

我们可以看到,上面的代码:

  • 声明了一个 NameVisitor 的结构体,这个结构体里有一个 Visitor 接口成员,这里意味着多态。
  • 在实现 Visit() 方法时,其调用了自己结构体内的那个 VisitorVisitor() 方法,这其实是一种修饰器的模式,用另一个Visitor修饰了自己(关于修饰器模式,参看《https://www.amoyw.com/2020/12/24/go-decoration/)》)
Other Visitor

这个Visitor主要用来访问 Info 结构中的 OtherThings 成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type OtherThingsVisitor struct {
visitor Visitor
}

func (v OtherThingsVisitor) Visit(fn VisitorFunc) error {
return v.visitor.Visit(func(info *Info, err error) error {
fmt.Println("OtherThingsVisitor() before call function")
err = fn(info, err)
if err == nil {
fmt.Printf("==> OtherThings=%s\n", info.OtherThings)
}
fmt.Println("OtherThingsVisitor() after call function")
return err
})
}

实现逻辑同上,我就不再重新讲了

Log Visitor
1
2
3
4
5
6
7
8
9
10
11
12
type LogVisitor struct {
visitor Visitor
}

func (v LogVisitor) Visit(fn VisitorFunc) error {
return v.visitor.Visit(func(info *Info, err error) error {
fmt.Println("LogVisitor() before call function")
err = fn(info, err)
fmt.Println("LogVisitor() after call function")
return err
})
}
使用方代码

现在我们看看如果使用上面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
info := Info{}
var v Visitor = &info
v = LogVisitor{v}
v = NameVisitor{v}
v = OtherThingsVisitor{v}

loadFile := func(info *Info, err error) error {
info.Name = "Hao Chen"
info.Namespace = "MegaEase"
info.OtherThings = "We are running as remote team."
return nil
}
v.Visit(loadFile)
}

上面的代码,我们可以看到

  • Visitor们一层套一层
  • 我用 loadFile 假装从文件中读如数据
  • 最后一条 v.Visit(loadfile) 我们上面的代码就全部开始激活工作了。

上面的代码输出如下的信息,你可以看到代码的执行顺序是怎么执行起来了

1
2
3
4
5
6
7
8
LogVisitor() before call function
NameVisitor() before call function
OtherThingsVisitor() before call function
==> OtherThings=We are running as remote team.
OtherThingsVisitor() after call function
==> Name=Hao Chen, NameSpace=MegaEase
NameVisitor() after call function
LogVisitor() after call function

我们可以看到,上面的代码有以下几种功效:

  • 解耦了数据和程序。
  • 使用了修饰器模式
  • 还做出来pipeline的模式

所以,其实,我们是可以把上面的代码重构一下的。

Visitor修饰器

下面,我们用修饰器模式来重构一下上面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
type DecoratedVisitor struct {
visitor Visitor
decorators []VisitorFunc
}

func NewDecoratedVisitor(v Visitor, fn ...VisitorFunc) Visitor {
if len(fn) == 0 {
return v
}
return DecoratedVisitor{v, fn}
}

// Visit implements Visitor
func (v DecoratedVisitor) Visit(fn VisitorFunc) error {
return v.visitor.Visit(func(info *Info, err error) error {
if err != nil {
return err
}
if err := fn(info, nil); err != nil {
return err
}
for i := range v.decorators {
if err := v.decorators[i](info, nil); err != nil {
return err
}
}
return nil
})
}

上面的代码并不复杂,

  • 用一个 DecoratedVisitor 的结构来存放所有的VistorFunc函数
  • NewDecoratedVisitor 可以把所有的 VisitorFunc转给它,构造 DecoratedVisitor 对象。
  • DecoratedVisitor实现了 Visit() 方法,里面就是来做一个for-loop,顺着调用所有的 VisitorFunc

于是,我们的代码就可以这样运作了:

1
2
3
4
5
info := Info{}
var v Visitor = &info
v = NewDecoratedVisitor(v, NameVisitor, OtherVisitor)

v.Visit(LoadFile)

是不是比之前的那个简单?注意,这个DecoratedVisitor 同样可以成为一个Visitor来使用。

好,上面的这些代码全部存在于 kubectl 的代码中,你看懂了这里面的代码逻辑,相信你也能够看懂 kubectl 的代码了。

前言

本篇文章,我们着重介绍Go编程中的Pipeline模式。对于Pipeline用过Unix/Linux命令行的人都不会陌生,他是一种把各种命令拼接起来完成一个更强功能的技术方法。在今天,流式处理,函数式编程,以及应用网关对微服务进行简单的API编排,其实都是受pipeline这种技术方式的影响,Pipeline这种技术在可以很容易的把代码按单一职责的原则拆分成多个高内聚低耦合的小模块,然后可以很方便地拼装起来去完成比较复杂的功能。

更新历史

2020 年 12 月 25 日 - 初稿

扩展阅读


HTTP 处理

这种Pipeline的模式,我们在《Go编程模式:修饰器》中有过一个示例,我们在这里再重温一下。在那篇文章中,我们有一堆如 WithServerHead()WithBasicAuth()WithDebugLog()这样的小功能代码,在我们需要实现某个HTTP API 的时候,我们就可以很容易的组织起来。

原来的代码是下面这个样子:

1
2
3
http.HandleFunc("/v1/hello", WithServerHeader(WithAuthCookie(hello)))
http.HandleFunc("/v2/hello", WithServerHeader(WithBasicAuth(hello)))
http.HandleFunc("/v3/hello", WithServerHeader(WithBasicAuth(WithDebugLog(hello))))

通过一个代理函数:

1
2
3
4
5
6
7
8
type HttpHandlerDecorator func(http.HandlerFunc) http.HandlerFunc
func Handler(h http.HandlerFunc, decors ...HttpHandlerDecorator) http.HandlerFunc {
for i := range decors {
d := decors[len(decors)-1-i] // iterate in reverse
h = d(h)
}
return h
}

我们就可以移除不断的嵌套像下面这样使用了:

1
http.HandleFunc("/v4/hello", Handler(hello,WithServerHeader, WithBasicAuth, WithDebugLog))

Channel 管理

当然,如果你要写出一个泛型的pipeline框架并不容易,而使用Go Generation,但是,我们别忘了Go语言最具特色的 Go Routine 和 Channel 这两个神器完全也可以被我们用来构造这种编程。

Rob Pike在 Go Concurrency Patterns: Pipelines and cancellation 这篇blog中介绍了如下的一种编程模式。

Channel转发函数

首先,我们需一个 echo()函数,其会把一个整型数组放到一个Channel中,并返回这个Channel

1
2
3
4
5
6
7
8
9
10
func echo(nums []int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}

然后,我们依照这个模式,我们可以写下这个函数。

平方函数
1
2
3
4
5
6
7
8
9
10
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
过滤奇数函数
1
2
3
4
5
6
7
8
9
10
11
12
func odd(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
if n%2 != 0 {
out <- n
}
}
close(out)
}()
return out
}
求和函数
1
2
3
4
5
6
7
8
9
10
11
12
func sum(in <-chan int) <-chan int {
out := make(chan int)
go func() {
var sum = 0
for n := range in {
sum += n
}
out <- sum
close(out)
}()
return out
}

然后,我们的用户端的代码如下所示:(注:你可能会觉得,sum()odd()sq()太过于相似。你其实可以通过我们之前的Map/Reduce编程模式或是Go Generation的方式来合并一下

1
2
3
4
var nums = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
for n := range sum(sq(odd(echo(nums)))) {
fmt.Println(n)
}

上面的代码类似于我们执行了Unix/Linux命令: echo $nums | sq | sum

同样,如果你不想有那么多的函数嵌套,你可以使用一个代理函数来完成。

1
2
3
4
5
6
7
8
9
10
type EchoFunc func ([]int) (<- chan int) 
type PipeFunc func (<- chan int) (<- chan int)

func pipeline(nums []int, echo EchoFunc, pipeFns ... PipeFunc) <- chan int {
ch := echo(nums)
for i := range pipeFns {
ch = pipeFns[i](ch)
}
return ch
}

然后,就可以这样做了:

1
2
3
4
var nums = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}    
for n := range pipeline(nums, gen, odd, sq, sum) {
fmt.Println(n)
}

Fan in/Out

动用Go语言的 Go Routine和 Channel还有一个好处,就是可以写出1对多,或多对1的pipeline,也就是Fan In/ Fan Out。下面,我们来看一个Fan in的示例:

我们想通过并发的方式来对一个很长的数组中的质数进行求和运算,我们想先把数组分段求和,然后再把其集中起来。

下面是我们的主函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func makeRange(min, max int) []int {
a := make([]int, max-min+1)
for i := range a {
a[i] = min + i
}
return a
}

func main() {
nums := makeRange(1, 10000)
in := echo(nums)

const nProcess = 5
var chans [nProcess]<-chan int
for i := range chans {
chans[i] = sum(prime(in))
}

for n := range sum(merge(chans[:])) {
fmt.Println(n)
}
}

再看我们的 prime() 函数的实现 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func is_prime(value int) bool {
for i := 2; i <= int(math.Floor(float64(value) / 2)); i++ {
if value%i == 0 {
return false
}
}
return value > 1
}

func prime(in <-chan int) <-chan int {
out := make(chan int)
go func () {
for n := range in {
if is_prime(n) {
out <- n
}
}
close(out)
}()
return out
}

我们可以看到,

  • 我们先制造了从1到10000的一个数组,
  • 然后,把这堆数组全部 echo到一个channel里 – in
  • 此时,生成 5 个 Channel,然后都调用 sum(prime(in)) ,于是每个Sum的Go Routine都会开始计算和
  • 最后再把所有的结果再求和拼起来,得到最终的结果。

其中的merge代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func merge(cs []<-chan int) <-chan int {
var wg sync.WaitGroup
out := make(chan int)

wg.Add(len(cs))
for _, c := range cs {
go func(c <-chan int) {
for n := range c {
out <- n
}
wg.Done()
}(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}

用图片表示一下,整个程序的结构如下所示:

img

前言

这种模式很容易的可以把一些函数装配到另外一些函数上,可以让你的代码更为的简单,也可以让一些“小功能型”的代码复用性更高,让代码中的函数可以像乐高玩具那样自由地拼装。

更新历史

2020 年 12 月 24 日 - 初稿


简单示例

我们先来看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

func decorator(f func(s string)) func(s string) {

return func(s string) {
fmt.Println("Started")
f(s)
fmt.Println("Done")
}
}

func Hello(s string) {
fmt.Println(s)
}

func main() {
decorator(Hello)("Hello, World!")
}

我们可以看到,我们动用了一个高阶函数 decorator(),在调用的时候,先把 Hello() 函数传进去,然后其返回一个匿名函数,这个匿名函数中除了运行了自己的代码,也调用了被传入的 Hello() 函数。

这个玩法和 Python 的异曲同工,只不过,有些遗憾的是,Go 并不支持像 Python 那样的 @decorator 语法糖。所以,在调用上有些难看。当然,如果你要想让代码容易读一些,你可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package main

import (
"fmt"
"reflect"
"runtime"
"time"
)

type SumFunc func(int64, int64) int64

func getFunctionName(i interface{}) string {
return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()
}

func timedSumFunc(f SumFunc) SumFunc {
return func(start, end int64) int64 {

defer func(t time.Time) {
fmt.Printf("--- Time Elapsed (%s): %v ---\n",
getFunctionName(f), time.Since(t))
}(time.Now())

return f(start, end)
}
}

func Sum1(start, end int64) int64 {
var sum int64
sum = 0
if start > end {
start, end = end, start
}
for i := start; i <= end; i++ {
sum += i
}
return sum
}

func Sum2(start, end int64) int64 {
if start > end {
start, end = end, start
}
return (end - start + 1) * (end + start) / 2
}

func main() {

sum1 := timedSumFunc(Sum1)
sum2 := timedSumFunc(Sum2)

fmt.Printf("%d, %d\n", sum1(-10000, 10000000), sum2(-10000, 10000000))
}

关于上面的代码,有几个事说明一下:

1)有两个 Sum 函数,Sum1() 函数就是简单的做个循环,Sum2() 函数动用了数据公式。(注意:start 和 end 有可能有负数的情况)

2)代码中使用了 Go 语言的反射机器来获取函数名。

3)修饰器函数是 timedSumFunc()

运行后输出:

1
2
3
4
$ go run time.sum.go
--- Time Elapsed (main.Sum1): 3.557469ms ---
--- Time Elapsed (main.Sum2): 291ns ---
49999954995000, 49999954995000

HTTP 相关的一个示例

我们再来看一个处理 HTTP 请求的相关的例子。

先看一个简单的 HTTP Server 的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"fmt"
"log"
"net/http"
"strings"
)

func WithServerHeader(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("--->WithServerHeader()")
w.Header().Set("Server", "HelloServer v0.0.1")
h(w, r)
}
}

func hello(w http.ResponseWriter, r *http.Request) {
log.Printf("Recieved Request %s from %s\n", r.URL.Path, r.RemoteAddr)
fmt.Fprintf(w, "Hello, World! "+r.URL.Path)
}

func main() {
http.HandleFunc("/v1/hello", WithServerHeader(hello))
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}

上面代码中使用到了修饰模式,WithServerHeader() 函数就是一个 Decorator,其传入一个 http.HandlerFunc,然后返回一个改写的版本。上面的例子还是比较简单,用 WithServerHeader() 就可以加入一个 Response 的 Header。

于是,这样的函数我们可以写出好些个。如下所示,有写 HTTP 响应头的,有写认证 Cookie 的,有检查认证Cookie的,有打日志的……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package main

import (
"fmt"
"log"
"net/http"
"strings"
)

func WithServerHeader(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("--->WithServerHeader()")
w.Header().Set("Server", "HelloServer v0.0.1")
h(w, r)
}
}

func WithAuthCookie(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("--->WithAuthCookie()")
cookie := &http.Cookie{Name: "Auth", Value: "Pass", Path: "/"}
http.SetCookie(w, cookie)
h(w, r)
}
}

func WithBasicAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("--->WithBasicAuth()")
cookie, err := r.Cookie("Auth")
if err != nil || cookie.Value != "Pass" {
w.WriteHeader(http.StatusForbidden)
return
}
h(w, r)
}
}

func WithDebugLog(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log.Println("--->WithDebugLog")
r.ParseForm()
log.Println(r.Form)
log.Println("path", r.URL.Path)
log.Println("scheme", r.URL.Scheme)
log.Println(r.Form["url_long"])
for k, v := range r.Form {
log.Println("key:", k)
log.Println("val:", strings.Join(v, ""))
}
h(w, r)
}
}
func hello(w http.ResponseWriter, r *http.Request) {
log.Printf("Recieved Request %s from %s\n", r.URL.Path, r.RemoteAddr)
fmt.Fprintf(w, "Hello, World! "+r.URL.Path)
}

func main() {
http.HandleFunc("/v1/hello", WithServerHeader(WithAuthCookie(hello)))
http.HandleFunc("/v2/hello", WithServerHeader(WithBasicAuth(hello)))
http.HandleFunc("/v3/hello", WithServerHeader(WithBasicAuth(WithDebugLog(hello))))
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}

多个修饰器的 Pipeline

在使用上,需要对函数一层层的套起来,看上去好像不是很好看,如果需要 decorator 比较多的话,代码会比较难看了。嗯,我们可以重构一下。

重构时,我们需要先写一个工具函数——用来遍历并调用各个 decorator:

1
2
3
4
5
6
7
8
9
type HttpHandlerDecorator func(http.HandlerFunc) http.HandlerFunc

func Handler(h http.HandlerFunc, decors ...HttpHandlerDecorator) http.HandlerFunc {
for i := range decors {
d := decors[len(decors)-1-i] // iterate in reverse
h = d(h)
}
return h
}

然后,我们就可以像下面这样使用了。

1
http.HandleFunc("/v4/hello", Handler(hello,WithServerHeader, WithBasicAuth, WithDebugLog))

这样的代码是不是更易读了一些?pipeline 的功能也就出来了。

泛型的修饰器

不过,对于 Go 的修饰器模式,还有一个小问题 —— 好像无法做到泛型,就像上面那个计算时间的函数一样,其代码耦合了需要被修饰的函数的接口类型,无法做到非常通用,如果这个事解决不了,那么,这个修饰器模式还是有点不好用的。

因为 Go 语言不像 Python 和 Java,Python是动态语言,而 Java 有语言虚拟机,所以他们可以干好些比较变态的事,然而 Go 语言是一个静态的语言,这意味着其类型需要在编译时就要搞定,否则无法编译。不过,Go 语言支持的最大的泛型是 interface{} 还有比较简单的 reflection 机制,在上面做做文章,应该还是可以搞定的。

废话不说,下面是我用 reflection 机制写的一个比较通用的修饰器(为了便于阅读,我删除了出错判断代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func Decorator(decoPtr, fn interface{}) (err error) {
var decoratedFunc, targetFunc reflect.Value

decoratedFunc = reflect.ValueOf(decoPtr).Elem()
targetFunc = reflect.ValueOf(fn)

v := reflect.MakeFunc(targetFunc.Type(),
func(in []reflect.Value) (out []reflect.Value) {
fmt.Println("before")
out = targetFunc.Call(in)
fmt.Println("after")
return
})

decoratedFunc.Set(v)
return
}

上面的代码动用了 reflect.MakeFunc() 函数制出了一个新的函数其中的 targetFunc.Call(in) 调用了被修饰的函数。关于 Go 语言的反射机制,推荐官方文章 —— 《The Laws of Reflection》,在这里我不多说了。

上面这个 Decorator() 需要两个参数,

  • 第一个是出参 decoPtr ,就是完成修饰后的函数
  • 第二个是入参 fn ,就是需要修饰的函数

这样写是不是有些二?的确是的。不过,这是我个人在 Go 语言里所能写出来的最好的的代码了。如果你知道更多优雅的,请你一定告诉我!

好的,让我们来看一下使用效果。首先假设我们有两个需要修饰的函数:

1
2
3
4
5
6
7
8
9
func foo(a, b, c int) int {
fmt.Printf("%d, %d, %d \n", a, b, c)
return a + b + c
}

func bar(a, b string) string {
fmt.Printf("%s, %s \n", a, b)
return a + b
}

然后,我们可以这样做:

1
2
3
4
type MyFoo func(int, int, int) int
var myfoo MyFoo
Decorator(&myfoo, foo)
myfoo(1, 2, 3)

你会发现,使用 Decorator() 时,还需要先声明一个函数签名,感觉好傻啊。一点都不泛型,不是吗?

嗯。如果你不想声明函数签名,那么你也可以这样

1
2
3
mybar := bar
Decorator(&mybar, bar)
mybar("hello,", "world!")

好吧,看上去不是那么的漂亮,但是 it works。看样子 Go 语言目前本身的特性无法做成像 Java 或 Python 那样,对此,我们只能多求 Go 语言多放糖了!

Again, 如果你有更好的写法,请你一定要告诉我。

前言

在本篇文章中,我们将要学习一下Go语言的代码生成的玩法。Go语言代码生成主要还是用来解决编程泛型的问题,泛型编程主要解决的问题是因为静态类型语言有类型,所以,相关的算法或是对数据处理的程序会因为类型不同而需要复制一份,这样导致数据类型和算法功能耦合的问题。泛型编程可以解决这样的问题,就是说,在写代码的时候,不用关心处理数据的类型,只需要关心相当处理逻辑。泛型编程是静态语言中非常非常重要的特征,如果没有泛型,我们很难做到多态,也很难完成抽象,会导致我们的代码冗余量很大。

更新历史

2020 年 12 月 23 日 - 初稿

扩展阅读

我们并不需要自己手写 gen.sh 这样的工具类,已经有很多第三方的已经写好的可以使用。下面是一个列表:


现实中的类比

举个现实当中的例子,用螺丝刀来做具比方,螺丝刀本来就是一个拧螺丝的动作,但是因为螺丝的类型太多,有平口的,有十字口的,有六角的……螺丝还有大小尺寸,导致我们的螺丝刀为了要适配各种千奇百怪的螺丝类型(样式和尺寸),导致要做出各种各样的螺丝刀。

img img

而真正的抽象是螺丝刀不应该关心螺丝的类型,只要关注好自己的功能是否完备,并让自己可以适配于不同类型的螺丝,如下所示,这就是所谓的泛型编程要解决的实际问题。

img

Go语方的类型检查

因为Go语言目前并不支持真正的泛型,所以,只能用 interface{} 这样的类似于 void* 这种过度泛型来玩这就导致了我们在实际过程中就需要进行类型检查。Go语言的类型检查有两种技术,一种是 Type Assert,一种是Reflection。

Type Assert

这种技术,一般是对某个变量进行 .(type)的损人和,其会返回两个值, variable, error,第一个返回值是被转换好的类型,第二个是如果不能转换类型,则会报错。

比如下面的示例,我们有一个通用类型的容器,可以进行 Put(val)Get(),注意,其使用了 interface{}作泛型

1
2
3
4
5
6
7
8
9
10
11
12
13
//Container is a generic container, accepting anything.
type Container []interface{}

//Put adds an element to the container.
func (c *Container) Put(elem interface{}) {
*c = append(*c, elem)
}
//Get gets an element from the container.
func (c *Container) Get() interface{} {
elem := (*c)[0]
*c = (*c)[1:]
return elem
}

在使用中,我们可以这样使用

1
2
3
intContainer := &Container{}
intContainer.Put(7)
intContainer.Put(42)

但是,在把数据取出来时,因为类型是 interface{} ,所以,你还要做一个转型,如果转型成功能才能进行后续操作(因为 interface{}太泛了,泛到什么类型都可以放)下在是一个Type Assert的示例:

1
2
3
4
5
6
7
// assert that the actual type is int
elem, ok := intContainer.Get().(int)
if !ok {
fmt.Println("Unable to read an int from intContainer")
}

fmt.Printf("assertExample: %d (%T)\n", elem, elem)
Reflection

对于反射,我们需要把上面的代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type Container struct {
s reflect.Value
}
func NewContainer(t reflect.Type, size int) *Container {
if size <=0 { size=64 }
return &Container{
s: reflect.MakeSlice(reflect.SliceOf(t), 0, size),
}
}
func (c *Container) Put(val interface{}) error {
if reflect.ValueOf(val).Type() != c.s.Type().Elem() {
return fmt.Errorf(“Put: cannot put a %T into a slice of %s",
val, c.s.Type().Elem()))
}
c.s = reflect.Append(c.s, reflect.ValueOf(val))
return nil
}
func (c *Container) Get(refval interface{}) error {
if reflect.ValueOf(refval).Kind() != reflect.Ptr ||
reflect.ValueOf(refval).Elem().Type() != c.s.Type().Elem() {
return fmt.Errorf("Get: needs *%s but got %T", c.s.Type().Elem(), refval)
}
reflect.ValueOf(refval).Elem().Set( c.s.Index(0) )
c.s = c.s.Slice(1, c.s.Len())
return nil
}

上面的代码并不难读,这是完全使用 reflection的玩法,其中

  • NewContainer()会根据参数的类型初始化一个Slice
  • Put()时候,会检查 val 是否和Slice的类型一致。
  • Get()时,我们需要用一个入参的方式,因为我们没有办法返回 reflect.Value 或是 interface{},不然还要做Type Assert
  • 但是有类型检查,所以,必然会有检查不对的总理 ,因些,需要返回 error

于是在使用上面这段代码的时候,会是下面这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
f1 := 3.1415926
f2 := 1.41421356237

c := NewMyContainer(reflect.TypeOf(f1), 16)

if err := c.Put(f1); err != nil {
panic(err)
}
if err := c.Put(f2); err != nil {
panic(err)
}

g := 0.0

if err := c.Get(&g); err != nil {
panic(err)
}
fmt.Printf("%v (%T)\n", g, g) //3.1415926 (float64)
fmt.Println(c.s.Index(0)) //1.4142135623

我们可以看到,Type Assert是不用了,但是用反射写出来的代码还是有点复杂的。那么有没有什么好的方法?

它山之石

对于泛型编程最牛的语言 C++ 来说,这类的问题都是使用 Template来解决的。

//用来描述泛型template <class T> T GetMax (T a, T b) { T result; result = (a>b)? a : b; return (result); } int i=5, j=6, k; //生成int类型的函数k=GetMax<int>(i,j); long l=10, m=5, n; //生成long类型的函数n=GetMax<long>(l,m);

C++的编译器会在编译时分析代码,根据不同的变量类型来自动化的生成相关类型的函数或类。C++叫模板的具体化。

这个技术是编译时的问题,所以,不需要我们在运行时进行任何的运行的类型识别,我们的程序也会变得比较的干净。

那么,我们是否可以在Go中使用C++的这种技术呢?答案是肯定的,只是Go的编译器不帮你干,你需要自己动手。

Go Generator

要玩 Go的代码生成,你需要三件事:

  1. 一个函数模板,其中设置好相应的占位符。
  2. 一个脚本,用于按规则来替换文本并生成新的代码。
  3. 一行注释代码。
函数模板

我们把我们之前的示例改成模板。取名为 container.tmp.go 放在 ./template/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package PACKAGE_NAME
type GENERIC_NAMEContainer struct {
s []GENERIC_TYPE
}
func NewGENERIC_NAMEContainer() *GENERIC_NAMEContainer {
return &GENERIC_NAMEContainer{s: []GENERIC_TYPE{}}
}
func (c *GENERIC_NAMEContainer) Put(val GENERIC_TYPE) {
c.s = append(c.s, val)
}
func (c *GENERIC_NAMEContainer) Get() GENERIC_TYPE {
r := c.s[0]
c.s = c.s[1:]
return r
}

我们可以看到函数模板中我们有如下的占位符:

  • PACKAGE_NAME – 包名
  • GENERIC_NAME – 名字
  • GENERIC_TYPE – 实际的类型

其它的代码都是一样的。

函数生成脚本

然后,我们有一个叫gen.sh的生成脚本,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash

set -e

SRC_FILE=${1}
PACKAGE=${2}
TYPE=${3}
DES=${4}
#uppcase the first char
PREFIX="$(tr '[:lower:]' '[:upper:]' <<< ${TYPE:0:1})${TYPE:1}"

DES_FILE=$(echo ${TYPE}| tr '[:upper:]' '[:lower:]')_${DES}.go

sed 's/PACKAGE_NAME/'"${PACKAGE}"'/g' ${SRC_FILE} | \
sed 's/GENERIC_TYPE/'"${TYPE}"'/g' | \
sed 's/GENERIC_NAME/'"${PREFIX}"'/g' > ${DES_FILE}

其需要4个参数:

  • 模板源文件
  • 包名
  • 实际需要具体化的类型
  • 用于构造目标文件名的后缀

然后其会用 sed 命令去替换我们的上面的函数模板,并生成到目标文件中。

生成代码

接下来,我们只需要在代码中打一个特殊的注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//go:generate ./gen.sh ./template/container.tmp.go gen uint32 container
func generateUint32Example() {
var u uint32 = 42
c := NewUint32Container()
c.Put(u)
v := c.Get()
fmt.Printf("generateExample: %d (%T)\n", v, v)
}

//go:generate ./gen.sh ./template/container.tmp.go gen string container
func generateStringExample() {
var s string = "Hello"
c := NewStringContainer()
c.Put(s)
v := c.Get()
fmt.Printf("generateExample: %s (%T)\n", v, v)
}

其中,

  • 第一个注释是生成包名为 gen 类型为 uint32 目标文件名以 container 为后缀
  • 第二个注释是生成包名为 gen 类型为 string 目标文件名以 container 为后缀

然后,在工程目录中直接执行 go generate 命令,就会生成如下两份代码,

一份文件名为uint32_container.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package gen

type Uint32Container struct {
s []uint32
}
func NewUint32Container() *Uint32Container {
return &Uint32Container{s: []uint32{}}
}
func (c *Uint32Container) Put(val uint32) {
c.s = append(c.s, val)
}
func (c *Uint32Container) Get() uint32 {
r := c.s[0]
c.s = c.s[1:]
return r
}

另一份的文件名为 string_container.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package gen

type StringContainer struct {
s []string
}
func NewStringContainer() *StringContainer {
return &StringContainer{s: []string{}}
}
func (c *StringContainer) Put(val string) {
c.s = append(c.s, val)
}
func (c *StringContainer) Get() string {
r := c.s[0]
c.s = c.s[1:]
return r
}

这两份代码可以让我们的代码完全编译通过,所付出的代价就是需要多执行一步 go generate 命令。

新版Filter

现在我们再回头看看我们之前《Go编程模式:Map-Reduce》中的那些个用反射整出来的例子,有了这样的技术,我就不必在代码里用那些晦涩难懂的反射来做运行时的类型检查了。我们可以写下很干净的代码,让编译器在编译时检查类型对不对。下面是一个Fitler的模板文件 filter.tmp.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package PACKAGE_NAME

type GENERIC_NAMEList []GENERIC_TYPE

type GENERIC_NAMEToBool func(*GENERIC_TYPE) bool

func (al GENERIC_NAMEList) Filter(f GENERIC_NAMEToBool) GENERIC_NAMEList {
var ret GENERIC_NAMEList
for _, a := range al {
if f(&a) {
ret = append(ret, a)
}
}
return ret
}

于是我们可在需要使用这个的地方,加上相关的 go generate 的注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type Employee struct {
Name string
Age int
Vacation int
Salary int
}

//go:generate ./gen.sh ./template/filter.tmp.go gen Employee filter
func filterEmployeeExample() {

var list = EmployeeList{
{"Hao", 44, 0, 8000},
{"Bob", 34, 10, 5000},
{"Alice", 23, 5, 9000},
{"Jack", 26, 0, 4000},
{"Tom", 48, 9, 7500},
}

var filter EmployeeList
filter = list.Filter(func(e *Employee) bool {
return e.Age > 40
})

fmt.Println("----- Employee.Age > 40 ------")
for _, e := range filter {
fmt.Println(e)
}

filter = list.Filter(func(e *Employee) bool {
return e.Salary <= 5000
})

fmt.Println("----- Employee.Salary <= 5000 ------")
for _, e := range filter {
fmt.Println(e)
}
}

前言

在本篇文章中,我们学习一下函数式编程的中非常重要的Map、Reduce、Filter的三种操作,这三种操作可以让我们非常方便灵活地进行一些数据处理——我们的程序中大多数情况下都是在到倒腾数据,尤其对于一些需要统计的业务场景,Map/Reduce/Filter是非常通用的玩法。

更新历史

2020 年 12 月 22 日 - 初稿


基本示例

Map示例

下面的程序代码中,我们写了两个Map函数,这两个函数需要两个参数,

  • 一个是字符串数组 []string,说明需要处理的数据一个字符串
  • 另一个是一个函数func(s string) stringfunc(s string) int
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func MapStrToStr(arr []string, fn func(s string) string) []string {
var newArray = []string{}
for _, it := range arr {
newArray = append(newArray, fn(it))
}
return newArray
}

func MapStrToInt(arr []string, fn func(s string) int) []int {
var newArray = []int{}
for _, it := range arr {
newArray = append(newArray, fn(it))
}
return newArray
}

整个Map函数运行逻辑都很相似,函数体都是在遍历第一个参数的数组,然后,调用第二个参数的函数,然后把其值组合成另一个数组返回。

于是我们就可以这样使用这两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
var list = []string{"Hao", "Chen", "MegaEase"}

x := MapStrToStr(list, func(s string) string {
return strings.ToUpper(s)
})
fmt.Printf("%v\n", x)
//["HAO", "CHEN", "MEGAEASE"]

y := MapStrToInt(list, func(s string) int {
return len(s)
})
fmt.Printf("%v\n", y)
//[3, 4, 8]

我们可以看到,我们给第一个 MapStrToStr() 传了函数做的是 转大写,于是出来的数组就成了全大写的,给MapStrToInt() 传的是算其长度,所以出来的数组是每个字符串的长度。

我们再来看一下Reduce和Filter的函数是什么样的。

Reduce 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Reduce(arr []string, fn func(s string) int) int {
sum := 0
for _, it := range arr {
sum += fn(it)
}
return sum
}

var list = []string{"Hao", "Chen", "MegaEase"}

x := Reduce(list, func(s string) int {
return len(s)
})
fmt.Printf("%v\n", x)
// 15
Filter示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Filter(arr []int, fn func(n int) bool) []int {
var newArray = []int{}
for _, it := range arr {
if fn(it) {
newArray = append(newArray, it)
}
}
return newArray
}

var intset = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
out := Filter(intset, func(n int) bool {
return n%2 == 1
})
fmt.Printf("%v\n", out)

out = Filter(intset, func(n int) bool {
return n > 5
})
fmt.Printf("%v\n", out)

下图是一个比喻,其非常形象地说明了Map-Reduce是的业务语义,其在数据处理中非常有用。

img

业务示例

通过上面的一些示例,你可能有一些明白,Map/Reduce/Filter只是一种控制逻辑,真正的业务逻辑是在传给他们的数据和那个函数来定义的。是的,这是一个很经典的“业务逻辑”和“控制逻辑”分离解耦的编程模式。下面我们来看一个有业务意义的代码,来让大家强化理解一下什么叫“控制逻辑”与业务逻辑分离。

员工信息

首先,我们一个员工对象,以及一些数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Employee struct {
Name string
Age int
Vacation int
Salary int
}

var list = []Employee{
{"Hao", 44, 0, 8000},
{"Bob", 34, 10, 5000},
{"Alice", 23, 5, 9000},
{"Jack", 26, 0, 4000},
{"Tom", 48, 9, 7500},
{"Marry", 29, 0, 6000},
{"Mike", 32, 8, 4000},
}
相关的Reduce/Fitler函数

然后,我们有如下的几个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func EmployeeCountIf(list []Employee, fn func(e *Employee) bool) int {
count := 0
for i, _ := range list {
if fn(&list[i]) {
count += 1
}
}
return count
}

func EmployeeFilterIn(list []Employee, fn func(e *Employee) bool) []Employee {
var newList []Employee
for i, _ := range list {
if fn(&list[i]) {
newList = append(newList, list[i])
}
}
return newList
}

func EmployeeSumIf(list []Employee, fn func(e *Employee) int) int {
var sum = 0
for i, _ := range list {
sum += fn(&list[i])
}
return sum
}

简单说明一下:

  • EmployeeConutIfEmployeeSumIf 分别用于统满足某个条件的个数或总数。它们都是Filter + Reduce的语义。
  • EmployeeFilterIn 就是按某种条件过虑。就是Fitler的语义。
各种自定义的统计示例

于是我们就可以有如下的代码。

1)统计有多少员工大于40岁

1
2
3
4
5
old := EmployeeCountIf(list, func(e *Employee) bool {
return e.Age > 40
})
fmt.Printf("old people: %d\n", old)
//old people: 2

2)统计有多少员工薪水大于6000

1
2
3
4
5
high_pay := EmployeeCountIf(list, func(e *Employee) bool {
return e.Salary >= 6000
})
fmt.Printf("High Salary people: %d\n", high_pay)
//High Salary people: 4

3)列出有没有休假的员工

1
2
3
4
5
no_vacation := EmployeeFilterIn(list, func(e *Employee) bool {
return e.Vacation == 0
})
fmt.Printf("People no vacation: %v\n", no_vacation)
//People no vacation: [{Hao 44 0 8000} {Jack 26 0 4000} {Marry 29 0 6000}]

4)统计所有员工的薪资总和

1
2
3
4
5
6
total_pay := EmployeeSumIf(list, func(e *Employee) int {
return e.Salary
})

fmt.Printf("Total Salary: %d\n", total_pay)
//Total Salary: 43500

5)统计30岁以下员工的薪资总和

1
2
3
4
5
6
younger_pay := EmployeeSumIf(list, func(e *Employee) int {
if e.Age < 30 {
return e.Salary
}
return 0
})

泛型Map-Reduce

我们可以看到,上面的Map-Reduce都因为要处理数据的类型不同而需要写出不同版本的Map-Reduce,虽然他们的代码看上去是很类似的。所以,这里就要带出来泛型编程了,Go语言在本文写作的时候还不支持泛型(注:Go开发团队技术负责人Russ Cox在2012年11月21golang-dev上的mail确认了Go泛型(type parameter)将在Go 1.18版本落地,即2022.2月份)。

简单版 Generic Map

所以,目前的Go语言的泛型只能用 interface{} + reflect来完成,interface{} 可以理解为C中的 void*,Java中的 Objectreflect是Go的反射机制包,用于在运行时检查类型。

下面我们来看一下一个非常简单不作任何类型检查的泛型的Map函数怎么写。

1
2
3
4
5
6
7
8
9
10
func Map(data interface{}, fn interface{}) []interface{} {
vfn := reflect.ValueOf(fn)
vdata := reflect.ValueOf(data)
result := make([]interface{}, vdata.Len())

for i := 0; i < vdata.Len(); i++ {
result[i] = vfn.Call([]reflect.Value{vdata.Index(i)})[0].Interface()
}
return result
}

上面的代码中,

  • 通过 reflect.ValueOf() 来获得 interface{} 的值,其中一个是数据 vdata,另一个是函数 vfn
  • 然后通过 vfn.Call() 方法来调用函数,通过 []refelct.Value{vdata.Index(i)}来获得数据。

Go语言中的反射的语法还是有点令人费解的,但是简单看一下手册还是能够读懂的。我这篇文章不讲反射,所以相关的基础知识还请大家自行Google相关的教程。

于是,我们就可以有下面的代码——不同类型的数据可以使用相同逻辑的Map()代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
square := func(x int) int {
return x * x
}
nums := []int{1, 2, 3, 4}

squared_arr := Map(nums,square)
fmt.Println(squared_arr)
//[1 4 9 16]



upcase := func(s string) string {
return strings.ToUpper(s)
}
strs := []string{"Hao", "Chen", "MegaEase"}
upstrs := Map(strs, upcase);
fmt.Println(upstrs)
//[HAO CHEN MEGAEASE]

但是因为反射是运行时的事,所以,如果类型什么出问题的话,就会有运行时的错误。比如:

1
2
x := Map(5, 5)
fmt.Println(x)

上面的代码可以很轻松的编译通过,但是在运行时就出问题了,还是panic错误……

1
2
3
4
5
6
7
8
9
10
panic: reflect: call of reflect.Value.Len on int Value

goroutine 1 [running]:
reflect.Value.Len(0x10b5240, 0x10eeb58, 0x82, 0x10716bc)
/usr/local/Cellar/go/1.15.3/libexec/src/reflect/value.go:1162 +0x185
main.Map(0x10b5240, 0x10eeb58, 0x10b5240, 0x10eeb60, 0x1, 0x14, 0x0)
/Users/.../map.go:12 +0x16b
main.main()
/Users/.../map.go:42 +0x465
exit status 2
健壮版的Generic Map

所以,如果要写一个健壮的程序,对于这种用interface{} 的“过度泛型”,就需要我们自己来做类型检查。下面是一个有类型检查的Map代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
func Transform(slice, function interface{}) interface{} {
return transform(slice, function, false)
}

func TransformInPlace(slice, function interface{}) interface{} {
return transform(slice, function, true)
}

func transform(slice, function interface{}, inPlace bool) interface{} {

//check the `slice` type is Slice
sliceInType := reflect.ValueOf(slice)
if sliceInType.Kind() != reflect.Slice {
panic("transform: not slice")
}

//check the function signature
fn := reflect.ValueOf(function)
elemType := sliceInType.Type().Elem()
if !verifyFuncSignature(fn, elemType, nil) {
panic("trasform: function must be of type func(" + sliceInType.Type().Elem().String() + ") outputElemType")
}

sliceOutType := sliceInType
if !inPlace {
sliceOutType = reflect.MakeSlice(reflect.SliceOf(fn.Type().Out(0)), sliceInType.Len(), sliceInType.Len())
}
for i := 0; i < sliceInType.Len(); i++ {
sliceOutType.Index(i).Set(fn.Call([]reflect.Value{sliceInType.Index(i)})[0])
}
return sliceOutType.Interface()

}

func verifyFuncSignature(fn reflect.Value, types ...reflect.Type) bool {

//Check it is a funciton
if fn.Kind() != reflect.Func {
return false
}
// NumIn() - returns a function type's input parameter count.
// NumOut() - returns a function type's output parameter count.
if (fn.Type().NumIn() != len(types)-1) || (fn.Type().NumOut() != 1) {
return false
}
// In() - returns the type of a function type's i'th input parameter.
for i := 0; i < len(types)-1; i++ {
if fn.Type().In(i) != types[i] {
return false
}
}
// Out() - returns the type of a function type's i'th output parameter.
outType := types[len(types)-1]
if outType != nil && fn.Type().Out(0) != outType {
return false
}
return true
}

上面的代码一下子就复杂起来了,可见,复杂的代码都是在处理异常的地方。我不打算Walk through 所有的代码,别看代码多,但是还是可以读懂的,下面列几个代码中的要点:

  • 代码中没有使用Map函数,因为和数据结构和关键有含义冲突的问题,所以使用Transform,这个来源于 C++ STL库中的命名。
  • 有两个版本的函数,一个是返回一个全新的数组 – Transform(),一个是“就地完成” – TransformInPlace()
  • 在主函数中,用 Kind() 方法检查了数据类型是不是 Slice,函数类型是不是Func
  • 检查函数的参数和返回类型是通过verifyFuncSignature()来完成的,其中:
    • NumIn() – 用来检查函数的“入参”
    • NumOut() 用来检查函数的“返回值”
  • 如果需要新生成一个Slice,会使用 reflect.MakeSlice() 来完成。

好了,有了上面的这段代码,我们的代码就很可以很开心的使用了:

可以用于字符串数组

1
2
3
4
5
list := []string{"1", "2", "3", "4", "5", "6"}
result := Transform(list, func(a string) string{
return a +a +a
})
//{"111","222","333","444","555","666"}

可以用于整形数组

1
2
3
4
5
list := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
TransformInPlace(list, func (a int) int {
return a*3
})
//{3, 6, 9, 12, 15, 18, 21, 24, 27}

可以用于结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
var list = []Employee{
{"Hao", 44, 0, 8000},
{"Bob", 34, 10, 5000},
{"Alice", 23, 5, 9000},
{"Jack", 26, 0, 4000},
{"Tom", 48, 9, 7500},
}

result := TransformInPlace(list, func(e Employee) Employee {
e.Salary += 1000
e.Age += 1
return e
})
健壮版的 Generic Reduce

同样,泛型版的 Reduce 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func Reduce(slice, pairFunc, zero interface{}) interface{} {
sliceInType := reflect.ValueOf(slice)
if sliceInType.Kind() != reflect.Slice {
panic("reduce: wrong type, not slice")
}

len := sliceInType.Len()
if len == 0 {
return zero
} else if len == 1 {
return sliceInType.Index(0)
}

elemType := sliceInType.Type().Elem()
fn := reflect.ValueOf(pairFunc)
if !verifyFuncSignature(fn, elemType, elemType, elemType) {
t := elemType.String()
panic("reduce: function must be of type func(" + t + ", " + t + ") " + t)
}

var ins [2]reflect.Value
ins[0] = sliceInType.Index(0)
ins[1] = sliceInType.Index(1)
out := fn.Call(ins[:])[0]

for i := 2; i < len; i++ {
ins[0] = out
ins[1] = sliceInType.Index(i)
out = fn.Call(ins[:])[0]
}
return out.Interface()
}
健壮版的 Generic Filter

同样,泛型版的 Filter 代码如下(同样分是否“就地计算”的两个版本):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
func Filter(slice, function interface{}) interface{} {
result, _ := filter(slice, function, false)
return result
}

func FilterInPlace(slicePtr, function interface{}) {
in := reflect.ValueOf(slicePtr)
if in.Kind() != reflect.Ptr {
panic("FilterInPlace: wrong type, " +
"not a pointer to slice")
}
_, n := filter(in.Elem().Interface(), function, true)
in.Elem().SetLen(n)
}

var boolType = reflect.ValueOf(true).Type()

func filter(slice, function interface{}, inPlace bool) (interface{}, int) {

sliceInType := reflect.ValueOf(slice)
if sliceInType.Kind() != reflect.Slice {
panic("filter: wrong type, not a slice")
}

fn := reflect.ValueOf(function)
elemType := sliceInType.Type().Elem()
if !verifyFuncSignature(fn, elemType, boolType) {
panic("filter: function must be of type func(" + elemType.String() + ") bool")
}

var which []int
for i := 0; i < sliceInType.Len(); i++ {
if fn.Call([]reflect.Value{sliceInType.Index(i)})[0].Bool() {
which = append(which, i)
}
}

out := sliceInType

if !inPlace {
out = reflect.MakeSlice(sliceInType.Type(), len(which), len(which))
}
for i := range which {
out.Index(i).Set(sliceInType.Index(which[i]))
}

return out.Interface(), len(which)
}

后记

还有几个未尽事宜:

1)使用反射来做这些东西,会有一个问题,那就是代码的性能会很差。所以,上面的代码不能用于你需要高性能的地方。怎么解决这个问题,我们会在本系列文章的下一篇文章中讨论。

2)上面的代码大量的参考了 Rob Pike的版本,他的代码在 https://github.com/robpike/filter

3)其实,在全世界范围内,有大量的程序员都在问Go语言官方什么时候在标准库中支持 Map/Reduce,Rob Pike说,这种东西难写吗?还要我们官方来帮你们写么?这种代码我多少年前就写过了,但是,我从来一次都没有用过,我还是喜欢用“For循环”,我觉得你最好也跟我一起用 “For循环”。

我个人觉得,Map/Reduce在数据处理的时候还是很有用的,Rob Pike可能平时也不怎么写“业务逻辑”的代码,所以,对他来说可能也不太了解业务的变伦有多么的频繁……

当然,好还是不好,由你来判断,但多学一些编程模式是对自己的帮助也是很有帮助的。

前言

反转控制IoC – Inversion of Control 是一种软件设计的方法,其主要的思想是把控制逻辑与业务逻辑分享,不要在业务逻辑里写控制逻辑,这样会让控制逻辑依赖于业务逻辑,而是反过来,让业务逻辑依赖控制逻辑。开关是控制逻辑,电器是业务逻辑,不要在电器中实现开关,而是把开关抽象成一种协议,让电器都依赖之。这样的编程方式可以有效的降低程序复杂度,并提升代码重用。

更新历史

2020 年 12 月 21 日 - 初稿


嵌入和委托

结构体嵌入

在Go语言中,我们可以很方便的把一个结构体给嵌到另一个结构体中。如下所示:

1
2
3
4
5
6
7
type Widget struct {
X, Y int
}
type Label struct {
Widget // Embedding (delegation)
Text string // Aggregation
}

上面的示例中,我们把 Widget嵌入到了 Label 中,于是,我们可以这样使用:

1
2
3
4
label := Label{Widget{10, 10}, "State:"}

label.X = 11
label.Y = 12

如果在 Label 结构体里出现了重名,就需要解决重名,例如,如果 成员 X 重名,用 label.X表明 是自己的X ,用 label.Wedget.X 表示嵌入过来的。

有了这样的嵌入,就可以像UI组件一样的在结构构的设计上进行层层分解。比如,我可以新出来两个结构体 ButtonListBox

1
2
3
4
5
6
7
8
9
type Button struct {
Label // Embedding (delegation)
}

type ListBox struct {
Widget // Embedding (delegation)
Texts []string // Aggregation
Index int // Aggregation
}
方法重写

然后,我们需要两个接口 Painter 用于把组件画出来,Clicker 用于表明点击事件:

1
2
3
4
5
6
7
type Painter interface {
Paint()
}

type Clicker interface {
Click()
}

当然,

  • 对于 Lable 来说,只有 Painter ,没有Clicker
  • 对于 ButtonListBox来说,PainterClicker都有。

下面是一些实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (label Label) Paint() {
fmt.Printf("%p:Label.Paint(%q)\n", &label, label.Text)
}

//因为这个接口可以通过 Label 的嵌入带到新的结构体,
//所以,可以在 Button 中可以重载这个接口方法以
func (button Button) Paint() { // Override
fmt.Printf("Button.Paint(%s)\n", button.Text)
}
func (button Button) Click() {
fmt.Printf("Button.Click(%s)\n", button.Text)
}


func (listBox ListBox) Paint() {
fmt.Printf("ListBox.Paint(%q)\n", listBox.Texts)
}
func (listBox ListBox) Click() {
fmt.Printf("ListBox.Click(%q)\n", listBox.Texts)
}

这里,需要重点提示一下,Button.Paint() 接口可以通过 Label 的嵌入带到新的结构体,如果 Button.Paint() 不实现的话,会调用 Label.Paint() ,所以,在 Button 中声明 Paint() 方法,相当于Override

嵌入结构多态

通过下面的程序可以看到,整个多态是怎么执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
button1 := Button{Label{Widget{10, 70}, "OK"}}
button2 := NewButton(50, 70, "Cancel")
listBox := ListBox{Widget{10, 40},
[]string{"AL", "AK", "AZ", "AR"}, 0}

for _, painter := range []Painter{label, listBox, button1, button2} {
painter.Paint()
}

for _, widget := range []interface{}{label, listBox, button1, button2} {
widget.(Painter).Paint()
if clicker, ok := widget.(Clicker); ok {
clicker.Click()
}
fmt.Println() // print a empty line
}

我们可以看到,我们可以使用接口来多态,也可以使用 泛型的 interface{} 来多态,但是需要有一个类型转换。

反转控制

我们再来看一个示例,我们有一个存放整数的数据结构,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type IntSet struct {
data map[int]bool
}
func NewIntSet() IntSet {
return IntSet{make(map[int]bool)}
}
func (set *IntSet) Add(x int) {
set.data[x] = true
}
func (set *IntSet) Delete(x int) {
delete(set.data, x)
}
func (set *IntSet) Contains(x int) bool {
return set.data[x]
}

其中实现了 Add()Delete()Contains() 三个操作,前两个是写操作,后一个是读操作。

实现Undo功能

现在我们想实现一个 Undo 的功能。我们可以把把 IntSet 再包装一下变成 UndoableIntSet 代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type UndoableIntSet struct { // Poor style
IntSet // Embedding (delegation)
functions []func()
}

func NewUndoableIntSet() UndoableIntSet {
return UndoableIntSet{NewIntSet(), nil}
}


func (set *UndoableIntSet) Add(x int) { // Override
if !set.Contains(x) {
set.data[x] = true
set.functions = append(set.functions, func() { set.Delete(x) })
} else {
set.functions = append(set.functions, nil)
}
}


func (set *UndoableIntSet) Delete(x int) { // Override
if set.Contains(x) {
delete(set.data, x)
set.functions = append(set.functions, func() { set.Add(x) })
} else {
set.functions = append(set.functions, nil)
}
}

func (set *UndoableIntSet) Undo() error {
if len(set.functions) == 0 {
return errors.New("No functions to undo")
}
index := len(set.functions) - 1
if function := set.functions[index]; function != nil {
function()
set.functions[index] = nil // For garbage collection
}
set.functions = set.functions[:index]
return nil
}

在上面的代码中,我们可以看到

  • 我们在 UndoableIntSet 中嵌入了IntSet ,然后Override了 它的 Add()Delete() 方法。
  • Contains() 方法没有Override,所以,会被带到 UndoableInSet 中来了。
  • 在Override的 Add()中,记录 Delete 操作
  • 在Override的 Delete() 中,记录 Add 操作
  • 在新加入 Undo() 中进行Undo操作。

通过这样的方式来为已有的代码扩展新的功能是一个很好的选择,这样,可以在重用原有代码功能和重新新的功能中达到一个平衡。但是,这种方式最大的问题是,Undo操作其实是一种控制逻辑,并不是业务逻辑,所以,在复用 Undo这个功能上是有问题。因为其中加入了大量跟 IntSet 相关的业务逻辑。

反转依赖

现在我们来看另一种方法:

我们先声明一种函数接口,表现我们的Undo控制可以接受的函数签名是什么样的:

1
type Undo []func()

有了上面这个协议后,我们的Undo控制逻辑就可以写成如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (undo *Undo) Add(function func()) {
*undo = append(*undo, function)
}

func (undo *Undo) Undo() error {
functions := *undo
if len(functions) == 0 {
return errors.New("No functions to undo")
}
index := len(functions) - 1
if function := functions[index]; function != nil {
function()
functions[index] = nil // For garbage collection
}
*undo = functions[:index]
return nil
}

这里你不必觉得奇怪, Undo 本来就是一个类型,不必是一个结构体,是一个函数数组也没什么问题。

然后,我们在我们的IntSet里嵌入 Undo,然后,再在 Add()Delete() 里使用上面的方法,就可以完成功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type IntSet struct {
data map[int]bool
undo Undo
}

func NewIntSet() IntSet {
return IntSet{data: make(map[int]bool)}
}

func (set *IntSet) Undo() error {
return set.undo.Undo()
}

func (set *IntSet) Contains(x int) bool {
return set.data[x]
}

func (set *IntSet) Add(x int) {
if !set.Contains(x) {
set.data[x] = true
set.undo.Add(func() { set.Delete(x) })
} else {
set.undo.Add(nil)
}
}

func (set *IntSet) Delete(x int) {
if set.Contains(x) {
delete(set.data, x)
set.undo.Add(func() { set.Add(x) })
} else {
set.undo.Add(nil)
}
}

这个就是控制反转,不再由 控制逻辑 Undo 来依赖业务逻辑 IntSet,而是由业务逻辑 IntSet 来依赖 Undo 。其依赖的是其实是一个协议,这个协议是一个没有参数的函数数组。我们也可以看到,我们 Undo 的代码就可以复用了。

前言

在本篇文章中,我们来讨论一下Functional Options这个编程模式。这是一个函数式编程的应用案例,编程技巧也很好,是目前在Go语言中最流行的一种编程模式。但是,在我们正式讨论这个模式之前,我们需要先来看看要解决什么样的问题。

更新历史

2020 年 12 月 20 日 - 初稿

扩展阅读


配置选项问题

在我们编程中,我们会经常性的需要对一个对象(或是业务实体)进行相关的配置。比如下面这个业务实体(注意,这仅只是一个示例):

1
2
3
4
5
6
7
8
type Server struct {
Addr string
Port int
Protocol string
Timeout time.Duration
MaxConns int
TLS *tls.Config
}

在这个 Server 对象中,我们可以看到:

  • 要有侦听的IP地址 Addr 和端口号 Port ,这两个配置选项是必填的(当然,IP地址和端口号都可以有默认值,当这里我们用于举例认为是没有默认值,而且不能为空,需要必填的)。
  • 然后,还有协议 ProtocolTimeoutMaxConns 字段,这几个字段是不能为空的,但是有默认值的,比如:协议是tcp, 超时30秒 和 最大链接数1024个。
  • 还有一个 TLS 这个是安全链接,需要配置相关的证书和私钥。这个是可以为空的。

所以,针对于上述这样的配置,我们需要有多种不同的创建不同配置 Server 的函数签名,如下所示(代码比较宽,需要左右滚动浏览):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func NewDefaultServer(addr string, port int) (*Server, error) {
return &Server{addr, port, "tcp", 30 * time.Second, 100, nil}, nil
}

func NewTLSServer(addr string, port int, tls *tls.Config) (*Server, error) {
return &Server{addr, port, "tcp", 30 * time.Second, 100, tls}, nil
}

func NewServerWithTimeout(addr string, port int, timeout time.Duration) (*Server, error) {
return &Server{addr, port, "tcp", timeout, 100, nil}, nil
}

func NewTLSServerWithMaxConnAndTimeout(addr string, port int, maxconns int, timeout time.Duration, tls *tls.Config) (*Server, error) {
return &Server{addr, port, "tcp", 30 * time.Second, maxconns, tls}, nil
}

因为Go语言不支持重载函数,所以,你得用不同的函数名来应对不同的配置选项。

配置对象方案

要解决这个问题,最常见的方式是使用一个配置对象,如下所示:

1
2
3
4
5
6
type Config struct {
Protocol string
Timeout time.Duration
Maxconns int
TLS *tls.Config
}

我们把那些非必输的选项都移到一个结构体里,于是 Server 对象变成了:

1
2
3
4
5
type Server struct {
Addr string
Port int
Conf *Config
}

于是,我们只需要一个 NewServer() 的函数了,在使用前需要构造 Config 对象。

1
2
3
4
5
6
7
8
9
func NewServer(addr string, port int, conf *Config) (*Server, error) {
//...
}

//Using the default configuratrion
srv1, _ := NewServer("localhost", 9000, nil)

conf := ServerConfig{Protocol:"tcp", Timeout: 60*time.Duration}
srv2, _ := NewServer("locahost", 9000, &conf)

这段代码算是不错了,大多数情况下,我们可能就止步于此了。但是,对于有洁癖的有追求的程序员来说,他们能看到其中有一点不好的是,Config并不是必需的,所以,你需要判断是否是nil或是 Empty –Config{}`这让我们的代码感觉还是有点不是很干净。

Builder模式

如果你是一个Java程序员,熟悉设计模式的一定会很自然地使用上Builder模式。比如如下的代码:

1
2
3
4
5
User user = new User.Builder()
.name("Hao Chen")
.email("haoel@hotmail.com")
.nickname("ABC")
.build();

仿照这个模式,我们可以把上面代码构造成如下的代码(注:下面的代码没有考虑出错处理,其中关于出错处理的更多内容,请参看《Go 编程模式:出错出理》):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//使用一个builder类来做包装
type ServerBuilder struct {
Server
}

func (sb *ServerBuilder) Create(addr string, port int) *ServerBuilder {
sb.Server.Addr = addr
sb.Server.Port = port
//其它代码设置其它成员的默认值
return sb
}

func (sb *ServerBuilder) WithProtocol(protocol string) *ServerBuilder {
sb.Server.Protocol = protocol
return sb
}

func (sb *ServerBuilder) WithMaxConn( maxconn int) *ServerBuilder {
sb.Server.MaxConns = maxconn
return sb
}

func (sb *ServerBuilder) WithTimeOut( timeout time.Duration) *ServerBuilder {
sb.Server.Timeout = timeout
return sb
}

func (sb *ServerBuilder) WithTLS( tls *tls.Config) *ServerBuilder {
sb.Server.TLS = tls
return sb
}

func (sb *ServerBuilder) Build() (Server) {
return sb.Server
}

于是就可以以如下的方式来使用了

1
2
3
4
5
6
sb := ServerBuilder{}
server, err := sb.Create("127.0.0.1", 8080).
WithProtocol("udp").
WithMaxConn(1024).
WithTimeOut(30*time.Second).
Build()

上面这样的方式也很清楚,不需要额外的Config类,使用链式的函数调用的方式来构造一个对象,只需要多加一个Builder类,这个Builder类似乎有点多余,我们似乎可以直接在Server 上进行这样的 Builder 构造,的确是这样的。但是在处理错误的时候可能就有点麻烦,不如一个包装类更好一些。

如果我们想省掉这个包装的结构体,那么就轮到我们的Functional Options上场了,函数式编程。

Functional Options

首先,我们先定义一个函数类型:

1
type Option func(*Server)

然后,我们可以使用函数式的方式定义一组如下的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Protocol(p string) Option {
return func(s *Server) {
s.Protocol = p
}
}
func Timeout(timeout time.Duration) Option {
return func(s *Server) {
s.Timeout = timeout
}
}
func MaxConns(maxconns int) Option {
return func(s *Server) {
s.MaxConns = maxconns
}
}
func TLS(tls *tls.Config) Option {
return func(s *Server) {
s.TLS = tls
}
}

上面这组代码传入一个参数,然后返回一个函数,返回的这个函数会设置自己的 Server 参数。例如:

  • 当我们调用其中的一个函数用 MaxConns(30)
  • 其返回值是一个 func(s* Server) { s.MaxConns = 30 } 的函数。

这个叫高阶函数。在数学上,就好像这样的数学定义,计算长方形面积的公式为: rect(width, height) = width * height; 这个函数需要两个参数,我们包装一下,就可以变成计算正方形面积的公式:square(width) = rect(width, width) 也就是说,squre(width)返回了另外一个函数,这个函数就是rect(w,h) 只不过他的两个参数是一样的。即:f(x) = g(x, x)

好了,现在我们再定一个 NewServer()的函数,其中,有一个可变参数 options 其可以传出多个上面上的函数,然后使用一个for-loop来设置我们的 Server 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func NewServer(addr string, port int, options ...func(*Server)) (*Server, error) {

srv := Server{
Addr: addr,
Port: port,
Protocol: "tcp",
Timeout: 30 * time.Second,
MaxConns: 1000,
TLS: nil,
}
for _, option := range options {
option(&srv)
}
//...
return &srv, nil
}

于是,我们在创建 Server 对象的时候,我们就可以这样来了。

1
2
3
s1, _ := NewServer("localhost", 1024)
s2, _ := NewServer("localhost", 2048, Protocol("udp"))
s3, _ := NewServer("0.0.0.0", 8080, Timeout(300*time.Second), MaxConns(1000))

怎么样,是不是高度的整洁和优雅?不但解决了使用 Config 对象方式 的需要有一个config参数,但在不需要的时候,是放 nil 还是放 Config{}的选择困难,也不需要引用一个Builder的控制对象,直接使用函数式编程的试,在代码阅读上也很优雅。

所以,以后,大家在要玩类似的代码时,强烈推荐使用Functional Options这种方式,这种方式至少带来了如下的好处:

  • 直觉式的编程
  • 高度的可配置化
  • 很容易维护和扩展
  • 自文档
  • 对于新来的人很容易上手
  • 没有什么令人困惑的事(是nil 还是空)

前言

错误处理一直以一是编程必需要面对的问题,错误处理如果做的好的话,代码的稳定性会很好。不同的语言有不同的出现处理的方式。Go语言也一样,在本篇文章中,我们来讨论一下Go语言的出错出处,尤其是那令人抓狂的 if err != nil

在正式讨论Go代码里满屏的 if err != nil 怎么办这个事之前,我想先说一说编程中的错误处理。这样可以让大家在更高的层面理解编程中的错误处理

更新历史

2020 年 12 月 19 日 - 初稿

扩展阅读


C语言的错误检查

首先,我们知道,处理错误最直接的方式是通过错误码,这也是传统的方式,在过程式语言中通常都是用这样的方式处理错误的。比如 C 语言,基本上来说,其通过函数的返回值标识是否有错,然后通过全局的 errno 变量并配合一个 errstr 的数组来告诉你为什么出错。

为什么是这样的设计?道理很简单,除了可以共用一些错误,更重要的是这其实是一种妥协。比如:read(), write(), open() 这些函数的返回值其实是返回有业务逻辑的值。也就是说,这些函数的返回值有两种语义,一种是成功的值,比如 open() 返回的文件句柄指针 FILE* ,或是错误 NULL。这样会导致调用者并不知道是什么原因出错了,需要去检查 errno 来获得出错的原因,从而可以正确地处理错误。

一般而言,这样的错误处理方式在大多数情况下是没什么问题的。但是也有例外的情况,我们来看一下下面这个 C 语言的函数:

1
int atoi(const char *str)

这个函数是把一个字符串转成整型。但是问题来了,如果一个要传的字符串是非法的(不是数字的格式),如 “ABC” 或者整型溢出了,那么这个函数应该返回什么呢?出错返回,返回什么数都不合理,因为这会和正常的结果混淆在一起。比如,返回 0,那么会和正常的对 “0” 字符的返回值完全混淆在一起。这样就无法判断出错的情况。你可能会说,是不是要检查一下 errno,按道理说应该是要去检查的,但是,我们在 C99 的规格说明书中可以看到这样的描述——

7.20.1The functions atof, atoi, atol, and atoll need not affect the value of the integer expression errno on an error. If the value of the result cannot be represented, the behavior is undefined.

atoi(), atof(), atol() 或是 atoll() 这样的函数是不会设置 errno的,而且,还说了,如果结果无法计算的话,行为是undefined。所以,后来,libc 又给出了一个新的函数strtol(),这个函数在出错的时会设置全局变量 errno

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
long val = strtol(in_str, &endptr, 10);  //10的意思是10进制

//如果无法转换
if (endptr == str) {
fprintf(stderr, "No digits were found\n");
exit(EXIT_FAILURE);
}

//如果整型溢出了
if ((errno == ERANGE && (val == LONG_MAX || val == LONG_MIN)) {
fprintf(stderr, "ERROR: number out of range for LONG\n");
exit(EXIT_FAILURE);
}

//如果是其它错误
if (errno != 0 && val == 0) {
perror("strtol");
exit(EXIT_FAILURE);
}

虽然,strtol() 函数解决了 atoi() 函数的问题,但是我们还是能感觉到不是很舒服和自然。

因为,这种用 返回值 + errno 的错误检查方式会有一些问题:

  • 程序员一不小心就会忘记返回值的检查,从而造成代码的 Bug;
  • 函数接口非常不纯洁,正常值和错误值混淆在一起,导致语义有问题。

所以,后来,有一些类库就开始区分这样的事情。比如,Windows 的系统调用开始使用 HRESULT 的返回来统一错误的返回值,这样可以明确函数调用时的返回值是成功还是错误。但这样一来,函数的 input 和 output 只能通过函数的参数来完成,于是出现了所谓的 入参 和 出参 这样的区别。

然而,这又使得函数接入中参数的语义变得复杂,一些参数是入参,一些参数是出参,函数接口变得复杂了一些。而且,依然没有解决函数的成功或失败可以被人为忽略的问题。

Java的错误处理

Java语言使用 try-catch-finally 通过使用异常的方式来处理错误,其实,这比起C语言的错处理进了一大步,使用抛异常和抓异常的方式可以让我们的代码有这样的一些好处:

  • 函数接口在 input(参数)和 output(返回值)以及错误处理的语义是比较清楚的。
  • 正常逻辑的代码可以与错误处理和资源清理的代码分开,提高了代码的可读性。
  • 异常不能被忽略(如果要忽略也需要 catch 住,这是显式忽略)。
  • 在面向对象的语言中(如 Java),异常是个对象,所以,可以实现多态式的 catch。
  • 与状态返回码相比,异常捕捉有一个显著的好处是,函数可以嵌套调用,或是链式调用。比如:
    • int x = add(a, div(b,c));
    • Pizza p = PizzaBuilder().SetSize(sz).SetPrice(p)...;

Go语言的错误处理

Go 语言的函数支持多返回值,所以,可以在返回接口把业务语义(业务返回值)和控制语义(出错返回值)区分开来。Go 语言的很多函数都会返回 result, err 两个值,于是:

  • 参数上基本上就是入参,而返回接口把结果和错误分离,这样使得函数的接口语义清晰;
  • 而且,Go 语言中的错误参数如果要忽略,需要显式地忽略,用 _ 这样的变量来忽略;
  • 另外,因为返回的 error 是个接口(其中只有一个方法 Error(),返回一个 string ),所以你可以扩展自定义的错误处理。

另外,如果一个函数返回了多个不同类型的 error,你也可以使用下面这样的方式:

1
2
3
4
5
6
7
8
9
10
11
12
if err != nil {
switch err.(type) {
case *json.SyntaxError:
...
case *ZeroDivisionError:
...
case *NullPointerError:
...
default:
...
}
}

我们可以看到,Go语言的错误处理的的方式,本质上是返回值检查,但是他也兼顾了异常的一些好处 – 对错误的扩展。

资源清理

出错后是需要做资源清理的,不同的编程语言有不同的资源清理的编程模式:

  • C语言 – 使用的是 goto fail; 的方式到一个集中的地方进行清理
  • C++语言- 一般来说使用 RAII模式,通过面向对象的代理模式,把需要清理的资源交给一个代理类,然后在析构函数来解决。
  • Java语言 – 可以在finally 语句块里进行清理。
  • Go语言 – 使用 defer 关键词进行清理。

下面是一个Go语言的资源清理的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Close(c io.Closer) {
err := c.Close()
if err != nil {
log.Fatal(err)
}
}

func main() {
r, err := Open("a")
if err != nil {
log.Fatalf("error opening 'a'\n")
}
defer Close(r) // 使用defer关键字在函数退出时关闭文件。

r, err = Open("b")
if err != nil {
log.Fatalf("error opening 'b'\n")
}
defer Close(r) // 使用defer关键字在函数退出时关闭文件。
}

Error Check Hell

好了,说到 Go 语言的 if err !=nil 的代码了,这样的代码的确是能让人写到吐。那么有没有什么好的方式呢,有的。我们先看如下的一个令人崩溃的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func parse(r io.Reader) (*Point, error) {

var p Point

if err := binary.Read(r, binary.BigEndian, &p.Longitude); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.Latitude); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.Distance); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.ElevationGain); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &p.ElevationLoss); err != nil {
return nil, err
}
}

要解决这个事,我们可以用函数式编程的方式,如下代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func parse(r io.Reader) (*Point, error) {
var p Point
var err error
read := func(data interface{}) {
if err != nil {
return
}
err = binary.Read(r, binary.BigEndian, data)
}

read(&p.Longitude)
read(&p.Latitude)
read(&p.Distance)
read(&p.ElevationGain)
read(&p.ElevationLoss)

if err != nil {
return &p, err
}
return &p, nil
}

上面的代码我们可以看到,我们通过使用Closure 的方式把相同的代码给抽出来重新定义一个函数,这样大量的 if err!=nil 处理的很干净了。但是会带来一个问题,那就是有一个 err 变量和一个内部的函数,感觉不是很干净。

那么,我们还能不能搞得更干净一点呢,我们从Go 语言的 bufio.Scanner()中似乎可以学习到一些东西:

1
2
3
4
5
6
7
8
9
10
scanner := bufio.NewScanner(input)

for scanner.Scan() {
token := scanner.Text()
// process token
}

if err := scanner.Err(); err != nil {
// process the error
}

上面的代码我们可以看到,scanner在操作底层的I/O的时候,那个for-loop中没有任何的 if err !=nil 的情况,退出循环后有一个 scanner.Err() 的检查。看来使用了结构体的方式。模仿它,我们可以把我们的代码重构成下面这样:

首先,定义一个结构体和一个成员函数

1
2
3
4
5
6
7
8
9
10
type Reader struct {
r io.Reader
err error
}

func (r *Reader) read(data interface{}) {
if r.err == nil {
r.err = binary.Read(r.r, binary.BigEndian, data)
}
}

然后,我们的代码就可以变成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func parse(input io.Reader) (*Point, error) {
var p Point
r := Reader{r: input}

r.read(&p.Longitude)
r.read(&p.Latitude)
r.read(&p.Distance)
r.read(&p.ElevationGain)
r.read(&p.ElevationLoss)

if r.err != nil {
return nil, r.err
}

return &p, nil
}

有了上面这个技术,我们的“流式接口 Fluent Interface”,也就很容易处理了。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package main

import (
"bytes"
"encoding/binary"
"fmt"
)

// 长度不够,少一个Weight
var b = []byte {0x48, 0x61, 0x6f, 0x20, 0x43, 0x68, 0x65, 0x6e, 0x00, 0x00, 0x2c}
var r = bytes.NewReader(b)

type Person struct {
Name [10]byte
Age uint8
Weight uint8
err error
}
func (p *Person) read(data interface{}) {
if p.err == nil {
p.err = binary.Read(r, binary.BigEndian, data)
}
}

func (p *Person) ReadName() *Person {
p.read(&p.Name)
return p
}
func (p *Person) ReadAge() *Person {
p.read(&p.Age)
return p
}
func (p *Person) ReadWeight() *Person {
p.read(&p.Weight)
return p
}
func (p *Person) Print() *Person {
if p.err == nil {
fmt.Printf("Name=%s, Age=%d, Weight=%d\n",p.Name, p.Age, p.Weight)
}
return p
}

func main() {
p := Person{}
p.ReadName().ReadAge().ReadWeight().Print()
fmt.Println(p.err) // EOF 错误
}

相信你应该看懂这个技巧了,但是,其使用场景也就只能在对于同一个业务对象的不断操作下可以简化错误处理,对于多个业务对象的话,还是得需要各种 if err != nil的方式。

包装错误

最后,多说一句,我们需要包装一下错误,而不是干巴巴地把err给返回到上层,我们需要把一些执行的上下文加入。

通常来说,我们会使用 fmt.Errorf()来完成这个事,比如:

1
2
3
if err != nil {
return fmt.Errorf("something failed: %v", err)
}

另外,在Go语言的开发者中,更为普遍的做法是将错误包装在另一个错误中,同时保留原始内容:

1
2
3
4
5
6
7
8
type authorizationError struct {
operation string
err error // original error
}

func (e *authorizationError) Error() string {
return fmt.Sprintf("authorization failed during %s: %v", e.operation, e.err)
}

当然,更好的方式是通过一种标准的访问方法,这样,我们最好使用一个接口,比如 causer接口中实现 Cause() 方法来暴露原始错误,以供进一步检查:

1
2
3
4
5
6
7
type causer interface {
Cause() error
}

func (e *authorizationError) Cause() error {
return e.err
}

这里有个好消息是,这样的代码不必再写了,有一个第三方的错误库(github.com/pkg/errors),对于这个库,我无论到哪都能看到他的存在,所以,这个基本上来说就是事实上的标准了。代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import "github.com/pkg/errors"

//错误包装
if err != nil {
return errors.Wrap(err, "read failed")
}

// Cause接口
switch err := errors.Cause(err).(type) {
case *MyError:
// handle specifically
default:
// unknown error
}