.NET Core 使用 Consul 服務註冊發現

Consul是一個用來實現分佈式系統服務發現與配置的開源工具。它內置了服務註冊與發現框架、分佈一致性協議實現、健康檢查、Key/Value存儲、多數據中心方案,再也不須要依賴其餘工具,使用起來也較爲簡單。html

安裝

Consul支持各類平臺的安裝,安裝文檔:https://www.consul.io/downloads,爲了快速使用,我這裏選擇用docker方式安裝node

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便可。git

這裏使用 Docker 搭建 3個 server 節點 + 1 個 client 節點,API 服務經過 client 節點進行服務註冊和發現。github

安裝完成啓動Consul,打開默認地址 http://localhost:8500 能夠看到Consului界面。web

快速使用

添加兩個webapi服務,ServiceA和ServiceB,一個webapi客戶端Client來調用服務。docker

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組件包shell

Install-Package Consul

服務註冊

接下來在兩個服務中添加必要的代碼來實現將服務註冊到Consul中。json

首先將Consul配置信息添加到appsettings.jsonbootstrap

{
    "Consul": {
        "Address": "http://host.docker.internal:8500",
        "HealthCheck": "/healthcheck",
        "Name": "ServiceA",
        "Ip": "host.docker.internal"
    }
}

由於咱們要將項目都運行在docker中,因此這裏的地址要用 host.docker.internal 代替,使用 localhost 沒法正常啓動,若是不在 docker 中運行,這裏就配置層 localhost。api

添加一個擴展方法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);
}

注意,這裏將IConfigurationIHostApplicationLifetime做爲參數傳進來的,根據實際開發作對應的修改就能夠了。

分別在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。

大功告成,服務註冊與發現,如今就算之中的某個節點掛掉,服務也能夠照常運行。