单体 or 微服务?Service Weaver:我全都要!

TL;DR

怎么理解 Service Weaver,就是一个应用中有很多的接口,这些接口间会互相调用。如果将操作系统进程(应用)比做一块电路板 ,接口比做元器件。可以选择将哪些元器件放入该电路板中,哪些元器件放入其他的电路板中。

同一块电路板中的元器件间通过板上的导线 连接(进程内的本地方法调用);不同电路板中的元器件间通过排线来连接(远程方法调用)。

总结几个关键词:

  • 一个编程框架
  • 用于编写、部署、管理分布式应用
  • 支持的语言 Go
  • 在本地以单进程、多进程运行
  • 在云端由框架拆分成微服务,并于云供应商集成
  • 单体方式开发,微服务方式部署

体验了一圈下来,给我的感觉有点类似 Notion、Obsidian 这类笔记软件。传统的笔记软件只能引用其他的笔记,而这类笔记可以细粒度到 heading 内容。

放到微服务下就是管理的维度不在是服务本身,而且更小的接口,并且对某些接口进行扩展,即使所有接口都位于同一个二进制文件中。

背景

架构的演进,总是在解决问题的过程中引入新的问题,然后再解决问题,循环往复。

从单体到微服务

软件架构从单体演进到微服务架构已经十多年了,尤其是近几年云原生风生水起,微服务架构已有深入人心的架势。

单体架构由于在规模扩大时,单体面临性能瓶颈和硬件限制、无法支撑业务的快读迭代、开发效率下降协同难度增加等原因,颓势日渐明显。然后就有了微服务架构的提出,来解决单体架构的各种问题。

上云

由于云平台提供显著的成本效益,减少初始投资并实现按需付费、提供极大的灵活性和可扩展性、提供的稳定性和可靠性确保业务连续性、专业的安全保障和合规性支持减轻企业的运营负担,企业将其业务和数据迁移至云计算平台。

问题

拆分成微服务,由此带来了不少好处:更高效的应用扩展、更小的错误传播半径、独立的安全域以及完善的模块边界。

反过来,如何正确地找到边界进行拆分并非易事。拆分的依据是什么?two pizza team?依据资源使用、组织架构、数据结构?亦或是考虑未来的增长?

微服务的拆分执行下来毫无章法,最终的结果是微服务越来越多、更多的故障点、更长的链路、更大的延迟。这实际上增加了应用的开发、部署和管理成本。

  • 原本单个二进制文件,拆分后有多个;原本一次部署完成,拆分后需要多个 CI/CD 流水线来部署;原本一个配置文件,拆分后需要维护多个。
  • 微服务彼此独立部署,无法忽略多版本的情况。需要调整部署策略来降低风险,同样还有本地开发和测试的难度增加。
  • 学习成本高,需要学习如何将应用二进制文件包装成容器,并了解云的各种工具和部署方式,即使对经验丰富的程序员来说也难以理解。
  • 同时还要解决分布式带来的各种问题,如服务发现、安全、负载均衡,以及服务间的调用。
  • 延迟增加,时间消耗在数据的序列化以及网络传输上。

为什么用 Service Weaver?

今年 3 月 Google 开源了 Service Weaver,希望能解决微服务架构的各种问题。

有了 Servier Weaver,你可以专注在业务逻辑的开发上,其他的工作交给 Service Weaver 来完成。

无需要纠结微服务的拆分规则,可以拆分为任意数量的组件。可以在部署的时候轻松指定哪些组件作为一个微服务来运行,哪些在不同的微服务上运行。

使用 Service 可以部署和管理单一二进制文件。

Service Weaver 使用自定义的序列化和传输协议,成本效益比行业最佳的解决方案(gRPC+protobuf)高出三倍。

如何使用 Service Weaver 开发应用?

Service Weaver 的核心是组件(component),一个类似 actor 的计算单元。

组件是个常见的 Go 接口,组件间的交互通过调用接口定义的方法来完成。

下面的示例中定义了一个 Reverser 组件,用于反转字符串。

go 复制代码
// The interface of the Reverser component.
type Reverser interface {
    Reverse(context.Context, string) (string, error)
}

// The implementation of the Reverser component.
type reverser struct{
    weaver.Implements[Reverser]
}

func (r reverser) Reverse(_ context.Context, s string) (string, error) {
    runes := []rune(s)
    n := len(runes)
    for i := 0; i < n/2; i++ {
        runes[i], runes[n-i-1] = runes[n-i-1], runes[i]
    }
    return string(runes), nil
}

其他组件可以调用 Reverser 组件的方法:

go 复制代码
reversed, err := reverser.Reverse(ctx, "Hello, World!")

组件的最大优点是不依赖于系统进程。上面的例子中,尽管没有编写任何网络和序列化相关的代码,这些组件可以运行在不同的进程中甚至是不同的机器上。

如上图所示,如果组件位于同一个进程中,方法的调用就是传统的 Go 方法调用;如果位于不同的进程中,方法的调用就是 RPC。

将应用拆分为不同组件的过程,有点类似微服务的拆分。组件也同样有着清晰的边界,以及很好的扩展性。但又没有微服务的缺陷:

  • 所有组件都运行同一个版本,无需考虑版本的兼容性。
  • 可以很容易地通过 go rungo test 运行和测试应用。
  • 微服务的拆分和合并是非常痛苦的。

如何管理 Service Weaver 应用?

部署

在云上运行就跟本地运行一样简单:

shell 复制代码
$ go run .                           # Run locally, in the same OS process.
$ weaver multi deploy weaver.toml    # Run locally, in multiple OS processes.
$ weaver gke deploy weaver.toml      # Run in the cloud.

配置

Service Weaver 需要的配置非常少。一个 在线商城 的演示中可能需要超过 1500 行配置,而 Service Weaver 编写的同样应用所需的配置不超过 10 行:

toml 复制代码
[weaver]
binary = "./online_boutique"
rollout = "6h"

[gke]
regions = ["us-west1", "us-east2"]
listeners.boutique = {public_hostname = "online-boutique.net"}

只需指定二进制文件、部署的持续时间、部署的区域以及公开访问的地址。就这么简单。

发布新版本

传统的微服务,在发布新版本时,旧版本的示例可以会与其他新版本的示例进行通信。

Service Weaver 使用不同的方式升级,确保客户端的请求完全在相同的版本下处理:不同版本的组件不会发生通信。

这就避免了大多数的系统故障:研究表明的:多大三分之二的故障是由系统多个版本之间的交互引起的

有了 Service Weaver,仅需更新代码、构建、运行即可,其他的交给 Service Weaver。

可观测性

Service Weaver 提供了用于日志、指标和链路跟踪的库,并自动与应用的部署环境集成。

下面的示例演示了如何为 Reverser 组件添加计数指标:

go 复制代码
var reverseCount = metrics.NewCounter(
    "reverse_count",
    "The number of times Reverser.Reverse has been called",
)

func (reverser) Reverse(_ context.Context, s string) (string, error) {
    reverseCount.Add(1.0)
    // ...
}

测试

传统的微服务,应用开发周期非常慢。在迭代中,需要安装笨重的云依赖、复杂的测试框架,或者部署到云上。这些都极大影响了开发进度。

有了 Service Weaver 可以想运行 Go 程序一样完成构建、运行、测试。提供的 weavertest 包可用来编写端到端测试,就像写单元测试一样简单。

演示

安装 Service Weaver

参考 安装文档,注意要求 Go 的版本不低于 1.21。在 macOS 上直接用 Homebrew 安装:

shell 复制代码
brew install  service-weaver
weaver version
weaver v0.21.2 darwin/arm64
go version
go version go1.21.3 darwin/arm64

初始化工程

shell 复制代码
mkdir hello-sample
cd hello-sample
go mod init hello-sample

使用上面 Reverser 组件的例子。

编写 Reverser 组件

创建 reverser.go 文件,内容如下:

go 复制代码
package main
import (
    "context"
    "github.com/ServiceWeaver/weaver"
)
// Reverser component.
type Reverser interface {
    Reverse(context.Context, string) (string, error)
}
// Implementation of the Reverser component.
type reverser struct{
    weaver.Implements[Reverser]
}
func (r *reverser) Reverse(_ context.Context, s string) (string, error) {
    runes := []rune(s)
    n := len(runes)
    for i := 0; i < n/2; i++ {
        runes[i], runes[n-i-1] = runes[n-i-1], runes[i]
    }
    return string(runes), nil
}

Reverser 组件通过定义创建接口 Reverser 定义,该接口定义了用于反转字符串的方法 Reverse;结构体类型 reverser 通过 weaver.Implements[Reverser] 定义为组件 Reverser 的实现。

接下来是调用组件的代码。

编写 Main 组件

创建 main.go 文件,内容如下,也就是 main 组件:

go 复制代码
package main
import (
    "context"
    "fmt"
    "log"
    "github.com/ServiceWeaver/weaver"
)
func main() {
    if err := weaver.Run(context.Background(), serve); err != nil {
        log.Fatal(err)
    }
}
type app struct{
    weaver.Implements[weaver.Main]
    reverser weaver.Ref[Reverser]
}
func serve(ctx context.Context, app *app) error {
    // Call the Reverse method.
    var r Reverser = app.reverser.Get()
    reversed, err := r.Reverse(ctx, "!dlroW ,olleH")
    if err != nil {
        return err
    }
    fmt.Println(reversed)
    return nil
}

weaver.Run(...) 初始化并运行 Service Weaver 应用,每个应用都有一组组件组成。运行时会自动创建 weaver.Main 并将其交给应用。

查看其源码可以看到使用了泛型:

go 复制代码
func Run[T any, P PointerToMain[T]](ctx context.Context, app func(context.Context, *T) error) error {
	...
}

Run 执行时会先找到 Main 组件的定义,在上面的代码中结构体类型 app 被定义为 weaver.Main 组件,然后创建该组件并将其交给代码中的 serve 来使用,然后调用 serve 函数。

在 Main 组件 app 中我们还可以看到 reverser weaver.Ref[Reverser]。在 Service Weaver 中,如果一个组件 X 要调用组件 Y,就会再 X 的定义中加入 y weaver.Ref[Y],因此这里 Main 组件将会调用 Reverser 组件。

在创建 Main 组件时,会从"注册表"中找到组件 Reverser 的"真正"实现,并将其交给 Main 组件。

这里的真正实现,可能是本地的调用,也可能是远程的调用。怎么实现的?还有很重要的一步:生成代码。

生成代码

然后执行下面的命令生成代码 weaver_gen.go

shell 复制代码
weaver generate

简单看下文件的内容(里面用了大量的反射),首先有个 init() 方法进行组件的注册,注册信息包括

  • 组件名
  • 接口
  • 接口的实现
  • 本地调用 stub
  • 远程调用的客户端 stub
  • 供远程调用的服务端 stub
  • 通过反射进行方法调用的 stub

其中几个 stub 也都在 weaver_gen.go 文件中,由上面的命令自动生成。

因此,假如不执行 weaver generate 命令就没有 weaver_gen.go 文件,在运行时也就找不到任何组件的注册信息的。

运行

现在可以运行应用:

shell 复制代码
go run .          
╭───────────────────────────────────────────────────╮
│ app        : hello-sample                         │
│ deployment : 09caf84b-822c-44d3-a149-a5b2e733a136 │
╰───────────────────────────────────────────────────╯
Hello, World!

对服务来说用这种方式来触发调用肯定不合理,而是应该让其接收 HTTP 的流量。

进阶 - 单进程部署部署

接下来,使用下面的代码替换 main.go

go 复制代码
package main

import (
    "context"
    "fmt"
    "log"
    "net/http"

    "github.com/ServiceWeaver/weaver"
)

func main() {
    if err := weaver.Run(context.Background(), serve); err != nil {
        log.Fatal(err)
    }
}

type app struct {
    weaver.Implements[weaver.Main]
    reverser weaver.Ref[Reverser]
    hello    weaver.Listener
}

func serve(ctx context.Context, app *app) error {
    // The hello listener will listen on a random port chosen by the operating
    // system. This behavior can be changed in the config file.
    fmt.Printf("hello listener available on %v\n", app.hello)

    // Serve the /hello endpoint.
    http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
        name := r.URL.Query().Get("name")
        if name == "" {
            name = "World"
        }
        reversed, err := app.reverser.Get().Reverse(ctx, name)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        fmt.Fprintf(w, "Hello, %s!\n", reversed)
    })
    return http.Serve(app.hello, nil)
}

在定义 Main 组件时加入了 hello weaver.Listener,Service Weaver 会自动初始化一个 HTTP 监听器,并接收网络流量。

serve 中定义了 HTTP 路径 /hello:在接收到流量后从请求中读取参数 name 的值,然后调用 Reverser 组件;同时 serve 返回的 http.Server 运行在 Main 组件的 HTTP 监听器上。

生成代码

由于修改了 Main 组件的定义,需要执行 weaver generate 重新生成代码。在更新后的 weaver_gen.go 中可以看到 Main 组件的注册信息多了一个 Listeners,值是个字符串数组 []string{"hello"},对应着代码中监听器的名字 hello

配置文件

此时如果运行应用,会随机给应用分配一个端口。如果要自定义端口的话,就需要增加配置文件了。

创建一个名为 weaver.toml 的文件,内容如下:

toml 复制代码
[single]
listeners.hello = {address = "localhost:12345"}

在配置文件中设置监听器 hello 的监听地址。

运行

在运行时需要将配置文件的路径提供给应用。

arduino 复制代码
SERVICEWEAVER_CONFIG=weaver.toml go run . 
╭───────────────────────────────────────────────────╮
│ app        : hello-sample                         │
│ deployment : 68e82ba2-a5ba-49c0-8b56-4edb775dba4b │
╰───────────────────────────────────────────────────╯
hello listener available on 127.0.0.1:12345

发送请求,可以收到反转后的字符串。

shell 复制代码
curl "localhost:12345/hello?name=world"
Hello, dlrow!

[!INFO] 可以通过 /debug/weaver/healthz 端点查看应用的健康状态。 curl -i "localhost:12345/debug/weaver/healthz" HTTP/1.1 200 OK Date: Wed, 11 Oct 2023 00:20:01 GMT Content-Length: 2 Content-Type: text/plain; charset=utf-8 OK

进阶 - 多进程

配置文件

接下来修改配置文件,用下面的内容替换:

toml 复制代码
[serviceweaver]
binary = "./hello-sample"
[multi]
listeners.hello = {address = "localhost:12345"}

在配置文件中,这次我们加入了二进制文件地址,以及多进程部署的地址。

运行

因为是多进程运行无法使用 go run 命令了,此时要用到 weaver multi 命令了(通过 weaver multi -h 查看使用方式):

shell 复制代码
weaver multi deploy weaver.toml 
╭───────────────────────────────────────────────────╮
│ app        : hello-sample                         │
│ deployment : 9956d5c9-e88b-4d0f-a808-d4f7f475ed36 │
╰───────────────────────────────────────────────────╯
S1011 08:36:28.419836 stdout               5df75365                      │ hello listener available on 127.0.0.1:12345
S1011 08:36:28.419922 stdout               74d79043                      │ hello listener available on 127.0.0.1:12345

weaver multi 为每个组件各创建了两个副本,因此可以看到打印了两行日志。如果再次发送请求,也会得到同样的应答。

这次我们通过 Weaver Dashboard 来查看应用情况,通过下面的命令启用 dashboard(为其随机分配监听接口):

shell 复制代码
weaver multi dashboard
Dashboard available at: http://127.0.0.1:56108

在浏览器中可以打开其 dashboard。

点击链接后可以获取其详细信息,其中就可以看到每个组件都有两个进程:

Dashboard 展示的信息相比命令行的内容会更加详细:

shell 复制代码
weaver multi status
╭────────────────────────────────────────────────────────────────╮
│ DEPLOYMENTS                                                    │
├──────────────┬──────────────────────────────────────┬──────────┤
│ APP          │ DEPLOYMENT                           │ AGE      │
├──────────────┼──────────────────────────────────────┼──────────┤
│ hello-sample │ 9956d5c9-e88b-4d0f-a808-d4f7f475ed36 │ 1h46m17s │
╰──────────────┴──────────────────────────────────────┴──────────╯
╭──────────────────────────────────────────────────────────────────╮
│ COMPONENTS                                                       │
├──────────────┬────────────┬───────────────────────┬──────────────┤
│ APP          │ DEPLOYMENT │ COMPONENT             │ REPLICA PIDS │
├──────────────┼────────────┼───────────────────────┼──────────────┤
│ hello-sample │ 9956d5c9   │ weaver.Main           │ 28079, 28082 │
│ hello-sample │ 9956d5c9   │ hello-sample.Reverser │ 28083, 28084 │
╰──────────────┴────────────┴───────────────────────┴──────────────╯
╭────────────────────────────────────────────────────────╮
│ LISTENERS                                              │
├──────────────┬────────────┬──────────┬─────────────────┤
│ APP          │ DEPLOYMENT │ LISTENER │ ADDRESS         │
├──────────────┼────────────┼──────────┼─────────────────┤
│ hello-sample │ 9956d5c9   │ hello    │ 127.0.0.1:12345 │
╰──────────────┴────────────┴──────────┴─────────────────╯

进阶 - 云端部署

[!IMPORTANT] 注意! 接下来的操作是在 X86 的平台上完成的。

安装 weaver-kube

使用 weaver-kube 可以将应用部署到普通的 Kubernetes 上。

shell 复制代码
go install github.com/ServiceWeaver/weaver-kube/cmd/weaver-kube@latest
weaver-kube version 
weaver kube v0.21.2 darwin/arm64

配置文件

使用下面的内容替换 weaver.toml 文件:

toml 复制代码
[serviceweaver]
binary = "./hello-sample"
[kube]
repo = "docker.io/addozhang"
listeners.hello = {public = true}

这次使用 kube 配置,对应 weaver kube deploy 操作。其中 repo 是执行操作时构建镜像所推送的仓库地址,设置监听器为 {public = true} 将会为其创建 Kubernetes LoadBalancer Service。

arduino 复制代码
weaver kube deploy weaver.toml

...
Generating kube deployment info ...
Generated roles and bindings
Replica sets generated successfully [hello-sample/Reverser github.com/ServiceWeaver/weaver/Main]
Generated kube deployment for replica set github.com/ServiceWeaver/weaver/Main
Generated kube autoscaler for replica set github.com/ServiceWeaver/weaver/Main
Generated kube listener service for listener hello
Generated kube deployment for replica set hello-sample/Reverser
Generated kube autoscaler for replica set hello-sample/Reverser
Generated Jaeger deployment
Generated Jaeger service
Generated kube deployment for config map hello-sample-prometheus-config-4beb9a0b
Generated kube deployment for Prometheus hello-sample-prometheus-21ee5ea1
Generated kube service for Prometheus hello-sample-prometheus-21ee5ea1
Generated kube deployment for config map hello-sample-loki-config-e2c46e76
Generated kube deployment for config map hello-sample-promtail-b648e7f2
Generated kube deployment for Loki hello-sample-loki-e8d90de6
Generated kube service for Loki hello-sample-loki-e8d90de6
Generated kube daemonset for Promtail hello-sample-promtail-b648e7f2
Generated kube deployment for config map hello-sample-grafana-config-a873d265
Generated Grafana deployment
Generated Grafana service
kube deployment information successfully generated
/tmp/kube_1cfbd355-10a4-47b6-aaec-efd9bbd17a06.yaml

镜像推送到仓库后,还会生成部署所需的 manifest 文件,执行命令进行部署:

shell 复制代码
kubectl apply -f /tmp/kube_1cfbd355-10a4-47b6-aaec-efd9bbd17a06.yaml

集群我用的是 k3s 创建的:

export INSTALL_K3S_VERSION=v1.27.1+k3s1 curl -sfL get.k3s.io | sh -s - --disable traefik --disable local-storage --disable metrics-server --write-kubeconfig-mode 644 --write-kubeconfig ~/.kube/config

测试

查看 Pod 和 LoadBalancer 的端口,可以看到为两个组件各创建了一个 Pod。

bash 复制代码
kubectl get po,svc -l appName=hello-sample
NAME                                                                  READY   STATUS    RESTARTS   AGE
pod/hello-sample-github-com-serviceweaver-weaver-main-a0713fddsfgb2   1/1     Running   0          6h16m
pod/hello-sample-hello-sample-reverser-ad954898-01bf315f-86df6pvchc   1/1     Running   0          6h16m

kubectl get svc -l lisName=hello                                                                                                                                                                                default ⎈
NAME                              TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
hello-sample-lis-hello-4701ccf4   LoadBalancer   10.43.195.210   10.0.2.4      80:31340/TCP   6h17m

在宿主机上执行命令,执行成功。

bash 复制代码
curl "localhost/hello?name=world"
Hello, dlrow!

总结

Service Weaver 的思路很好,针对目前微服务架构的问题做了优化和提升,再加上有 Google 的背书,希望继续演进下去。尤其当前只支持 Go 语言,虽说 Go 在云原生中风生水起,当时业务开发大部分仍是 Java 技术栈。在业务开发中,Go 的受众还是相当小。

当前的版本是 v0.21.2,仍处于很早期的阶段,请谨慎对待(源码里搜了下 TODO 关键词有 100 多处)。

没有完美的架构,没有银弹!

关注"云原生指北"微信公众号 (转载本站文章请注明作者和出处乱世浮生,请勿用于任何商业用途)

相关推荐
云原生指北1 年前
Netflix 零配置服务网格与按需集群发现
我的收藏
云原生指北1 年前
探索 Gateway API 在 Service Mesh 中的工作机制
我的收藏
云原生指北1 年前
Kubernetes 容器运行时接口 CRI
我的收藏