Consul
是一個用來實現分布式系統服務發現與配置的開源工具。它內置了服務注冊與發現框架、分布一致性協議實現、健康檢查、Key/Value存儲、多數據中心方案,不再需要依賴其他工具,使用起來也較為簡單。
Consul
官網:https://www.consul.io- 開源地址:https://github.com/hashicorp/consul、https://github.com/G-Research/consuldotnet
安裝
Consul
支持各種平台的安裝,安裝文檔:https://www.consul.io/downloads,為了快速使用,我這里選擇用docker方式安裝。
version: "3"
services:
service_1:
image: consul
command: agent -server -client=0.0.0.0 -bootstrap-expect=3 -node=service_1
volumes:
- /usr/local/docker/consul/data/service_1:/data
service_2:
image: consul
command: agent -server -client=0.0.0.0 -retry-join=service_1 -node=service_2
volumes:
- /usr/local/docker/consul/data/service_2:/data
depends_on:
- service_1
service_3:
image: consul
command: agent -server -client=0.0.0.0 -retry-join=service_1 -node=service_3
volumes:
- /usr/local/docker/consul/data/service_3:/data
depends_on:
- service_1
client_1:
image: consul
command: agent -client=0.0.0.0 -retry-join=service_1 -ui -node=client_1
ports:
- 8500:8500
volumes:
- /usr/local/docker/consul/data/client_1:/data
depends_on:
- service_2
- service_3
提供一個docker-compose.yaml
,使用docker-compose up
編排腳本啟動Consul
,如果你不熟悉,可以選擇其它方式能運行Consul
即可。
這里使用 Docker 搭建 3個 server 節點 + 1 個 client 節點,API 服務通過 client 節點進行服務注冊和發現。
安裝完成啟動Consul
,打開默認地址 http://localhost:8500 可以看到Consul
ui界面。
快速使用
添加兩個webapi服務,ServiceA和ServiceB,一個webapi客戶端Client來調用服務。
dotnet new sln -n consul_demo
dotnet new webapi -n ServiceA
dotnet sln add ServiceA/ServiceA.csproj
dotnet new webapi -n ServiceB
dotnet sln add ServiceB/ServiceB.csproj
dotnet new webapi -n Client
dotnet sln add Client/Client.csproj
在項目中添加Consul
組件包
Install-Package Consul
服務注冊
接下來在兩個服務中添加必要的代碼來實現將服務注冊到Consul
中。
首先將Consul
配置信息添加到appsettings.json
{
"Consul": {
"Address": "http://host.docker.internal:8500",
"HealthCheck": "/healthcheck",
"Name": "ServiceA",
"Ip": "host.docker.internal"
}
}
因為我們要將項目都運行在docker中,所以這里的地址要用 host.docker.internal 代替,使用 localhost 無法正常啟動,如果不在 docker 中運行,這里就配置層 localhost。
添加一個擴展方法UseConul(this IApplicationBuilder app, IConfiguration configuration, IHostApplicationLifetime lifetime)
。
using System;
using Consul;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace ServiceA
{
public static class Extensions
{
public static IApplicationBuilder UseConul(this IApplicationBuilder app, IConfiguration configuration, IHostApplicationLifetime lifetime)
{
var client = new ConsulClient(options =>
{
options.Address = new Uri(configuration["Consul:Address"]); // Consul客戶端地址
});
var registration = new AgentServiceRegistration
{
ID = Guid.NewGuid().ToString(), // 唯一Id
Name = configuration["Consul:Name"], // 服務名
Address = configuration["Consul:Ip"], // 服務綁定IP
Port = Convert.ToInt32(configuration["Consul:Port"]), // 服務綁定端口
Check = new AgentServiceCheck
{
DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(5), // 服務啟動多久后注冊
Interval = TimeSpan.FromSeconds(10), // 健康檢查時間間隔
HTTP = $"http://{configuration["Consul:Ip"]}:{configuration["Consul:Port"]}{configuration["Consul:HealthCheck"]}", // 健康檢查地址
Timeout = TimeSpan.FromSeconds(5) // 超時時間
}
};
// 注冊服務
client.Agent.ServiceRegister(registration).Wait();
// 應用程序終止時,取消服務注冊
lifetime.ApplicationStopping.Register(() =>
{
client.Agent.ServiceDeregister(registration.ID).Wait();
});
return app;
}
}
}
然后在Startup.cs
中使用擴展方法即可。
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IHostApplicationLifetime lifetime)
{
...
app.UseConul(Configuration, lifetime);
}
注意,這里將IConfiguration
和IHostApplicationLifetime
作為參數傳進來的,根據實際開發做對應的修改就可以了。
分別在ServiceA和ServiceB都完成一遍上述操作,因為不是實際項目,這里就產生的許多重復代碼,在真正的項目開發過程中可以考慮放在一個單獨的項目中,ServiceA和ServiceB分別引用,調用。
接着去實現健康檢查接口。
// ServiceA
using Microsoft.AspNetCore.Mvc;
namespace ServiceA.Controllers
{
[Route("[controller]")]
[ApiController]
public class HealthCheckController : ControllerBase
{
/// <summary>
/// 健康檢查
/// </summary>
/// <returns></returns>
[HttpGet]
public IActionResult api()
{
return Ok();
}
}
}
// ServiceB
using Microsoft.AspNetCore.Mvc;
namespace ServiceB.Controllers
{
[Route("[controller]")]
[ApiController]
public class HealthCheckController : ControllerBase
{
/// <summary>
/// 健康檢查
/// </summary>
/// <returns></returns>
[HttpGet]
public IActionResult Get()
{
return Ok();
}
}
}
最后在ServiceA和ServiceB中都添加一個接口。
// ServiceA
using System;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
namespace ServiceA.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ServiceAController : ControllerBase
{
[HttpGet]
public IActionResult Get([FromServices] IConfiguration configuration)
{
var result = new
{
msg = $"我是{nameof(ServiceA)},當前時間:{DateTime.Now:G}",
ip = Request.HttpContext.Connection.LocalIpAddress.ToString(),
port = configuration["Consul:Port"]
};
return Ok(result);
}
}
}
// ServiceB
using System;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
namespace ServiceB.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ServiceBController : ControllerBase
{
[HttpGet]
public IActionResult Get([FromServices] IConfiguration configuration)
{
var result = new
{
msg = $"我是{nameof(ServiceB)},當前時間:{DateTime.Now:G}",
ip = Request.HttpContext.Connection.LocalIpAddress.ToString(),
port = configuration["Consul:Port"]
};
return Ok(result);
}
}
}
這樣我們寫了兩個服務,ServiceA和ServiceB。都添加了健康檢查接口和一個自己的服務接口,返回一段json。
我們現在來運行看看效果,可以使用任何方式,只要能啟動即可,我這里選擇在docker中運行,直接在 Visual Studio中對着兩個解決方案右鍵添加,選擇Docker支持,默認會幫我們自動創建好Dockfile,非常方便。
生成的Dockfile文件內容如下:
# ServiceA
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build
WORKDIR /src
COPY ["ServiceA/ServiceA.csproj", "ServiceA/"]
RUN dotnet restore "ServiceA/ServiceA.csproj"
COPY . .
WORKDIR "/src/ServiceA"
RUN dotnet build "ServiceA.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "ServiceA.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ServiceA.dll"]
# ServiceB
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build
WORKDIR /src
COPY ["ServiceB/ServiceB.csproj", "ServiceB/"]
RUN dotnet restore "ServiceB/ServiceB.csproj"
COPY . .
WORKDIR "/src/ServiceB"
RUN dotnet build "ServiceB.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "ServiceB.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ServiceB.dll"]
然后定位到項目根目錄,使用命令去編譯兩個鏡像,service_a和service_b
docker build -t service_a:dev -f ./ServiceA/Dockerfile .
docker build -t service_b:dev -f ./ServiceB/Dockerfile .
看到 Successfully 就成功了,通過docker image ls
可以看到我們打包的兩個鏡像。
這里順便提一句,已經可以看到我們編譯的鏡像,service_a和service_b了,但是還有許多名稱為<none>
的鏡像,這些鏡像可以不用管它,這種叫做虛懸鏡像,既沒有倉庫名,也沒有標簽。是因為docker build
導致的這種現象。由於新舊鏡像同名,舊鏡像名稱被取消,從而出現倉庫名、標簽均為 <none>
的鏡像。
一般來說,虛懸鏡像已經失去了存在的價值,是可以隨意刪除的,可以docker image prune
命令刪除,這樣鏡像列表就干凈多了。
最后將兩個鏡像service_a和service_b,分別運行三個實例。
docker run -d -p 5050:80 --name service_a1 service_a:dev --Consul:Port="5050"
docker run -d -p 5051:80 --name service_a2 service_a:dev --Consul:Port="5051"
docker run -d -p 5052:80 --name service_a3 service_a:dev --Consul:Port="5052"
docker run -d -p 5060:80 --name service_b1 service_b:dev --Consul:Port="5060"
docker run -d -p 5061:80 --name service_b2 service_b:dev --Consul:Port="5061"
docker run -d -p 5062:80 --name service_b3 service_b:dev --Consul:Port="5062"
運行成功,接下來就是見證奇跡的時刻,去到Consul
看看。
成功將兩個服務注冊到Consul
,並且每個服務都有多個實例。
訪問一下接口試試吧,看看能不能成功出現結果。
因為終端編碼問題,導致顯示亂碼,這個不影響,ok,至此服務注冊大功告成。
服務發現
搞定了服務注冊,接下來演示一下如何服務發現,在Client項目中先將Consul
地址配置到appsettings.json
中。
{
"Consul": {
"Address": "http://host.docker.internal:8500"
}
}
然后添加一個接口,IService.cs
,添加三個方法,分別獲取兩個服務的返回結果以及初始化服務的方法。
using System.Threading.Tasks;
namespace Client
{
public interface IService
{
/// <summary>
/// 獲取 ServiceA 返回數據
/// </summary>
/// <returns></returns>
Task<string> GetServiceA();
/// <summary>
/// 獲取 ServiceB 返回數據
/// </summary>
/// <returns></returns>
Task<string> GetServiceB();
/// <summary>
/// 初始化服務
/// </summary>
void InitServices();
}
}
實現類:Service.cs
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Consul;
using Microsoft.Extensions.Configuration;
namespace Client
{
public class Service : IService
{
private readonly IConfiguration _configuration;
private readonly ConsulClient _consulClient;
private ConcurrentBag<string> _serviceAUrls;
private ConcurrentBag<string> _serviceBUrls;
private IHttpClientFactory _httpClient;
public Service(IConfiguration configuration, IHttpClientFactory httpClient)
{
_configuration = configuration;
_consulClient = new ConsulClient(options =>
{
options.Address = new Uri(_configuration["Consul:Address"]);
});
_httpClient = httpClient;
}
public async Task<string> GetServiceA()
{
if (_serviceAUrls == null)
return await Task.FromResult("ServiceA正在初始化...");
using var httpClient = _httpClient.CreateClient();
var serviceUrl = _serviceAUrls.ElementAt(new Random().Next(_serviceAUrls.Count()));
Console.WriteLine("ServiceA:" + serviceUrl);
var result = await httpClient.GetStringAsync($"{serviceUrl}/api/servicea");
return result;
}
public async Task<string> GetServiceB()
{
if (_serviceBUrls == null)
return await Task.FromResult("ServiceB正在初始化...");
using var httpClient = _httpClient.CreateClient();
var serviceUrl = _serviceBUrls.ElementAt(new Random().Next(_serviceBUrls.Count()));
Console.WriteLine("ServiceB:" + serviceUrl);
var result = await httpClient.GetStringAsync($"{serviceUrl}/api/serviceb");
return result;
}
public void InitServices()
{
var serviceNames = new string[] { "ServiceA", "ServiceB" };
foreach (var item in serviceNames)
{
Task.Run(async () =>
{
var queryOptions = new QueryOptions
{
WaitTime = TimeSpan.FromMinutes(5)
};
while (true)
{
await InitServicesAsync(queryOptions, item);
}
});
}
async Task InitServicesAsync(QueryOptions queryOptions, string serviceName)
{
var result = await _consulClient.Health.Service(serviceName, null, true, queryOptions);
if (queryOptions.WaitIndex != result.LastIndex)
{
queryOptions.WaitIndex = result.LastIndex;
var services = result.Response.Select(x => $"http://{x.Service.Address}:{x.Service.Port}");
if (serviceName == "ServiceA")
{
_serviceAUrls = new ConcurrentBag<string>(services);
}
else if (serviceName == "ServiceB")
{
_serviceBUrls = new ConcurrentBag<string>(services);
}
}
}
}
}
}
代碼就不解釋了,相信都可以看懂,使用了Random
類隨機獲取一個服務,關於這點可以選擇更合適的負載均衡方式。
在Startup.cs
中添加接口依賴注入、使用初始化服務等代碼。
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Client
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddHttpClient();
services.AddSingleton<IService, Service>();
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IService service)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
service.InitServices();
}
}
}
一切就緒,添加api訪問我們的兩個服務。
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace Client.Controllers
{
[Route("api")]
[ApiController]
public class HomeController : ControllerBase
{
[HttpGet]
[Route("service_result")]
public async Task<IActionResult> GetService([FromServices] IService service)
{
return Ok(new
{
serviceA = await service.GetServiceA(),
serviceB = await service.GetServiceB()
});
}
}
}
直接在Visual Studio中運行Client項目,在瀏覽器訪問api。
大功告成,服務注冊與發現,現在就算之中的某個節點掛掉,服務也可以照常運行。