注:关于代码的分层,事实上还没有金科玉律的标准。所以本文所述的分层理念,仅仅是从笔者个人的开发生涯的理解上给出的。仅供参考。如果读者有任何意见和想法,欢迎和我交流讨论!
序言
在很久很久以前,昔々の時代,long long ago,那时候的js还没有大放异彩,那时候的前端还没有专门的工程师,甚至,我们的Java代码还会直接写在前端页面里。而所谓的Ajax、局部刷新,在当时也是不折不扣的“高端技巧”。在那时浏览器跟服务器的交互,除了文档(jsp、html等)、静态资源(css、图片等)外基本上就没别的了。用户每次的请求,或许是超链接跳转,或许是表单提交,都会导致页面重刷。于是jsp的数量是非常非常多的,随便一个简单模块的增删改查就得有列表页、新增页、详情页等。所以有些年龄的Java程序员可能见过这种文件结构:
开发者在页面上又要书写Java逻辑访问数据库、加工数据,又要照顾页面本身。所以写出来的页面代码,很可能会有些奇怪:
在刚上大学的时候,我甚至搞不清JS和JSP的区别。后来分别学习过两者之后,我才知道两者是八竿子打不到一块的。可是,直到看到这种代码,我才明白,原来JS和JSP也是可以水乳交融的,当年的我,果然还是太肤浅了……
……
……
……
个屁啊!
这种代码是给生产项目程序员看的吗?很容易人格分裂好嘛!
如果您看了这种代码表示very good甚至有“极客风范”,那也许我们不在同一个频道吧。
如大家所见,直接在jsp插入Java代码的方式,或者在Servlet中打印HTML字符串的方式,是不折不扣的“反人类”设计。其负面作用远不止代码耦合无法复用、视图和逻辑混乱不堪……所以从Java Web推出之始,所有人都在做的一件事就是——减负。
何为“减负”?软件工程里有个词叫“单一职责原则”,一个类只应该扮演一个角色。虽然你可以狡辩说jsp确实只扮演了“渲染视图输出”的角色,但事务的认知是分粒度的。过去的jsp/servlet解决方案,细粒度去看,不论是数据库的访问,还是事务的管理、还是数据的加工处理、还是身份验证等,都塞在同一块代码里,这是不仅不够面向对象,甚至还能让人看到面向过程的影子。想要逻辑清晰、代码可控,我们就要抽象代码成类,并给类赋予单一职责,将与页面视图渲染本身无关的部分从它身上剥离掉!
沿着这个话题下去,我们可以聊一聊所谓的MVC Web框架的实现思路。但跟本次我要咆哮的主题无关所以暂时打住话头。我们还是聊软件工程相关的。
垃圾代码
上文提到了“单一职责原则”,除了它,你可能还听说过“开闭原则”、“依赖倒转原则”、“高内聚低耦合”等名词。前人探索和思考散发出的智慧是我们应该充满感激并辩证吸收的,而不是挂在嘴边忘在心里实践背离。
是时候了,上代码:
从命名上来看,该代码装模作样地依次实现了“Controller”层、“Service”层以及“Dao”层。
为什么我说是“装模作样”呢?
因为这分层完全就是没有参透代码“分层”的目的。
“分层”是对逻辑的一种切分,也是一种抽象。 切分可以降低代码的耦合,抽象可以提高代码的复用。可以说这是面向对象的工程化软件开发的无上法宝——虽然不能说是银弹吧。
按照业务的复杂度和项目细分的粒度,一般情况下我们会把Java web项目分为如下几层:
- 控制器层
- 业务逻辑层
- 数据访问层
- 通用数据模型层
到这里,一般的老师或者课本就会照本宣科,讲什么“控制器层分发请求,业务逻辑层处理业务,数据访问层和数据库打交道”之类的话了。我相信初学web开发的同学很少有人能参透这些分工的目的和意义,反而云山雾绕不知所以。所以我打算换个角度跟你讲,这样分层的道理。
外卖系统和分层
我比较喜欢用类比去描述事物,这样读者可能看起来更有画面感。要类比的话,把Java web项目比作订外卖还是有些相似的。
控制器层
订外卖的话,控制器,其实就是派单系统。我们通过APP订了餐,派单系统要把单子递给商家,然后商家把做好的餐送给我们。派单系统的主要职责是什么呢?我们可以列举一下:
- 接收客户订单;
- 验证客户的订单是否正确,是不是忘了填食品名,是不是在全聚德点了“东京烤鸡”等。如果填错了就要告诉客户填错了;
- 如果客户订单填写正常,就要把单子派给商户,并连带订单中的各种要求一并告知商家;
- 如果商家表示我们打烊了,或者客户点的天鹅肉昨天被癞蛤蟆偷吃了,或者大厨的宽油热锅烧着眉毛做不了了,这些信息会及时告诉派单系统这顿饭做不了了;
- 如果商家的菜新鲜出炉,派单系统要负责告诉配送员妥当将食品送到客户手里;
这就是控制器的职责。验证用户输入,分发给对应的逻辑处理器,妥善处理逻辑错误,及时返回数据给用户。对于控制器来说,它关心的是:用户传递了什么,谁来处理它,有没有发生错误,怎么返回数据。至于具体的处理,也就是商家大厨具体怎么做菜,则不是控制器所关心的。你见过美团外卖他家接了单后自己就咣咣炒菜给你送上来的吗?这也就是为什么说不要让Controller层抢活,导致service层无事可做的原因。一个外卖平台不要跟大厨抢活干好嘛!此外就是《咆哮①》讲到的,控制器层应该是一切异常的终点。什么意思呢?你要是用美团点餐的时候,商户打烊了APP不通知你而是咔一下闪退了,你难道不窝火吗?是一个道理。
业务逻辑层
接下来我们聊业务逻辑层。特别地,我们聊Spring下的业务逻辑层。业务逻辑即用户点外卖环节的商家。对于简单的系统,业务逻辑只需要一层就够了。但更复杂的业务也许会再讲业务逻辑分为复杂业务和通用业务等子层。我习惯性将复杂业务称为“service”,通用业务称为“biz”。这就好比是面馆大厨师傅手下有若干个和面的、拉面的或煮汤的用于处理中间环节食材的助理师傅。和面的师傅和拉面的师傅就是通用业务层,而炒面师傅煮面师傅则是复杂业务层。总的来说,商家大厨的职责是:
- 接收无误的订单信息,加工食材做饭,并将做好的饭妥投给派单系统指派的送餐师傅;
- 大厨拿食材做饭就好,他不应该亲自买菜。做硬菜的大师傅也不需要亲自给萝卜雕花,那是手下助理师傅的活;
- 如果下一层发生问题,如面用完了(食材问题)或和面师傅伤者手了(中间环节出现问题),要及时报告派单系统这顿饭做不了了,以及做不了了的原因。而且要及时打扫现场,比如购置足量面粉、及时送师傅就医等,保证不会影响下一单生意。
如果把上述描述转换成业务层的职责,那就是业务层应该接收已经通过控制器层校验的数据。比如,对于经过对称加密的报文,其解密工作和校验工作就应该在Controller层完成,将无误的明文信息传入service层,而不是在service层执行类似的操作。
service层应该只将业务处理结果返回,而不应代替Controller层直接返回json或xml报文。 除了插入业务,我们可能希望service返回id给Controller,其余只要不涉及多状态的业务,也就是要么调用成功要么调用失败的业务,将这种service方法的返回值设为void
也未尝不可。有些同学会问那发生业务异常怎么办?这就是Java异常机制的作用了。你可以定义一个ServiceException
,将错误信息填在message
字段提供给Controller层使用就可以。不要忘了我们在Controller层是一定一定要try-catch住业务代码以避免异常逃逸到Controller层的外部去的。当然对于某些一定要返回业务处理状态的方法,一定不要简单地用int整数返回0123去描述接口状态。两个方案,要么使用String类型的返回值返回字符串常量,字面值即是返回的状态,要么使用枚举,以枚举项的名称作为状态的解释。直接使用数字作为service返回值的,我真的想用我的阿姆斯特朗回旋加速阿姆斯特朗炮把你的脑浆炸出来。即使你说你在方法上加过了0123具体意义的详细的注释我也不会放过你。因为我在Controller层断点调试时看到一个service返回了个整数的时候,我整个人就是懵逼的。还有就是,如果业务发生了异常,不论是不是被你catch到了,一定要throw新的异常,特别地应该是用自定义的运行时异常ServiceException
出去。不然的话根据Spring默认的事务管理机制,没有抛出异常事务就不会回滚,很有可能数据库中就会混入不正确的值了!
那么还有就是service层应该专注于处理数据,而不是和数据库打交道。换句话说,不应该在service层直接操作数据库。狭义的说,就是不要在service层写SQL。那是数据访问层的任务,service层不应该抢活。但是对于一些特殊业务,特别是跨越多个数据源的业务,因为dao层原则上都只与自己责任内的数据源打交道,这时用一个通用业务层的实例去处理一些边界状态,可能就是不可避免的了。
还有就是,service层本身也应该对自己的行为负责。对于下一级可能会发生的问题,service层也应该予以捕获和处理。换句话说,将业务代码中所有的异常捕获并统一转化为ServiceException
是一种良好的实践,甚至极端一些,service层的代码只应该抛出ServiceException
或此类自定义异常。Controller层关心的只是业务的完成状态,而不应该让它直接了解到是SQL出了问题,还是文件系统出了问题。在Controller看来,这些都是业务发生了问题。一股脑把各类第三方库的、Java SDK本身的乱七八糟的异常都甩手给Controller层,也是非常不妥的。
既然提到了异常,那就多嘴几句。不论是Eclipse还是IDEA,都有一个非常SB的设计,那就是在程序员调用了声明异常抛出的方法后,会标红报错,但程序员用自动修复时,默认的修复选项却是将该异常声明提升至本方法的声明里,而不是用try-catch包裹并处理。那些技术弟弟程序员,看到标红代码自然而然地就依赖IDE的自动修复功能,把一大堆一大堆的异常声明传染得到处都是。比如Controller上的throws Exception
,这还算好的,我还见过方法体声明了一大堆找不到文件的、找不到方法的、编码异常的等五花八门的弟弟代码。Java的强制处理声明式异常是一个弊大于利的设计,希望各位弟弟早日学会try-catch。自然不用说的是,自定义的DaoException
或ServiceException
,一定要继承自RuntimeException
,而不是Exception
。至于弄不清楚两者有什么区别的弟弟,哥哥只能劝你善良……
数据访问层
终于讲到了初学者可能最直观可感的部分,数据访问层,data access layer,其对象一般称为DAO。层如其名,本层的关注点就是“数据”。这种数据,99%的场合便是来自数据库。所以武断地讲DAO层就是直接和数据库进行交互的层也不算错。在外卖系统中,DAO层就像是仓库管理员,管理各类新鲜食材。大厨要什么,就告诉仓库管理员取对应的东西来。而外卖派单系统只关心菜做得如何,对于菜是哪儿种的,在哪儿买的,则完全不关心——只要菜的味道是好的品质是好的那就够了。更极端一些,即使不要仓库管理员,全聚德的大厨直接去便宜坊订一只鸭子交给外卖系统,从理论上也是没问题的。所以对于一些分布式系统来说,甚至可以没有DAO层。
从我给出的不太准确的解释中,或多或少可以看出DAO层和Service层的区别,DAO层关心的是数据库,而Service层关心的是数据的获取、加工、处理。控制器层是不直接依赖数据访问层的,数据访问层本身也不应该指代具体的业务逻辑。直接依赖于数据访问层的,只应该是业务逻辑层,但业务逻辑层也不仅仅只依赖数据访问层才能获取数据。分布式系统里Service层的数据可以通过web接口获取,而不是查库,当然有时这种获取数据的对象也会被叫做DAO。
说起来貌似清晰直截,但实际上因为概念和实践上的一些问题,初学者还是很容易将两者的分工搞混的。其源头就是SQL本身具有一定的业务描述能力,而大部分业务,不论增删改查,都不可避免地要与数据库进行交互。所以从潜意识里,“SQL = 业务”的认知就建立起来了。以插入为例,尽管从分层的角度,该业务的实现应该是位于service层的save()
方法,但本质上起业务作用的,却是在DAO层执行的insert into xxx ...
SQL语句。所以究竟是DAO层承担了业务功能,还是Service层在承担业务功能呢?
看问题要看全局。
诚然,上例是SQL的调用完成了“插入业务”,但如果是更复杂的例子呢?例如分别取出销量前三的笔记本电脑、手机、显示器的品牌,将其去重取并集后按字典倒序列举。这种例子,还能简单地通过一条SQL语句来实现吗?有些“SQL=业务”主义教徒可能真的会写出这种SQL然后说“完全没问题”,但随着业务的描述越来越复杂,对应的纯SQL语句也会趋向于人力理解不能。这时候,更好的设计应该是设计一个“获取某分类商品前三品牌列表”的DAO层方法,在sevice层调用3次,之后用Java集合操作去去重排序,合并成一个List返回给Controller层。从这个例子中,您就应该可以感受到,DAO方法本身并不是业务,但service方法则是。
我个人的解释是,不论是极简业务还是复杂业务,业务的描述都是service层的代码。 对于极简业务来说,例如不和其他模块有耦合的简单增删改查业务,确实是全部依赖DAO去实现的业务;但大部分业务,其实现逻辑还是应该来自service层,DAO层中的代码,是被service层业务代码所依赖的一些关键代码。DAO层的代码应是普世的,如果有可能的话,可以被尽可能多的service层代码复用的。尽管插入操作的业务代码只依赖一行DAO层方法的调用,但它仍然是业务代码,DAO层代码则是实现该业务所依赖的部分。
通用数据模型层
尽管上文贴出的DAO层代码,是直接通过JDBC操作的数据库,但实际上大部分生产环境项目,还是会依赖某种ORM框架去实现DAO层代码。ORM框架会将关系数据库的表间关系映射为Java的对象间关系,这也使得所谓的“通用数据模型”并不像传统的pojo那么纯粹。特别是建立了双向绑定的对象,或包含用户登录密码这种敏感字段的数据,是不适合直接作为最终结果让Controller返回给用户的。前者在JSON序列化时会发生循环引用导致栈溢出,后者则可能会导致敏感数据被抓包者利用。为了解决这类问题,最好的做法就是构建DTO类。DTO全称为“Data Transformation Object”,即数据传输对象。那么,DTO对象的构建时机应该是什么时候呢?是DAO层还是Service层呢?
稍加思索,你就会发现,实际上所谓的“数据传输对象”本身所描述的,就是“业务”,是对数据集合进行投影操作的业务。
但生活中就是充满各种变数,所以我们也要有不少变通,不是吗?
如果不考虑数据库的查询效率,DAO层直接获取某对象映射表的所有数据,组装成对象集合提供给Service层,Service层再挑挑拣拣拼装出业务所需的对象返回给Controller层,一切都是那么优雅完美。
可问题就在于“查询效率”是不可忽视的。特别是对拥有动辄数十数百字段的大库表来说,获取太多本不需要的数据肯定会影响效率,而且先全拉取再挑挑拣拣的逻辑在对象的数量级膨胀后,也会造成很大的内存压力和GC压力。所以不可避免地,需要从SQL的构建开始限制获取字段的数量。这一步,就我个人的水平和能力所限,没有太好的解决方案。在这里总结几种方法吧:
- 让DAO层特供返回DTO的方法。
这是最容易想到的方案,直接在DAO中拼装和返回需要的DTO。但这终究还是让DAO层承担了一部分Service层的功能,会使DTO层下潜为DAO层的依赖。 - 在Service中添加投影方案描述。
这种方法依然让DAO层返回Entity类对象,但通过投影描述,使得DAO层获取数据时只会查询需要的字段。这也意味着实体类的其他字段会置空。这种方式的实现方式较为繁琐,除非ORM框架本身提供相关支持,但Service层理论上又不能直接接触ORM框架的API,所以也会比较蹩脚。此外,如果使用了@NotNull
之类的验证方案,也会因为置空产生验证通不过的问题。 - 引入
Biz
层。
我们定义DAO处理的是纯粹的数据库交互层,那与之相对的,我们也可以引入一种“不纯粹”的数据库交互层。我将其命名为Biz
层,“biz”即“business”,也是指“业务”。只不过这种业务是专供复杂业务使用的低级业务。开发者可以把一部分定位模糊的业务(特别是多数据源的或跨多表的,单纯DAO不好描述的业务)下沉到该层里。这一层可依赖DAO层也可以不依赖DAO层,自身也可以直接交互数据库,但不具备事务管理的能力,且直接被Service层依赖。Biz
层的引入是对Service/DAO分层先天贫血的一种补充和妥协。尽管它可能很好用,但也破坏了分层的初心,重新耦合了业务和数据访问,增加了复杂度。故不宜过多使用。 - 拆库。
根据单一职责原则,数据表本身要描述的信息,在设计时也应该是带有强烈的目的性的。这也意味着,如果某个表的字段越多,就越意味着该数据表可能承担了过多职责。例如产品表就不应该包含该产品生产厂家的老板的姓名。与其考虑如何缩减查询的字段,不如一开始就把表设计得足够精炼,这样即使查询的时候带上了三五个无用字段,一来不会产生太过分的性能浪费,二来开发者理解数据结构也会更容易,三来开发效率也会得到大大提高。合理的表结构设计还可以降低数据库的存储压力,何乐而不为呢?哦对,那些外包公司给政府单位干low逼百年祖传表结构的同行,不好意思,这条不适合您们,要怪,就怪你们公司拉不来优质项目吧。哦对,这种公司的代码质量,貌似压根不会有人意识到要看本文吧……
Duke的咆哮
本文我以为会在2个小时内写完,没想到来来回回写了足足10小时,整整一个周末的时间。我这个人吧,也算是个刀子嘴豆腐心的人。口口声声要“咆哮”,要“发泄”,到头来还是苦口婆心讲,像个小丑一样考虑怎样讲能激起读者的兴趣,寓教于乐也得到收获。心情愈发低落,但也希望本文能点亮些希望的种子吧。程序员都应该和垃圾代码做斗争,并为之奋斗一生。那么,至少,在这最后的环节,让我痛痛快快咆哮一回吧!
让我们回过头再来看垃圾代码:
首先看Controller层,方法参数列表里用@RequestBody
注解了一个Subscribe
类,由于没有加DTO字样的后缀,所以默认的推测,该类应该是一个entity。如果是使用JPA或Hibernate的话,直接在Controller里使用entity作为请求参数接收对象是危险的。因为很可能有一些字段是不希望被用户看到和提交的。但因为entity类是对表对应所有字段的映射,抓包者可以传入意料之外的字段。开发者出于谨慎,就得对所有不应传入的字段进行校验,这会加大不少的开发成本吧。如果要请求的字段较多,更合理的方式是使用一个DTO对象作为参数接收器。但我们分析上下文,这个请求一共只用了Subscribe
类对象的两个属性id
和userId
。在这种场景下,您真的有必要硬生生拉上来一个对象吗?我一般的惯例是,如果请求参数少于3个,为了避免不必要的复杂度,可以使用直接列举的参数列表或路径参数(GET请求)或Map(POST请求且主体为JSON)去接收。对于多于3个的参数,则建议构建专用的DTO去接收参数,以充分利用静态语言的优势。
所以,从第一行代码开始,他就已经输了。
不仅输了,而且输了两次。
连我的IDEA都智能地报告说:后边这句throws Exception
就是一句逗比代码。其逗比程度高过下面不写try-catch的那种。没有try-catch,好歹声明异常抛出代表该方法有可能抛出异常,你try-catch处理了所有Exception
,该方法理论上就不会抛任何异常了。这时候你throws声明该方法会抛出异常,是蠢呢还是坏呢?还好这是在Controller里,开发者不需要调用这个方法。如果是在其他场合,这种写法就是在说,*虽然劳资什么Exception都不会抛出,但我tm就是非得恶心你上层代码try-catch一下哦,啦啦啦。*那我简直要打死你好么?
接下来,“Boolean flag = ...
”。虽然Boolean
和boolean
只有一个字母之差,但前者为引用类型,后者为原始类型。对于只有正误两个取值的布尔来说,虽然创建一个引用类型不会带来多少性能损失,但这种地方用不对,只能说明开发者的Java功底就是狗屁。
再往后,整个代码片段最灵异的代码出现了。一个用于订阅的业务方法,返回了数字!乍一看,应该是用数字来指代成功或失败吧。但是并没有方法注释告诉我们哪个数字对应什么状态。一边这么想着,一边让我们打开Service层方法的实现。OK,反正已经预料到是这个屎结果了,service层是孤儿,没爹没妈,为啥有它?因为装模作样的在分层啊!
那么,来到DAO层,最最最震惊我的代码出现了!这个返回值,它的含义居然是SQL语句执行影响的行数!
行数!
行数!
行……数……
我tm没看错吧?
姑且不论这个嵌套着又查又更新的SQL有多SB,单单这个返回值,穿过DAO穿过Service像输卵管中的小蝌蚪笨拙地滑入了Controller麻麻的怀抱的神奇操作,就已经把在下唬得屁滚尿流了!
那么回过头我们来看这段SQL:
UPDATE TM_SUBSCRIBER SET IFSUBSCRIBE = '1' WHERE ID = (
select sub.ID from TM_SUBSCRIBER sub join TM_ONLINE_STUDY s
on sub.STUDYID = s.ID
where SUB.COMPANYID=? AND sub.DELETEFLAG='0'
AND s.DELETEFLAG='0' AND s.id=?
)
槽点多得我都列举不完!
两个表的别名取的那么有诗意sub
和s
贯彻谭浩强式命名指南暂且不提,您sub
和SUB
混用也罢,毕竟某些特定版本的特定SQL不区分别名大小写。您两个查询变量一个天上一个地下被两个删除标识查询限定隔开也罢,您能不能先解释一下这段SQL的含义?
说人话:
先获取某公司是否参加了某在线课程,然后如果参加了,就将其订阅标识设为true……
这TM什么逻辑?!
感情某公司如果报名了该课程,你们不管三七二十一先在订阅表里插入该公司,等该公司点击了“订阅”你们再将“是否订阅”置true?
难道……
不应该是当某公司点击了订阅按钮后,往订阅表里插入一条包含课程ID和公司ID的记录到订阅表里么?
跪了,服了,哭了。
因为TM要接手这种SB代码人,是我!!!!!!!!!!!!!!!