ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

三层架构与依赖注入:从依赖方向到落地实践,彻底理清边界

三层架构与依赖注入:从依赖方向到落地实践,彻底理清边界 上周末帮朋友评审一个半年新人的项目订单模块的一个业务方法里他直接 new 出一个 SqlConnection然后手写 SQL、拼接 where、循环 DataTable 把结果塞进字典整个方法七十多行中间还夹着两条业务规则。我问他为什么不用三层架构他有点委屈“Controller、Service、DAO 我都分了文件夹呀。”可问题恰恰就出在这——把代码放进不同文件夹和真正建立起三层架构的边界是两码事。连带绕不开的还有依赖注入因为三层架构里层与层之间该怎么协作最终会指向同一个话题依赖。今天这篇笔记就把这两件事一次性聊透顺带结合 Java Spring 和 WinForm Dapper 两个场景说下落地经验。1. 三层架构被误解最深的地方分文件夹不等于分层1.1 分层解决的是“依赖方向”不是“代码位置”很多初学者理解三层架构第一反应是建三个文件夹或者三个工程然后照着“UI 放页面、BLL 放业务、DAL 放数据库操作”往里面塞代码。说实话这种划分本身没错错的是只做了“物理隔离”没做“逻辑隔离”。所谓逻辑隔离核心是依赖方向的单一性。在经典三层架构里依赖关系是单向的表现层依赖业务层业务层依赖数据访问层数据访问层不反向依赖任何上层。也就是说UI 不应该知道数据库连接长什么样DAL 里不应该出现“这个订单是否允许删除”这种业务规则。我判断一个项目到底有没有真正的三层从来不看文件夹只看两件事能不能在不改动 UI 和业务层的前提下把数据访问层从 SQL Server 换成 PostgreSQL或者从手写 ADO.NET 换成 Dapper业务层能不能脱离数据库和界面单独跑单元测试如果两个答案都是“不能”那不管文件夹分得多整齐本质上还是“伪装成分层的单层架构”。层与层之间的依赖一旦反向或者跨层直连所谓的层就只是摆设。1.2 三个层各自该管什么不该管什么网上讲三层职责的文章很多但大多讲得太抽象。我用自己的话总结一份非常具体的分工表几乎可以直接拿来当团队评审标准层该管的不该管的表现层UI/Controller参数接收、基础格式校验、会话获取、视图模型装配、提示信息展示业务规则、SQL 语句、事务控制、数据库连接业务层Service/BLL业务规则校验、用例编排、事务边界、领域状态流转SqlConnection、DataTable 直接返回给 UI、控件对象、字符串拼 SQL数据访问层DAL/RepositorySQL 或 ORM 操作、参数映射、结果集转实体、持久化细节if (订单金额 1000) 这类业务分支、登录状态判断、界面逻辑这里重点说一下业务层和表现层之间的界线。很多人会把“邮箱格式是否正确”“手机号是不是 11 位”这种校验写在 Controller 里图省事。但问题是这类规则一旦在多个入口出现比如网页端、接口、定时任务你就得复制三遍。真正的做法是表现层只做“这个参数是不是必填”这种通用校验业务规则必须下沉到 Service。同理业务层最常见的越界行为是在方法里写出长 SQL。Service 拿到一个数据访问层返回的实体列表再进行内存过滤本身没有错但 SQL 里的 where 条件如果拼出了业务语义等于是把数据访问层的实现细节泄露到了业务层。Dapper 这类工具特别容易让人犯这个毛病因为它太灵活了随手就能 Query 一把。1.3 一个用户模块的例子边界是怎么被需求和测试逼出来的我拿一个最常见的需求来演示边界混乱的后果用户注册后来产品经理说要加一条规则——“用户必须用企业邮箱注册”。如果当时没有真分层最省事的写法是在前端提交时加一个正则或者在 Controller 里判断一下邮箱后缀。第一版跑得很开心直到第二个入口出现后台管理员手动创建用户也要走这条规则。你发现规则没法复用只能在多的地方再写一遍。再过一阵子第三方系统通过 API 接入你又得写第三遍。到那时“企业邮箱校验”这条规则散落在三个地方改一个域名后缀你得同时改三处漏一处就出线上事故。如果层边界清楚事情就简单得多在 UserService.CreateUser 方法里加一条 if (!IsCompanyEmail(user.Email)) throw;Controller、后台管理、API 全走同一个 Service规则天然只存在一份。而且这个 Service 的单元测试能直接构建假数据访问层来验证不用启动数据库。我经常用这个例子跟人解释“边界是什么”边界不是类名、文件名、文件夹名而是当你面对一次需求变更时改动是否被限制在一个地方。被限制住了说明边界有效改一处崩三处说明分层只是看起来存在。2. 依赖注入为什么是三层架构的刚需而不是加分项2.1 没有DI时Service层是怎么慢慢变成代码泥潭的三层架构的分层思想出现得很早但光有分层还不够因为层与层之间要协作必须有一个对象知道另一层的对象。新手最自然的写法是直接 newpublic class UserService { private UserRepository userRepository; public UserService() { this.userRepository new UserRepository(); } }这行代码看起来没问题但它破坏了三层架构的边界UserService 不只知道 UserRepository 这个抽象还知道 UserRepository 的具体实现、它的构造参数、它依赖的数据库连接方式。一旦 UserRepository 构造函数变了比如从无参变成需要传入 DataSource所有 new 过它的地方全部要跟着改。更麻烦的是这些需要 new 的依赖往往不止一个。现实中的 Service 不会只依赖一个 Repository它可能还要依赖邮件服务、日志服务、配置中心。每注册一个依赖构造函数里就多一段 new 的代码。用不了几个月Service 层就会变成一堆 new 链条你想测试它还得真实连库、真实发邮件、真实连第三方。这就是为什么我说依赖注入对三层架构不是“优化项”而是“必需品”。三层架构的价值在边界清楚而边界靠接口维持接口的价值在实现可替换而可替换如果没有外部注入就只能靠改源码完成。新人代码里那种一言不合 new 的现象本质上是把依赖写死在类内部把架构退化成“两层”界面和数据库。2.2 手工构造器注入最简单也最容易被忽略的DI形式很多人一听依赖注入先想到 Spring 或者 Autofac其实依赖注入本身是个很朴素的技术把依赖从“类自己创建”改成“由外部传进来”。不借助任何容器光靠构造函数就能做到。public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository userRepository; } }调用方也不复杂在程序入口处统一组装UserRepository repository new SqlServerUserRepository(); UserService userService new UserService(repository); UserController controller new UserController(userService);这其实就是依赖注入而且是很多大型项目里最推荐的形式——构造器注入。它不依赖任何框架却已经完整实现了依赖倒置UserService 依赖的是 UserRepository 这个抽象接口或抽象类而不是某个具体数据库实现。我见过不少项目一上来就上重量级容器结果团队成员连“谁负责创建对象”都没搞清楚到处 Autowired出了循环依赖一脸懵。所以我一直建议在引入 IoC 容器之前先手工写两周构造器注入。这个过程会逼你想清楚两个问题——每个类到底依赖什么对象的装配集中在哪里想清楚了再用容器就不会乱。2.3 从“面向接口编程”到“控制反转容器”的关键一步手工构造器注入往上一步就是 IoC 容器。容器的核心价值是把“组装对象”这件事从各个业务类里拿走集中到一个叫“组合根”的地方统一处理。这里涉及一个很多新人绕不过去的概念控制反转。到底反转了什么原来是 UserService 在构造函数里决定“我依赖谁、什么时候创建、什么时候销毁”现在这些都交给外部容器UserService 只负责声明“我需要一个 UserRepository”至于传进来的是 SQL Server 实现还是内存实现它不关心。这就是从“自己控制”变成“被外部控制”所以叫反转。容器带来的第二重价值是生命周期管理。手工 new 的时候没人管 UserRepository 是每次新建好、还是整个应用共用一个好。容器可以通过注册代码统一指定 Transient、Singleton、Scoped而且可以保证同一个请求内拿到的对象是同一个。这一点在桌面应用、Web 应用里差异巨大。但我要泼一盆冷水IoC 容器不是必需品。一个人维护的小工具、两三个窗体的 WinForm 程序手工构造器注入完全够用。Spring Boot 和 .NET 自带容器普及之后很多人误以为“用了 Spring 就等于会依赖注入”真相是 Spring 只是把容器塞到你面前架构有没有变清楚还是看你接口设计和管理生命周期的水平。3. Java侧落地Spring Boot里的DI怎么用才对3.1 字段注入、Setter注入、构造器注入我为什么一直推构造器Java 侧的依赖注入几乎就是 Spring 的代名词。但 Spring 的注入方式有三种团队群里经常因此吵起来。// 字段注入 Service public class UserService { Autowired private UserRepository userRepository; } // Setter 注入 Service public class UserService { private UserRepository userRepository; Autowired public void setUserRepository(UserRepository userRepository) { this.userRepository userRepository; } } // 构造器注入Spring Boot 单构造函数时连 Autowired 都不用写 Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository userRepository; } }我的立场很明确能用构造器就绝不用字段注入。原因有三点。第一构造器注入能强制依赖在对象创建时就满足字段注入则允许对象先创建、依赖后填。很多空指针和“启动没事、运行一半报错”的诡异问题都来自字段注入下依赖没有被正确赋值。第二构造器注入方便测试。写单元测试时直接用 new UserService(mockRepository) 就能把假依赖传进去不需要 Spring 上下文不需要反射干干净净。第三构造器注入能优雅地暴露设计问题。当你发现一个 Service 的构造函数有五六个参数你第一时间意识到这个类职责太重了换成字段注入你能看到的就是一堆 Autowired 整齐排在那儿问题被隐藏得非常体面。字段注入唯一能拿出来说的优点是“少写模板代码”但这点便利换来的耦合和隐蔽完全不值。Spring 官方多份文档也在推构造器注入团队新项目直接把它写进规范省得反复解释。3.2 接口放哪、实现放哪、DTO在哪转换一套可复制的分层约定三层架构落到 Spring Boot最典型的分层是 Controller - Service - Repository。真正难的不是命名而是接口与实现的摆放、数据模型的转换位置。我建议一个非常朴素的约定Controller 只依赖 Service 接口不直接见 Repository。Service 依赖 Repository 接口接口里只出现业务对象和参数不出现 JdbcTemplate、SqlSession 这类技术细节。Repository 的接口定义在业务侧实现在基础设施侧。比如 UserRepository 接口放在 service.repository 包下UserRepositoryImpl 放在 infrastructure.repository 包里。举个例子会更清楚public interface UserRepository { OptionalUser findById(Long id); void save(User user); } Service public class UserServiceImpl implements UserService { private final UserRepository userRepository; public UserServiceImpl(UserRepository userRepository) { this.userRepository userRepository; } Override public void createUser(CreateUserCommand command) { if (command.getEmail().endsWith(internal.com)) { throw new IllegalArgumentException(不能注册内部邮箱); } User user new User(command.getName(), command.getEmail()); userRepository.save(user); } }这里有一个很多人纠结的问题Service 层要不要也定义接口我的答案是Controller 依赖的 Service 可以定义成接口但如果团队规模不大、接口只有一种实现你完全可以拿具体类直接注入。区别在于Repository 一定要接口化因为它是数据访问层和业务层的边界需要经常替换、模拟Service 往往是业务规则的核心载体过度抽接口反而会让阅读代码的人多跳一层。DTO 转换的位置同样要约定固定。我的习惯是Controller 拿到请求参数后转成 Service 需要的 Command/Params 对象Service 业务完成后返回 Service 层的 Model/DTOController 再做一次组装转成前端需要的 ViewModel。绝对不要出现 Controller 直接把 HttpServletRequest 传进 Service 的做法那等于把表现层技术泄漏给了业务层。3.3 循环依赖的本质是架构坏味道别靠Lazy糊弄Spring 开发中循环依赖的报错信息大概长这样“Bean named userService is requested in a circular reference”。新手第一时间去搜“如何解决循环依赖”搜到一堆 Lazy、DependsOn 的偏方然后往代码里一贴报错消失了架构问题却留下来了。我见了太多循环依赖实例真正的根因只有一个两个类都不该互相持有对方。A 依赖 BB 又依赖 A说明要么有一个公共逻辑被切错了位置要么两个类的关系根本不是单纯的依赖而是相互协作的兄弟关系应该由第三层来调度。比如说OrderService 为了判断用户是否买了东西而依赖 UserServiceUserService 又想查询订单统计而依赖 OrderService。正当的解法是把这个交叉逻辑抽到一个新的协作者里比如 OrderStatsService它同时依赖 UserRepository 和 OrderRepository然后把数据组装好而不是让两个 Service 互相注入。Spring 默认允许字段注入下的循环依赖但对构造器注入的循环依赖直接拒绝这就是我前面力推构造器注入的另一个原因它能在启动时就暴露问题而不是等到运行期某次调用才爆炸。遇到报错先检查依赖方向再考虑重构而不是急着加 Lazy。4. WinForm老项目改造当我用Dapper和DI重建一个小工具4.1 从Program.cs开始把IServiceCollection引入WinForm说完 Java再说一个很多人懒得碰的场景WinForm 老项目。大多数 WinForm 代码都是双击按钮然后直接在事件里 new SqlConnectionSQL 和界面糊在一起。改造这类项目优先引入的不是什么重型框架而是 Microsoft.Extensions.DependencyInjection 这个标准容器再来一个轻量 ORM——Dapper。WinForm 的入口是 Program.cs 的 Main 方法。传统写法是直接 Application.Run(new LoginForm())改造后我们应该在这里建立容器[STAThread] static void Main() { ApplicationConfiguration.Initialize(); var services new ServiceCollection(); services.AddTransientIDbConnection(sp new SqlConnection(ConfigurationManager.ConnectionStrings[Default].ConnectionString)); services.AddTransientIUserRepository, UserRepository(); services.AddTransientIUserService, UserService(); services.AddTransientLoginForm(); services.AddTransientMainForm(); using var provider services.BuildServiceProvider(); Application.Run(provider.GetRequiredServiceLoginForm()); }注意注册顺序不重要但有几个细节必须强调窗体要注册成 Transient否则会出现关闭后再次打开时数据残留的问题SqlConnection 不要注册成 Singleton因为 SQL Server 连接对象本身不是线程安全的而且长连接很容易耗尽连接池。4.2 Dapper仓储层怎么写才能保持边界干净Dapper 的特点就是轻、快不强制任何设计。正因为不强制很多人用它写出更烂的代码——直接在 UI 里 Query或者让 Service 方法第一行就执行 var conn new SqlConnection(...)。我的做法是把 Dapper 完全封装在 Repository 里Service 层只能看到接口方法感觉不到 Dapper 存在public class UserRepository : IUserRepository { private readonly IDbConnection _connection; public UserRepository(IDbConnection connection) { _connection connection; } public async TaskUser GetByIdAsync(int id) { var sql SELECT Id, Name, Email FROM User WHERE Id Id; return await _connection.QueryFirstOrDefaultAsyncUser(sql, new { Id id }); } public async Task SaveAsync(User user) { var sql INSERT INTO User(Name, Email) VALUES(Name, Email); await _connection.ExecuteAsync(sql, user); } }连接对象通过构造函数注入Repository 自己不创建、不释放连接。释放连接的责任在谁那里通常是在调用方或者容器的作用域管理里。WinForm 没有像 Web 那样的请求作用域所以我要么在 Service 方法里用 using 明确管理要么注册一个 ConnectionFactory让每次调用都拿到新的短连接。强调一句SQL 参数一律用 占位符严禁拼接字符串Dapper 的匿名参数对象并不复杂拼字符串省的那点时间远不够后面一次 SQL 注入事故赔的。4.3 生命周期和窗体弹出的坑单例不能随便用在 WinForm 里用 DI最容易踩的生命周期坑有三个。第一个坑是窗口重复打开。有人图省事把 MainForm 注册成 Singleton第一次打开没问题关闭后再次打开发现表单数据还是旧的、控件事件被重复订阅、甚至报对象已释放。从我实践看所有窗体都注册 Transient每次要打开就 GetRequiredService ()这才是正路。第二个坑是服务里持有 IDbConnection。如果某个 Service 注册成 Singleton构造函数又注入了一个短连接对象那么这个连接在整个程序生命周期内都不会被释放。实测遇到过一次“连接池耗尽”查到最后就是一个单例的日志服务里长持了一个 SqlConnection。第三个坑是 Transient 依赖链过长时手动 GetRequiredService 太频繁。不是每次都 Get 顶层 Service而是应该注册好整个对象图只在入口窗体或需要的时候 Get 一次“顶层对象”。你在 Main 里已经拿到了 LoginForm不要在 LoginForm 里再 new ServiceCollection那种做法等于又回到了手工组装的老路容器白接了。4.4 老项目渐进改造的路线图很多朋友接手老项目时想一步到位重写这通常风险极大。我更推荐按这个顺序渐进先抽出 Repository 接口把散落在窗体和 Service 里的 SQL 挪进去。这一步只动数据访问不碰业务回归测试比较容易。再把拆出来的业务逻辑挪到 Service。此时窗体里基本只剩事件处理和 DTO 组装。最后接入 DI 容器把窗体的对象创建收归 Program.cs。窗体构造函数里只会看到 IUserService 这类抽象。每完成一步就跑一遍原有手工用例对比界面行为有没有变化。没有自动化测试兜底时宁可步子小一点。这套路线我实际用过。有一个十来个窗体的内部工具改造完界面代码减少了一半新增的功能能写单元测试同事接手也不再需要翻遍所有事件方法才能找到一条 SQL。5. 这篇学习笔记留给自己的五条验收标准5.1 判断分层是否成功的两道题每次写完一个模块或者评审完一段代码我会用两道题快速验证分层有没有成立。第一题如果数据库从 SQL Server 换成 PostgreSQL代码改动的范围应该只落在数据访问层。如果 UI 或者 Service 也要跟着改说明依赖方向已经失控了。这里有个自查方法在代码里搜 SqlConnection 和 ConnectionString如果出现在 Controller 或者窗体里基本可以判死刑。第二题如果今天要写一个纯业务单元的测试用例能不能完全不启动数据库比如测“企业邮箱校验”这个规则我期望看到的代码是 new UserService(mockRepository) 然后传一个非法邮箱断言抛异常。如果测这条规则你得先造数据库环境说明业务规则和持久化没有分离。这两道题的价值在于它们很简单、不需要复杂工具只要在代码评审时问一句“你怎么测试”就够了。5.2 判断DI是否引入过度的三句自问DI 用得好是解耦用得过度就是灾难。我自己会在设计时问三句话。第一句这个接口真的有第二个实现吗如果 Repository 只有一个实现类而且未来没有替换为内存版、Mock 版、另一个数据库版本的计划那强行把具体类和接口分开放只是多加了一层跳转。这种事宁可从简等需要时再抽接口也来得及。第二句对象生命周期我是真的想清楚了吗如果我对一个依赖到底是单例还是每次新建都没有概念就别急着注册一堆服务。生命周期写错比不写 DI 更隐蔽因为它启动时不出错运行一段时间才爆。第三句如果我关掉 Spring 或 ServiceCollection这段代码还能不能看懂依赖注入好不好用的最终衡量标准是代码的可读性有没有提升。如果一个类里塞了十几个依赖不管多优雅地注入本质都是上帝类大爆炸。5.3 值得反复回看的两个小技巧最后留两个我反复用的小技巧。第一个是画依赖方向的箭头。任何两层之间只允许一个方向的箭头如果发现箭头出现环路无论用什么容器、什么框架架构都已经生病了。方向这件事不需要画正规架构图拿纸画一个圈就够了。第二个是牢记“组合根”原则。所有依赖的装配只允许出现在程序入口那一层其余地方只接收、不创建。WinForm 的 Main 方法、Spring Boot 的配置类本质都是组合根。我见过很多“越治越乱”的代码就是因为有人在一个 Service 里依赖注入了一个 Factory让原本集中在组合根里的创建逻辑又散开了。这三个概念——三层架构、依赖注入、组合根——从表面上看是三个知识内核其实是同一件事让依赖变得可见、可替换、可测试。对我个人来说与其背一堆架构名词不如每次写代码前多问一句“这个依赖是谁给我、我能不能不关心它怎么来的”。答案是“外部容器”的时候依赖注入才算真正理解。
返回列表