资讯动态

拒绝过度设计:打造开箱即用的.NET快速开发框架实践指南

发布时间:2026/9/29 7:18:33 来源:尧图企业网站定制
做一个拒绝过度设计的 .NET 快速开发框架不是因为我懒而是因为我在一个 15 人的研发团队里亲眼见过“完美架构”如何把项目拖垮。那时我们讨论领域事件、CQRS 拆分和容器化编排的时间远超写业务接口的时间。半年后连第一个完整模块都没能上线。后来我换了一条路用一套刻意“简陋”的 .NET 快速开发框架三周就把核心业务跑通了。这篇文章就是想聊聊这套框架的设计取舍、开箱即用的边界以及我为什么坚定地拒绝掉那些看起来很高级的架构。1. 过度设计的病很多团队都犯过1.1 一个让我彻底转变观念的项目那是两年前公司要做一个内部运营管理系统涉及订单、库存、客户、审批流。启动会上架构师画了一整块白板的微服务拆分图按领域拆了 8 个服务每个服务独立数据库还规划了消息队列做最终一致性引入 Consul 做服务发现Kubernetes 负责编排前端用微前端。听起来很完善对吧实际开发时团队光是搭环境、联调服务间通信、处理分布式事务的补偿逻辑就耗掉了三分之一的时间。最终这个项目以 6 个人写了 4 个月、上线时仍有十几个挂起缺陷的结果收场。讽刺的是其中 80% 的接口本质还是单表或两表关联的增删改查。后来另一组同事用 ASP.NET Core 单体应用直接 EF Core 连一个库一周交付了同样的一个子模块。差别不在技术能力而在设计决策本身。所以当我自己动手做框架时第一条原则就写死了如果不是用户能直接感知的性能或可用性问题就不值得在架构层面投入。这不是短期主义恰恰是长期主义——把复杂度留在该留的地方把简单留给日常开发。1.2 “过度设计”的三个典型症状我总结了三个常见症状每个团队都能对号入座症状一会议时间花在架构名词上而不是业务语义上。当一个团队在评审会上讨论“聚合根到底该不该包含子实体”的时间超过讨论“审批流在下个节点要如何处理驳回”的时间架构就已经开始反噬业务了。业务团队想知道的是接口什么时候能提供、数据字段怎么对齐而不是你用了哪个模式的变体。症状二一个查询接口要穿透五个抽象层。常见的是 Controller → Service 接口 → Service 实现 → Repository 接口 → EF DbContext。理论上这叫依赖倒置但实际中这五层里有四层都是机械转调。我见过一个团队因为 Repository 泛型约束设计失误导致某个查询无法表达 Include 多级导航属性最终在 Service 层写了一大段拼接表达式。复杂度的总量不会消失它只会在你不注意的地方重新冒出来。症状三大量“为了未来”预留的代码。做过项目的人都懂为了将来可能要支持多租户每个表都加 TenantId为了将来可能要做软删除每个实体都加 IsDeleted。结果这些字段在两年里一次也没被查询过反而因为漏掉过滤条件导致过一次数据越权。预留的设计若没有明确触发条件大概率只是让团队背着未知的重量往前跑。2. 开箱即用到底该包含什么2.1 我理解的“开箱”底线清单“开箱即用”不是口号具体到 .NET 快速开发框架里我定义了一条底线清单。满足以下能力才算得上拿来就能干活登录认证与权限控制默认集成 JWT 认证用户、角色、菜单、按钮权限点可配置不需要从零写。统一响应模型与全局异常处理所有接口返回统一结构包括 code、message、data异常由中间件统一捕获并转成规范响应。操作审计日志谁在什么时候做了什么操作自动记录不侵入业务代码。自动生成代码的能力根据数据表结构生成 Entity、Service、Controller 等模板代码减少重复劳动。数据访问层的默认约定增删改查的基础方法直接可用复杂查询在生成的代码基础上扩展。Swagger 集成与联调环境支持接口文档自动生成方便前端与测试直接对接。清单之外的东西比如分布式缓存、消息队列、工作流引擎、多语言支持我刻意不纳入默认集合。用到再加而不是默认全送。框架一旦把用不到的能力塞给开发者它就成了另一种形式的“过度设计”。2.2 最核心的约定数据库即接口契约这套快速开发框架最重要的一个设计决策是让数据库表结构直接决定接口形态。我定义的约定如下表名规范业务表统一t_或biz_前缀系统表用sys_前缀框架启动时自动扫描注册路由。审计字段自动处理每个表如果有CreateBy、CreateTime、UpdateBy、UpdateTime字段框架在插入与更新时自动填充当前用户和当前时间不需要在代码里手动赋值。主键约定默认主键为Idbigint 自增或雪花ID生成器自动识别。软删除约定如果表包含IsDeleted字段查询时框架自动追加过滤条件删除操作自动改成 Update。字段命名映射数据库采用下划线命名如user_name实体自动生成驼峰属性UserName避免手工映射。这套约定的核心价值在于——消灭配置和映射的样板代码。开发者建好表生成器读表结构控制器和 Service 骨架马上就能跑起来。项目里新增一个模块物理时间大约在 10 分钟以内。2.3 代码生成器只生成长得一样的部分我见过不少团队对代码生成器态度微妙觉得它“没技术含量”。但根据我的经验代码生成器恰恰是“专注干活”的关键所在。团队里最枯燥的、长得一模一样的 CRUD 代码本来就该交给模板而不是让 10 个开发各写各的风格。我的生成器机制是这样的读取数据库里某张业务表的元数据包括字段名、类型、注释、主键、可空性。生成实体类文件包含字段属性、注释、导航属性的占位。生成 Service 接口与实现类基类BaseServiceT已实现分页、按条件查询、插入、更新、批量删除。生成 Controller路由规则为/api/{Module}/{TableName}默认提供分页、详情、新增、修改、删除五个接口。若检测到表包含Status字段额外生成启停用接口若包含Sort字段生成排序保存接口。生成后开发者做的事情只有两件在 Service 里加业务校验以及调整查询条件。我见过最快的一个下单接口从建表到接口跑通再加上库存扣减逻辑一共一个下午其中大半时间还是画前端页面。3. 核心实现从零接到业务只花半天3.1 一个订单管理的完整落地过程来看具体操作。假设我们有一个订单表DDL 大致如下CREATE TABLE biz_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) DEFAULT NULL COMMENT 订单编号, customer_id bigint DEFAULT NULL COMMENT 客户ID, total_amount decimal(10,2) DEFAULT NULL COMMENT 订单总金额, status int DEFAULT 0 COMMENT 状态0待支付1已支付2已发货3已完成, remark varchar(255) DEFAULT NULL COMMENT 备注, create_by bigint DEFAULT NULL COMMENT 创建人, create_time datetime DEFAULT NULL COMMENT 创建时间, update_by bigint DEFAULT NULL COMMENT 更新人, update_time datetime DEFAULT NULL COMMENT 更新时间, is_deleted tinyint DEFAULT 0 COMMENT 软删除标记, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;在生成器里选择这张表输出public class SysOrderEntity { public long Id { get; set; } public string OrderNo { get; set; } public long? CustomerId { get; set; } public decimal? TotalAmount { get; set; } public int Status { get; set; } public string Remark { get; set; } public long? CreateBy { get; set; } public DateTime? CreateTime { get; set; } public long? UpdateBy { get; set; } public DateTime? UpdateTime { get; set; } public bool IsDeleted { get; set; } }同时生成一个自定义 Service 骨架。我重点讲一下订单号和金额这两个业务的代码逻辑public static string GenerateOrderNo() { return DateTime.Now.ToString(yyyyMMddHHmmss) new Random().Next(1000, 9999).ToString(); } public override async Taskbool BeforeAddAsync(SysOrderEntity entity) { entity.OrderNo GenerateOrderNo(); entity.Status 0; return await base.BeforeAddAsync(entity); }框架的BaseServiceT里有一个模板方法模式的钩子BeforeAddAsync和AfterAddAsync。你不用理解复杂的 AOP 概念只需要记住想在插入前改字段或做校验就重写BeforeAddAsync想在插入后做点日志或联动逻辑就重写AfterAddAsync。灵活性和简单性同时拿到日常开发几乎没有理解成本。3.2 通用 CRUD 与业务扩展的不冲突写法有位读者之前问过一个问题如果所有实体都走同一个BaseServiceT那每个业务特有的查询条件怎么写我的答案是在生成的子类里写别动基类。举例订单列表需要按订单号模糊搜索、按状态筛选、按客户 ID 精确匹配。public class SysOrderService : BaseServiceSysOrderEntity { public async TaskPageResultSysOrderEntity QueryOrderPageAsync(OrderQueryDto dto) { var query Q.QuerySysOrderEntity() .WhereIf(!string.IsNullOrEmpty(dto.OrderNo), w w.OrderNo.Contains(dto.OrderNo)) .WhereIf(dto.Status.HasValue, w w.Status dto.Status.Value) .WhereIf(dto.CustomerId.HasValue, w w.CustomerId dto.CustomerId.Value); return await ToPageAsync(query, dto.PageIndex, dto.PageSize, dto.OrderBy ?? id desc); } }这里的Q.QueryT()是框架内置的查询构造器支持WhereIf、OrderBy、Include、Select等常用方法。它相当于一个极简的 Repository但不需要额外的接口定义。当一个查询变得足够复杂时再考虑原生 SQL 或存储过程而不是一开始就把数据访问层做成万能抽象。通用 CRUD 和业务扩展之所以不冲突核心在于“模板生成子类 基类兜底”的分层思路。生成器生成的类用partial修饰或者直接继承基类你可以在自己写的同名文件里放扩展方法。团队约定只新增不改坏。这样代码生成器后续重新生成时不会被你的手写代码覆盖掉。3.3 权限与日志默认接好用时再扩展权限我采用“用户-角色-菜单/按钮”的经典模型但做了显著简化。用户登录成功后根据角色返回菜单树和权限点集合Controller 接口通过一个特性标注权限标识比如[ApiPermission(sys_order_add)] [HttpPost] public async TaskApiResult AddAsync(SysOrderEntity entity) { return Ok(await _service.AddAsync(entity)); }框架在请求管线里拦截验证当前用户是否包含sys_order_add权限点。整个加起来不到两百行代码没有单独的权限微服务也没有引入 Policy Server。在内部管理类系统中这个方案稳定、易理解、排查问题很直接。操作日志则用了一个轻量机制全局过滤器记录请求路径、方法、参数、调用结果、耗时、操作人。这个过滤器在遇到[ApiPermission]标注时会顺带记录权限点名称后端的审计表sys_operation_log就能给出“谁在什么时间调用了什么接口、参数是什么、结果如何”的完整证据链。有合规审计需求时这个表就是第一手素材。4. 我刻意拒绝的那些“高级架构”4.1 不引入微服务的理由很多人觉得微服务是工程能力的标志。但在中小型业务系统里微服务往往是“用一个高复杂度替代另一个复杂度”。单体应用只需要解决一个问题——部署单点微服务要解决注册发现、配置中心、链路追踪、分布式事务、跨服务鉴权、日志聚合、容器编排等一系列问题。每个问题本身都能写一本书但它们全部都是额外的运维负担。我的经验标准是除非单个服务已经出现明确需要独立扩缩容的模块或者团队人数超过 50 人需要独立发布边界否则老老实实用单体。单体不是妥协而是清醒。将来真要拆你可以按业务边界把模块拆成独立的 Minimal API而框架前期并不阻碍这个方向。4.2 不滥用 DDD贫血模型是务实的妥协DDD 里最迷人的部分是聚合、值对象、领域服务、事件溯源它适合复杂业务规则的场景。但这套快速开发框架面向的是“运单管理、商品管理、报表查询、配置维护”这种大量 CRUD 加少量校验的活。这种业务用领域驱动只会让简单的事情变复杂。我选择的是贫血模型。实体就是数据载体Service 承载业务逻辑。这不是理论上的最优解但它是团队协作里的高效解新人能快速上手测试能准确预估工期代码评审可以集中讨论业务规则而不是概念边界。什么时候值得用 DDD当一个模块的业务规则复杂到 Service 层塞满了 if-else、出现多个实体状态联动修改、有明确业务不变式需要维护时再局部引入聚合和领域服务。值得为此单独开模块但这套框架不默认提供。4.3 不做消息队列和分布式事务我的框架默认不引入消息队列逻辑也很简单在单库单服务的情况下一个数据库事务就足够保证一致性。用消息队列 最终一致性来解决本地事务能解决的问题纯属给自己加戏。比如下单流程创建订单 扣减库存 记录操作日志这三步在单体应用里放一个IUnitOfWork事务里执行失败全回滚逻辑清晰、数据可靠。如果拆成订单服务和库存服务再为了补偿库存写一堆生僻的 Saga 代码收益在哪里项目初期并发并没有高到单库事务撑不住。架构里应不应该有消息队列我的判断标准是是否有模块需要异步且削峰地处理高吞吐数据是否有消费者和生产者生命周期不同步比如 A 服务写入事件而 B 服务可能几小时后再处理是否需要发布订阅让多个下游对同一事件做出反应。只有出现这些真实场景时才考虑引入。事务消息、死信队列、顺序消费这些高阶概念留给确实需要它们的团队。5. 团队使用这套框架的真实体验与边界5.1 新成员上手时间与交付速度的变化我统计过两组数据曾用“完整分层架构”的团队一个刚毕业的新人从入职到提交第一个可用的业务接口平均需要 3 到 5 个工作日主要时间花在理解 Repository 抽象、依赖注入容器配置和项目分层规范上。换到这套框架后新人半天看文档一天半就能提交一个分页查询接口。因为框架强制性约定了几乎所有常见操作的写法不需要在“该用哪个类、该注入什么接口”上做判断。交付速度上一个常规表单页配合后端接口包含校验、权限、日志、分页老手基本可以按小时计。我曾经把一个 40 张表模块的 MVP 交付时间压缩到 9 个工作日其中还包含前端页面的开发。高效的感觉很好但你得撑得住诱惑——快速交付容易让人想继续压榨更多需求这时候反而要小心。5.2 两个必须提醒的坑坑一生成器生成的代码不要过早“高级化”。团队里总有人喜欢把BaseServiceT里的 Add 方法改成 virtual、塞进事件通知、加上各种扩展点美其名曰为将来考虑。我的建议是——你为“将来”做的每一次重构都在用今天的稳定性做赌注。框架的核心是约定约定就要稳定。真要加扩展点在业务子类里做不要在框架底层做。坑二约定要写进团队规约而不是停留在框架代码注释里。我对团队只立了一条简单规矩表结构先评审再生成代码生成代码后手写逻辑放进扩展文件不回改生成文件。如果这条规矩被破坏很快就会出现代码被覆盖导致业务逻辑丢失的事故。所以我把团队规约文档放在 Wiki 首页并在代码仓库里加了根目录 README 与 PR 模板自动提醒开发者检查。5.3 什么时候应该放弃这套框架再务实的框架也有边界。我的经验是出现以下三个信号时要认真考虑迁移或局部替换领域复杂度已经显著超过 CRUD 范畴比如要维护复杂状态机、跨实体的业务不变式、复杂的领域规则计算。这时需求在倒逼更丰富的建模贫血模型会显得力不从心。并发和性能要求达到较高水位比如单表数据量千万级以上且查询模式复杂、需要专用缓存策略或读写分离。框架默认实现可以辅助但你需要额外的专业工程设计。多团队独立交付同一平台每个团队都有自己的发布节奏和部署边界单体代码库会变成发布冲突的集火点。这时拆分服务其实是组织沟通成本的体现。这几个信号出现时不要犹豫去演进架构。框架也不该成为拒绝成长的借口——它只是帮你在 80% 的常规业务里省下时间好让你有精力去处理真正复杂的 20%。从动手写这套框架到现在我最大的体会是技术选型不是秀肌肉而是帮团队把力气花在用户看得见的地方。一个拒绝过度设计的 .NET 快速开发框架表面上少了很多“东西”实际上开发者获得的是清晰的约束和极低的决策成本。项目里少了抽象层、少了一堆待维护的“弹性机制”代码反而更结实了。如果你也正在为一个小团队、一堆限定场景的增删改查而苦恼不妨从极简开始——把约定定清楚把生成器用好剩下的时间让业务跑起来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑