C#/.NET 微服务架构:从入门到精通(七):服务注册与发现(Consul)与 API 网关(Ocelot)

上一篇我们完成了里程碑式的突破:实现了单服务独立 Docker 容器化与 Jenkins 自动化流水线,现在我们的商品服务、订单服务已经能够一键构建、自动部署、独立运行。但此时的微服务还是一个个 “信息孤岛”—— 服务之间不知道彼此的存在,外部客户端也无法统一访问所有服务。

本篇将解决微服务架构最核心的两个基础问题:服务如何动态找到对方与外部流量如何统一治理。我们将基于已有的 Docker 部署体系,深度集成Consul 服务注册与发现和Ocelot API 网关,让所有独立部署的服务形成一个有机的整体,真正具备生产级微服务的通信能力。

一、为什么我们现在需要 Consul 和 Ocelot?

在上一篇的部署成果基础上,我们面临两个无法回避的痛点:

痛点 1:服务间通信的 “地址地狱”

现在我们的商品服务和订单服务都跑在 Docker 容器中:

  • 每次重启容器,IP 和端口都可能变化;
  • 为了高可用,每个服务部署了 3 个实例,总共有 6 个不同的地址;
  • 如果硬编码地址,每次扩缩容、重启服务都要修改所有调用方的配置并重新部署。

Consul 就是来解决这个问题的:它作为统一的服务注册中心,所有服务启动时自动上报自己的地址,调用方只需知道服务名,就能从 Consul 获取所有健康实例的地址,实现动态通信。

痛点 2:外部访问的 “混乱局面”

没有网关时:

  • 客户端需要维护 3 个商品服务地址、3 个订单服务地址,复杂度极高;
  • 每个服务都要单独实现认证、限流、跨域、日志,重复造轮子;
  • 所有服务直接暴露在公网,攻击面巨大,安全无法保障。

Ocelot 就是来解决这个问题的:它作为所有外部请求的唯一入口,统一处理认证、限流、路由等横切关注点,客户端只需知道网关一个地址,所有请求都由网关转发到对应的后端服务。

我们的最终目标架构:

客户端 → Ocelot网关 → Consul注册中心 → 健康的商品/订单服务实例
  • 所有服务自动注册到 Consul,Consul 实时监控服务健康状态;
  • Ocelot 从 Consul 动态拉取服务列表,根据请求路径转发到对应服务;
  • 整个过程完全自动化,无需任何硬编码配置。

二、实战步骤 1:改造现有服务,集成 Consul 客户端

我们不需要修改任何业务代码,只需为已有的商品服务和订单服务添加 Consul 客户端集成,让它们启动时自动注册到 Consul。我们这里以商品服务为例:

1. 安装 Consul NuGet 包

image

 在ProductApi中执行:

dotnet add package Consul --version 1.8.0
dotnet add package Consul.AspNetCore --version 1.8.0

2. 改造服务的 Program.cs

using Asp.Versioning;
using Asp.Versioning.ApiExplorer;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
using ProductApi.DbContexts;
using System.Reflection;
using Consul;
using Consul.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

#region 版本控制

// 注册接口版本控制
builder.Services.AddApiVersioning(options =>
{
    options.DefaultApiVersion = new ApiVersion(1, 0); // 默认版本v1.0
    options.AssumeDefaultVersionWhenUnspecified = true; // 未指定版本时使用默认版本
    options.ReportApiVersions = true; // 在响应头中返回支持的版本
    // 配置版本读取方式(支持多种方式,这里演示URL路径方式,最直观)
    options.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader() // URL路径版本:/api/v1/User
                                         // 也可添加查询字符串方式:/api/User?api-version=1.0
                                         // new QueryStringApiVersionReader("api-version"),
                                         // 或请求头方式:Header中添加Api-Version: 1.0
                                         // new HeaderApiVersionReader("Api-Version")
    );
})
//注册版本探索器(与Swagger集成必需)
.AddMvc()
.AddApiExplorer(options =>
{
    options.GroupNameFormat = "'v'VVV"; // 版本组名格式:v1、v1.1
    options.SubstituteApiVersionInUrl = true; // 替换URL中的版本占位符
});

#endregion

builder.Services.AddEndpointsApiExplorer();

#region Swagger集成

//注册Swagger生成器
builder.Services.AddSwaggerGen(c =>
{// 动态获取所有版本信息,生成对应的SwaggerDoc
    using var scope = builder.Services.BuildServiceProvider().CreateScope();
    var provider = scope.ServiceProvider.GetRequiredService<IApiVersionDescriptionProvider>();
    foreach (var description in provider.ApiVersionDescriptions)
    {
        c.SwaggerDoc(description.GroupName, new OpenApiInfo
        {
            Title = $"商品 API {description.GroupName}",
            Version = description.GroupName,
            Description = description.IsDeprecated ? "该版本已废弃" : "当前稳定版本",
            Contact = new OpenApiContact
            {
                Name = "挺秃然的i",
                Url = new Uri("https://www.cnblogs.com/lantingxu")
            }
        });
    }

    // 引入控制器XML注释
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
    c.IncludeXmlComments(xmlPath, true); // true表示包含控制器注释

    // 启用JWT授权
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Description = "请输入JWT Token(格式:Bearer {Token})",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer"
    });
    // 全局应用JWT认证要求
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            new string[] { }
        }
    });
});

#endregion

#region 注册EF Core DbContext

//注册EF Core DbContext(连接MySQL)
var connectionString = builder.Configuration.GetConnectionString("ProductDb");
builder.Services.AddDbContext<ProductDbContext>(options =>
{
    options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString),
        mysqlOpt => mysqlOpt.MigrationsAssembly(typeof(ProductDbContext).Assembly.FullName));
});

#endregion

#region Consul

builder.Services.AddConsul(config =>
{
    config.Address = new Uri(builder.Configuration["Consul:ConsulAddress"]);
});

#endregion


var app = builder.Build();

//配置Swagger中间件(仅在开发环境启用,生产环境可关闭)
if (app.Environment.IsDevelopment())
{
    app.UseSwagger(); // 生成Swagger JSON端点(/swagger/v1/swagger.json)
    app.UseSwaggerUI(c =>
    {
        //c.SwaggerEndpoint("/swagger/v1/swagger.json", "商品 API v1");

        // 动态获取所有版本,配置Swagger端点
        var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
        foreach (var description in provider.ApiVersionDescriptions)
        {
            c.SwaggerEndpoint(
                $"/swagger/{description.GroupName}/swagger.json",
                $"商品 API {description.GroupName}"
            );
        }
        c.RoutePrefix = "swagger"; // 将Swagger UI设为swagger路径(访问http://localhost:端口/swagger即可打开)
    });
}

app.UseAuthorization();

app.MapControllers();

// 1. 从配置读取固定值
var config = builder.Configuration;
var serviceHost = config["Consul:ServiceHost"];
var servicePort = int.Parse(config["Consul:ServicePort"]);

// 2. 注册到Consul(必须在 app.Run() 之前)
using (var scope = app.Services.CreateScope())
{
    var consulClient = scope.ServiceProvider.GetRequiredService<IConsulClient>();

    try
    {
        var registration = new AgentServiceRegistration
        {
            Name = config["Consul:ServiceName"],
            ID = $"{config["Consul:ServiceName"]}-{Guid.NewGuid()}",
            Address = serviceHost,  // 直接用配置的容器名
            Port = servicePort,    // 直接用配置的80端口
            Check = new AgentServiceCheck
            {
                HTTP = $"http://{serviceHost}:{servicePort}/health",  // 健康检查地址也要用容器名
                Interval = TimeSpan.FromSeconds(10),
                Timeout = TimeSpan.FromSeconds(5),
                DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(30)
            }
        };

        await consulClient.Agent.ServiceRegister(registration);
        Console.WriteLine($"✅ 成功注册到Consul,服务名:{registration.Name},地址:{serviceHost}:{servicePort}");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"❌ 注册失败:{ex.Message}");
    }
}

app.Run();

3. 修改服务的 appsettings.json

image

"Consul": {
  "ConsulAddress": "http://consul:8500", // Docker网络内通过服务名访问Consul
  "ServiceName": "Product-Api",
  "ServiceHost": "productapi",
  "ServicePort": "80"
}

三、实战步骤 2:Docker 安装Consul

开发模式(仅测试用):

# 拉取最新官方Consul镜像
docker pull hashicorp/consul:latest

# 启动开发模式Consul
docker run -d \
  --name consul-dev \
  -p 8500:8500 \
  -p 8600:8600/udp \
  hashicorp/consul:latest agent -dev -client=0.0.0.0
# 验证安装
docker ps | grep consul

开发模式所有数据都存储在内存中,容器重启后数据会全部丢失,绝对不能用于生产环境。

验证 Consul:

访问http://你的服务器IP:8500,打开 Consul Web UI,此时还没有任何服务注册。

image

 四、实战步骤 3:搭建 Ocelot API 网关

1. 创建网关项目

image

 2. 安装 NuGet 包

dotnet add package Ocelot --version 22.0.1
dotnet add package Ocelot.Provider.Consul --version 22.0.1

image

 3. 编写 Ocelot 配置文件 ocelot.json

{
  "Routes": [
    // 商品服务路由
    {
      "DownstreamPathTemplate": "/api/{version}/Product/{everything}",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/api/{version}/product/{everything}",
      "UpstreamHttpMethod": [ "Get", "Post", "Put", "Delete" ],
      "ServiceName": "Product-Api", // 与Consul中的服务名完全一致
      "LoadBalancerOptions": {
        "Type": "RoundRobin" // 轮询负载均衡
      }
    },
    // 订单服务路由
    {
      "DownstreamPathTemplate": "/api/{version}/Order/{everything}",
      "DownstreamScheme": "http",
      "UpstreamPathTemplate": "/api/{version}/order/{everything}",
      "UpstreamHttpMethod": [ "Get", "Post", "Put", "Delete" ],
      "ServiceName": "Order-Api",
      "LoadBalancerOptions": {
        "Type": "RoundRobin"
      }
    }
  ],
  "GlobalConfiguration": {
    "ServiceDiscoveryProvider": {
      "Host": "consul", // 必须是Consul容器名,不能是IP
      "Port": 8500,
      "Type": "Consul", // 必须大写C,不能写错
      "PollingInterval": 1000 // 可选:每秒从Consul拉取一次服务列表,避免缓存
    }
  }
}

4. 编写网关的 Dockerfile

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80

# 设置中文编码
ENV LANG=zh_CN.UTF-8
ENV LC_ALL=zh_CN.UTF-8

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

COPY ["Gateway/Gateway.csproj", "Gateway/"]

RUN dotnet restore "Gateway/Gateway.csproj"

COPY . .

# 构建
WORKDIR "/src/Gateway"
RUN dotnet build "Gateway.csproj" -c Release -o /app/build

# 发布
FROM build AS publish
RUN dotnet publish "Gateway.csproj" -c Release -o /app/publish /p:UseAppHost=false

# 最终运行
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Gateway.dll"]

5. 编写网关的 Jenkinsfile

pipeline {
    agent any
    
    options {
        timeout(time: 30, unit: 'MINUTES')
        disableResume() // 符合你的禁用Resume要求
        skipStagesAfterUnstable()
    }

    environment {
        // 基础配置
        GIT_URL = 'https://gitee.com/kb248/e-commerce-demo.git'
        GIT_CRED_ID = 'gitee-auth'
        GIT_BRANCH = '*/master'
        
        SERVICE_NAME = 'gateway'
        DOCKERFILE_PATH = 'Gateway/Dockerfile'
        HOST_PORT = '5000'
        CONTAINER_PORT = '80'
        DOCKER_IMAGE_TAG = "${SERVICE_NAME}:${BUILD_NUMBER}"
    }

    stages {
        stage('1. 拉取全量代码') {
            steps {
                echo "正在拉取代码..."
                checkout scmGit(
                    branches: [[name: GIT_BRANCH]], 
                    extensions: [[$class: 'CleanBeforeCheckout']], // 构建前清理工作空间
                    userRemoteConfigs: [[
                        credentialsId: GIT_CRED_ID, 
                        url: GIT_URL
                    ]]
                )
            }
        }

        stage('2. 验证 Dockerfile') {
            steps {
                script {
                    if (!fileExists(DOCKERFILE_PATH)) {
                        error "❌ 未找到 Dockerfile: ${DOCKERFILE_PATH},请在Gateway目录下创建Dockerfile"
                    }
                    echo "✅ Dockerfile 验证通过"
                }
            }
        }

        stage('3. 构建 Docker 镜像') {
            steps {
                echo "构建镜像: ${DOCKER_IMAGE_TAG}"
                sh "docker build -t ${DOCKER_IMAGE_TAG} -f ${DOCKERFILE_PATH} ."
                echo "✅ 镜像构建完成"
            }
        }

        stage('4. 清理旧容器和镜像') {
            steps {
                echo "清理旧容器和悬空镜像..."
                sh """
                docker stop ${SERVICE_NAME} || true
                docker rm ${SERVICE_NAME} || true
                docker image prune -f
                """
            }
        }

        stage('5. 启动新容器') {
            steps {
                echo "启动容器: ${SERVICE_NAME}"
                sh "docker run -d --network dorm-network -p ${HOST_PORT}:${CONTAINER_PORT} -e ASPNETCORE_ENVIRONMENT=Development -e ASPNETCORE_URLS=http://0.0.0.0:${CONTAINER_PORT} -e LANG=zh_CN.UTF-8 -e LC_ALL=zh_CN.UTF-8 --name ${SERVICE_NAME} ${DOCKER_IMAGE_TAG}"
                
                // 等待容器启动并验证健康状态
                sleep 10
                sh "docker ps | grep ${SERVICE_NAME}"
                echo "✅ 容器启动成功"
            }
        }
    }

    post {
        always {
            cleanWs() // 构建完成后清理工作空间
            echo "工作空间清理完成"
        }
        success {
            script {
                def containerId = sh(
                    script: "docker ps -qf name=${SERVICE_NAME}",
                    returnStdout: true
                ).trim()
                
                echo "========================================="
                echo "✅ 产品服务发布成功!"
                echo "📖 访问地址: http://你的服务器IP:${HOST_PORT}/swagger"
                echo "🐳 容器ID: ${containerId}"
                echo "========================================="
            }
        }
        failure {
            echo "❌ 构建失败!请查看详细日志"
            // 输出容器日志帮助排查(容器可能已停止,使用docker logs -f 会卡住,去掉-f)
            sh "docker logs ${SERVICE_NAME} 2>&1 || true"
        }
    }
}

6.调整Program文件

using Ocelot.DependencyInjection;
using Ocelot.Middleware;
using Ocelot.Provider.Consul; // 必须引入这个命名空间

var builder = WebApplication.CreateBuilder(args);

// 1. 【必须】加载ocelot.json配置文件,不能漏
builder.Configuration.AddJsonFile(
    "ocelot.json",
    optional: false, // 必须设为false,配置文件不存在会直接报错,方便排查
    reloadOnChange: true
);

// 2. 【致命关键】必须同时注入 Ocelot + Consul 扩展
// 只写 AddOcelot() 不写 AddConsul(),Ocelot不会从Consul拿服务,必然502
builder.Services
    .AddOcelot(builder.Configuration) // 必须传入Configuration
    .AddConsul(); // 这一行绝对不能漏!

var app = builder.Build();

// 3. 【必须】启用Ocelot中间件,必须是最后一个中间件
await app.UseOcelot();

app.Run();

7. 部署网关

提交代码到 Git,触发 Jenkins 流水线,自动部署网关到服务器。

五、实战步骤 4:全链路验证

现在我们已经完成了所有组件的部署,来验证整个微服务体系是否正常工作。

 1. 验证服务注册

访问 Consul Web UI http://你的服务器IP:8500,可以看到:

image

 2. 验证网关路由

通过网关访问商品服务:

image

 返回商品列表,说明网关成功将请求转发到商品服务。

 通过网关访问订单服务:

image

 返回订单列表,说明网关成功将请求转发到订单服务。

六、常见问题解决

1. 服务注册成功但健康检查失败

  • 检查SERVICE_HOST_IP是否为宿主机的公网 / 内网 IP,不是容器内部 IP;
  • 检查宿主机防火墙是否开放了服务的端口,Consul 能访问到健康检查端点;
  • 检查健康检查路径是否正确,服务是否能正常返回 200 状态码。

2. Ocelot 无法连接 Consul

  • 确认 Ocelot 和 Consul 在同一个 Docker 网络中;
  • 检查ocelot.jsonServiceDiscoveryProviderHost是否为consul(Docker 服务名);
  • 检查 Consul 容器是否正常运行,端口 8500 是否开放。

3. Ocelot 转发请求 404

  • 检查UpstreamPathTemplateDownstreamPathTemplate的匹配规则;
  • 确认 Consul 中的服务名与ocelot.json中的ServiceName完全一致(大小写敏感);
  • 确认下游服务的接口路径正确,能直接访问。

七、总结

本篇我们基于上一篇的 Docker+Jenkins 部署体系,完成了Consul 服务注册与发现和Ocelot API 网关的深度集成,实现了微服务架构的核心通信能力:
  1. 改造了已有的商品服务和订单服务,实现了自动注册到 Consul 和优雅注销;
  2. 用 Docker 部署了 Consul 服务端,实现了服务的健康检查和故障自动剔除;
  3. 搭建了独立的 Ocelot API 网关,实现了动态路由、负载均衡和统一认证;
  4. 所有组件都通过 Jenkins 自动部署,保持了整个 CI/CD 流程的一致性。
现在我们已经拥有了一个完整的、生产级的微服务基础架构:独立部署的服务、自动化的 CI/CD 流水线、动态的服务发现、统一的流量入口。
posted @ 2026-05-25 08:57  挺秃然的i  阅读(27)  评论(0)    收藏  举报