☰
从Suteki.Shop看分层架构:Model与Service协作的电商设计
2026/10/10 7:56:45 网站建设 项目流程

Suteki.Shop 是 ASP.NET MVC 时代很值得反复读的一个电商示例项目。它用 NHibernate 做数据访问,用 Castle Windsor 做依赖注入,把 Model、Service、Repository、Controller 四层拆得非常典型。这篇文章专门拆它的 Model 和 Service 两层,聊实体怎么设计、服务怎么封装、两者之间怎么协作,以及这套老代码放到今天还能不能抄、该怎么抄。想弄明白分层架构为什么能扛住业务复杂度,或者正打算用 ASP.NET Core 重写类似电商系统的开发者,这篇应该能给你一些可落地的参考。

1. 项目整体架构与 Model/Service 的定位

Suteki.Shop 的分层思路一句话就能说清楚:请求先进 Controller,Controller 只做参数接收、调用结果传递和视图返回;真正的业务规则放在 Service;数据访问统一收进 Repository;Model 在最底层,只负责描述业务对象和基础行为。

当时 ASP.NET MVC 刚流行起来,社区里大量示例代码都是 Controller 里一把梭,数据访问直接在 Action 里 new 一个上下文就开始查。看起来很方便,但项目一旦超过十个页面,问题马上暴露:重复代码多、业务规则散落、没法脱离 Web 环境做单元测试。Suteki.Shop 展示的是另一条路,Controller 普遍很薄,真正有肉的业务逻辑都在 Service 里。现在大家常说的 Clean Architecture、Onion Architecture,在这套老代码里其实已经能看到雏形了。

Model 与 Service 之间的依赖方向也是单向的:Service 依赖 Model,Model 完全不依赖 Service。实体对象只是被 Service 读取和操作的载体,里面不引用任何服务类型,也不引用 NHibernate 的 API。这样实体可以独立编译、独立测试,也可以被不同服务复用的场景下替换实现,不会彼此污染。NHibernate 的映射文件之所以放在外部 XML 而不是写在类里,也是为了让 Model 保持纯净,不掺持久化框架的东西。

我第一次读这套代码的时候,建议也从一条用户故事走一遍:用户浏览商品列表,点进商品详情,加入购物车,最后提交订单。浏览商品走 ProductController 和 ProductService,购物车走 BasketService,下单走 OrderService。沿着这条链路看完,分层的价值就非常直观了——每个环节都有明确的负责人,没有谁偷偷越界。

2. Model 层设计拆解:从实体基类到值对象

2.1 统一实体标识:IEntity 接口

Suteki.Shop 的 Model 里几乎每个实体都实现了 IEntity 接口,定义很简单,就一个 Id。

public interface IEntity { int Id { get; set; } }

这个接口的存在不是装饰品,它是泛型仓储的约束基础。后面定义 IRepository 时,直接写 where T : IEntity,就能保证任何实体都有可用的主键标识,增删改查方法因此可以对任意实体通用。仓库类只需要写一份,所有实体都能复用,这是典型的基础设施抽象套路。

主键用了 int,而不是 Guid 或 long,这是当时示例项目的合理选择。单库单表场景下,自增 int 简单可靠、索引友好,序列化出来也更短。但如果你打算做分布式拆库,自增整型跨库会有冲突风险,到时候换 Guid 也不算重。核心判断标准是业务场景,而不是追新。Suteki.Shop 这种单体示例项目用 int 没有任何问题。

有个细节需要注意:实体在调用 Save 方法之前,Id 通常是 0,保存成功后 NHibernate 才会把生成的主键回填到实体上。所以在写业务逻辑时,千万不要拿 Id 去做业务判断。需要判断唯一性时,应该基于业务唯一字段,比如商品编号、订单号,而不是依赖尚未生成的主键。

2.2 金额不要裸奔:Money 值对象

电商里最核心的数据就是金额。Suteki.Shop 没有让所有价格都用裸 decimal 满天飞,而是封装了一个 Money 类型。这个设计到现在依然值得学。

金额本质上不是单纯一个数字,它自带币种、舍入规则、显示格式。如果所有方法签名里都写 decimal,每个方法内部都得自己做舍入和格式化,遇到多币种更是灾难。Money 类型把金额和币种绑在一起,加减比较运算都定义在这个类型内部,ToString 输出也会按当前文化区域格式化。后续想调整舍入规则或者加汇率换算,只需改这一个类,所有调用方自动生效。

这里说一句实战经验:我自己写电商项目时吃过浮点数的亏。用 double 存金额,连续加减乘除几轮之后,出现 0.1 + 0.2 = 0.30000000000000004 这种结果。后来统一改用 decimal,并且让金额字段一律走值对象封装,问题才彻底消失。Suteki.Shop 里的 Money 对象本质上就是在做同样的事,只是封装得更完整。

2.3 核心实体关系:Product、Category、Order、OrderLine

Suteki.Shop 的 Model 层围绕购物流程设计了几个核心实体,关系非常清晰:

  • Product(商品):包含名称、描述、价格(Money),以及分类集合。商品是商品目录的聚合根,所有商品操作都以它为入口。
  • Category(分类):商品和分类是多对多关系,一个商品可以挂多个分类,一个分类下也可以有多个商品。
  • Order(订单):包含客户信息、收货地址、订单明细集合,以及总金额。订单必须有至少一条明细,这个不变量由 Service 保证。
  • OrderLine(订单明细):关联商品,同时记录下单瞬间的商品单价和数量。价格要打快照,而不是下单之后再去商品表关联查,否则商品调价会污染历史订单。

从这组关系能看出 Model 层的设计原则:实体描述业务对象是什么,关联关系用集合属性表达,业务动作则通过 Service 暴露专门方法。比如计算订单总金额,不是让 Controller 拿实体自己遍历累加,而是由 OrderService 统一完成。这样总金额的计算规则只存在一处,不会出现不同页面算出不同结果的尴尬。

多对多关系在代码里直接用 Category 集合体现,而不是拆一个中间表实体,是因为这个关系本身不需要额外信息。如果有“关联创建时间、排序权重”这类附属数据,才需要拆出中间实体。是否拆中间表,判断标准是关系上是否承载了额外字段。

2.4 NHibernate 映射与继承策略的选择

Suteki.Shop 的数据访问走 NHibernate,实体映射放在 .hbm.xml 文件中。用 XML 而不是 Attribute 注解,是为了保持实体类干净,不引入持久化框架的特性标记。但代价是阅读成本高,看代码时要对照 XML 和类,两边核对字段,这是老项目的常见体验。

映射文件里主要配置主键生成策略、关联关系、级联行为、延迟加载策略。新手最容易在这几个地方踩坑。一对多集合忘了配级联删除,删父数据时会报外键约束错误;延迟加载配得不合适,会出现 N+1 查询。

继承映射方面,Suteki.Shop 里有通过继承扩展实体类型的写法,这正好引出一个通用问题:实体继承树上映射到数据库时,三种策略怎么选。

策略表结构查询方式适用场景
Table Per Hierarchy一张表放整个继承树,用判别列区分单表查询,性能好子类差异不大、字段少
Table Per Type每个类型一张表,共享主键查询需要 join子类间差异较大但结构稳定
Table Per Concrete Class每个具体类一张表多态查询处理繁琐子类独立性强、多态查询少

如果子类之间只是多了几个字段,选 TPH 性价比最高。字段差异巨大时用 TPH 会留下一大堆空列,反而浪费空间和索引效率。这个权衡在做领域模型设计时绕不开,Suteki.Shop 给我们摆出来了一个实际的取舍样本。

3. Service 层设计核心:把业务逻辑从 Controller 里捞出来

3.1 接口先行:服务先定义契约

Suteki.Shop 里每个 Service 基本都对应一个接口,例如 IProductService、IBasketService、IOrderService。Controller 只依赖接口,不依赖具体实现类,这是整个 Service 层设计的基石。

public interface IProductService { Product GetById(int id); IList<Product> GetAll(); IList<Product> GetByCategory(int categoryId); }

依赖接口的好处有两个。第一,可替换,单元测试时可以注入一份伪实现或者内存实现,不连数据库也能跑测试;第二,信息隐藏,Controller 完全不需要知道 Service 内部用了哪些 Repository、怎么协调事务,一个方法签名就把复杂度挡在外面。

接口设计要注意克制,不要做成“上帝接口”。一个服务接口动辄十几个方法,看起来省了建接口的功夫,实际已经违背接口隔离原则。服务接口的粒度应该和业务用例对齐,通常一个聚合根对应一个服务,方法控制在几个到十几个之间。Suteki.Shop 这类小项目按业务对象拆服务,方向完全正确。

3.2 依赖注入与生命周期管理

Suteki.Shop 用 Castle Windsor 做 IoC 容器,Controller 的构造函数里声明需要的服务,容器负责实例化并注入。在当年这算很超前的写法,因为大量 ASP.NET MVC 示例还在用 Service Locator 或者直接在 Controller 里 new 服务,根本没法测试。

注册服务时,生命周期选择是个容易踩坑的点。Windsor 里常见的三种生命周期:

生命周期行为适合场景
Transient每次解析都创建新实例无状态服务
PerWebRequest每个 HTTP 请求内共享一个实例无状态服务、工作单元对象
Singleton全局只创建一个实例无状态且线程安全的工具服务

Service 本身不持有状态,注册成 Transient 或 PerWebRequest 都可以。但 NHibernate 的 ISession 绝对不能注册成 Singleton。ISession 对应一个数据库工作单元,内部有缓存和事务状态,全局共享会产生并发冲突。正确的做法是:ISessionFactory 注册成 Singleton,ISession 注册成 PerWebRequest,这样一次请求内的多次数据操作共享同一个 Session,请求结束时统一释放。

我在后来的项目里看过太多类似翻车现场:有人把 DbContext 注册成单例,结果并发一上来,各种“多线程访问 DbContext”的报错。只要理解了一句生命周期即状态边界,这类问题就能提前避免。

3.3 典型服务拆解:BasketService 与 OrderService

挑两个代表性的服务拆一下。

BasketService 负责购物车。购物车在电商里状态比较特殊,它既关联当前用户,又得在结账时转化为正式订单。BasketService 的职责包括:获取或创建当前购物车、向购物车添加商品、调整商品数量、清空购物车、计算购物车总额。购物车相关的每个 Controller Action,基本都只是从 BasketService 拿到结果,然后丢给视图,自己的逻辑很薄。

OrderService 负责下单,复杂度明显更高。下单流程需要把购物车明细迁移成订单明细、校验库存、计算总金额、保存订单、清空购物车,中间还有事务边界。这一整套动作如果全堆在 Controller 的 Action 里,那个 Action 会臃肿到没法维护。放在实体里也不行,实体会被迫依赖仓储,纯净性就破坏了。OrderService 作为协调者,把购物车服务和订单仓储都通过构造函数注入,一步步编排流程,每个步骤清晰可控。

这里有一个值得抄的设计细节:修改订单明细数量、往购物车添加商品这类操作,不是让 Controller 拿到实体自己改属性,而是调用 Service 暴露的专用方法。这其实就是领域驱动设计里的做法,方法名就是业务动作,比放任外部直接改属性要安全得多,业务规则也能在方法内部统一校验。

3.4 Service 层不该碰什么

Service 层是业务逻辑层,但不能变成什么都装的杂货层。我见过太多项目把 Service 当成垃圾场:权限判断往里塞、日志往里塞、缓存往里塞、数据格式化也往里塞。Suteki.Shop 的 Service 相对克制,这本身就是个很好的示范。

有几个明确的红线:

  • 不出现 HttpContext、Request、Response 之类的 Web 设施。一旦出现 Web 上下文,服务就再也无法脱离 Web 环境测试,纯单元测试直接废掉。
  • 不把 IQueryable 直接丢给 Controller 去查询。查询细节一旦泄露出仓库层,调用方随时可以追加 Where、Include,性能问题变得很难定位。Service 对外返回实体或者 IList 就好。
  • 不做数据格式化。把金额转成带币符号的展示字符串、日期转成某种显示格式,这些属于视图层的职责,不该出现在业务服务里。

4. Repository 与数据访问的衔接

4.1 泛型仓储:统一增删改查

Suteki.Shop 的仓储设计走的是泛型路线,一个基础接口覆盖所有实体的通用操作。

public interface IRepository<T> where T : IEntity { T GetById(int id); IList<T> GetAll(); void Save(T entity); void Delete(T entity); }

泛型仓储的价值在于通用操作只写一次,不用每个实体都建一个 Repository 类。如果某个实体需要特有的查询,再在具体仓储接口里扩展方法。这套模式到现在用 EF Core 也完全成立。

但要提醒一句,泛型仓储只是数据访问的起点,不该试图覆盖所有查询。一旦出现多条件筛选、分页、聚合这类复杂查询,通用 GetAll 就力不从心了,应该在具体仓储或 Service 里用查询方法承载。一个基础仓储里塞了几十个通用查询,是这片设计里常见的扩变方向。

4.2 Session 生命周期与事务边界

NHibernate 的 ISession 是工作单元核心。Suteki.Shop 通过容器把 ISession 的生命周期绑定到 Web 请求,一次请求内共享同一个 Session,请求结束统一释放。这样做的好处是,一次请求里的多次数据操作共享同一级联缓存,不需要担心跨操作的数据状态不一致。

事务边界放在 Service 层,这一点特别关键。一个 Service 方法里往往连着执行多个仓储操作,比如先更新订单、再减少库存、再记流水,这三个操作必须放在同一个事务里才有原子性。如果每个仓储方法自开事务,就会出现第一个提交成功、第二个抛异常的中间状态,数据就彻底对不上了。

网上很多人问 NHibernate 的 “could not initialize proxy - no Session” 错误,十有八九就是 Session 生命周期放得太早。实体被延迟加载的关联,在 View 渲染时还需要 Session 开着。Suteki.Shop 把 Session 设为 PerWebRequest,基本规避了这个问题,这也是这个生命周期选择背后的原因。

4.3 查询封装:别把查询能力撒得到处都是

Suteki.Shop 的查询虽然底层用了 NHibernate 的 ICriteria 和 HQL,但对外暴露的 Service 方法返回类型基本是实体或者 IList,而不是查询接口。这意味着什么?意味着 Controller 和视图层根本不知道数据是用什么方式查出来的,也不需要在意。将来换 ORM 甚至换数据库,上层一行都不用动。

这个经验放到今天的 ASP.NET Core + EF Core 项目里依然非常重要。Service 方法返回 IReadOnlyList 或领域模型,不要返回 IQueryable。IQueryable 看起来灵活,其实是数据访问封装的最大漏洞。一旦泄漏出去,调用方可以随意往里追加各种操作,查询逻辑散落到视图层,性能排查和单元测试都会变成噩梦。

5. 这套老代码在今天的借鉴意义

5.1 可以直接平移的分层思路

Suteki.Shop 的技术栈虽然老了,但分层思路搬到 ASP.NET Core 几乎零成本:

  • Controller 保持瘦,只做 HTTP 适配
  • Service 承载业务用例
  • Repository 隔离数据访问
  • 实体保持 POCO,不依赖框架
  • 依赖注入贯穿全程

ASP.NET Core 项目模板自带依赖注入容器,完全不需要再引入 Castle Windsor。EF Core 的 DbContext 自带工作单元模式,和 NHibernate 的 Session 对应,默认 Scoped 生命周期恰好等价于 PerWebRequest,几乎就是为这套分层模式量身定做的。把 IRepository 翻译成 EF Core 版,基本是直译。

5.2 该果断放弃的过时方案

不推荐照抄的地方也很明显。

Castle Windsor 可以放下了,除非有特别复杂的动态装配需求,否则 ASP.NET Core 内置容器足够稳定好用,少一个外部组件就少一份版本兼容负担。

XML 映射文件该被淘汰了。NHibernate 时代 XML 是主流,但现在 EF Core 的 Fluent API 和注解都比 XML 直观太多。Suteki.Shop 里那些 .hbm.xml,阅读时得对照类和 XML 来回核实,这是阅读老代码最大的时间成本之一。

测试策略也要升级。那个年代测服务主要靠 mock 框架手动造伪依赖,现在很多团队直接用 Testcontainers 拉起真实数据库做集成测试,数据访问的真实性更有保障。分层思想不变,但测试手段可以更贴近真实运行环境。

5.3 如果我现在改造它会怎么做

假若要把它重写成现代 ASP.NET Core 版本,我大概率会这样做:

  • 项目结构保持四层,但目录按 Clean Architecture 的职责重新组织,核心业务逻辑和外部设施分离更彻底。
  • 实体继续放在领域层,不引用任何基础设施。
  • 服务名称可以保留,但内部配合中介者模式或普通用例类编排,避免单个服务类越来越大。
  • 数据访问继续用仓储封装,但异常一类接口注意防止查询对象向上泄漏。
  • 引入显式结果类型,比如 Result ,让业务失败成为方法签名的显式分支,而不是到处抛异常。

这些改动不是说原项目做得差,而是同一个思路在不同时代的完整表达本来就不一样。骨架留着,血肉按当下的生态更新。

6. 运行示例与仿写时的常见问题排查

6.1 数据库连接与初始化

Suteki.Shop 的连接串在 Web.config 里配置,默认可能指向 SQL Express 或 SQLite。初次运行最大的问题是数据库没有自动创建。你需要确认项目里是否配置了自动建表逻辑,如果没有,就手动执行项目自带的 SQL 脚本,或者写个初始化方法调用建表 API。

调试这类老项目时,我建议第一步先确认数据库到底建没建。很多所谓“程序起不来”,十有八九就是连接串指向的实例不对、库不存在、账号没权限,而错误堆栈里报的却是映射错误或者程序集加载失败,特别容易误导。

6.2 NHibernate 映射常见报错

“NHibernate.MappingException: Could not compile the mapping document” 这类错误基本都能定位到 .hbm.xml。常见原因有三个:XML 里的类名或程序集名写错,类型解析不到;属性名和实体类不一致,大小写写错;主键生成策略配错,identity 和 assigned 混用。

排查这类问题有个笨但有效的方法:把错误堆栈中提到的文件打开,逐个节点和实体类对照着看。经验上,八成映射错误都是拼写和大小写问题,查完拼写基本就解决了。

6.3 Castle Windsor 装配失败

“Component XXX could not be resolved” 表示某个类型没有注册。排查顺序应该是:先确认服务接口和实现类是否在容器里注册过,再核对 Controller 构造参数类型和注册类型是否一致,最后检查有没有循环依赖。Windsor 的调试日志很啰嗦,但关键信息还是能看到,注意找第一个失败的组件,后面往往都是连锁反应。

6.4 通用调试建议

老项目的调用链长,调试时不要只盯着 Controller。建议在 Service 实现类上打断点,然后一层层往下看:Controller 进来、Service 里怎么调 Repository、数据在哪个节点发生变化,整个过程一目了然。

另一个实用技巧是把 NHibernate 的 SQL 输出打开。打开之后,任何一次数据请求都会把真实 SQL 打印出来,排查 N+1 查询、延迟加载、笛卡尔积这些问题会非常高效。这个习惯放到 EF Core 项目里一样适用。

最后再分享一点个人的真实体会。我读 Suteki.Shop 最大的收获,不是学会了 NHibernate 或者 Castle Windsor 的用法,而是想明白了一个道理:分层不是把文件多塞进几个文件夹就算完成,而是在写 Controller 的时候克制住不写业务逻辑,在写 Service 的时候克制住不直接碰 Session,在写实体的时候克制住不依赖基础设施。这套代码今天看确实老了,但那种边界意识一点都不过时。如果你正打算用 ASP.NET Core 重写一个电商项目,不妨先花一晚上把这套老代码的分层逻辑捋一遍,再动键盘写代码,效果会比直接抄一堆新框架的 Demo 好得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询