GoSuda

在 .NET Aspire 中尝试以可扩展的方式运行 Go 服务器

By snowmerak views ...

dotnet aspire?

dotnet aspire 是为了帮助开发者进行云原生开发与配置而诞生的工具,因为云原生环境正在不断扩大。该工具让 .NET 开发者能够轻松部署 .NET 项目、各种云原生基础设施,以及使用其他语言编写的服务或容器等。

当然,它从 docker 一直到 k8s 都已经上线并投入运营,许多领域、行业和开发者都在从传统的本地(on-premises)环境迁移到云原生环境,并且已经完成了迁移。如今这已经是一个成熟的领域了。因此,我认为无需再解释主机名、端口配置、防火墙、指标管理等方面过去的各种不便。

所以,仅凭上面的说明,您恐怕仍然完全摸不着 dotnet aspire 究竟是什么。原因在于,微软本身也没有给出准确的定义。因此,我在这里也不会另行给出定义。不过,本文将使用我所理解的 dotnet aspire 的基本功能,您可以以此为参考,确定自己的理解定位。

项目配置

创建 dotnet aspire 项目

如果没有 dotnet aspire 模板,就需要先安装模板。使用以下命令安装模板。如果没有安装 .net,请自行安装。

1dotnet new install Aspire.ProjectTemplates

然后在合适的文件夹中创建一个新的解决方案。

1dotnet new sln

之后,在解决方案文件夹中执行以下命令,以创建 aspire-apphost 模板的项目。

1dotnet new aspire-apphost -o AppHost

这样就会生成一个只包含用于配置的简单代码的 aspire-apphost 项目。

添加 Valkey

接下来我们简单地添加 Valkey。

在直接添加之前,需要说明的是,dotnet aspire 通过 community hosting 提供了多种第三方解决方案。
当然,valkey 也能获得这种社区托管的支持,并且可以通过以下 nuget 包轻松使用。

1dotnet add package Aspire.Hosting.Valkey

除此之外,还提供了多种集成的托管方式,可以在这里查看。
回到 valkey,在 AppHost 项目中打开 Program.cs 文件,并按如下方式进行修改。

1var builder = DistributedApplication.CreateBuilder(args);
2
3var cache = builder.AddValkey("cache")
4    .WithDataVolume(isReadOnly: false)
5    .WithPersistence(interval: TimeSpan.FromMinutes(5),
6        keysChangedThreshold: 100);
7
8builder.Build().Run();

cache 是 IResourceBuilder 接口的实现,其中包含构建 valkey 服务所需的信息。
WithDataVolume 会创建用于存储缓存数据的卷,而 WithPersistence 则使缓存数据能够被持续保存。
从这些内容来看,它的作用似乎与 docker-compose 中的 volumes 类似。
当然,这也是您可以轻松实现的内容。
不过,这超出了本文的范围,因此这里不再展开讨论。

创建 Go 语言 Echo 服务器

接下来我们添加一个简单的 Go 语言服务器。
首先在解决方案文件夹中通过 go work init 创建工作区。
对于 .NET 开发者而言,可以将 Go 工作区理解为与解决方案相似的概念。

然后创建名为 EchoServer 的文件夹,进入其中并执行 go mod init EchoServer。
通过该命令创建 Go 模块。模块可以理解为与 .NET 开发者所熟悉的项目相似的概念。
接着创建 main.go 文件,并按如下方式编写。

 1package main
 2
 3import (
 4	"log"
 5	"net/http"
 6	"os"
 7)
 8
 9func main() {
10	addr := os.Getenv("PORT")
11	log.Printf("Server started on %s", addr)
12
13	http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
14		name := request.URL.Query().Get("name")
15		writer.Write([]byte("Hello, " + name))
16	})
17
18	http.ListenAndServe(":"+addr, nil)
19}

当 Aspire AppHost 运行时,如果注入了服务器需要监听的 PORT 环境变量,该服务器会读取此端口并启动。
这是一个简单的服务器,接收 name 查询参数并返回 Hello, {name}。

现在我们将这个服务器添加到 dotnet aspire 中。

将 Echo 服务器添加到 aspire

回到之前添加 Valkey 的 Aspire AppHost 项目,并为 Go 语言添加社区托管包。

1dotnet add package CommunityToolkit.Aspire.Hosting.Golang

然后打开 Program.cs 文件,添加以下语句。

 1var builder = DistributedApplication.CreateBuilder(args);
 2
 3var cache = builder.AddValkey("cache")
 4    .WithDataVolume(isReadOnly: false)
 5    .WithPersistence(interval: TimeSpan.FromMinutes(5),
 6        keysChangedThreshold: 100);
 7
 8var echoServer = builder.AddGolangApp("echo-server", "../EchoServer")
 9    .WithHttpEndpoint(port: 3000, env: "PORT")
10    .WithExternalHttpEndpoints();
11
12builder.Build().Run();

这里 echoServer 是 IResourceBuilder 接口的实现,其中包含构建 Go 语言服务器所需的信息。
刚才添加的 AddGolangApp 方法是用于添加 Go 语言服务器的自定义宿主扩展方法。
它固定使用 3000 端口,并且可以看到会注入 PORT 环境变量。
最后,WithExternalHttpEndpoints 使其可以从外部访问。

为了测试,访问 http://localhost:3000/?name=world,您应该可以看到输出 Hello, world。

但是,目前的 dotnet aspire 对于非 dotnet 项目存在一个沉重的代价。
那就是……

项目扩展

那水平扩展该怎么做?

目前 dotnet aspire 只为通过 AddProject 方法添加的 .NET 项目的构建器提供 WithReplica 选项。
但对于 Go 语言宿主或 AddContainer 等外部项目,并不提供该选项。

因此,必须使用单独的负载均衡器或反向代理自行实现。
但这样一来,该反向代理可能成为 SPOF(单点故障),因此反向代理最好也能提供 WithReplica 选项。
如此一来,反向代理必然需要是 .NET 项目。

此前我曾使用 nginx、traefik、自行实现等方法来解决这一问题,但一旦受到必须是 .NET 项目这一限制,我手头就暂时没有可行的方法了。
于是我寻找了用 .NET 实现的反向代理,幸运的是找到了一个选择:YARP。
YARP 是用 .NET 实现的反向代理,既可以承担负载均衡器的角色,又提供多种功能,因此我判断它是一个很好的选择。

接下来,我们添加 YARP。

使用 YARP 配置反向代理

首先创建一个用于 YARP 的项目。

1dotnet new web -n ReverseProxy

然后进入该项目并安装 YARP。

1dotnet add package Yarp.ReverseProxy --version 2.2.0

安装完成后,打开 Program.cs 文件,并按如下方式编写。

 1using Yarp.ReverseProxy.Configuration;
 2
 3var builder = WebApplication.CreateBuilder(args);
 4
 5var routes = new List<RouteConfig>();
 6var clusters = new List<ClusterConfig>();
 7
 8builder.Services.AddReverseProxy()
 9    .LoadFromMemory(routes, clusters);
10
11var app = builder.Build();
12
13app.MapReverseProxy();
14app.Run(url: $"http://0.0.0.0:{Environment.GetEnvironmentVariable("PORT") ?? "5000"}");

这段代码是使用 YARP 的基本代码。
routes 包含反向代理所使用的路由信息,clusters 包含反向代理所使用的集群信息。
这些信息通过 LoadFromMemory 方法加载到反向代理中。
最后,使用 MapReverseProxy 方法映射并运行反向代理。

然后为了实际使用,在 aspire apphost 项目中引用反向代理项目,并在 Program.cs 文件中添加和修改以下语句。

1dotnet add reference ../ReverseProxy
1var echoServer = builder.AddGolangApp("echo-server", "../EchoServer")
2    .WithHttpEndpoint(env: "PORT");
3
4var reverseProxy = builder.AddProject<Projects.ReverseProxy>("gateway")
5    .WithReference(echoServer)
6    .WithHttpEndpoint(port: 3000, env: "PORT", isProxied: true)
7    .WithExternalHttpEndpoints();

现在反向代理可以引用 echo server。
结构已经改为:外部传入的请求由反向代理接收,再转发给 echo server。

修改反向代理

首先需要更改分配给反向代理项目的监听地址。
删除 Properties/launchSettings.json 文件中的 applicationUrl。
然后打开 Program.cs 文件,并按如下方式进行大幅修改。

 1using Yarp.ReverseProxy.Configuration;
 2
 3var builder = WebApplication.CreateBuilder(args);
 4
 5var routes = new List<RouteConfig>
 6{
 7    new RouteConfig
 8    {
 9        ClusterId = "cluster-echo",
10        RouteId = "route-echo",
11        Match = new RouteMatch
12        {
13            Path = "/"
14        }
15    }
16};
17
18var echoServerAddr = Environment.GetEnvironmentVariable("services__echo-server__http__0") ?? "http://localhost:8080";
19
20var clusters = new List<ClusterConfig>
21{
22    new ClusterConfig
23    {
24        ClusterId = "cluster-echo",
25        Destinations = new Dictionary<string, DestinationConfig>
26        {
27            { "destination-echo", new DestinationConfig { Address = echoServerAddr } }
28        }
29    }
30};
31
32builder.Services.AddReverseProxy()
33    .LoadFromMemory(routes, clusters);
34
35var app = builder.Build();
36
37app.MapReverseProxy();
38app.Run(url: $"http://0.0.0.0:{Environment.GetEnvironmentVariable("PORT") ?? "5000"}");

首先修改 routes 和 clusters 的相关信息。
分别添加 echo-route 和 echo-cluster,设置为将请求发送到 echo server。
并且修改为从环境变量中读取 echo server 的地址并使用。

该地址的规则是 services__{service-name}__http__{index}。
对于 echo server,服务名称为 echo-server,且为单个实例,因此索引使用 0。
如果添加 asp .net core 服务器,则可以通过 WithReplica 创建多个实例,因此只需递增索引即可使用。
而异常处理中的 http://localhost:8080 是毫无意义的垃圾值。

现在运行项目,并访问 http://localhost:3000/?name=world,您应该仍然可以看到输出 Hello, world。

扩展思路

至此,我们已经确认可以将 Go 服务器添加到 dotnet aspire 中,并通过反向代理转发请求。
接下来,就可以将这一过程扩展为以编程方式实现。
例如,可以为 echo server 的服务名称添加编号以创建多个实例,并自动为反向代理添加配置。

将 aspire apphost 项目的 Program.cs 文件修改为使用反向代理和 echo server 的以下代码。

 1var reverseProxy = builder.AddProject<Projects.ReverseProxy>("gateway")
 2    .WithHttpEndpoint(port: 3000, env: "PORT", isProxied: true)
 3    .WithExternalHttpEndpoints();
 4
 5for (var i = 0; i < 8; i++)
 6{
 7    var echoServer = builder.AddGolangApp($"echo-server-{i}", "../EchoServer")
 8        .WithHttpEndpoint(env: "PORT");
 9    reverseProxy.WithReference(echoServer);
10}

然后将反向代理项目的 Program.cs 文件修改为如下内容。

 1var echoServerDestinations = new Dictionary<string, DestinationConfig>();
 2for (var i = 0; i < 8; i++)
 3{
 4    echoServerDestinations[$"destination-{i}"] = new DestinationConfig
 5    {
 6        Address = Environment.GetEnvironmentVariable($"services__echo-server-{i}__http__0") ?? "http://localhost:8080"
 7    };
 8}
 9
10var clusters = new List<ClusterConfig>
11{
12    new ClusterConfig
13    {
14        ClusterId = "cluster-echo",
15        Destinations = echoServerDestinations
16    }
17};

为扩展到 8 个的 echo server 实例添加目标配置。
现在反向代理掌握了所有新增 echo server 的目标信息,能够转发请求了。
访问原有的 http://localhost:3000/?name=world,您应该仍然可以看到输出 Hello, world。

结语

本文介绍了如何将 Go 服务器添加到 dotnet aspire 中,并通过反向代理转发请求的过程。
不过,关于扩展部分我们尚未全部撰写完毕,而是在单独的仓库中编写了可以通过环境变量以更加编程化方式实现的示例。
有关详细的项目结构和代码,请参阅 snowmerak/AspireStartPack。

我个人期待 dotnet aspire 能够作为 docker compose 的替代方案,并在云部署工具方面发挥其独特作用。
目前已经存在可以生成 docker compose 或 k8s manifest 的生成器,我认为这使得普通开发者接触基础设施工具的门槛有所降低。