Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

历史遗留问题,让人无奈的过去

大约三年前,我还没正式毕业。实习期懒得出去找什么公司做实习了,就跟某位同学合作。来了一个软件项目,客户是修手机的,需要有一个系统来支撑他们的业务。

这个系统的价值交付方式是通过 WebApp(SPA)。用户前端的功能主要是:

  1. 浏览可维修的机种与维修服务
  2. 下单并开始一次维修
  3. 管理账单与查看维修进度
  4. 购买保险
  5. 查看已购保险并选择开始理赔服务

还有一些杂乱功能:

  1. 查看平台发布的文章
  2. 查看维修站点
  3. 管理自己的地址

作为老板的同学只给了我一个月的工期,现在想起来都觉得是剥削。(虽然以现在的技术水平来说只做个后端是没有太大问题的)

那时候还是 Spring IoC 和 MVC 独立配置的时代,SpringBoot 是个刚刚听说的新玩意,完全不知道怎么用。刚刚毕业出来,脑袋里还有所谓「三层架构」的思维。那时候所教的三层架构,实际只是“分三层来管代码”这么个概念,实践上是完全傻乎乎地把一个 maven 的项目硬生生分成三个模块: controller 、service 、database 。现在我的眼光来看,简直就是幼稚得不行让人发笑。但回想过来实际上这些都是老师教的,就感觉有点唏嘘和无奈了。

结构

项目总共有这些模块:

  • core-commons
  • core-modules
  • core-web-modules
  • web-backend
  • web-fontend

core-commons

核心的 Commons 模块。我那时候并没有太多的积累,直到上一年的年底都还有这个东西存在。它是我至今位置所有的 commons 基底,经过了好几次的迭代,某些层面上来说代表了我的积累(每次起项目都会直接 copy 的东西)。在我后来的项目里,它已经退化成项目包树下私有的 commons 包,只存放针对项目本身的通用 utilities 和 bean 。而早期的 commoms ,在后来跑出了项目,成为了独立的 commons 依赖包。

core-modules

整个系统的核心功能模块,database 和 service 都共存于此。

我是比较追新而且希望能了解技术细节和问题原因的人,在早期我就尝试抛弃所教的所谓三层。那时候的我认为,这种数据层和服务层分离的操作,在这种 spring 项目下是完全没有必要的:我老早就使用 Spring Data JPA 了。

学校的教学里,还是 SSH(Structs2 + Spring + Hibernate),即便是新的,也不过只是 Spring IoC + Spring MVC + Hibernate (SSMH)。感觉不是 Struct2 的确太难用都不意识到 Spring MVC …… 而 Hiberbate ,老师们还沉浸于他们祖传的 BaseDAO ,那时候我意识到这个 BaseDAO 并没有解决太多的问题:它只针对实体本身的 CRUD 操作:基于 ID 的查询、新建实体、更新实体和删除实体。其他的地方完全就是纯粹的 SQL ,进阶点顶多就是 HQL 的拼装。我在想:HQL 拼装和 SQL 拼装有什么区别嘛(摔)!而且他们那种 Hibernate 的用法是 XML 弄一大堆的 mapping (Jesus)。这根本就没解决什么问题,我还不如直接用 PreparedStatement 开玩?后来的我是在某个意外认识的师兄(后来成为同睡同住的好朋友 w )的帮助下,接触了真实的 Spring 生态,完全抛弃了学校教的所有东西, Spring 全家桶一起上!一键起飞直接秒掉同专业方向的几乎所有人(咳咳,飘了)。

不过,那时候的我还没有充足的理由去说服自己抛弃所谓的三层架构,于是使用“代码分离”的想法,只分两份代码: facade + logic 层。 logic 层包含了 service 和 data 以及 service 本身需要的一些 dao (我现在一般叫它 bean)和 utilities ;facade 层管理在 HTTP 之前的数据递交过程,包括权限和数据转换。这些东西在后来的日子里,它们都不再根据项目分开,变成基于高层次的抽象而共同存在,于是后来的项目也大多是单体项目了。

web-backend 和 web-frontend

它们是切切实实的、需要直接干掉的历史遗留问题(/w \)。这两层其实是上述的 facade 层,管理数据交换过程。

项目本身没有什么权限上的要求,同时因为那时候的我技术能力有限,我是使用 interceptor(拦截器)来处理权限问题。用它来处理权限,在 Spring MVC 的范围里是简直一劳永逸的。那时候的权限判断逻辑很简单:我判断当前用户登录的方式,如果是用普通用户登录的方式(手机、微信登录),则通过 Session 只会获取到 NormalUser 类型的用户;通过管理员方式登录,则只能获取到 ManagerUser 类型的用。NormalUser 类型的用户只能访问 web-fontend 的接口、ManagerUser 只能访问 web-backend 的接口(至于如何实现这个限制,在后面会提到)。

我记得那时候应该是因为开发周期紧张的原因,所以其实这个系统没有什么「鉴别权限」的机制可言的。而系统运行起来的确是可以鉴别权限,这点我是记得的(毕竟是要交付,功能得完整),这是怎么做到的呢?其实这个操作很 trick ,且后面很多操作都很没必要。

  1. web-backend 和 web-frontend 是两个 war 类型的 maven 工程,它们共同依赖 commons-modules 。

  2. 两个 war 工程分别 build 当然是会出现两个 war 了。一般来说,我做的项目都有个特点:前台(用户端)和后台(管理端)都是分别的两个子域名。

  3. build 出来的两个 war 都会是独立运行的应用程序(linux 系统层面),我用 nginx 针对两个不同的 war 分别使用不同的 server_name 来分别 proxy_pass ,相当于在 Tomcat 上是两个不同的 web 应用。同时,两边的 login 方法查询数据库表和登录的函数都是不一样的,这样就使得从两个域名访问的 login 接口的对应函数会有几乎完全不同的行为,而且因为两个应用在 Tomcat 里是独立的,更加不会有两边的 User 实例相同的情况(其实 NormalUser 和 ManagerUser 都继承了 User 接口),毕竟连内存区域都不一样,ClassLoader 就更加不一样了。

  4. 即便 ManagerUser 和 NormalUser 同名,因为没有对应的操作接口(还记得 frontend 和 backend 实际上是两个 war 吗?),所以在应用代码里(也就是独立 build 出来的 war 里)也不可能是同一种类 instance ,即便不在 interceper 上使用 instanceof 判断,也可以区分是那种用户。

上面那么多废话,说白了就是:

  1. 它们用了同一份 core-modules 代码,所以拥有了同一个 service 层代码,但
  2. 因为是不同的 war ,所以在 Tomcat 上是独立应用。不在独立 war 里对应的 controller 里写 method 去暴露 service 的操作是不可能实现等价意义的操作。
  3. 因为暴露出去的接口是不同的 war 处理的,所以代码功能不可能串(就像妈是女的爸是男的一样)。

最后再简化一下,其实这就是两个不同的 web 应用在草同一个数据库……

(看自己以前写的屎就是会晕)

怀缅 Servlet

看着项目某个里的 /src/main/webapp/WEB-INF 加上里面的 web.xml 就能感觉到一股浓浓的时代气息,经典的 Servlet 时代产物。的确那时候我的大专学校是在教 Servlet (已经懂了还掌握新技术的我就是那个天天睡觉耍帅也不会被老师批评的家伙 w )。

我开始意识到 Servlet 这玩意要被时代淘汰的地方在这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- Web MVC Dispatcher Configuration(Back) (from ./web-backend/src/main/webapp/WEB-INF/web.xml  -->
<servlet>
<servlet-name>springMVCDispatcher_back</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath*:context-webmvc-back.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>springMVCDispatcher_back</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>

一个 Servlet 应用应该通过很多个 <servlet-mapping> 去处理不同的 servlet 请求。但 SprintMVC 直接就是 <url-pattern>/*</url-pattern>, 就是说任何 URL 都会丢给 SpringMVC 处理。那 Servlet 除了用于配置抢先 SpringMVC 的处理请求,就没有其他存在意义了。毕竟 DispatcherServlet 之后大致的处理方式就是 (inbound) -> interceptor -> controller methods -> content renders (json, xml, template engines...) -> (outbound),那 Servlet 没意义不就是写明白的了么, Servlet 就这么被 Spring 反客为主了(x

看到这里实在是感叹 Servlet 时代的消逝和 SpringMVC 时代的崛起。

怀缅 application-context.xml

(其实 SpringIoC 的初始化不一定是这个名字,看配置的)

Servlet 让我想起那些 old days, Spring IoC 其实也是。Sprint IoC 现在更多的是躲在幕后,使用 package scanner 配合各种 annotation ,不被人感知地默默工作。

再看 web.xml<servlet-mapping> 之前需要依赖的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- Get current web root dir -->
<!-- It must be loaded before other Listener and Servlet!! -->
<context-param>
<param-name>webAppRootKey</param-name>
<param-value>xxxxxxxx.back.web.root</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.util.WebAppRootListener</listener-class>
</listener>

<!-- Application Context Configuration -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath*:context-application.xml</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

关于 webAppRootKey 的说明网上有很多,它的作用更多的是跟随标准。我就不额外说明这些“远古技术”了,可以参考这个:SpringMVC 监听器 WebAppRootListener 与 ContextLoaderListener;官方:WebAppRootListener

注意一下 <param-name>contextConfigLocation</param-name> 这个存在,它跟上面的是对应的。

<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> ,顾名思义,就是“上下文加载监听器”, Spring 通过这个东西,在 Servlet 应用启动之前,开始加载自己。而用于在 Servlet 应用里“反客为主”的 <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> 就是靠这个东西初始化的上下文内容来工作的。

Spring 的 CotextLoader 起来之后,在 DispatcherServlet 之前会初始化 Spring IoC 本身,根据 <param-name>contextConfigLocation</param-name> 去读取配置文件,像这里我就读取 classpath 下的 context-aplication.xml 了。这样 Spring 就会按照经典的 xml IoC 配置去初始化整个容器,效果就是初始化我整个应用的内容。

可以看得到,这两个是分别的 Listener ,对应的 Params 我按依赖其传参的 Listener 归组了。它们会被放在一个大 Map 里等着需要它们的组件去获取它。

不禁感叹以前的东西真够复杂的。。现在我一个 SpringBoot parent 起来就自带个 Tomcat/Jetty Server 帮我搞好所有东西,我只需要 src 下写点代码打点注解就好了。

再往里面就是 applicationContext 的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">

<context:property-placeholder location="WEB-INF/application.properties"/>
<context:component-scan base-package="xx.xxxxxxxx.modules" use-default-filters="false">
<context:include-filter type="annotation" expression="org.springframework.stereotype.Repository"/>
<context:include-filter type="annotation" expression="org.springframework.stereotype.Service"/>
<context:include-filter type="annotation" expression="org.springframework.stereotype.Component"/>

<context:exclude-filter type="regex" expression="cn\.lexiuxia\.modules\.sms\.SmsService"/>
<context:exclude-filter type="regex" expression="cn\.lexiuxia\.modules\.mail\.MailingService"/>
</context:component-scan>

<!-- 我设计的 FileManager ,以后再说 -->
<bean class="xx.xxxxxxxx.commons.resources.config.StorageServiceConfiguration">
<property name="webRoot" value="${xxxxxxxx.back.web.root}"/>
<property name="rootPath" value="static/"/>
<property name="createDirIfNotExist" value="true"/>
<property name="filenameGenerator">
<bean class="xx.xxxxxxxx.commons.resources.file.DateTimeFilenameGenerator"/>
</property>
</bean>

<import resource="context-infrastructure.xml"/>
</beans>

以前还得用 <context:property-placeholder location="WEB-INF/application.properties"/> 来声明一个 property 文件,这样就能在外部直接修改部分属性而不用重新编译整个容器。现在直接使用 SpringBoot 在启动 jar 包的时候直接传参告诉它去文件系统里找替代配置文件就行了。

<context:component-scan> 拿来告诉 spring 容器应该管包树的哪个地方,而现在使用 SpringBoot 就能让 Spring 知道自己该管哪里:以打了 @SpringBootApplication 的注解所在的包开始找就好了。这个魔法倒是不难做。因为 main 函数一般都在这个类里,直接从 main 函数的代码上下文开始就行了(利用反射是 Spring 的拿手好戏不是吗 w )

定义一个 bean ,可以使用 <bean> 标签来做,不过既然用了 <context:component-scan> 的话,就没必要了。那时候的我应该是不熟悉 spring 的配置,不知道注解情况下如何初始化的时候配置 bean ,所以并没有直接使用注解的方式初始化。

<import resource="context-infrastructure.xml"/> 是那时候用来分割配置文件用的。我一直看不习惯 xml 所以就把一些关于数据库的配置塞里面了。

有点孤独的 context-webmvc-backend.xml

它的加载点在 web.xml<param-name>contextConfigLocation</param-name>

它的加载独立于 Spring IoC ,但同时又依赖它。Spring MVC 的内容,会作为一个子级容器,塞在主容器下,主容器访问不了它,但它能访问主容器的内容。它的初始化,戴表着实际的数据递交门面(facade) 的初始化。因为它也其实是 spring ioc 容器初始化的配置文件,所以按照上面 context-application.xml 来配置是没有问题的。分开的原因很简单:它本来就是独立于 spring IoC 加载的一个子容器。

我还记得当时踩的坑:重复加载了两次父容器的内容,导致获取的实例有歧义点:会拿到两个。所以在里面我直接限制了它的加载范围:
(这也是我当时对 Spring 有点害怕的原因。。。)

1
2
3
4
<context:annotation-config/>
<context:component-scan base-package="xx.xxxxxxxx.web.back" use-default-filters="false">
<context:include-filter type="annotation" expression="org.springframework.stereotype.Controller"/>
</context:component-scan>

于是很简单地,关于门面数据的代码我都在这里配置了,例如 interceptors 、content negotiation (非页面内容渲染)、negotiatingViewResolver(页面内容渲染) 和一些 exception 处理器。看着这个复杂但又还能理解的 xml 配置,感觉就是“给我一次机会我绝对不会再这样做这个玩意”(x

这个项目现在怎样了?

这个项目当时实际是花了三个月去处理,后来客户违约,项目搁置,我们所做的东西都没有了价值,我也没收到钱。三年过去了,这个东西一直静静地躺在这里。作为一家破烂大专在读生的我,能做出这种应用后端,我有点小自豪的同时有点失望。自豪是因为,我独力做出一个能用的后端,规模绝对比任何一个同级的同学做出来的都要大,结构比任何一个同学的都要好。失望的就是,我比得过同级的人又怎么样,超越了落后的人不代表水平跟得上时代。当时的我最失望的就是我居然没把权限系统做上去。我为了让前台后台分开而做的这个 trick 实在是脏得不行,而且对比起自己网上见过的朋友们,这种没有架构、也是慌乱糊出来的屎真的拿不出手。

现在看来,它还是我的实践,证明了我不少以前的想法,为我以后的其他实践继续提供了支持。

重构

三年后到今天,觉得自己并没有太多的经验积累,觉得内疚,觉得自己是时候面对以前的遗憾了。

首先下手整顿的是这些毫无意义的模块分割。在 maven 下要分模块,我现在的经验是这样的:

  1. 高度独立且通用的工具库:例如任何项目都能用上的 commons 。这种模块不能太大,因为不可能什么项目都能完整用上它。不能太小,这样各个项目都会存在相同的工具。应该谨慎地增加它的内容。后来我制作了 snnmtn-utils ,它只包含一些我常用的东西。而 web 相关的 snnmtn-utils-web 则包含我在 web 工程内才用到的东西。

  2. 组件:专门针对这个项目的组件,可能是一些中间件或者局部框架。这种模块一般在巨型项目里会用到,我的项目都没有巨型一说,很少考虑这种情况。而且它一般都会跟第一种一样,被分离出项目独立存在。

  3. 自成一体的项目:一个大项目下的子项目,例如独立部署的分布式系统中的子系统。它们也是组件的一种,不过层面在项目结构上而不是一些关键部位的构建。

  4. 特殊用途:这种我暂时还没经验,见过一些专门用于管理依赖的模块(例如以前的 Spring);额外的 maven 插件(这种算是组件了,不过它在项目以外的层面,例如代码管理、自动生成);协议库,专门管理项目之间的抽象(我个人不是很喜欢这个东西,它其实是组件库的一种)。

回到这个项目本身,它除了第一种,并没有任何需要分模块的必要。所以我把它全部代码塞回去 root project 下的 /src 下面。

使用 SpringBoot

老旧的 Spring 工程改造,最好的办法大概就是直接 SpringBoot 化。这样可以直接一个 jar 包启动,不需要再配置额外的 Servlet 容器。而且它减少很多手工配置的依赖,Spring 已经把以前很多常用的依赖加进去了,例如 contentNegotiation 一般来说会配置成 json ,现在的 Spring 默认就是 jackson 来处理。比起以前,现在只需要一个 @RestController 就能搞定 REST Api 而不需要额外配置。

不仅仅是 SpringMVC ,数据库也是方便了很多,直接使用 Spring Data JPA ,就能把以前的项目数据层依赖全部提换掉,例如 hibernate 的一大串以及针对 JPA 的 EntityManager 的 Spring 配置。

构建部分再也不需要 war 插件来打包工程了,直接一个 spring boot 的构建插件就能出来对应的 jar 包。

消灭了很多 pom.xml 的内容,以及以前 Servlet 那些又难看又冗余的内容,就可以开始对实际的业务部分进行操作了。

后续

在下一篇我会继续记录这个过程。

评论