使用 gRPC 实现购物车服务

This commit is contained in:
hello 2024-07-10 22:41:32 +08:00
parent 49f2796cc2
commit c192d209fd
27 changed files with 368 additions and 53 deletions

View File

@ -21,7 +21,8 @@ public class OpenApiConfigureOptions(IConfiguredServiceEndPointResolver serviceR
try
{
HttpResponseMessage response = httpClient.GetAsync(uriBuilder.Uri).GetAwaiter().GetResult();
HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri) { Version = new Version(2, 0) };
HttpResponseMessage response = httpClient.SendAsync(request).GetAwaiter().GetResult();
if (response.IsSuccessStatusCode)
{
urlDescriptors.Add(new UrlDescriptor

View File

@ -9,6 +9,6 @@
<ProjectReference Include="..\HelloShop.ServiceDefaults\HelloShop.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="8.0.2" />
</ItemGroup>
</Project>

View File

@ -15,6 +15,6 @@
<ProjectReference Include="..\HelloShop.WebApp\HelloShop.WebApp.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="8.0.1" />
<PackageReference Include="Aspire.Hosting.AppHost" Version="8.0.2" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,19 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using AutoMapper;
using HelloShop.BasketService.Entities;
using HelloShop.BasketService.Protos;
namespace HelloShop.BasketService.AutoMapper
{
public class BasketsMapConfiguration : Profile
{
public BasketsMapConfiguration()
{
CreateMap<CustomerBasket, CustomerBasketResponse>();
CreateMap<BasketItem, BasketListItem>().ReverseMap();
CreateMap<UpdateBasketRequest, CustomerBasket>();
}
}
}

View File

@ -0,0 +1,12 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
namespace HelloShop.BasketService.Entities
{
public class BasketItem
{
public int ProductId { get; set; }
public int Quantity { get; set; }
}
}

View File

@ -0,0 +1,12 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
namespace HelloShop.BasketService.Entities
{
public class CustomerBasket
{
public int BuyerId { get; set; }
public List<BasketItem> Items { get; set; } = [];
}
}

View File

@ -1,14 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IncludeHttpRuleProtos>true</IncludeHttpRuleProtos>
</PropertyGroup>
<ItemGroup>
<None Remove="Protos\basket.proto" />
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\basket.proto" GrpcServices="Server" />
<Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Calzolari.Grpc.AspNetCore.Validation" Version="8.1.0" />
<PackageReference Include="Grpc.AspNetCore" Version="2.63.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Grpc.Swagger" Version="0.8.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HelloShop.ServiceDefaults\HelloShop.ServiceDefaults.csproj" />

View File

@ -1,22 +1,61 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using Calzolari.Grpc.AspNetCore.Validation;
using HelloShop.BasketService.Repositories;
using HelloShop.BasketService.Services;
using HelloShop.ServiceDefaults.Extensions;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Add services to the container.
builder.Services.AddGrpc();
// Add extensions services to the container.
const string issuerSigningKey = HelloShop.ServiceDefaults.Constants.IdentityConstants.IssuerSigningKey;
builder.Services.AddAuthentication().AddJwtBearer(options =>
{
options.TokenValidationParameters.ValidateIssuer = false;
options.TokenValidationParameters.ValidateAudience = false;
options.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(Encoding.Default.GetBytes(issuerSigningKey));
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSingleton<IBasketRepository, DistributedCacheBasketRepository>();
builder.Services.AddGrpc(options => options.EnableMessageValidation()).AddJsonTranscoding();
builder.Services.AddGrpcSwagger();
builder.Services.AddOpenApi();
builder.Services.AddDataSeedingProviders();
builder.Services.AddCustomLocalization();
builder.Services.AddModelMapper().AddModelValidator();
builder.Services.AddLocalization().AddPermissionDefinitions();
builder.Services.AddAuthorization().AddRemotePermissionChecker().AddCustomAuthorization();
builder.Services.AddGrpcValidation();
builder.Services.AddCors();
// End addd extensions services to the container.
var app = builder.Build();
app.UseCors(options => options.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader());
app.MapDefaultEndpoints();
// Configure the HTTP request pipeline.
app.MapGrpcService<GreeterService>();
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client.").WithTags("Welcome");
// Configure extensions request pipeline.
app.UseAuthentication().UseAuthorization();
app.MapGrpcService<GreeterService>();
app.MapGrpcService<CustomerBasketService>();
app.UseDataSeedingProviders();
app.UseCustomLocalization();
app.UseOpenApi();
app.MapGroup("api/Permissions").MapPermissionDefinitions("Permissions");
// End configure extensions request pipeline.
app.Run();

View File

@ -4,7 +4,8 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:8004",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
@ -13,7 +14,8 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:8104;http://localhost:8004",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"

View File

@ -0,0 +1,42 @@
syntax = "proto3";
import "google/protobuf/empty.proto";
import "google/api/annotations.proto";
option csharp_namespace = "HelloShop.BasketService.Protos";
package basket;
service Basket {
rpc GetBasket(google.protobuf.Empty) returns (CustomerBasketResponse) {
option (google.api.http) = {
get: "/api/basket"
};
}
rpc UpdateBasket(UpdateBasketRequest) returns (CustomerBasketResponse) {
option (google.api.http) = {
put: "/api/basket"
body: "*"
};
}
rpc DeleteBasket(google.protobuf.Empty) returns (google.protobuf.Empty) {
option (google.api.http) = {
delete: "/api/basket"
};
}
}
message CustomerBasketResponse {
repeated BasketListItem items = 1;
}
message BasketListItem {
int32 product_id = 1;
int32 quantity = 2;
}
message UpdateBasketRequest {
repeated BasketListItem items = 1;
}

View File

@ -1,13 +1,20 @@
syntax = "proto3";
option csharp_namespace = "HelloShop.BasketService";
option csharp_namespace = "HelloShop.BasketService.Protos";
import "google/api/annotations.proto";
package greet;
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply);
rpc SayHello (HelloRequest) returns (HelloReply){
option (google.api.http) = {
post: "/api/greet"
body: "*"
};
}
}
// The request message containing the user's name.

View File

@ -0,0 +1,28 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using HelloShop.BasketService.Entities;
using Microsoft.Extensions.Caching.Distributed;
namespace HelloShop.BasketService.Repositories
{
public class DistributedCacheBasketRepository(IDistributedCache cache) : IBasketRepository
{
private const string BasketKeyPrefix = "basket";
private static string GetBasketKey(int customerId) => $"{BasketKeyPrefix}:{customerId}";
public async Task DeleteBasketAsync(int customerId, CancellationToken token = default) => await cache.RemoveAsync(GetBasketKey(customerId), token);
public async Task<CustomerBasket?> GetBasketAsync(int customerId, CancellationToken token = default) => await cache.GetObjectAsync<CustomerBasket>(GetBasketKey(customerId), token);
public async Task<CustomerBasket?> UpdateBasketAsync(CustomerBasket basket, CancellationToken token = default)
{
DistributedCacheEntryOptions options = new() { SlidingExpiration = TimeSpan.MaxValue };
await cache.SetObjectAsync(GetBasketKey(basket.BuyerId), basket, options, token);
return await GetBasketAsync(basket.BuyerId, token);
}
}
}

View File

@ -0,0 +1,16 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using HelloShop.BasketService.Entities;
namespace HelloShop.BasketService.Repositories
{
public interface IBasketRepository
{
Task<CustomerBasket?> GetBasketAsync(int customerId, CancellationToken token = default);
Task<CustomerBasket?> UpdateBasketAsync(CustomerBasket basket, CancellationToken token = default);
Task DeleteBasketAsync(int customerId, CancellationToken token = default);
}
}

View File

@ -0,0 +1,78 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using AutoMapper;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using HelloShop.BasketService.Entities;
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using HelloShop.BasketService.Protos;
using HelloShop.BasketService.Repositories;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
namespace HelloShop.BasketService.Services
{
[Authorize]
public class CustomerBasketService(IBasketRepository repository, ILogger<CustomerBasketService> logger, IMapper mapper) : Basket.BasketBase
{
public override async Task<CustomerBasketResponse> GetBasket(Empty request, ServerCallContext context)
{
string? nameIdentifier = context.GetHttpContext().User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(nameIdentifier, out int userId))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "The caller is not authenticated."));
}
logger.LogDebug("Begin GetBasketById call from method {Method} for basket id {Id}", context.Method, userId);
CustomerBasket? basket = await repository.GetBasketAsync(userId, context.CancellationToken);
if (basket is not null)
{
mapper.Map<CustomerBasketResponse>(basket);
}
return new();
}
public override async Task<CustomerBasketResponse> UpdateBasket(UpdateBasketRequest request, ServerCallContext context)
{
string? nameIdentifier = context.GetHttpContext().User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(nameIdentifier, out int userId))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "The caller is not authenticated."));
}
logger.LogDebug("Begin UpdateBasket call from method {Method} for basket id {Id}", context.Method, userId);
CustomerBasket customerBasket = mapper.Map<CustomerBasket>(request, opts => opts.AfterMap((src, dest) => dest.BuyerId = userId));
CustomerBasket? result = await repository.UpdateBasketAsync(customerBasket, context.CancellationToken);
result = result ?? throw new RpcException(new Status(StatusCode.NotFound, $"Basket with buyer id {userId} does not exist"));
return mapper.Map<CustomerBasketResponse>(result);
}
public override async Task<Empty> DeleteBasket(Empty request, ServerCallContext context)
{
string? nameIdentifier = context.GetHttpContext().User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(nameIdentifier, out int userId))
{
throw new RpcException(new Status(StatusCode.Unauthenticated, "The caller is not authenticated."));
}
await repository.DeleteBasketAsync(userId, context.CancellationToken);
return new();
}
}
}

View File

@ -2,17 +2,12 @@
// See the license file in the project root for more information.
using Grpc.Core;
using HelloShop.BasketService.Protos;
namespace HelloShop.BasketService.Services;
public class GreeterService : Greeter.GreeterBase
{
private readonly ILogger<GreeterService> _logger;
public GreeterService(ILogger<GreeterService> logger)
{
_logger = logger;
}
public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
{
return Task.FromResult(new HelloReply

View File

@ -0,0 +1,18 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using FluentValidation;
using FluentValidation.Validators;
using HelloShop.BasketService.Protos;
namespace HelloShop.BasketService.Validations.Baskets
{
public class BasketListItemValidator : AbstractValidator<BasketListItem>
{
public BasketListItemValidator()
{
RuleFor(x => x.ProductId).GreaterThan(0);
RuleFor(x => x.Quantity).GreaterThan(0);
}
}
}

View File

@ -0,0 +1,17 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using FluentValidation;
using HelloShop.BasketService.Protos;
namespace HelloShop.BasketService.Validations.Baskets
{
public class UpdateBasketRequestValidator : AbstractValidator<UpdateBasketRequest>
{
public UpdateBasketRequestValidator()
{
RuleFor(x => x.Items).NotNull();
RuleForEach(x => x.Items).SetValidator(new BasketListItemValidator());
}
}
}

View File

@ -0,0 +1,16 @@
// Copyright (c) HelloShop Corporation. All rights reserved.
// See the license file in the project root for more information.
using FluentValidation;
using HelloShop.BasketService.Protos;
namespace HelloShop.BasketService.Validations.Greeters
{
public class HelloRequestValidator : AbstractValidator<HelloRequest>
{
public HelloRequestValidator()
{
RuleFor(x => x.Name).NotNull().NotEmpty().Length(3, 20);
}
}
}

View File

@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
},
"AllowedHosts": "*"
}

View File

@ -58,9 +58,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="8.0.61" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="8.0.61" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="8.0.61" />
<PackageReference Include="Microsoft.Maui.Controls" Version="8.0.70" />
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="8.0.70" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="8.0.70" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
</ItemGroup>

View File

@ -8,9 +8,9 @@
<ProjectReference Include="..\HelloShop.ServiceDefaults\HelloShop.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -8,8 +8,8 @@
<ProjectReference Include="..\HelloShop.ServiceDefaults\HelloShop.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -22,7 +22,7 @@ namespace HelloShop.ServiceDefaults.Extensions
services.Configure<SwaggerUIOptions>(options =>
{
options.DocumentTitle = Assembly.GetExecutingAssembly().GetName().Name;
options.DocumentTitle = Assembly.GetEntryAssembly()?.GetName().Name;
options.InjectStylesheet("/ServiceDefaults/Resources/OpenApi/Custom.css");
});

View File

@ -7,8 +7,8 @@
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.6.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.7.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="8.0.2" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />

View File

@ -9,14 +9,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.Testing" Version="8.0.1" />
<PackageReference Include="Aspire.Hosting.Testing" Version="8.0.2" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -14,11 +14,11 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -14,11 +14,11 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>