From 1ea49c45146548f4c62c44440b80d1a7bc8b1bda Mon Sep 17 00:00:00 2001 From: hello Date: Thu, 4 Apr 2024 06:28:27 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/helloshop/resource-based-authorization.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/notes/helloshop/resource-based-authorization.md b/notes/helloshop/resource-based-authorization.md index 0e47262..af546fa 100644 --- a/notes/helloshop/resource-based-authorization.md +++ b/notes/helloshop/resource-based-authorization.md @@ -1,10 +1,10 @@ -# 基于资源的授权的最佳实践 +# 基于资源授权的最佳实践 ## 在 Identity Service 中提供权限检查接口 ```csharp [HttpHead] -public async Task>> CheckPermission(string permissionName, string? resourceType = null, string? resourceId = null +public async Task CheckPermission(string permissionName, string? resourceType = null, string? resourceId = null { if (await permissionChecker.IsGrantedAsync(permissionName, resourceType, resourceId)) { From e11e517afca8a7bca55a57d9343efaa91f3bb0ad Mon Sep 17 00:00:00 2001 From: hello Date: Sat, 6 Apr 2024 10:34:34 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=B8=AE=E5=8A=A9?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/helloshop/model-binding.md | 51 +++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/notes/helloshop/model-binding.md b/notes/helloshop/model-binding.md index 3136659..ded17b8 100644 --- a/notes/helloshop/model-binding.md +++ b/notes/helloshop/model-binding.md @@ -1,7 +1,56 @@ -## 模型绑定约定 +## 模型绑定最佳实践 实体对象是 EF 中的概念, 每个实体对象对应数据库中的一张表。模型对象是 MVC 中的概念,是 HTTP 请求和响应的数据结构。HTTP 请求中通过 URL 参数、表单、标头、 JSON 数据等方式传递数据,这些数据最终会被绑定为模型对象中,模型经过转换为实体对象后,被持久化到数据库中。 +```csharp +public class Product +{ + public int Id { get; set; } + public string Name { get; set; } + public decimal Price { get; set; } +} +``` + +```csharp +public class ProductModel +{ + public string Name { get; set; } + public decimal Price { get; set; } +} +``` + +```csharp +public class ProductController : Controller +{ + private readonly ShopDbContext _context; + + public ProductController(ShopDbContext context) + { + _context = context; + } + + [HttpPost] + public async Task Create(ProductModel model) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var product = new Product + { + Name = model.Name, + Price = model.Price + }; + + _context.Products.Add(product); + await _context.SaveChangesAsync(); + + return CreatedAtAction(nameof(Get), new { id = product.Id }, product); + } +} +``` + 针对不同 HTTP 操作,应该使用不同的模型对象,比如创建模型、更新模型、查询模型、删除模型等,这样的好处是可以更好的区分模型对象的职责,比如创建模型只需要包含创建所需的字段,更新模型只需要包含更新所需的字段,查询模型只需要包含查询所需的字段,删除模型只需要包含删除所需的字段,职责单一,维护性好,还可以针对不同的模型提供不同的验证规则。 ![model-entity-mapper](https://oss.xcode.me/notes/helloshop/model-entity-mapper.svg) From 6e07c83caf2b01fbc54b069532b7a2c1501be8bc Mon Sep 17 00:00:00 2001 From: hello Date: Tue, 9 Apr 2024 06:55:50 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=E5=92=8C?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/helloshop/model-validations.md | 57 ++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/notes/helloshop/model-validations.md b/notes/helloshop/model-validations.md index 7b3767f..17e1c1b 100644 --- a/notes/helloshop/model-validations.md +++ b/notes/helloshop/model-validations.md @@ -1,11 +1,32 @@ # 模型自动验证机制 -## 使用 FluentValidation 开源库 +模型验证是 ASP.NET Core MVC 中的一个重要特性,它可以帮助我们验证用户输入的数据是否符合预期。 + +## 基于数据注解的验证 + +参考微软 [数据注解](https://learn.microsoft.com/zh-cn/aspnet/core/mvc/models/validation?view=aspnetcore-5.0#built-in-attributes-1) 文档。 + +```csharp +public class User +{ + [Required] + [StringLength(32)] + public string Name { get; set; } + + [Required] + [EmailAddress] + public string Email { get; set; } +} +``` + +## 基于链式调用的验证 FluentValidation 是一个.NET库,用于构建类型安全的验证规则。它的设计目标是提供一个简单、清晰的API,同时还能够支持复杂的验证规则。 +参考 [FluentValidation](https://fluentvalidation.net/) 官方文档。 + ```shell -dotnet add package FluentValidation.DependencyInjectionExtensions +dotnet add package FluentValidation.AspNetCore ``` ## 实现验证器 @@ -24,7 +45,9 @@ public class UserValidator : AbstractValidator ## 自动依赖注入 ```csharp -builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); +services.AddValidatorsFromAssembly(assembly).AddFluentValidationAutoValidation(); + +ValidatorOptions.Global.LanguageManager = new CustomFluentValidationLanguageManager(); ``` ## 自定义验证错误消息 @@ -40,15 +63,35 @@ public class UserValidator : AbstractValidator } ``` +## 自定义验证逻辑 + +```csharp +public class UserCreateRequestValidator : AbstractValidator +{ + public UserCreateRequestValidator(IdentityDbContext context) + { + RuleFor(m => m.UserName).NotNull().NotEmpty().Length(5, 20).Matches("^[a-zA-Z]+$"); + RuleFor(m => m.PhoneNumber).NotNull().NotEmpty().Length(11).Matches(@"^1\d{10}$").Must((model, phoneNumber) => + { + return !context.Users.Any(e => e.PhoneNumber == phoneNumber); + }); + RuleFor(m => m.Password).NotNull().NotEmpty().Length(5, 20); + RuleFor(m => m.Email).EmailAddress().Length(5, 50); + } +} +``` + + ## 通用错误消息 ```csharp -public class UserValidator : AbstractValidator +public class CustomFluentValidationLanguageManager : FluentValidation.Resources.LanguageManager { - public UserValidator() + public CustomFluentValidationLanguageManager() { - RuleFor(x => x.Name).NotEmpty().MaximumLength(32).WithMessage("{PropertyName} is required and must be less than {MaxLength} characters."); - RuleFor(x => x.Email).NotEmpty().EmailAddress().WithMessage("{PropertyName} is required and must be a valid email address."); + AddTranslation("en", "NotNullValidator", "The {PropertyName} field is required."); + AddTranslation("en", "MaximumLengthValidator", "The {PropertyName} field must be less than {MaxLength} characters."); + AddTranslation("en", "EmailAddressValidator", "The {PropertyName} field must be a valid email address."); } } ``` \ No newline at end of file From fdd21fdf7a0ed218db21f9bb9d74aa5c2cd5b27d Mon Sep 17 00:00:00 2001 From: hello Date: Tue, 9 Apr 2024 07:13:35 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=92=8C=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/helloshop/localization.md | 157 ++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 notes/helloshop/localization.md diff --git a/notes/helloshop/localization.md b/notes/helloshop/localization.md new file mode 100644 index 0000000..2605d75 --- /dev/null +++ b/notes/helloshop/localization.md @@ -0,0 +1,157 @@ +# 全球化与本地化 + +## 创建资源文件 + +```text +Welcome.en-US.resx +Welcome.zh-CN.resx +``` + +## 添加本地化服务和资源定位 + +```csharp +public static IServiceCollection AddCustomLocalization(this IServiceCollection services) +{ + services.AddLocalization(options => options.ResourcesPath = "Resources"); + + return services; +} +``` + +## 使用本地化服务 + +```csharp +public class HelloWorldController(IStringLocalizerFactory stringLocalizerFactory) : ControllerBase +{ + [HttpGet] + public IActionResult Get() + { + var location = Assembly.GetExecutingAssembly().FullName; + + ArgumentException.ThrowIfNullOrWhiteSpace(location); + + var localizer = stringLocalizerFactory.Create("Welcome", location); + + return Ok(localizer["HelloWorld"].Value); + } +} +``` + +## 使用 HTTP 请求设置语言 + +UseRequestLocalization 中间件从请求中获取语言设置,然后设置当前线程的语言,以便在后续的请求中使用,这样就可以实现全局的本地化,而不需要在每个控制器中设置本地化。 + +```csharp +public static IApplicationBuilder UseCustomLocalization(this IApplicationBuilder app) +{ + var supportedCultures = new[] { "zh-CN", "en-US" }; + + var localizationOptions = new RequestLocalizationOptions().SetDefaultCulture(supportedCultures.First()) + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures); + + app.UseRequestLocalization(localizationOptions); + + return app; +} +``` + +使用下面的方式从 Header 、 QueryString 或者 Cookie 中获取语言设置: + +```text +Accept-Language: en-US +``` + +```text +http://localhost:5000/?culture=en-Us +``` + +```text +.AspNetCore.Culture=en-US +``` + +## OpenApi 设置 Accept-Language + +```csharp +services.AddSwaggerGen(c => +{ + c.OperationFilter(); +}); +``` + +```csharp +public class AcceptLanguageHeaderOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (operation.Parameters == null) + { + operation.Parameters = new List(); + } + + operation.Parameters.Add(new OpenApiParameter + { + Name = "Accept-Language", + In = ParameterLocation.Header, + Required = false, + Schema = new OpenApiSchema + { + Type = "string" + } + }); + } +} +``` + +## 实现模型和属性的本地化 + +```csharp + IStringLocalizerFactory localizerFactory = app.ApplicationServices.GetRequiredService(); + + ValidatorOptions.Global.DisplayNameResolver = (type, memberInfo, lambdaExpression) => + { + string displayName = memberInfo.Name; + + DisplayAttribute? displayAttribute = memberInfo.GetCustomAttribute(true); + + displayName = displayAttribute?.Name ?? displayName; + + DisplayNameAttribute? displayNameAttribute = memberInfo.GetCustomAttribute(true); + + displayName = displayNameAttribute?.DisplayName ?? displayName; + + var localizer = localizerFactory.Create(type); + + return localizer[displayName]; + }; +``` + +## 本地化验证错误消息 + +```csharp +public UserCreateRequestValidator(IdentityServiceDbContext dbContext, IStringLocalizer localizer) +{ + RuleFor(m => m.PhoneNumber).NotNull().NotEmpty().Length(11).Matches(@"^1\d{10}$").Must((model, phoneNumber) => + { + return !dbContext.Users.Any(e => e.PhoneNumber == phoneNumber); + }).WithMessage(localizer["PhoneNumberExists"]); +} +``` + +## 微软 Resx 编辑工具 + +必应搜索 `Resx Editor` 关键字可以找到很多工具,可以编辑 Resx 文件。 + +https://learn.microsoft.com/zh-cn/dotnet/framework/tools + +## 使用必应翻译资源文件 + +必应搜索 `Resx Translation` 关键字可以找到很多翻译工具,可以将英文资源文件翻译成其他语言。 + +https://github.com/salarcode/AutoResxTranslator + +https://github.com/stevencohn/ResxTranslator + +https://github.com/HakanL/resxtranslator + +https://apps.microsoft.com/detail/9mtpd7jzxnnn From 469285f2069c40dae1367b86641e472a27fa0b5f Mon Sep 17 00:00:00 2001 From: hello Date: Tue, 9 Apr 2024 07:14:32 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=E5=88=86=E9=A1=B5=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/helloshop/pagging.md | 105 +++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 notes/helloshop/pagging.md diff --git a/notes/helloshop/pagging.md b/notes/helloshop/pagging.md new file mode 100644 index 0000000..c3ce78f --- /dev/null +++ b/notes/helloshop/pagging.md @@ -0,0 +1,105 @@ +# 分页排序和多条件查询 + +## 分页参数 + +请求应使用 GET 方法 + +```shell +http://localhost:8080/api/products?keyword=test&pagenumber=1&pagesize=5&orderby=id desc,price asc +``` + +响应返回如下 + +```json +{ + "totalCount": 100, + "items": [ + { + "id": 1, + "name": "test", + "price": 100 + }, + { + "id": 2, + "name": "test", + "price": 200 + } + ] +} +``` + +## 分页请求模型 + +```csharp +public class PagedAndSortedRequest : PagedRequest +{ + public string? OrderBy { get; init; } +} +``` + +## 分页响应模型 + +```csharp +public class PagedResponse(IReadOnlyList items, int totalCount) +{ + public IReadOnlyList Items { get; init; } = items; + + public int TotalCount { get; init; } = totalCount; +} +``` + +## 扩展 IQueryable 以便通过属性名称排序 + +```shell +QueryableOrderByExtensions.cs +``` + +将字符串条件转化为排序表达式 + +```csharp +IOrderedQueryable OrderBy(IQueryable source, string propertyName) +``` + +## 扩展 IQueryable 以便搜索和排序 + +```shell +QueryableExtensions.cs +``` + +扩展分页和排序方法 + +```csharp +IQueryable SortBy(this IQueryable query, string? orderBy = null); +IQueryable PageBy(this IQueryable query, PagedRequest pagedRequest) +IQueryable SortAndPageBy(this IQueryable query, PagedAndSortedRequest? pagedAndSortedRequest = null); +``` + +再扩展一个 WhereIf 条件查询 + +```csharp +IQueryable WhereIf(this IQueryable source, bool condition, Expression> predicate); +``` + +## 在 API 控制器中使用扩展方法 + +```csharp +[HttpGet] +[Authorize(IdentityPermissions.Users.Default)] +public async Task>> GetUsers([FromQuery] UserListRequest model) +{ + IQueryable users = dbContext.Set(); + + if (model.Keyword is not null) + { + users = users.Where(e => e.UserName != null && e.UserName.Contains(model.Keyword)); + } + + users = users.WhereIf(model.PhoneNumber is not null, e => e.PhoneNumber == model.PhoneNumber); + + var pagedUsers = users.SortAndPageBy(model); + + var list = new List(); + + return new PagedResponse(mapper.Map>(await pagedUsers.ToListAsync()), await users.CountAsync()); +} +``` From c6d8e134ebead4ae89b69c3a5a0ac42e016caa23 Mon Sep 17 00:00:00 2001 From: hello Date: Tue, 9 Apr 2024 07:20:10 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/helloshop/drawio.md | 2 ++ notes/helloshop/localization.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/notes/helloshop/drawio.md b/notes/helloshop/drawio.md index c12c21a..07c76e8 100644 --- a/notes/helloshop/drawio.md +++ b/notes/helloshop/drawio.md @@ -1,3 +1,5 @@ +![2024-04-09-07-17-04](https://oss.xcode.me/notes/helloshop/2024-04-09-07-17-04.png) + # 制作动态流程图和架构图 ## 微软 Visio 工具 diff --git a/notes/helloshop/localization.md b/notes/helloshop/localization.md index 2605d75..2c64338 100644 --- a/notes/helloshop/localization.md +++ b/notes/helloshop/localization.md @@ -148,6 +148,8 @@ https://learn.microsoft.com/zh-cn/dotnet/framework/tools 必应搜索 `Resx Translation` 关键字可以找到很多翻译工具,可以将英文资源文件翻译成其他语言。 +![自动翻译工具](https://oss.xcode.me/notes/helloshop/auto-resx-translator.png) + https://github.com/salarcode/AutoResxTranslator https://github.com/stevencohn/ResxTranslator From cabe56abb7e676d81e37d7e8a8d9589450c04a92 Mon Sep 17 00:00:00 2001 From: hello Date: Tue, 9 Apr 2024 07:34:49 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=8C=96=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/helloshop/localization.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/notes/helloshop/localization.md b/notes/helloshop/localization.md index 2c64338..090669a 100644 --- a/notes/helloshop/localization.md +++ b/notes/helloshop/localization.md @@ -1,5 +1,7 @@ # 全球化与本地化 +实现多语言的方法有很多种,可以使用资源文件、数据库、配置文件等方式,本文主要介绍使用资源文件的方式实现多语言,也是微软官方推荐的方式。其它的比如 PO 文件、JSON 文件等也可以实现多语言,但是不如资源文件方便. + ## 创建资源文件 ```text @@ -70,13 +72,10 @@ http://localhost:5000/?culture=en-Us .AspNetCore.Culture=en-US ``` -## OpenApi 设置 Accept-Language +## OpenApi 设置 Accept-Language 以实现多语言 ```csharp -services.AddSwaggerGen(c => -{ - c.OperationFilter(); -}); +services.Configure(options => options.OperationFilter()); ``` ```csharp @@ -84,23 +83,23 @@ public class AcceptLanguageHeaderOperationFilter : IOperationFilter { public void Apply(OpenApiOperation operation, OperationFilterContext context) { - if (operation.Parameters == null) + var parameter = new OpenApiParameter { - operation.Parameters = new List(); - } - - operation.Parameters.Add(new OpenApiParameter - { - Name = "Accept-Language", + Name = HeaderNames.AcceptLanguage, In = ParameterLocation.Header, Required = false, Schema = new OpenApiSchema { - Type = "string" + Default = new OpenApiString("zh-CN"), + Type = "string", + Enum = [new OpenApiString("zh-CN"), new OpenApiString("en-US")] } - }); + }; + + operation.Parameters.Add(parameter); } } + ``` ## 实现模型和属性的本地化 From 89f0ea742f1f46dce0914ebf865b69a888c0692b Mon Sep 17 00:00:00 2001 From: hello Date: Tue, 9 Apr 2024 07:58:42 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=E5=88=86=E9=A1=B5=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=92=8C=E5=A4=9A=E6=9D=A1=E4=BB=B6=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/helloshop/{pagging.md => paging.md} | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) rename notes/helloshop/{pagging.md => paging.md} (74%) diff --git a/notes/helloshop/pagging.md b/notes/helloshop/paging.md similarity index 74% rename from notes/helloshop/pagging.md rename to notes/helloshop/paging.md index c3ce78f..4b6aa88 100644 --- a/notes/helloshop/pagging.md +++ b/notes/helloshop/paging.md @@ -103,3 +103,31 @@ public async Task>> GetUsers([FromQuery return new PagedResponse(mapper.Map>(await pagedUsers.ToListAsync()), await users.CountAsync()); } ``` + +## 实现灵活的复杂查询 + +可以使用 OData 或者 GraphQL 来实现更复杂的查询。 + +OData 是一种基于 REST 的协议,它使用 URL 来查询和操作数据。OData 通过 URL 查询字符串参数来过滤、排序、分页和选择数据。示例: + +```shell +http://localhost:8080/api/products?$filter=price gt 100&$orderby=price desc&$top=5&$skip=10 +``` + +关于 OData 的更多信息请参考 [零度 OData 课程](https://www.xcode.me/Training?keyword=odata) + +GraphQL 是一种用于 API 的查询语言,它提供了一种更高效、强大和灵活的替代方案。GraphQL 通过一个单一的端点来查询和操作数据。示例: + +```shell +http://localhost:8080/graphql +``` + +```graphql +query { + products(filter: {price: {gt: 100}}, orderBy: {price: desc}, top: 5, skip: 10) { + id + name + price + } +} +``` \ No newline at end of file