ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ASP.NET Core远程验证:让服务端成为唯一校验权威

ASP.NET Core远程验证:让服务端成为唯一校验权威 如果某个功能上线第一周就被提了三个 Bug可能不是代码写得不好而是方案选错了。我们在做一套后台管理系统的时候遇到了一个看起来很简单、却反复出问题的字段——用户名。最初实现不难后端写一个方法查询数据库确认不存在就能注册。问题出现在几天后产品提了一个新需求用户输入完用户名要在离开输入框的时候立刻收到“已被占用”的提示。前端同事很快接了一个接口后端同事也写了一个对应的校验方法。逻辑本身不复杂但两边规则逐渐出现了偏差前端用Trim()后的字符串后端却用原始字符串前端只查IsDeleted false的记录后端却把逻辑删除的也算进去了。结果用户在前端看到“用户名可用”提交后端后却被拒了或者反过来前端一直提示占用后端却能够正常保存。这不是一个简单的协作问题而是校验架构的问题。后来我们把这部分重构到了 ASP.NET Core 的远程验证Remote Validation上把校验逻辑彻底收归服务端客户端只负责发请求、渲染结果。这个系列的前两篇聊了 .NET 9 里的启动流程和中间件这一篇想专门把远程验证讲透它不是把后端校验搬到前端也不是在前端多调一个接口而是让“字段是否合法”这个判断在体系里重新有了唯一的权威源。1. 先搞清楚远程验证真正解决的是哪类问题1.1 同一个字段的校验为什么会分成两套逻辑很多团队一开始会把校验分成轻重两级前端做即时交互后端做最终拦截。这个思路本身没问题但实际落地时最容易失控的地方是“同一份校验规则到底由谁维护”。纯前端校验的问题是规则暴露在浏览器里可以被绕过。纯后端校验的问题是每做一次校验用户就要等一个完整请求体验割裂。远程验证走了一条中间路线规则只写在服务端前端通过异步请求向服务端询问“这个值当前是否合法”拿到结果后再决定给用户展示什么提示。这么做的本质是把“校验逻辑”从客户端剥离出来而不是把“校验动作”从输入框挪到接口里。我在最初接手时也误判过觉得远程验证无非就是在前端的 change 事件里发一个 Ajax然后把返回结果塞进错误提示区域。后来才发现它真正的价值是——当只有一套校验规则时你不会再遇到“前端认为通过、后端认为不通过”这类问题。服务端就是规则本身前端只是规则的一个展示层。1.2 远程验证适合处理哪些字段又不适合碰哪些字段先说适合的场景通常具备两个特征校验结果依赖服务端状态而且这种状态不能完整预留给客户端。典型例子用户名、邮箱、手机号是否唯一邀请码或折扣码是否有效业务单据编号是否存在用户输入的旧密码是否正确区域和城市组合是否有对应关系这类校验的特点是本地根本没有足够的信息做判断必须查数据库或其他服务。不适合的场景也要说清楚。如果某个字段只做格式校验比如邮箱格式、长度限制、必填项就不值得走远程验证。这类规则在客户端就能完成远程请求只会放大网络延迟还会增加服务端压力。更需要注意的一类是敏感数据校验。身份证号、完整手机号、银行卡号等字段如果通过远程验证接口传入查询会在网络日志、数据库慢查询日志甚至浏览器历史里留下完整痕迹。我之前接手过一个项目为了“提升体验”在用户名框里同时校验手机号是否已被注册结果请求里直接带着完整手机号四处转发。后来我们改成哈希后比对才勉强把风险降下来。所以远程验证适合解决“依赖服务端状态的轻量查询”不适合当作所有校验的统一入口。2. 在 .NET 9 中启用远程验证的完整流程2.1 环境准备与一个最小可运行示例在 ASP.NET Core MVC 项目里启用远程验证依赖的是 .NET 自带的RemoteAttribute不需要额外安装太多包。客户端通常使用jquery-validation-unobtrusive它已经包含了>public class RegisterViewModel { [Remote( action: ValidateUsername, controller: AccountValidation, HttpMethod HttpVerbs.Post, ErrorMessage 用户名已被占用 )] public string Username { get; set; } }对应的控制器方法[AcceptVerbs(HttpVerbs.Get, HttpVerbs.Post)] [Route(account/validate-username)] public async TaskIActionResult ValidateUsername([FromBody] string username) { if (string.IsNullOrWhiteSpace(username)) { return Json(用户名不能为空); } var exists await _userRepository.IsUsernameTakenAsync(username.Trim()); if (exists) { return Json(用户名已被占用); } return Json(true); }这里RemoteAttribute的action和controller参数用来定位校验入口。视图渲染时Razor 会把这个特性转换成一段 HTML 标签属性最终在前端表现为类似>script src~/lib/jquery-validation/jquery.validate.js/script script src~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js/script这样一个最小链路就通了用户输入用户名离开输入框后浏览器发起请求服务端执行校验然后把结果回传给前端渲染。2.2 关键参数HttpMethod、AdditionalFields、ErrorMessageRemoteAttribute有几个参数实际使用中比较关键。HttpMethod默认是 GET但很多校验动作会涉及查询甚至在某些业务里会顺带写一点操作日志。为了避免查询参数出现在服务器日志和浏览器历史里更稳妥的做法是使用 POST并通过请求体传递参数。上面代码里就明确指定了HttpMethod HttpVerbs.Post。AdditionalFields用来声明“当前校验还依赖其他字段”。比如“编辑用户名”时需要排除当前用户自己往往还要传一个UserId进去。这个参数的值是逗号分隔的属性名例如[Remote( action: ValidateUsername, controller: AccountValidation, AdditionalFields __RequestVerificationToken, UserId, HttpMethod HttpVerbs.Post )]如果校验规则依赖多个字段Action 的方法签名也要对应加上这些参数public async TaskIActionResult ValidateUsername(string username, int userId)ErrorMessage不仅仅是后端校验失败时显示的错误消息它还会生成远程验证失败时的前端错误文案。不过更推荐的做法是服务端返回不同的错误信息由服务端动态决定提示内容而不是在特性里写死一个固定字符串。2.3 服务端返回格式true 还是字符串jquery-validation-unobtrusive对远程验证的请求结果有固定约定返回 JSONtrue表示校验通过。返回 JSON 字符串通常是错误消息表示校验不通过这段字符串会显示到表单的错误位置。所以服务端 Action 可以直接返回Json(true)表示通过返回Json(错误原因)表示不通过。不要返回一个复杂对象也不建议返回 HTTP 400 状态码因为要让脚本自动识别最简单的协议就是布尔值和字符串。这里有一个经验之谈尽量把“这个值不合法”的信息整理成对用户友好的提示而不是“校验失败”这种面向开发的文字。比如不是用户名应该返回“用户名已被占用”不是错误而是“该邀请码已于昨天过期”服务端返回字符串本身就在承担业务提示的角色。远程验证接口不是内部 API它直接对接用户界面所以输入输出协议都要按用户体验来设计。3. 生产环境里最容易踩的五个坑最小链路跑通只是开始。真正让远程验证变得头疼的往往是下面这几个问题。3.1 客户端校验可以被绕过服务端校验始终不能省远程验证给用户提供了即时反馈但这不等于服务端可以只依赖这个验证。攻击者完全可以绕过浏览器直接构造请求提交到服务器。在提交数据时服务端控制器里仍然要使用ModelState.IsValid并且要再次调用IsUsernameTakenAsync或者依赖数据库唯一索引来做最终保护。远程验证更像是一个体验优化层真正的安全边界必须留在提交前的模型校验和业务逻辑层。我看到过最危险的做法是把远程验证接口当成“唯一校验入口”提交时不再校验认为“反正用户输入时已经查过了”。这是典型的把体验手段当成了安全边界。3.2 远程请求失败时默认行为可能不是你想要的想象一个场景远程校验接口因为数据库连接超时返回了 500前端脚本拿到了非预期响应。这时候表单应该允许提交还是阻止提交从产品体验角度如果校验服务挂了用户不应该卡在“无法提交”的绝境里。但从数据完整角度如果校验服务不可用贸然放行可能产生脏数据。这两种诉求需要明确取舍。常见做法是在客户端脚本里监听远程验证失败事件当网络异常或服务端返回 500 时按“校验不通过”处理并给出“暂时无法校验请稍后重试”的提示。这样宁可让用户在那一刻没法提交也不能让一条已经冲突的数据进入数据库。另外需要给远程校验接口配置合理的超时时间比如 5 秒。如果服务端长时间没有响应浏览器会一直转圈用户会以为页面卡死了。3.3 高频请求对数据库的压力远程验证最常见的实现是每次输入变化后都向后端发起一次查询。用户输入“username”这个过程可能触发七八次请求。如果查询没有走索引或者表数据量大数据库压力会明显上升。常规处理手段包括在前端加防抖debounce通常 300 到 500 毫秒后再触发。在服务端做短时间缓存比如同一参数 1 秒内返回相同结果。给查询字段建立唯一索引或者至少建立加速唯一检查的索引。更稳妥的做法是远程验证接口只访问一个经过优化的查询方法不要顺手关联多张表。毕竟它只负责回答一个问题“这个值是否已经存在”3.4 不要把远程接口当成“可拖动的查询接口”有些远程验证接口会在返回校验结果的同时把用户的基本信息一起塞进 JSON 返回给前端。比如校验用户名唯一时把注册时间、手机号、联系地址都带上了。这么做虽然方便但会让浏览器获得不该看到的业务数据。远程验证接口的返回协议应该控制在最小范围要么是true要么是错误提示字符串。不要返回数据库实体不要返回业务对象列表。3.5 并发提交与竞态条件仔细想一下“用户名是否唯一”这个校验过程用户输入时远程验证查了一次结果是“不存在”用户点击提交服务器又查了一次结果变成“存在”了。第二次是谁先改的可能是另一个用户刚好注册了一样的用户名。这属于典型的竞态条件。远程验证只能在提交前降低冲突概率不能保证提交那一刻一定唯一。解决方式要在数据库层做给用户名列加唯一索引或者在事务里做条件插入。远程验证负责的是“让绝大多数冲突在输入阶段就被发现”数据库唯一索引负责的是“即使并发之间出现争抢最终也只有一个成功”。4. 从跑通到可上线的排查链路4.1 按顺序定位问题而不是上来改代码远程验证出了问题现象往往很统一输入框没有给出任何提示或者提示一直显示“校验失败”但看不出原因。我通常按以下顺序排查先看请求有没有发出去。打开浏览器开发者工具在 Network 面板里看是否有ValidateUsername的请求。如果没有说明 Remote 特性没有被正确渲染或者相关>public interface IAccountValidationService { Taskbool IsUsernameTakenAsync(string username, CancellationToken cancellationToken); }控制器里调用它[AcceptVerbs(HttpVerbs.Get, HttpVerbs.Post)] [Route(account/validate-username)] public async TaskIActionResult ValidateUsername( string username, CancellationToken cancellationToken) { var normalized username?.Trim() ?? string.Empty; if (await _accountValidationService.IsUsernameTakenAsync(normalized, cancellationToken)) { return Json(用户名已被占用); } return Json(true); }这样的话远程验证 Action 不直接关心数据库细节不涉及业务表结构变化。之后就算换了 ORM、换了表名只要服务接口不变前端逻辑和控制器可以保持稳定。5.3 什么时候应该放弃远程验证远程验证不是万能方案。遇到以下场景我会更倾向于不用它离线优先应用比如移动端 App 在弱网环境使用每次输入都要等服务器响应体验会很差。此时更适合把唯一性校验放到表单提交阶段统一处理。高频低延迟接口如果校验请求的 TPS 已经到了每秒几百次且大部分参数都能在本地缓存判断这就不适合让每个字符都产生远程请求。应该在前端维护一份“最近已确认可用”的本地缓存。内部系统用户量小、业务简单、后端可直接返回完整错误列表的场合远程验证显得多余让提交按钮后再统一显示错误往往更简单。当远程验证开始成为性能瓶颈或者你需要为一个字段维护复杂的依赖规则时请考虑是否真的值得让用户承受“一次输入一次请求”的代价。5.4 回到最开始的主判断远程验证这个能力在 ASP.NET Core 里已经存在了很多年.NET 9 延续了它的机制也让它在现代前端工作流中更顺滑。但它的核心价值没有变让服务端成为校验规则的唯一权威同时让客户端保留即时反馈的体验。它不是用来替代安全校验的也不是为了让后端少写几行代码。它解决的是规则分散带来的系统性问题。只要记住这一点就不会在选型时高估它也不会在实现时低估它。最终能让一个字段不再反复出错的原因往往不是某个魔法特性而是把规则放到了一个明确的位置并且用三层校验把所有边界都看住了。
返回列表