使用ASP.NET Core、Ocelot、MongoDB和JWT的微服务

104 篇文章 7 订阅
68 篇文章 5 订阅

目录

介绍

开发环境

技术

体系结构

源代码

微服务

API网关

客户端应用

单元测试

使用健康检查进行监视

如何运行应用程序

如何部署应用程序

进一步阅读


本文显示了一个使用ASP.NET CoreOcelotMongoDBJWT的微服务体系结构的工作示例。本文介绍如何使用ASP.NET Core创建微服务,如何使用Ocelot创建API网关,如何使用MongoDB创建存储库,如何在微服务中处理JWT,如何使用xUnitMoq对单元进行微服务测试,如何使用健康检查来监视微服务,最后是如何在Linux发行版上使用Docker容器部署微服务。

介绍

微服务架构由一组小型、独立且松散耦合的服务组成。每个服务都是独立的,实现单个业务功能,负责持久保存其自己的数据,是一个单独的代码库,并且可以独立部署。

API网关是客户端的入口点。客户端不是直接调用服务,而是调用API网关,该网关将调用转发到适当的服务。

使用微服务架构有多个优点:

  • 开发人员可以更好地了解服务的功能。
  • 一种服务的故障不会影响其他服务。
  • 管理错误修复和功能发布更加容易。
  • 可以将服务部署在多台服务器中以提高性能。
  • 服务易于更改和测试。
  • 服务易于部署。
  • 允许选择适合特定功能的技术。

在选择微服务架构之前,需要考虑以下挑战:

  • 服务很简单,但是整个系统整体来说更复杂。
  • 服务之间的通信可能很复杂。
  • 更多服务等于更多资源。
  • 全局测试可能很困难。
  • 调试可能会更困难。

微服务架构对于大型公司而言非常有用,但对于需要快速创建和迭代且不想参与复杂编排的小型公司而言,它可能会变得复杂。

本文显示了一个使用ASP.NET CoreOcelotMongoDBJWT的微服务体系结构的工作示例。

本文介绍如何使用ASP.NET Core创建微服务,如何使用Ocelot创建API网关,如何使用MongoDB创建存储库,如何在微服务中处理JWT,如何使用xUnitMoq对单元进行微服务测试,如何使用健康检查来监视微服务,最后是如何在Linux发行版上使用Docker容器部署微服务。

微服务和网关是使用ASP.NET CoreC#开发的。为了简单起见,客户端应用程序是使用HTMLJavaScript开发的。

开发环境

  • Visual Studio 2019
  • .NET Core 3.1
  • MongoDB
  • Postman

技术

  • C#
  • ASP.NET Core
  • Ocelot
  • Swashbuckle
  • Serilog
  • JWT
  • MongoDB
  • xUnit
  • Moq
  • HTML
  • CSS
  • JavaScript

体系结构

有三种微服务:

  • Catalog微服务:允许管理目录。
  • Cart微服务:允许管理购物车。
  • Identity微服务:允许管理用户。

每个微服务都实现单个业务功能,并拥有自己的MongoDB数据库。

有两个API网关,一个用于前端,一个用于后端。

以下是前端API网关:

  • GET /catalog:检索目录项。
  • GET /catalog/{id}:检索目录项。
  • GET /cart:检索购物车项目。
  • POST /cart:添加购物车项目。
  • PUT /cart:更新购物车项目。
  • DELETE /cart:删除购物车项目。
  • POST /identity/login:执行登录。
  • POST /identity/register:注册用户。
  • GET /identity/validate:验证JWT令牌。

以下是后端API网关:

  • GET /catalog:检索目录项。
  • GET /catalog/{id}:检索目录项。
  • POST /catalog:创建目录项。
  • PUT /catalog:更新目录项。
  • DELETE /catalog/{id}:删除目录项。
  • POST /identity /login:执行登录。
  • GET /identity/validate:验证JWT令牌。

最后,有两个客户端应用程序。用于访问商店的前端和用于管理商店的后端。

前端允许注册用户查看可用的目录项,允许将目录项添加到购物车,并允许从购物车中删除目录项。

这是前端商店页面的屏幕截图:

后端允许管理员用户查看可用的目录项,允许添加新的目录项,允许更新目录项,并允许删除目录项。

这是后端商店页面的屏幕截图:

源代码

  • CatalogMicroservice 项目包含管理目录的微服务的源代码。
  • CartMicroservice 项目包含管理购物车的微服务的源代码。
  • IdentityMicroservice 项目包含管理用户的微服务的源代码。
  • Middleware 项目包含微服务使用的通用功能的源代码。
  • FrontendGateway 项目包含前端API网关的源代码。
  • BackendGateway 项目包含后端API网关的源代码。
  • Frontend 项目包含前端客户端应用程序的源代码。
  • Backend 项目包含后端客户端应用程序的源代码。
  • test 解决方案文件夹包含所有微服务的单元测试。

您还可以在GitHub找到源代码。

微服务

让我们从最简单的微服务CatalogMicroservice开始。

CatalogMicroservice 负责管理目录。

以下是CatalogMicroservice使用的模型:

public class CatalogItem
{
    public static readonly string DocumentName = "catalogItems";

    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal Price { get; set; }
}

下面是存储库接口:

public interface ICatalogRepository
{
    List<CatalogItem> GetCatalogItems();
    CatalogItem GetCatalogItem(Guid catalogItemId);
    void InsertCatalogItem(CatalogItem catalogItem);
    void UpdateCatalogItem(CatalogItem catalogItem);
    void DeleteCatalogItem(Guid catalogItemId);
}

以下是存储库:

public class CatalogRepository : ICatalogRepository
{
    private readonly IMongoCollection<CatalogItem> _col;

    public CatalogRepository(IMongoDatabase db)
    {
        _col = db.GetCollection<CatalogItem>(CatalogItem.DocumentName);
    }

    public List<CatalogItem> GetCatalogItems() =>
        _col.Find(FilterDefinition<CatalogItem>.Empty).ToList();

    public CatalogItem GetCatalogItem(Guid catalogItemId) =>
        _col.Find(c => c.Id == catalogItemId).FirstOrDefault();

    public void InsertCatalogItem(CatalogItem catalogItem) =>
        _col.InsertOne(catalogItem);

    public void UpdateCatalogItem(CatalogItem catalogItem) =>
        _col.UpdateOne(c => c.Id == catalogItem.Id, Builders<CatalogItem>.Update
            .Set(c => c.Name, catalogItem.Name)
            .Set(c => c.Description, catalogItem.Description)
            .Set(c => c.Price, catalogItem.Price));

    public void DeleteCatalogItem(Guid catalogItemId) =>
        _col.DeleteOne(c => c.Id == catalogItemId);
}

下面是控制器:

[Route("api/[controller]")]
[ApiController]
public class CatalogController : ControllerBase
{
    private readonly ICatalogRepository _catalogRepository;

    public CatalogController(ICatalogRepository catalogRepository)
    {
        _catalogRepository = catalogRepository;
    }

    // GET: api/<CatalogController>
    [HttpGet]
    public ActionResult<IEnumerable<CatalogItem>> Get()
    {
        var catalogItems = _catalogRepository.GetCatalogItems();
        return Ok(catalogItems);
    }

    // GET api/<CatalogController>/110ec627-2f05-4a7e-9a95-7a91e8005da8
    [HttpGet("{id}")]
    public ActionResult<CatalogItem> Get(Guid id)
    {
        var catalogItem = _catalogRepository.GetCatalogItem(id);
        return Ok(catalogItem);
    }

    // POST api/<CatalogController>
    [HttpPost]
    public ActionResult Post([FromBody] CatalogItem catalogItem)
    {
        _catalogRepository.InsertCatalogItem(catalogItem);
        return CreatedAtAction(nameof(Get), new { id = catalogItem.Id }, catalogItem);
    }

    // PUT api/<CatalogController>
    [HttpPut]
    public ActionResult Put([FromBody] CatalogItem catalogItem)
    {
        if (catalogItem != null)
        {
            _catalogRepository.UpdateCatalogItem(catalogItem);
            return new OkResult();
        }
        return new NoContentResult();
    }

    // DELETE api/<CatalogController>/110ec627-2f05-4a7e-9a95-7a91e8005da8
    [HttpDelete("{id}")]
    public ActionResult Delete(Guid id)
    {
        _catalogRepository.DeleteCatalogItem(id);
        return new OkResult();
    }
}

ICatalogRepository是使用Startup.cs中的依赖项注入添加的:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddMongoDb(Configuration);
    services.AddSingleton<ICatalogRepository>(sp => new CatalogRepository(sp.GetService<IMongoDatabase>()));
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "Catalog", Version = "v1" });
    });
}

下面是AddMongoDB扩展方法:

public static void AddMongoDb(this IServiceCollection services, IConfiguration configuration)
{
    services.Configure<MongoOptions>(configuration.GetSection("mongo"));
    services.AddSingleton(sp =>
    {
        var options = sp.GetService<IOptions<MongoOptions>>();

        return new MongoClient(options.Value.ConnectionString);
    });
    services.AddSingleton(sp =>
    {
        var options = sp.GetService<IOptions<MongoOptions>>();
        var client = sp.GetService<MongoClient>();

        return client.GetDatabase(options.Value.Database);
    });
}

下面是Startup.cs中的Configure方法:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseSwagger();

    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Catalog V1");
    });

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

以下是appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "mongo": {
    "connectionString": "mongodb://localhost:27017",
    "database": "catalog"
  }
}

现在,让我们测试一下CatalogMicroservice

打开Postman并使用以下有效负载执行以下POST请求http://localhost:44326/api/catalog 以创建新的目录项:

{
  "name": "Samsung Galaxy S10",
  "description": "Samsung Galaxy S10 mobile phone",
  "price": 1000
}

然后,执行以下GET请求http://localhost:44326/api/catalog 以检索目录:

我们可以看到CatalogMicroservice效果很好。PUTDELETE可以用相同的方式测试请求。

API文档是使用Swashbuckle生成的。Swagger中间件在Startup.cs中配置,在Startup.cs中的ConfigureServicesConfigure方法中配置

如果CatalogMicroservice使用IISExpressDocker 运行项目,则将获得Swagger UI

CartMicroservice的实现方式与CatalogMicroservice非常相似

现在,让我们看看IdentityMicroservice

IdentityMicroservice 负责管理用户。

以下是使用IdentityMicroservice的模型:

public class User
{
    public static readonly string DocumentName = "users";

    public Guid Id { get; set; }
    public string Email { get; set; }
    public string Password { get; set; }
    public string Salt { get; set; }
    public bool IsAdmin { get; set; }

    public void SetPassword(string password, IEncryptor encryptor)
    {
        Salt = encryptor.GetSalt(password);
        Password = encryptor.GetHash(password, Salt);
    }

    public bool ValidatePassword(string password, IEncryptor encryptor)
    {
        var isValid = Password.Equals(encryptor.GetHash(password, Salt));
        return isValid;
    }
}

IEncryptor 用于加密密码。

下面是存储库接口:

public interface IUserRepository
{
    User GetUser(string email);
    void InsertUser(User user);
}

以下是存储库:

public class UserRepository : IUserRepository
{
    private readonly IMongoCollection<User> _col;

    public UserRepository(IMongoDatabase db)
    {
        _col = db.GetCollection<User>(User.DocumentName);
    }

    public User GetUser(string email) =>
        _col.Find(u => u.Email == email).FirstOrDefault();

    public void InsertUser(User user) =>
        _col.InsertOne(user);
}

下面是控制器:

[Route("api/[controller]")]
[ApiController]
public class IdentityController : ControllerBase
{
    private readonly IUserRepository _userRepository;
    private readonly IJwtBuilder _jwtBuilder;
    private readonly IEncryptor _encryptor;

    public IdentityController(IUserRepository userRepository, IJwtBuilder jwtBuilder, IEncryptor encryptor)
    {
        _userRepository = userRepository;
        _jwtBuilder = jwtBuilder;
        _encryptor = encryptor;
    }

    [HttpPost("login")]
    public ActionResult<string> Login([FromBody] User user, [FromQuery(Name = "d")] string destination = "frontend")
    {
        var u = _userRepository.GetUser(user.Email);

        if (u == null)
        {
            return NotFound("User not found.");
        }

        if (destination == "backend" && !u.IsAdmin)
        {
            return BadRequest("Could not authenticate user.");
        }

        var isValid = u.ValidatePassword(user.Password, _encryptor);

        if (!isValid)
        {
            return BadRequest("Could not authenticate user.");
        }

        var token = _jwtBuilder.GetToken(u.Id);

        return Ok(token);
    }

    [HttpPost("register")]
    public ActionResult Register([FromBody] User user)
    {
        var u = _userRepository.GetUser(user.Email);

        if (u != null)
        {
            return BadRequest("User already exists.");
        }

        user.SetPassword(user.Password, _encryptor);
        _userRepository.InsertUser(user);

        return Ok();
    }

    [HttpGet("validate")]
    public ActionResult<Guid> Validate([FromQuery(Name = "email")] string email, [FromQuery(Name = "token")] string token)
    {
        var u = _userRepository.GetUser(email);

        if (u == null)
        {
            return NotFound("User not found.");
        }

        var userId = _jwtBuilder.ValidateToken(token);

        if (userId != u.Id)
        {
            return BadRequest("Invalid token.");
        }

        return Ok(userId);
    }
}

IUserRepositoryIJwtBuilderIEncryptor使用Startup.cs中的依赖项注入添加:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddMongoDb(Configuration);
    services.AddSingleton<IUserRepository>(sp => new UserRepository(sp.GetService<IMongoDatabase>()));
    services.AddJwt(Configuration);
    services.AddTransient<IEncryptor, Encryptor>();
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "Identity", Version = "v1" });
    });
}

下面是Startup.cs中的Configure方法:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseSwagger();

    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "Identity V1");
    });

    app.UseRouting();

    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

下面是AddJwt扩展方法:

public static void AddJwt(this IServiceCollection services, IConfiguration configuration)
{
    var options = new JwtOptions();
    var section = configuration.GetSection("jwt");
    section.Bind(options);
    services.Configure<JwtOptions>(section);
    services.AddSingleton<IJwtBuilder, JwtBuilder>();
    services.AddAuthentication()
    .AddJwtBearer(cfg =>
    {
        cfg.RequireHttpsMetadata = false;
        cfg.SaveToken = true;
        cfg.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = false,
            IssuerSigningKey = 
               new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Secret))
        };
    });
}

IJwtBuilder 负责创建JWT令牌并对其进行验证:

public interface IJwtBuilder
{
    string GetToken(Guid userId);
    Guid ValidateToken(string token);
}

下面是IJwtBuilder实现:

public class JwtBuilder : IJwtBuilder
{
    private readonly JwtOptions _options;

    public JwtBuilder(IOptions<JwtOptions> options)
    {
        _options = options.Value;
    }

    public string GetToken(Guid userId)
    {
        var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret));
        var signingCredentials = 
            new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
        var claims = new Claim[]
        {
            new Claim("userId", userId.ToString()),
        };
        var expirationDate = DateTime.Now.AddMinutes(_options.ExpiryMinutes);
        var jwt = new JwtSecurityToken
          (claims: claims, signingCredentials: signingCredentials, expires: expirationDate);
        var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt);

        return encodedJwt;
    }

    public Guid ValidateToken(string token)
    {
        var principal = GetPrincipal(token);
        if (principal == null)
        {
            return Guid.Empty;
        }

        ClaimsIdentity identity;
        try
        {
            identity = (ClaimsIdentity)principal.Identity;
        }
        catch (NullReferenceException)
        {
            return Guid.Empty;
        }
        var userIdClaim = identity.FindFirst("userId");
        var userId = new Guid(userIdClaim.Value);
        return userId;
    }

    private ClaimsPrincipal GetPrincipal(string token)
    {
        try
        {
            var tokenHandler = new JwtSecurityTokenHandler();
            var jwtToken = (JwtSecurityToken)tokenHandler.ReadToken(token);
            if (jwtToken == null)
            {
                return null;
            }
            var key = Encoding.UTF8.GetBytes(_options.Secret);
            var parameters = new TokenValidationParameters()
            {
                RequireExpirationTime = true,
                ValidateIssuer = false,
                ValidateAudience = false,
                IssuerSigningKey = new SymmetricSecurityKey(key)
            };
            IdentityModelEventSource.ShowPII = true;
            SecurityToken securityToken;
            ClaimsPrincipal principal = tokenHandler.ValidateToken(token,
                    parameters, out securityToken);
            return principal;
        }
        catch (Exception)
        {
            return null;
        }
    }
}

IEncryptor 只是负责加密密码:

public interface IEncryptor
{
    string GetSalt(string value);
    string GetHash(string value, string salt);
}

下面是IEncryptor实现:

public class Encryptor: IEncryptor
{
    private static readonly int saltSize = 40;
    private static readonly int iterationsCount = 10000;

    public string GetSalt(string value)
    {
        var saltBytes = new byte[saltSize];
        var rng = RandomNumberGenerator.Create();
        rng.GetBytes(saltBytes);

        return Convert.ToBase64String(saltBytes);
    }

    public string GetHash(string value, string salt)
    {
        var pbkdf2 = new Rfc2898DeriveBytes(value, GetBytes(salt), iterationsCount);

        return Convert.ToBase64String(pbkdf2.GetBytes(saltSize));
    }

    private static byte[] GetBytes(string value)
    {
        var bytes = new byte[value.Length * sizeof(char)];
        Buffer.BlockCopy(value.ToCharArray(), 0, bytes, 0, bytes.Length);

        return bytes;
    }
}

以下是appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "mongo": {
    "connectionString": "mongodb://localhost:27017",
    "database": "identity"
  },
  "jwt": {
    "secret": "9095a623-a23a-481a-aa0c-e0ad96edc103",
    "expiryMinutes": 60
  }
}

现在,让我们测试一下IdentityMicroservice

打开Postman并使用以下有效负载执行以下POST请求http://localhost:44397/api/identity/register来注册用户:

{
  "email": "user@store.com",
  "password": "pass"
}

现在,使用以下有效负载执行以下POST请求http://localhost:44397/api/identity/login以创建JWT令牌:

{
  "email": "user@store.com",
  "password": "pass"
}

然后,您可以在jwt.io上检查生成的令牌:

就是这样。您可以以验证JWT令牌的相同方式执行以下GET请求http://localhost:44397/api/identity/validate?email={email}&token={token}。如果令牌有效,则响应将是作为Guid的用户ID

如果IdentityMicroservice使用IISExpressDocker 运行项目,则将获得Swagger UI

API网关

有两个API网关,一个用于前端,一个用于后端。

让我们从前端开始。

program.cs中添加了configuration.json配置文件,如下所示:

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
    .ConfigureAppConfiguration((hostingContext, config) =>
    {
        config
            .SetBasePath(hostingContext.HostingEnvironment.ContentRootPath)
            .AddJsonFile("appsettings.json", true, true)
            .AddJsonFile($"appsettings.{hostingContext.HostingEnvironment.EnvironmentName}.json", true, true)
            .AddJsonFile($"configuration.{hostingContext.HostingEnvironment.EnvironmentName}.json", optional: false, reloadOnChange: true)
            .AddEnvironmentVariables();
    })
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder
        .UseSerilog((_, config) =>
        {
            config
                .MinimumLevel.Information()
                .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
                .Enrich.FromLogContext()
                .WriteTo.Console();
        })
        .UseStartup<Startup>();
    });

Serilog配置为将日志写入控制台。您当然可以使用WriteTo.File(@"Logs\store.log")Serilog.Sinks.File nuget包将日志写入文本文件。

然后,这里是Startup.cs

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    // This method gets called by the runtime. Use this method to add services to the container.
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
        services.AddOcelot(Configuration);

        var jwtSection = Configuration.GetSection("jwt");
        var jwtOptions = jwtSection.Get<JwtOptions>();
        var key = Encoding.UTF8.GetBytes(jwtOptions.Secret);

        services.AddAuthentication(x =>
        {
            x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
            x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
        })
        .AddJwtBearer(x =>
        {
            x.RequireHttpsMetadata = false;
            x.SaveToken = true;
            x.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(key),
                ValidateIssuer = false,
                ValidateAudience = false
            };
        });

        services.AddCors(options =>
        {
            options.AddPolicy("CorsPolicy",
                builder => builder.AllowAnyOrigin()
                    .AllowAnyMethod()
                    .AllowAnyHeader());
        });
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public async void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        app.UseMiddleware<RequestResponseLoggingMiddleware>();

        app.UseCors("CorsPolicy");

        app.UseAuthentication();
        await app.UseOcelot();
    }
}

这是RequestResponseLoggingMiddleware.cs

public class RequestResponseLoggingMiddleware
{
    private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
    private readonly RequestDelegate _next;

    public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger<RequestResponseLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        context.Request.EnableBuffering();
        var builder = new StringBuilder();
        var request = await FormatRequest(context.Request);
        builder.Append("Request: ").AppendLine(request);
        builder.AppendLine("Request headers:");

        foreach (var header in context.Request.Headers)
        {
            builder.Append(header.Key).Append(": ").AppendLine(header.Value);
        }

        var originalBodyStream = context.Response.Body;
        using var responseBody = new MemoryStream();
        context.Response.Body = responseBody;
        await _next(context);

        var response = await FormatResponse(context.Response);
        builder.Append("Response: ").AppendLine(response);
        builder.AppendLine("Response headers: ");

        foreach (var header in context.Response.Headers)
        {
            builder.Append(header.Key).Append(": ").AppendLine(header.Value);
        }

        _logger.LogInformation(builder.ToString());

        await responseBody.CopyToAsync(originalBodyStream);
    }

    private async Task<string> FormatRequest(HttpRequest request)
    {
        using var reader = new StreamReader(
            request.Body,
            encoding: Encoding.UTF8,
            detectEncodingFromByteOrderMarks: false,
            leaveOpen: true);
        var body = await reader.ReadToEndAsync();
        var formattedRequest = $"{request.Method} {request.Scheme}://{request.Host}{request.Path}{request.QueryString} {body}";
        request.Body.Position = 0;
        return formattedRequest;
    }

    private async Task<string> FormatResponse(HttpResponse response)
    {
        response.Body.Seek(0, SeekOrigin.Begin);
        string text = await new StreamReader(response.Body).ReadToEndAsync();
        response.Body.Seek(0, SeekOrigin.Begin);
        return $"{response.StatusCode}: {text}";
    }
}

我们在网关中使用了日志记录,因此我们无需检查每个微服务的日志。

这是configuration.Development.json

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/api/catalog",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44326
        }
      ],
      "UpstreamPathTemplate": "/catalog",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/catalog/{id}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44326
        }
      ],
      "UpstreamPathTemplate": "/catalog/{id}",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44388
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/identity/login",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44397
        }
      ],
      "UpstreamPathTemplate": "/identity/login",
      "UpstreamHttpMethod": [ "POST" ]
    },
    {
      "DownstreamPathTemplate": "/api/identity/register",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44397
        }
      ],
      "UpstreamPathTemplate": "/identity/register",
      "UpstreamHttpMethod": [ "POST" ]
    },
    {
      "DownstreamPathTemplate": "/api/identity/validate",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 44397
        }
      ],
      "UpstreamPathTemplate": "/identity/validate",
      "UpstreamHttpMethod": [ "GET" ]
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "http://localhost:44300"
  }
}

最后,下面是appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "jwt": {
    "secret": "9095a623-a23a-481a-aa0c-e0ad96edc103"
  }
}

现在,让我们测试前端网关。

首先,使用以下有效负载执行以下POST请求http//localhost:44300/identity/login创建一个JWT令牌:

{
  "email": "user@store.com",
  "password": "pass"
}

我们已经在测试IdentityMicroservice时创建了该用户。如果您没有创建该用户,则可以通过执行以下POST请求http://localhost:44300/identity/register 来创建一个具有上述有效负载的用户。

然后,转到Postman中的授权选项卡,选择Bearer Token类型,然后将JWT令牌复制粘贴到Token字段中。然后,执行以下GET请求 http://localhost:44300/catalog以检索目录:

如果JWT令牌无效,则响应为401 Unauthorized

您可以在jwt.io上检查令牌:

如果在Visual Studio中打开控制台,则会得到以下日志:

就是这样。您可以用相同的方式测试其他API方法。

后端网关几乎以相同的方式完成。唯一的区别是在configuration.json文件中。

客户端应用

有两个客户端应用程序。一个用于前端,一个用于后端。

为了简单起见,客户端应用程序是使用HTMLJavaScript制作的。

例如,让我们选择前端的登录页面。这是HTML

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Login</title>
    <link href="css/bootstrap.min.css" rel="stylesheet" />
    <link href="css/login.css" rel="stylesheet" />
</head>
<body>
    <div class="header"></div>
    <div class="login">
        <table>
            <tr>
                <td>Email</td>
                <td><input id="email" type="text" autocomplete="off" class="form-control" /></td>
            </tr>
            <tr>
                <td>Password</td>
                <td><input id="password" type="password" class="form-control" /></td>
            </tr>
            <tr>
                <td></td>
                <td>
                    <input id="login" type="button" value="Login" class="btn btn-primary" />
                    <input id="register" type="button" value="Register" class="btn btn-secondary" />
                </td>
            </tr>
        </table>
    </div>
    <script src="js/settings.js"></script>
    <script src="js/common.js"></script>
    <script src="js/login.js"></script>
</body>
</html>

这是settings.js

const settings = {
    uri: "http://" + window.location.hostname + ":44300/"
};

这是login.js

   

window.onload = function () {
    "use strict";

    window.localStorage.removeItem("auth");

    function login() {
        const user = {
            "email": document.getElementById("email").value,
            "password": document.getElementById("password").value
        };
        common.post(settings.uri + "identity/login", function (token) {
            const auth = {
                "email": user.email,
                "token": token
            };
            window.localStorage.setItem("auth", JSON.stringify(auth));
            window.location = "/store.html";
        }, function () {
            alert("Wrong credentials.");
        }, user);
    };

    document.getElementById("login").onclick = function () {
        login();
    };

    document.getElementById("password").onkeyup = function (e) {
        if (e.keyCode === 13) {
            login();
        }
    };

    document.getElementById("register").onclick = function () {
        window.location = "/register.html";
    };
};

common.js包含用于执行GETPOSTDELETE请求的功能:

const common = {
    post: function (url, callback, errorCallback, content, token) {
        const xmlhttp = new XMLHttpRequest();
        xmlhttp.onreadystatechange = function () {
            if (this.readyState === 4 && this.status >= 200 && this.status < 300 && callback) {
                callback(this.responseText);
            } else if (this.readyState === 4 && errorCallback) {
                errorCallback();
            }
        };
        xmlhttp.onerror = function () {
            if (errorCallback) {
                errorCallback();
            }
        };
        xmlhttp.open("POST", url, true);
        xmlhttp.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
        if (token) {
            xmlhttp.setRequestHeader("Authorization", "Bearer " + token);
        }
        xmlhttp.send(JSON.stringify(content));
    },
    get: function (url, callback, errorCallback, token) {
        const xmlhttp = new XMLHttpRequest();
        xmlhttp.onreadystatechange = function () {
            if (this.readyState === 4 && this.status >= 200 && this.status < 300 && callback) {
                callback(this.responseText);
            } else if (this.readyState === 4 && errorCallback) {
                errorCallback();
            }
        };
        xmlhttp.onerror = function () {
            if (errorCallback) {
                errorCallback();
            }
        };
        xmlhttp.open("GET", url, true);
        if (token) {
            xmlhttp.setRequestHeader("Authorization", "Bearer " + token);
        }
        xmlhttp.send();
    },
    delete: function (url, callback, errorCallback, token) {
        const xmlhttp = new XMLHttpRequest();
        xmlhttp.onreadystatechange = function () {
            if (this.readyState === 4 && this.status >= 200 && this.status < 300 && callback) {
                callback(this.responseText);
            } else if (this.readyState === 4 && errorCallback) {
                errorCallback();
            }
        };
        xmlhttp.onerror = function () {
            if (errorCallback) {
                errorCallback();
            }
        };
        xmlhttp.open("DELETE", url, true);
        if (token) {
            xmlhttp.setRequestHeader("Authorization", "Bearer " + token);
        }
        xmlhttp.send();
    }
};

前端和后端的其他页面的处理方式几乎相同。

在前端,有四个页面。登录页面,用于注册用户的页面,用于访问商店的页面以及用于访问购物车的页面。

前端允许注册用户查看可用的目录项,允许将目录项添加到购物车,并允许从购物车中删除目录项。

这是前端商店页面的屏幕截图:

在后端,有两页。登录页面和用于管理商店的页面。

后端允许管理员用户查看可用的目录项,允许添加新的目录项,允许更新目录项,并允许删除目录项。

这是后端商店页面的屏幕截图:

单元测试

在本节中,我们将使用xUnitMoq进行单元测试目录微服务。

在对控制器逻辑进行单元测试时,仅测试单个操作的内容,而不测试其依赖项或框架本身的行为。

xUnit简化了测试过程,使我们可以花更多的时间专注于编写测试。

Moq.NET的模拟框架。我们将使用它来模拟存储库和中间件服务。

为了对目录微服务进行单元测试,首先创建了一个xUnit测试项目CatalogMicroservice.UnitTests。然后,创建了一个单元测试类CatalogControllerTest。此类包含目录控制器的单元测试方法。

项目CatalogMicroservice的引用已添加到CatalogMicroservice.UnitTests项目中。

然后,使用Nuget软件包管理器添加了Moq。在这一点上,我们可以开始专注于编写更严格的测试。

CatalogController的引用已添加到CatalogControllerTest

private readonly CatalogController _controller;

然后,在下级单元测试类的构造函数中,添加了一个模拟存储库,如下所示:

public CatalogControllerTest()
{
    var mockRepo = new Mock<ICatalogRepository>();
    mockRepo.Setup(repo => repo.GetCatalogItems()).Returns(_items);
    mockRepo.Setup(repo => repo.GetCatalogItem(It.IsAny<Guid>()))
    .Returns<Guid>(id => _items.FirstOrDefault(i => i.Id == id));
    mockRepo.Setup(repo => repo.InsertCatalogItem(It.IsAny<CatalogItem>()))
    .Callback<CatalogItem>(i => _items.Add(i));
    mockRepo.Setup(repo => repo.UpdateCatalogItem(It.IsAny<CatalogItem>()))
    .Callback<CatalogItem>(i =>
    {
        var item = _items.FirstOrDefault(i => i.Id == i.Id);
        if (item != null)
        {
            item.Name = i.Name;
            item.Description = i.Description;
            item.Price = i.Price;
        }
    });
    mockRepo.Setup(repo => repo.DeleteCatalogItem(It.IsAny<Guid>()))
    .Callback<Guid>(id => _items.RemoveAll(i => i.Id == id));
    _controller = new CatalogController(mockRepo.Object);
}

其中_itemsCatalogItem的清单。

然后,这是GET api/catalog的测试:

[Fact]
public void GetCatalogItemsTest()
{
    var okObjectResult = _controller.Get();
    var okResult = Assert.IsType<OkObjectResult>(okObjectResult.Result);
    var items = Assert.IsType<List<CatalogItem>>(okResult.Value);
    Assert.Equal(2, items.Count);
}

这是GET api/catalog/{id}的测试

[Fact]
public void GetCatalogItemTest()
{
    var id = new Guid("ce2dbb82-6689-487b-9691-0a05ebabce4a");
    var okObjectResult = _controller.Get(id);
    var okResult = Assert.IsType<OkObjectResult>(okObjectResult.Result);
    var item = Assert.IsType<CatalogItem>(okResult.Value);
    Assert.Equal(id, item.Id);
}

这是POST api/calatlog的测试:

[Fact]
public void InsertCatalogItemTest()
{
    var createdResponse = _controller.Post(new CatalogItem { Id = new Guid("d378ff93-dc4b-4bf6-8756-58b6901cd47b"), Name = "iPhone X", Description = "iPhone X mobile phone", Price = 1000 });
    var response = Assert.IsType<CreatedAtActionResult>(createdResponse);
    var item = Assert.IsType<CatalogItem>(response.Value);
    Assert.Equal("iPhone X", item.Name);
}

这是PUT api/catalog的测试:

[Fact]
public void UpdateCatalogItemTest()
{
    var id = new Guid("ce2dbb82-6689-487b-9691-0a05ebabce4a");
    var okObjectResult = _controller.Put(new CatalogItem { Id = id, Name = "Samsung Galaxy S10+", Description = "Samsung Galaxy S10+ mobile phone", Price = 1100 });
    Assert.IsType<OkResult>(okObjectResult);
    var item = _items.First(i => i.Id == id);
    Assert.Equal("Samsung Galaxy S10+", item.Name);
    okObjectResult = _controller.Put(null);
    Assert.IsType<NoContentResult>(okObjectResult);
}

这是DELETE api /catalog/{id}的测试

[Fact]
public void DeleteCatalogItemTest()
{
    var id = new Guid("ce2dbb82-6689-487b-9691-0a05ebabce4a");
    var item = _items.FirstOrDefault(i => i.Id == id);
    Assert.NotNull(item);
    var okObjectResult = _controller.Delete(id);
    Assert.IsType<OkResult>(okObjectResult);
    item = _items.FirstOrDefault(i => i.Id == id);
    Assert.Null(item);
}

就是这样。购物车微服务和身份微服务的单元测试以相同的方式编写。

如果我们运行单元测试项目,我们将注意到它们全部通过:

使用健康检查进行监视

在本节中,我们将看到如何将健康检查添加到目录微服务中以进行监视。

健康检查是服务提供的用于检查服务是否正常运行的端点。

健康检查用于监视服务,例如:

  • 数据库(SQL ServerOracleMySqlMongoDB等)
  • 外部API连接
  • 磁盘连接(读/写)
  • 缓存服务(RedisMemcached等)

如果找不到适合自己的实现,则可以创建自己的自定义实现。

要将健康检查添加到目录微服务中,添加了以下nuget包:

  • AspNetCore.HealthChecks.MongoDb
  • AspNetCore.HealthChecks.UI
  • AspNetCore.HealthChecks.UI.Client
  • AspNetCore.HealthChecks.UI.InMemory.Storage

AspNetCore.HealthChecks.MongoDb软件包用于检查MongoDB的健康。

AspNetCore.HealthChecks.UI软件包用于使用健康检查UI,该UI存储并显示来自已配置HealthChecks uri 的健康检查结果。

然后,Startup.cs中的ConfigureServices方法更新如下:

services.AddHealthChecks()
    .AddMongoDb(
    mongodbConnectionString: Configuration.GetSection("mongo").Get<MongoOptions>().ConnectionString,
    name: "mongo",
    failureStatus: HealthStatus.Unhealthy
    );
services.AddHealthChecksUI().AddInMemoryStorage();

并且Startup.cs中的Configure方法已更新如下:

app.UseHealthChecks("/healthz", new HealthCheckOptions()
{
    Predicate = _ => true,
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

app.UseHealthChecksUI();

最后,appsettings.json更新如下:

"HealthChecksUI": {
  "HealthChecks": [
    {
      "Name": "HTTP-Api-Basic",
      "Uri": "http://localhost:44326/healthz"
    }
  ],
  "EvaluationTimeOnSeconds": 10,
  "MinimumSecondsBetweenFailureNotifications": 60
}

如果运行目录微服务,则在访问http://localhost:44326/healthchecks-ui时将获得以下UI

就是这样。其他微服务和网关的健康检查也以相同的方式实现。

如何运行应用程序

您可以在Visual Studio 2019中使用IISExpress运行应用程序。

如果尚未安装,则需要安装MongoDB

首先,右键单击解决方案,单击属性,然后选择多个启动项目。选择除中间件项目以外的所有项目作为启动项目:

然后,按F5键运行该应用程序。

您可以从http://localhost:44317/访问前端。

您可以从http://localhost:44301/访问后端。

首次登录前端,只需单击注册以创建新用户并登录。

首次登录到后端,您将需要创建一个管理员用户。为此,请打开Postman并使用以下有效负载执行以下POST请求http://localhost:44397/api/identity/register 

{
  "email": "admin@store.com",
  "password": "pass",
  "isAdmin": true
}

您还可以使用Swagger UI创建管理员用户:http://localhost:44397/swagger

最后,您可以使用您创建的admin用户登录到后端。

如何部署应用程序

您可以在Linux发行版上使用Docker容器部署应用程序。

如果未安装DockerDocker Compose,则需要安装它们。

首先,将源代码复制到Linux计算机上的文件夹中。

然后打开一个终端,转到该文件夹​​.sln文件所在的文件夹)并运行以下命令:

docker-compose up

就是这样,该应用程序将被部署并运行。

然后,您可以从http://host-ip:44317/访问前端,并从http://host-ip:44301/访问后端。

这是在Ubuntu上运行的应用程序的屏幕截图:

对于那些想了解部署方式的人,这里是docker-compose.yml

 

version: "3"
services:
  mongo:
    image: mongo
    ports:
       - 27017:27017

  catalog:
    build:
      context: .
      dockerfile: src/microservices/CatalogMicroservice/Dockerfile
    depends_on:
      - mongo
    ports:
      - 44326:80

  cart:
    build:
      context: .
      dockerfile: src/microservices/CartMicroservice/Dockerfile
    depends_on:
      - mongo
    ports:
      - 44388:80

  identity:
    build:
      context: .
      dockerfile: src/microservices/IdentityMicroservice/Dockerfile
    depends_on:
      - mongo
    ports:
      - 44397:80

  frontendgw:
    build:
      context: .
      dockerfile: src/gateways/FrontendGateway/Dockerfile
    depends_on:
      - mongo
      - catalog
      - cart
      - identity
    ports:
      - 44300:80

  backendgw:
    build:
      context: .
      dockerfile: src/gateways/BackendGateway/Dockerfile
    depends_on:
      - mongo
      - catalog
      - identity
    ports:
      - 44359:80

  frontend:
    build:
      context: .
      dockerfile: src/uis/Frontend/Dockerfile
    ports:
      - 44317:80

  backend:
    build:
      context: .
      dockerfile: src/uis/Backend/Dockerfile
    ports:
      - 44301:80

然后,在微服务和网关中使用appsettings.Production.json,在网关中使用configuration.Production.json

例如,这是目录微服务的appsettings.Production.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "mongo": {
    "connectionString": "mongodb://mongo",
    "database": "catalog"
  },
  "HealthChecksUI": {
    "HealthChecks": [
      {
        "Name": "HTTP-Api-Basic",
        "Uri": "http://catalog/healthz"
      }
    ],
    "EvaluationTimeOnSeconds": 10,
    "MinimumSecondsBetweenFailureNotifications": 60
  }
}

这是目录微服务的Dockerfile

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 ["src/microservices/CatalogMicroservice/CatalogMicroservice.csproj", "src/microservices/CatalogMicroservice/"]
RUN dotnet restore "src/microservices/CatalogMicroservice/CatalogMicroservice.csproj"
COPY . .
WORKDIR "/src/src/microservices/CatalogMicroservice"
RUN dotnet build "CatalogMicroservice.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "CatalogMicroservice.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "CatalogMicroservice.dll"]

多级构建在这里进行说明。它有助于使构建容器的过程更高效,并通过允许容器只包含应用程序在运行时需要的部分来缩小容器。

这是前端网关的configuration.Production.json

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/api/catalog",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "catalog",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/catalog",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/catalog/{id}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "catalog",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/catalog/{id}",
      "UpstreamHttpMethod": [ "GET" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/cart",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "cart",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/cart",
      "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "Bearer",
        "AllowedScopes": []
      }
    },
    {
      "DownstreamPathTemplate": "/api/identity/login",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "identity",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/identity/login",
      "UpstreamHttpMethod": [ "POST" ]
    },
    {
      "DownstreamPathTemplate": "/api/identity/register",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "identity",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/identity/register",
      "UpstreamHttpMethod": [ "POST" ]
    },
    {
      "DownstreamPathTemplate": "/api/identity/validate",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "identity",
          "Port": 80
        }
      ],
      "UpstreamPathTemplate": "/identity/validate",
      "UpstreamHttpMethod": [ "GET" ]
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "http://localhost:44300"
  }
}

这是前端网关的appsettings.Production.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "jwt": {
    "secret": "9095a623-a23a-481a-aa0c-e0ad96edc103"
  },
  "mongo": {
    "connectionString": "mongodb://mongo"
  },
  "HealthChecksUI": {
    "HealthChecks": [
      {
        "Name": "HTTP-Api-Basic",
        "Uri": "http://frontendgw/healthz"
      }
    ],
    "EvaluationTimeOnSeconds": 10,
    "MinimumSecondsBetweenFailureNotifications": 60
  }
}

最后,这是前端网关的Dockerfile

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 ["src/gateways/FrontendGateway/FrontendGateway.csproj", "src/gateways/FrontendGateway/"]
RUN dotnet restore "src/gateways/FrontendGateway/FrontendGateway.csproj"
COPY . .
WORKDIR "/src/src/gateways/FrontendGateway"
RUN dotnet build "FrontendGateway.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "FrontendGateway.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FrontendGateway.dll"]

其他微服务和后端网关的配置几乎以相同的方式完成。

部署就是这样。

进一步阅读

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值