前言
本文将介绍一个简单的设计方案:如何基于Blazor开发内部后台登录页面(及相关模块)。为了方便初学者理解正文,本文会先介绍一些工程上必须掌握的基础知识,有经验的开发者可以选择性跳过。
Table of Contents
托管Blazor server应用(Blazor server)
Blazor WA应用可以单独部署,称之为独立Blazor WA(Standalone),通常用于(不需要后端的)离线应用或者后端服务基于非ASP.NET Core的情形。而将Blazor作为ASP.NET Core应用的前端部分一起部署,则被称为托管Blazor(Hosted)。很显然,若要开发一个前后端分离的应用,采用托管Blazor,才能最大程度地发挥Blazor的开发和部署优势。
项目基本结构
托管Blazor WA应用的项目解决方案,主要包含三大子项目:
小程序:客户端项目:前端模块,即Blazor应用。
WeixinAdmin:ASP.NET Core Blazor server服务端项目:后端模块,调用ASP.NET Core Web API。在最后部署的时候,是由此项目进行发布的,因此该项目会引用Client项目。
Weixin.Api:ASP.NET Core Web API项目:共享模块,主要是存放前后端可以共用的数据或逻辑,其他2个项目都要引用它。
而针对Client项目,内部也有自己的默认结构,这里请读者自行阅读Blazor项目结构官方文档,篇幅所限,后文将默认读者已经熟悉这些基础结构。
WeixinAdmin:ASP.NET Core Blazor server
依赖注入
依赖注入是ASP.NET Core里一个非常基础的设计模式。Blazor里延续了和后端开发同样的风格。例如前端向后端发送请求,需要使用HttpClient,在Program.cs文件里,可以看到:
- public class Program
- {
- public static async Task Main(string[] args)
- {
- var builder = WebAssemblyHostBuilder.CreateDefault(args);
- builder.RootComponents.Add<App>("#app");
- builder.Services
- .AddScoped(sp => new HttpClient
- {
- BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),
- Timeout = TimeSpan.FromSeconds(3)
- });
- await builder.Build().RunAsync();
- }
- }
又例如:我们按照Ant-Design-Blazor项目的《快速上手》说明,引入该开源组件Nuget包后,也需要在这里加上依赖注入的代码行(其他需要的操作详见项目文档):
builder.Services.AddAntDesign();
这对ASP.NET Core后端开发者来说,完全没有理解门槛。而在Page文件里,需要使用HttpClient时,只需要使用@inject关键词声明即可:
- @inject HttpClient MyHttpClient
- <div>
- .......
- </div>
- @code{
- private async Task<string> GetAsync()
- {
- string rsp = await MyHttpClient.GetStringAsync(xxxx);
- return rsp;
- }
- }
这里请读者自行阅读Blazor依赖注入的官方文档。对Angular开发者来说,应该也会感到十分亲切。
设计认证方式
谈到登录,自然最先要考虑登录的认证方式,常见的有Cookie、Session或Token。对后端渲染的应用来说,使用Session应该更简单;而对前后端分离的应用来说,后端Web API应当是无状态的,因此一般只选择Cookie或Token,由前端持有自己的身份票据,后端做验证而不存储。
而在Cookie和Token之间,我按照官方文档的建议选择了使用Json Web Token。这里有必要将官方的理由引用过来,方便读者参考:
还有对 SPA 进行身份验证的其他选项,例如使用 SameSite cookie。但是,Blazor WebAssembly 的工程设计决定,OAuth 和 OIDC 是在 Blazor WebAssembly 应用中进行身份验证的最佳选择。出于以下功能和安全原因,选择了以 JSON Web 令牌 (JWT) 为基础的基于令牌的身份验证而不是基于 cookie 的身份验证:
使用基于令牌的协议可以减小攻击面,因为并非所有请求中都会发送令牌。
服务器终结点不要求针对跨站点请求伪造 (CSRF) 进行保护,因为会显式发送令牌。因此,可以将 Blazor WebAssembly 应用与 MVC 或 Razor Pages 应用一起托管。
令牌的权限比 cookie 窄。例如,令牌不能用于管理用户帐户或更改用户密码,除非显式实现了此类功能。
令牌的生命周期更短(默认为一小时),这限制了攻击时间窗口。还可随时撤销令牌。
自包含 JWT 向客户端和服务器提供身份验证进程保证。例如,客户端可以检测和验证它收到的令牌是否合法,以及是否是在给定身份验证过程中发出的。如果有第三方尝试在身份验证进程中偷换令牌,客户端可以检测被偷换的令牌并避免使用它。
OAuth 和 OIDC 的令牌不依赖于用户代理行为正确以确保应用安全。
基于令牌的协议(例如 OAuth 和 OIDC)允许用同一组安全特征对托管和独立应用进行验证和授权。
官方最推荐的方式是使用OAuth和OIDC。但开发内部后台,还要另搞一个OAuth服务器,对绝大多数开发者来说维护和部署成本过高了。所以我使用了传统的Password模式+后端自生成JWT。对内部后台应用来说,这么做已经足够安全。
还需要考虑的问题是,前端如何存放JWT呢?我们仍有两种选择,Cookie和LocalStorage。如果拿到了JWT放到一个前端自生成的Cookie里……那为什么不一开始就用Cookie呢?显得有些自我矛盾。我选择了储存到LocalStorage里。借助开源项目Blazor.LocalStorage,我们可以很轻松地达到目的,当然,跟Antd一样要用到依赖注入:
- builder.Services.AddBlazoredLocalStorage(config =>
- {
- config.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
- config.JsonSerializerOptions.IgnoreNullValues = true;
- config.JsonSerializerOptions.IgnoreReadOnlyProperties = true;
- config.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
- config.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
- config.JsonSerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip;
- config.JsonSerializerOptions.WriteIndented = false;
- });
设计后端接口
既然已经确认要使用JWT,那么后端自然要提供一个认证的接口:
- public class AccountController : ApiControllerBase
- {
- private readonly IMemoryCache _cache;
- private readonly IOptionsMonitor<JwtOption> _jwtOpt;
- private readonly IPasswordCryptor _passwordCryptor;
- private readonly MyDbContext _efContext;
- public AccountController(ILogger<AccountController> logger,
- IMemoryCache cache,
- IOptionsMonitor<JwtOption> jwtOpt,
- IPasswordCryptor passwordCryptor,
- MyDbContext efContext) : base(logger)
- {
- _cache = cache;
- _jwtOpt = jwtOpt;
- _passwordCryptor = passwordCryptor;
- _efContext = efContext;
- }
- [HttpPost]
- public async Task<IActionResult> Login([FromForm] LoginRqtDto rqtDto)
- {
- var cryptedPwd = _passwordCryptor.Encrypt(rqtDto.Password, default);
- string adminIdCacheKey = CacheKeyHelper.GetAdminIdCacheKey(rqtDto.Account);
- if (!_cache.TryGetValue(adminIdCacheKey, out int adminId))
- {
- adminId = await _efContext.Admins
- .Where(a => a.Account == rqtDto.Account && a.Password == cryptedPwd)
- .Select(a => a.AdminId)
- .FirstOrDefaultAsync();
- if (adminId < 1)
- {
- return Unauthorized();
- }
- _cache.Set(adminIdCacheKey, adminId, TimeSpan.FromDays(1));
- }
- else
- {
- bool checkPwd = await _efContext.Admins.AnyAsync(a => a.AdminId == adminId && a.Password == cryptedPwd);
- if (!checkPwd)
- {
- return Unauthorized();
- }
- }
- var claims = new Claim[]
- {
- new(ClaimTypes.NameIdentifier, adminId.ToString()),
- new(ClaimTypes.Name, rqtDto.Account),
- new(ClaimTypes.Role, "admin")
- };
- var jwtSetting = _jwtOpt.CurrentValue;
- var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSetting.Key));
- var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
- var expiry = DateTime.Now.AddHours(jwtSetting.ExpiryInHours);
- var token = new JwtSecurityToken(jwtSetting.Issuer, jwtSetting.Audience, claims, expires: expiry, signingCredentials: creds);
- var tokenText = new JwtSecurityTokenHandler().WriteToken(token);
- return Ok(tokenText);
- }
- }
还需要配置JWT相关的参数:
- "JWT": {
- "Key": "xxx",
- "Issuer": "xxx",
- "Audience": "xxx",
- "ExpiryInHours": 8
- }
及依赖注入:
- public static IServiceCollection AddAuth(this IServiceCollection services, IConfiguration configuration)
- {
- services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
- .AddJwtBearer(options =>
- {
- options.TokenValidationParameters = new TokenValidationParameters
- {
- ValidateIssuer = true,
- ValidateAudience = true,
- ValidateLifetime = true,
- ValidateIssuerSigningKey = true,
- ValidIssuer = configuration.GetValue<string>("JWT:Issuer"),
- ValidAudience = configuration.GetValue<string>("JWT:Audience"),
- IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration.GetValue<string>("JWT:Key"))),
- RequireExpirationTime = true
- };
- });
- services.Configure<JwtOption>(configuration.GetSection("JWT"));
- return services;
- }
以上代码仅供读者参考,可按实际需要增删改。另有一句与本文主旨无关的提醒:虽然是内部后台系统,但管理员登录密码还是要做加盐Hash处理,明文保存密码在任何地方都不可取!
设计前端服务
有的读者可能更喜欢UI先行,那么可以先看下面一节“设计登录页面”。
有了跟后端一样的依赖注入,我们可以将前端的认证也封装成服务。在项目中增加Services文件夹,添加AuthService.cs文件:
- using Blazored.LocalStorage;
- using Microsoft.AspNetCore.Components.Authorization;
- internal class AuthService : IAuthService
- {
- private readonly HttpClient _httpClient;
- private readonly AuthenticationStateProvider _authenticationStateProvider;
- private readonly ILocalStorageService _localStorage;
- public AuthService(HttpClient httpClient,
- AuthenticationStateProvider authenticationStateProvider,
- ILocalStorageService localStorage)
- {
- _httpClient = httpClient;
- _authenticationStateProvider = authenticationStateProvider;
- _localStorage = localStorage;
- }
- public async Task<bool> Login(LoginRqtDto rqtDto)
- {
- var content = new FormUrlEncodedContent(new KeyValuePair<string, string>[]
- {
- new(nameof(LoginRqtDto.Account), rqtDto.Account),
- new(nameof(LoginRqtDto.Password), rqtDto.Password),
- });
- using var rsp = await _httpClient.PostAsync("/account/login", content);
- if (!rsp.IsSuccessStatusCode)
- {
- return false;
- }
- var authToken = await rsp.Content.ReadAsStringAsync();
- await _localStorage.SetItemAsync("authToken", authToken);
- ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsAuthenticated(rqtDto.Account);
- _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken);
- return true;
- }
- public async Task Logout()
- {
- await _localStorage.RemoveItemAsync("authToken");
- ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsLoggedOut();
- _httpClient.DefaultRequestHeaders.Authorization = null;
- }
- }
首先要注意的是AuthenticationStateProvider,这是一个抽象类,由Microsoft.AspNetCore.Components.Authorization类库提供,它用来提供当前用户的认证状态信息。既然是抽象类,我们需要自定义一个它的子类,基于JWT和LocalStorage实现它要求的规则(即GetAuthenticationStateAsync方法):
- using System.Security.Claims;
- using Blazored.LocalStorage;
- using Microsoft.AspNetCore.Components.Authorization;
- public class ApiAuthenticationStateProvider : AuthenticationStateProvider
- {
- private readonly HttpClient _httpClient;
- private readonly ILocalStorageService _localStorage;
- public ApiAuthenticationStateProvider(HttpClient httpClient, ILocalStorageService localStorage)
- {
- _httpClient = httpClient;
- _localStorage = localStorage;
- }
- public override async Task<AuthenticationState> GetAuthenticationStateAsync()
- {
- var savedToken = await _localStorage.GetItemAsync<string>("authToken");
- if (string.IsNullOrWhiteSpace(savedToken))
- {
- return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
- }
- _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", savedToken);
- return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(savedToken), "jwt")));
- }
- public void MarkUserAsAuthenticated(string account)
- {
- var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, account) }, "apiauth"));
- var authState = Task.FromResult(new AuthenticationState(authenticatedUser));
- NotifyAuthenticationStateChanged(authState);
- }
- public void MarkUserAsLoggedOut()
- {
- var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
- var authState = Task.FromResult(new AuthenticationState(anonymousUser));
- NotifyAuthenticationStateChanged(authState);
- }
- private static IEnumerable<Claim> ParseClaimsFromJwt(string jwt)
- {
- var claims = new List<Claim>();
- var payload = jwt.Split('.')[1];
- var jsonBytes = ParseBase64WithoutPadding(payload);
- var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);
- if (keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles) && roles is string rolesText)
- {
- if (rolesText.StartsWith('['))
- {
- var parsedRoles = JsonSerializer.Deserialize<string[]>(rolesText);
- foreach (var parsedRole in parsedRoles)
- {
- claims.Add(new Claim(ClaimTypes.Role, parsedRole));
- }
- }
- else
- {
- claims.Add(new Claim(ClaimTypes.Role, rolesText));
- }
- keyValuePairs.Remove(ClaimTypes.Role);
- }
- claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));
- return claims;
- }
- private static byte[] ParseBase64WithoutPadding(string base64)
- {
- switch (base64.Length % 4)
- {
- case 2: base64 += "=="; break;
- case 3: base64 += "="; break;
- }
- return Convert.FromBase64String(base64);
- }
- }
逻辑并不复杂。以上代码需要读者对JWT和System.Security.Claims类库比较熟悉,建议初学者动手实践和调试。
ILocalStorageService自然是由上文提到的Blazor.LocalStorage类库依赖注入。
之前系列文章都提到了Blazor在.NET全栈开发下,具有极大的开发效率优势。这里就有体现——既然后端已经提供了接口,注意到LoginRqtDto类:
- using System.ComponentModel.DataAnnotations;
- public class LoginRqtDto
- {
- [Display(Name = "账号")]
- [Required]
- [StringLength(20, MinimumLength = 3)]
- public string Account { get; set; }
- [Display(Name = "密码")]
- [Required]
- [StringLength(20, MinimumLength = 5]
- public string Password { get; set; }
- }
我们自然可以将该类放到Shared项目中,使得前端Blazor项目在调用Login接口时可以不必再另写请求参数的Model。另外,不单单是类本身的属性,特性也可以被前后端共同利用,这一点放到下文再讲。
写完了该服务,可别忘了依赖注入!我的习惯是让Program.cs里的代码尽可能精简,因此,我会创建一个Extensions文件夹,添加ServiceCollectionExtension.cs文件:
- using Microsoft.AspNetCore.Components.Authorization;
- using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
- using Microsoft.Extensions.DependencyInjection;
- internal static class ServiceCollectionExtension
- {
- public static IServiceCollection AddAuth(this IServiceCollection services)
- {
- services
- .AddAuthorizationCore()
- .AddScoped<AuthenticationStateProvider, ApiAuthenticationStateProvider>()
- .AddScoped<IAuthService, AuthService>();
- return services;
- }
- }
现在只需要在Program.cs里加一行代码:
builder.Services.AddAuth();
设计登录页面
登录页面的独特之处,在于布局。例如内容页面是有侧边导航栏的,但登录页面显然就没什么必要了。因此,我建议单独写一个LoginLayout组件,和默认布局MainLayout分开,只用于Login页面:
- @inherits LayoutComponentBase
- <Layout Style="padding:0;margin:0">
- <Header Style="height:10%">
- <div style="margin:10px;">
- <AntDesign.Row Justify="space-around" Align="middle">
- <AntDesign.Col Span="8">
- <img src="/imgs/logo.png" style="align-self:center" />
- </AntDesign.Col>
- <AntDesign.Col Span="8" Offset="8" Style="text-align:center">
- <span style="color:white; font-size:24px">欢迎使用 @ProductionName 后台管理系统</span>
- </AntDesign.Col>
- </AntDesign.Row>
- </div>
- </Header>
- <Content Style="background-color:white; min-height:500px">
- <AntDesign.Row>
- <AntDesign.Col Span="20" Offset="2">
- <div style="margin:100px 0">
- @Body
- </div>
- </AntDesign.Col>
- </AntDesign.Row>
- </Content>
- <MyFooter />
- </Layout>
- @code {
- private const string ProductionName = "Demo";
- }
借助于Antd的Layout和Grid组件,可以很轻松地搭建整个Login页面的布局,这里我采用了最简单的上中下三层布局。注意到@Body,Body是一种约定命名,表示布局内的页面主体。
对Login页面来说,@Body其实就是账户输入、密码输入和登录按钮。让我们在Pages文件夹里添加一个Login.razor:
- @page "/login"
- @layout LoginLayout
- @inject NavigationManager NavigationManager
- @inject MessageService MsgService
- @inject IAuthService AuthService
- <AntDesign.Form Model="@_loginData" Style="height:100%"
- OnFinish="OnFinish"
- LabelColSpan="4"
- WrapperColSpan="4">
- <FormItem WrapperColOffset="10" WrapperColSpan="4">
- <AntDesign.Input Placeholder="请输入账号" AllowClear="true" @bind-Value="@context.Account">
- <Prefix>
- <Icon Type="user"></Icon>
- </Prefix>
- </AntDesign.Input>
- </FormItem>
- <FormItem WrapperColOffset="10" WrapperColSpan="4">
- <InputPassword Placeholder="请输入密码" @bind-Value="@context.Password">
- <Prefix>
- <Icon Type="lock"></Icon>
- </Prefix>
- </InputPassword>
- </FormItem>
- <FormItem WrapperColOffset="11" WrapperColSpan="2">
- <Button Type="@ButtonType.Primary" HtmlType="submit" Block>
- 登录
- </Button>
- </FormItem>
- </AntDesign.Form>
- @code {
- private LoginRqtDto _loginData = new();
- private async Task OnFinish(EditContext editContext)
- {
- var result = await AuthService.Login(_loginData);
- if (!result)
- {
- await MsgService.Error("帐号或密码错误!");
- return;
- }
- await MsgService.Success("登录成功!");
- NavigationManager.NavigateTo("/home");
- }
- }
我们使用@layout指令来指定当前页面组件使用哪一种布局;使用Antd提供的Form组件,可以很方便地完成控件布局并添加提交功能;再一次使用LoginRqtDto类,将其属性与控件的值双向绑定,实现最大化代码复用;使用依赖注入,在页面内方便地调用内置的NavigationManager和Antd提供的MessageService,分别用于页面跳转和消息提示。
页面效果如下:
登录页面依赖于Antd组件的出色实现,诸如密码的开闭显示等细节,都不必我们手动实现。还有一些细节并未在上面的代码里体现。例如,后端使用System.ComponentModel.DataAnnotations类库,可以很方便地对接口参数进行校验(如上文提到的LoginRqtDto类)。那么同样是使用C#,Blazor是否也可以这样做呢?
当然可以!Antd组件同样利用了接口参数的校验特性!相较于一般前后端开发,都需要通过API文档、团队纪律和组织沟通,来保证前后端各种数据和逻辑的一致性。而使用Blazor开发,在代码层面就可以天然地让前后端的行为一致!只要让定义接口的人将自己的数据放到Shared项目里即可。
前端校验提示(关于上图,有过Antd-Blazor开发经验的读者可能会好奇:这里校验提示为什么是中文而不是默认的英文?我将在下文“本地化校验提示”做简要说明。)
使用AuthorizeView组件动态显示内容
登录页面及服务设计好之后,还没有结束。对SPA应用来说,每个页面有自己单独的路由,用户可以手动输入路由绕过登录页面来访问其他页面。我们理所应当地希望如果用户未登录或认证失败,那么其他页面对用户将不提供任何有价值的数据。
对后端来说,数据相关的接口都必须加上[Authorize]特性,以校验访问者的身份。
对前端来说,应当以友好的方式提示用户登录,而不是依旧发送页面请求,依赖后端接口返回401或403再手动处理。
MainLayout和AuthorizeView组件可以帮助我们统一处理这种情况。
使用AuthorizeView组件之前,我们需要在App.razor文件里,使用CascadingAuthenticationState组件包裹Router组件:
- @using Microsoft.AspNetCore.Components.Authorization
- <CascadingAuthenticationState>
- <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
- <Found Context="routeData">
- <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
- </Found>
- <NotFound>
- <MyNotFound />
- </NotFound>
- </Router>
- </CascadingAuthenticationState>
- <AntContainer />
然后在MainLayout的Content部分使用AuthorizeView组件:
- <Content Style="background-color:white; min-height:500px">
- <AuthorizeView>
- <Authorized>
- @Body
- </Authorized>
- <NotAuthorized>
- <div style="margin: 100px 0; width:100%; text-align: center; color: red;">
- <span style="font-size:20px">检测到登录超时,请重新<a href="/login" style="text-decoration:underline">登录</a>!</span>
- </div>
- </NotAuthorized>
- </AuthorizeView>
- <BackTop></BackTop>
- </Content>
单从标签命名上看就很容易理解:认证通过则显示@Body的内容,否则显示一行字提示用户访问登录页。让我们看下不登录情况下直接访问Home首页的效果:
NotAuthorized时的Content这样,对于默认使用MainLayout布局的其他所有页面,若用户未认证,则只会显示上图的效果。同理,我们可以实现布局的Header部分动态显示:未认证情况下,不应显示上方“首页/关于”导航栏和右上方的账号信息,这里本文不再赘述。
本地化校验提示
至此本文核心内容都已经结束了。但在编写登录页面的过程中,有一个细节值得一提。
在设计登录页面一节中,我提到了前端校验提示。目前Antd组件在校验提示上,还是使用System.ComponentModel.DataAnnotations类库的默认提示:提示是全英文的。
在上文提到的LoginRqtDto中,我们可以使用Display特性,来修改校验失败提示时属性的展示名称。但并不能修改整个提示的内容,因此读者只会看到中英文混合的一段提示文本。
注意到校验特性的父类ValidationAttribute,有ErrorMessageResourceName和ErrorMessageResourceType两个属性。也就是说该父类在设计上,是支持本地化的,我们可以创建Resource资源,来替换类库默认的错误提示。
在XXX.Shared项目中,创建Resources文件夹,添加一个DA_zh_CN.resx文件(命名随意):
中文提示资源IDE VS会自动生成一个的DA_zh_CN.designer.cs文件,为你创建DA_zh_CN类。
将上文提到的LoginRqtDto改为:
- public class LoginRqtDto
- {
- [Display(Name = "账号")]
- [Required(ErrorMessageResourceName = "RequiredError", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]
- [StringLength(20, MinimumLength = 3, ErrorMessageResourceName = "StringLengthError_IncludingMin", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]
- public string Account { get; set; }
- [Display(Name = "密码")]
- [Required(ErrorMessageResourceName = "RequiredError", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]
- [StringLength(20, MinimumLength = 5, ErrorMessageResourceName = "StringLengthError_IncludingMin", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]
- public string Password { get; set; }
- }
好了,收工。这里resx文件里“名称”列,我也不是随意取的,而是照搬官方源码里的名称。有兴趣的读者可以参阅System.ComponentModel.DataAnnotations类库的相关源码。
我也希望未来能有更简单的方式实现控件本地化校验提示。
结束语
下一篇文章会简单许多,我将介绍如何使用Antd的Card组件和优雅的Razor语法,做一个可灵活配置的、用于导航的首页。再会!