目录
本文显示了一个使用ASP.NET Core,Ocelot,MongoDB和JWT的微服务体系结构的工作示例。本文介绍如何使用ASP.NET Core创建微服务,如何使用Ocelot创建API网关,如何使用MongoDB创建存储库,如何在微服务中处理JWT,如何使用xUnit和Moq对单元进行微服务测试,如何使用健康检查来监视微服务,最后是如何在Linux发行版上使用Docker容器部署微服务。
介绍
微服务架构由一组小型、独立且松散耦合的服务组成。每个服务都是独立的,实现单个业务功能,负责持久保存其自己的数据,是一个单独的代码库,并且可以独立部署。
API网关是客户端的入口点。客户端不是直接调用服务,而是调用API网关,该网关将调用转发到适当的服务。
使用微服务架构有多个优点:
- 开发人员可以更好地了解服务的功能。
- 一种服务的故障不会影响其他服务。
- 管理错误修复和功能发布更加容易。
- 可以将服务部署在多台服务器中以提高性能。
- 服务易于更改和测试。
- 服务易于部署。
- 允许选择适合特定功能的技术。
在选择微服务架构之前,需要考虑以下挑战:
- 服务很简单,但是整个系统整体来说更复杂。
- 服务之间的通信可能很复杂。
- 更多服务等于更多资源。
- 全局测试可能很困难。
- 调试可能会更困难。
微服务架构对于大型公司而言非常有用,但对于需要快速创建和迭代且不想参与复杂编排的小型公司而言,它可能会变得复杂。
本文显示了一个使用ASP.NET Core、Ocelot、MongoDB和JWT的微服务体系结构的工作示例。
本文介绍如何使用ASP.NET Core创建微服务,如何使用Ocelot创建API网关,如何使用MongoDB创建存储库,如何在微服务中处理JWT,如何使用xUnit和Moq对单元进行微服务测试,如何使用健康检查来监视微服务,最后是如何在Linux发行版上使用Docker容器部署微服务。
微服务和网关是使用ASP.NET Core和C#开发的。为了简单起见,客户端应用程序是使用HTML和JavaScript开发的。
开发环境
- 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效果很好。PUT和DELETE可以用相同的方式测试请求。
API文档是使用Swashbuckle生成的。Swagger中间件在Startup.cs中配置,在Startup.cs中的ConfigureServices和Configure方法中配置。
如果CatalogMicroservice使用IISExpress或Docker 运行项目,则将获得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);
}
}
IUserRepository,IJwtBuilder和IEncryptor使用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使用IISExpress或Docker 运行项目,则将获得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文件中。
客户端应用
有两个客户端应用程序。一个用于前端,一个用于后端。
为了简单起见,客户端应用程序是使用HTML和JavaScript制作的。
例如,让我们选择前端的登录页面。这是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包含用于执行GET,POST和DELETE请求的功能:
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();
}
};
前端和后端的其他页面的处理方式几乎相同。
在前端,有四个页面。登录页面,用于注册用户的页面,用于访问商店的页面以及用于访问购物车的页面。
前端允许注册用户查看可用的目录项,允许将目录项添加到购物车,并允许从购物车中删除目录项。
这是前端商店页面的屏幕截图:
在后端,有两页。登录页面和用于管理商店的页面。
后端允许管理员用户查看可用的目录项,允许添加新的目录项,允许更新目录项,并允许删除目录项。
这是后端商店页面的屏幕截图:
单元测试
在本节中,我们将使用xUnit和Moq进行单元测试目录微服务。
在对控制器逻辑进行单元测试时,仅测试单个操作的内容,而不测试其依赖项或框架本身的行为。
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);
}
其中_items是CatalogItem的清单。
然后,这是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 Server,Oracle,MySql,MongoDB等)
- 外部API连接
- 磁盘连接(读/写)
- 缓存服务(Redis,Memcached等)
如果找不到适合自己的实现,则可以创建自己的自定义实现。
要将健康检查添加到目录微服务中,添加了以下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容器部署应用程序。
如果未安装Docker和Docker 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"]
其他微服务和后端网关的配置几乎以相同的方式完成。
部署就是这样。