系统分析与设计大作业
重构合同管理系统所用到的设计模式之分析
作者:
- 徐峰
- 丁健
- 童文星
- 郑淘沙
- 王亚琦
项目背景与挑战
随着公司本土业务的不断扩大, 后台合同管理系统已经不堪重负。 在吞吐量、稳定性以及可扩展性上都无法满足日益增长的业务需求。
挑战一
对于每个合同,销售人员从准备材料、和客户签单、递交到合同部门再审批,需要长达一周的时间。随着业务量的快速增长,签订合同的成本急剧增加。
挑战二
因为业务量的快速增长,系统的响应速度越来越慢。而且有些表单提交操作会超时,需要重复提交表单才能成功, 大大降低了大家的工作效率。
挑战三
合同管理系统是公司后台支撑系统最重要的一块。而该系统已经上线使用了快8年,使用的是老旧的JSP开发的CRM系统。
一方面,系统架构过于陈旧,性能和可靠性都无法满足业务需求。
另一方面,经过多个补丁修改后,功能变得复杂,而且代码结构混乱,模块间耦合度极高。由于是遗留系统,熟悉该代码的人早已不再维护该系统。继任者对其也望而却步,只能做些简单的缺陷修复工作。维护该系统的工作已成了烫手山芋,无人敢接。
挑战四
系统界面已经过时,用户体验也不好。公司领导想重新设计界面,但是又不想破坏复杂的后台逻辑。
解决方案
原系统架构图如下:
在无法中断业务处理的情况下,为了解决当前面临的挑战,技术团队制定了以下的策略:
- 仔细研究了挑战一的原因,发现合同部门的领导经常在外出差导致审批被耽搁,而且销售人员也需要在外洽谈客户,如果能远程访问系统,无疑能大大加快流程的运转。对此问题的解决方法就是需要引入移动端的应用来方便领导和销售人员在外使用。 但是因为老系统并没有提供服务接口供移动端使用,所以需要重新实现。为了减少重复代码,网页端和移动端应该使用同一套服务接口来调用后台业务。所以需要在系统的外围,构建业务服务接口,将系统的核心功能分离出来。
- 对于挑战二,主要是因为个别服务的并发性很高,所以需要将该服务改成独立的服务,然后可以水平扩展,提高服务的并发性。
- 对于挑战三,可以通过不断构建功能服务接口,逐渐将原有系统分成多个独立的服务。做到功能的高内聚,低耦合。
- 对于挑战四,可以尝试采用前后台分离的架构,在前端使用MV*框架来构建交互式的用户界面,而后端只负责提供和处理业务数据。
现在广泛使用的后台服务都是基于REST规范Web服务的。对于该系统的后台, 决定使用基于Java的Dropwizard框架来实现REST服务。而前台可以使用目前最火的AngularJS Web框架。
模式分析
MVC
前端使用AngularJS(后面简称ng)实现。ng是目前最流行的前端框架之一, 由Google主导, 发展很快, 框架功能也异常强大. ng的设计上也使用了MVC等模式来实现模块化和降低代码耦合度。
先看一下控制器。
控制器
控制器的主要职责是构造模型,并把模型和回调方法一起发送给视图。视图可以看做是作用域在模板上的投影。控制器和模型的分离非常重要,因为:
- 控制器是由JavaScript实现的,所以它不应该包含任何和页面渲染(DOM操作)有关的代码。
- 视图模板是用HTML定义的。HTML是声明式的,适合用于表达页面的展现。视图不应该包含任何行为。
- 因为控制器和视图没有直接的调用关系,所以可以使用多个视图对应同一个控制器。可以通过这种原理来实现换肤、适配不同设备(手机或者桌面电脑)。
看一个例子:
模型
模型就是用来和模板结合生成视图的数据。模型必须被作用域所引用,才能被渲染生成视图。ng对模型本身没有任何限制和要求。不需要继承任何类,也不需要实现指定的方法。简而言之,模型可以是原生的JavaScript对象。
视图
所谓视图,就是指用户所能看见的。视图的生命周期由一个HTML模板开始,然后将模型合并到模板中,并最终渲染到浏览器的DOM中。 ng的模板引擎和其他模板引擎是不同的。 其他模板引擎一般采用变量替换的方式,也就是用模型实际的值替换模板中的变量,整个替换过程模板都是当做字符串来处理的。 而ng的模板引擎直接处理的是DOM。先将模板解析成DOM,然后这个DOM会作为输入传递给模板引擎,也就是ng特有的编译器。编译器查看节点的指令,找到指令后,就会监视指令中相应的模型。这样做,就使得视图能”连续地“更新,不需要模板和数据的重新合并。模型也就成了视图变化的唯一动因。
视图模板的例子:
使用MVC模式后,能很好的把页面逻辑和展现分离,使得代码结构十分清晰。View只负责数据的展现,Controller只负责把Model传递给View,并侦听一些DOM事件。Model则负责数据的获取和相关验证逻辑。
SOA
由于实现了前后台的分离,所有原有的后台逻辑都改成了RESTful的WEB服务。最大程度上实现了SOA的架构。
基于JSON格式的Web服务 在Web服务诞生之前,应用间做集成的话会使用CORBA,DCOM,RMI等技术。这些技术有着如下这些缺点:
- 互不兼容
- 被防火墙屏蔽
- 二进制调试,阅读起来困难
之后Web服务的出现克服了这些缺点,它是一种远程调用技术,使用HTTP协议传输数据,数据用XML格式表示,又称为XML-RPC。 Web服务使用的主要是SOAP协议,全称是简单对象访问协议。现在SOAP的定义已经扩展为一种基于XML的消息传递框架。
由于Web应用的快速发展,和AJAX技术的大量采用,基于SOAP的Web服务已不能适应JavaScript框架的需要,一种基于JSON格式的Web服务逐渐替代了基于SOAP的Web服务。
Java下面有很多框架能实现基于JSON格式的Web服务,而我们在项目中使用了Dropwizard框架作为后台服务的框架。该框架内部则使用了Jersey来实现Web服务。
下面代码定义了一个以URL的接口服务。
随着近两年微服务概念在软件体系架构领域的诞生,项目组也考虑采纳微服务机构的思想。微服务通过将功能分解到多个独立的服务,以实现对解决方案或者复杂系统的解耦。
什么是微服务?
微服务(Micro Service)是对SOA架构模式的扩展,那么怎样定义微服务呢?
实际上,微服务本身没有一个严格的定义。不过从业界的讨论来看,微服务通常有如下几个特征:
-
小且专注于一个功能
每个服务都是很小的应用,只关注一个业务功能,这一点和面对对象原则中的“单一职责原则”类似。每个服务只实现一个功能,并且把它做到最好。
-
运行在独立的进程中
每个服务都运行在一个独立的进程中,意味着不同的服务可以部署在不同的机器上,也可以部署在同一台机器上。
-
轻量级的通讯机制
服务和服务之间通过轻量级的机制实现彼此间的通讯。一般使用和语言和平台无关的协议,比如XML,JSON或者二进制串流,而不是传统的RPC等技术,如RMI。
-
松耦合
不需要改变依赖,只更改当前服务本身,就可以独立部署。意味着该服务与其它服务之间在部署和运行上不互相依赖,相对独立。 服务接口的设计遵循“宽进严出”的原则:对输入数据只获取自己需要的数据,无用的数据则丢弃。对输出数据,保证结构上不做大的变化,需要考虑向前兼容性。
总之,微服务架构采用多个服务间的互相协作的方式来实现传统大后台的所有功能。而且每个服务独立运行在不同的进程中,它们之间通过轻量级的通讯机制交换数据,并且各服务可以通过CI工具独立部署。
微服务架构的优势
和传统单块架构系统相比,微服务有着如下显著的优势:
-
异构性
传统的单块架构要求一个统一的技术平台来实现所有的业务需求,而使用微服务后,可以针对不同业务选择最合适的技术实现。比如,针对身份认证系统,可以选用基于NodeJS的OAuth和MongoDB组件来构建;针对合同审批流程,可以采用基于Java的jBPM框架来构建。 基于微服务架构,很容易在遗留系统上尝试新的技术或解决方案。比如,先挑选风险最小的服务做尝试,快速开发成型,并得到反馈后再考虑该技术或者方案是否试用于其它服务。
-
独立测试与部署
单块架构的应用运行在一个进程中,一旦一个微小的功能发生改变,就需要对整个系统重新测试并部署。而对于微服务而言,如果改动只发生在一个服务中,只需要对该服务进行测试和部署就可以。
-
扩展性强
这里的扩展性是指的性能上的扩展性。单块架构系统由于单进程的局限性,水平扩展时只能基于整个系统作扩展,无法针对某一个模块单独扩展。而SOA的架构则可以解决性能伸缩性的扩展问题。根据模块的负荷,水平扩展该模块。 就合同管理系统来说,最核心也是最繁忙的就是合同查询模块,可以通过负载均衡来增加一个该模块的服务,解决性能瓶颈问题。
经过几个月的努力,我们重新构建了合同管理系统,将之前的产品、价格、销售、合同签署、合同审核以及PDF生成都定义成了独立的服务。相比之前大而全的单块架构,新的系统性能更好,扩展性强,也易于维护。通过一套健全的监控系统,可以很方便的跟踪每个服务的健康状态,一旦出现问题能及时通知管理员,并定位问题,提高了整个系统的可靠性。
重构后的系统架构如下:
数据库访问
Dropwizard使用了JDBI库来实现轻量级的数据库访问,如果需要类似ORM的数据库访问,仍然可以选择Hibernate,它和Dropwizard有着很好的集成。
下面我们来看看JDBI是如何使用的。
一般情况下,数据库访问采用的是JDBC,但是JDBC操作非常繁琐,要获取某个操作的结果,一般需要3个步骤:
- 建立JDBC连接。
- 拼写并执行SQL语句。
- 查找结果需要进行迭代查询,有时我们只想获取某个域的值,也需要遍历结果以获得需要的值。
传统的JDBC在获取数据库操作结果这一方面比较繁琐,JDBI则在这一方面有较优秀的表现。 JDBI是JAVA下的一个SQL便捷库,它提供了两种类型的API:Fluent API和SQL对象API。 下面我们首先看一下JDBI提供的Fluent API,是如何让数据库操作变得轻松便捷的。
通过这段代码,可以看出JDBI类型与JDBC数据源类似,一般都由一个传入的JDBC数据源创建,有URL和CREDENTIALS或其他方式的构造函数。通过JDBI实例,可获取一个句柄。一个句柄代表某个数据库的一条连接。句柄依赖于JDBC连接对象。
使用句柄,可以创建并执行SQL语句、查询、调用、分批处理或准备分批处理。上例中,我们通过调用句柄的execute方法,执行create语句,创建了表something;然后再次调用execute方法,执行insert语句,使用了2个定位参数“id”,“name”向表中插入了一项记录;之后通过句柄的createQuery方法,执行query语句,通过bind方法和定位参数设定查找条件:id为1,查询的结果存储在string对象name中,之后又调用了assetThat在查询结果中筛查name为Brian的项。最后调用了句柄的close方法关闭句柄。
语句中的命名参数机制和查询都是由JDBI提供的,JDBI解析SQL语句,并在创建备用语句时使用定位参数。上例使用了默认的冒号分割解析。
JDBI提供的第二种类型的API是SQL对象API,SQL对象API简化了创建DAO对象的常用的语法:一条语句只能对应一个方法。SQL对象定义是一个注释接口,例如:
在DAO层,我们经常需要重复写相同的SQL语句,但是Hibernate/JPA在这方面的处理就相当便捷,但我们也不必对此有过多依赖。 JDBI提供了EJB3.0的精髓,即将命名查询进行简化标注。 MyDao接口定义了2个updates,第一个方法createSomethingTable与fluent api例子中一样,用于创建一个表,第二个方法insert也做了与fluent一样的插入操作,第三个方法findNameById定义了查询操作。 后两个方法中的参数是直接传递给方法,并由name绑定,而非像fluent api中一样,是将参数通过拼写好的语句传递给方法。
最后一个方法close比较特殊,当close()方法被调用时,将关闭所有JDBC连接。 可以声明该方法以抛出异常,就像java.io.Closeable中的close方法一样。
为了使用这个SQL对象定义,可以在代码中这么写:
通过DBI实例获取一个SQL对象实例,然后通过SQL对象实例调用相关的方法。 创建SQL对象实例的方法有很多,之前的例子中都有将对象绑定到指定的句柄,所以在处理完后,要将句柄关闭。
JDBC与JDBI在性能上相比,JDBC灵活性有优势,并且在比较复杂的项目中,JDBC仍然是首选。 如果JDBC的代码写的非常优化,那么JDBC架构运行效率最高,但是实际项目中,这一点几乎做不到,这需要程序员非常精通JDBC,运用Batch语句,调整PreapredStatement的Batch Size和Fetch Size等参数,以及在必要的情况下采用结果集cache等等。 而一般情况下程序员是做不到这一点的。 因此JDBI在易学性、易用性上的优势,尤其是在取数据库操作结果集的简易性上,使得JDBI在现代WEB开发中也占有一席之地。
依赖注入模式
依赖注入(Dependency Injection)是一种编程模式,但它的价值却见仁见智,有的人认为它是无价的,有的人却认为是完全无用的。 我相信在复杂的代码库中,DI是非常有用的;但在简单的代码库中,它很大程度上却不是必须的。 Java有一个JSR-330规定的简单标准的DI应用程序接口,JDR-330实现如下:Spring IoC、Guice、Dagger、Sisu和HK2。它们各自由一些主要的公司或者组织来发展。 鉴于此,人们常常面临着一个选择的矛盾。但是不要担心,如果你坚持JSR-330标准,即使有所偏离,你也可以在任何时候改变你的DI解决方案。 如果你想让你的应用程序是完全可配置的用户模式,选择Spring IoC;如果不是,甚至切换到其他也不能满足你的要求时,选择用Dagger开始。
这里我们用Dagger作为例子。 为了保持整洁,我们将只留下HelloWorldResource。 但这一次,不是用手动创建服务并且配置对象,我们将使用Dagger从YAML文件中读取我们的配置,并注入到我们的服务。
这是服务:
注意@Inject和@Named的注释。这些都是JSR-330标准的一部分,所以不管我们使用什么DI工具,我们的服务代码都将保持不变。实际上连接和注入的依赖关系是我们所使用的Dagger特定代码。在一个module类上,Dagger指定依赖连接配置。这是我们的:
Dagger很酷的一点,是它验证所有的依赖项,在compile time使用注释处理器时,都是要满足的。 比如如果我们忘记定义provideDefaultName ,这就作为我们的类型在NetBeans中出现提示:
为了获得一个HelloWorldResource的满配置实例,这就是我们放在应用程序run方法中的:
从整个代码中,你会注意到,ModernModule类复制一些JModernConfiguration的行为。 这就很好的用@Module简单注解了JModernConfiguration,并且用@Provides简单注解了getTemplate和getDefaultName方法。 不过不幸的是,Dagger禁止子类化的模块。
而在第三段代码中可以看到,方法HelloWorldResource和getTemplate、getDefaultName之间存在强耦合关系,如下图所示:
这使得HelloWorldResource只能做简单的应用,很难作为一个成熟的组件去发布,因为在不同的应用环境中(包括同一套软件系统被不同用户使用的时候),它所要依赖的getTemplate或getDefaultName可能是千差万别的。所以,为了能实现真正的基于组件的开发,必须有一种机制能同时满足下面两个要求:
- 解除HelloWorldResource对具体getDefaultName类型的强依赖(编译期依赖)。
- 在运行的时候为HelloWorldResource提供正确的getDefaultName类型的实例。
换句话说,就是在运行的时候才产生HelloWorldResource和getDefaultName之间的依赖关系(把这种依赖关系在一个合适的时候“注入”运行时),这恐怕就是Dependency Injection这个术语的由来。再换句话说,我们提到过解除强依赖,这并不是说HelloWorldResource和getDefaultName之间的依赖关系不存在了,事实上HelloWorldResource无论如何也需要某类getDefaultName提供的服务,我们只是把这种依赖的建立时间推后了,从编译器推迟到运行时了。
依赖关系在OO程序中是广泛存在的,只要A类型中用到了B类型实例,A就依赖于B。而把概念抽象到了服务使用者和服务提供者的角度,这也符合现在SOA的设计思路。从另一种抽象方式上来看,可以把HelloWorldResource看成我们要构建的主系统,而getDefaultName是系统中的plugin,主系统并不强依赖于任何一个插件,但一旦插件被加载,主系统就应该可以准确调用适当插件的功能。
其实不管是面向服务的编程模式,还是基于插件的框架式编程,为了实现松耦合(服务调用者和提供者之间的or框架和插件之间的),都需要在必要的位置实现面向接口编程,在此基础之上,还应该有一种方便的机制实现具体类型之间的运行时绑定,这就是DI所要解决的问题。
比较DI的实现代码,在系统实现了依赖注入,组件间的依赖关系就变成了下图:
在这里,ModernModule提供一个容器,由容器来完成(1)具体ServiceProvider的创建(2)ServiceUser和ServiceProvider的运行时绑定。
需要在强调一下的是,依赖并未消失,只是延后到了容器被构建的时刻。所以上图所示,容器本身(更准确的说,是一个容器运行实例的构建过程)对ServiceUser和ServiceProvoder都是存在依赖关系的。所以,在这样的体系结构里,ServiceUser、ServiceProvider和容器都是稳定的,互相之间也没有任何依赖关系;所有的依赖关系、所有的变化都被封装进了容器实例的创建过程里,符合我们对服务应用的理解。而且,在实际开发中我们一般会采用配置文件来辅助容器实例的创建,将这种变化性排斥到编译期之外。
依赖注入方式的实现有三种典型方式,我们这里主要使用的是Constructor Injection(构造器注入),除此外还有Setter Injection(设值注入)和Interface Injection (接口注入),这里不做展开。
而依赖注入是控制反转(Inversion of Control)实现的一种类型,控制反转是一个重要的面向对象编程的法则来削减计算机程序的耦合问题,也是轻量级框架的核心。 控制反转的还有另一种应用较广泛的实现类型是服务定位器(Service Locator)。 也就是说,由ServiceLocator来专门负责提供具体的ServiceProvider。 当然,这样的话ServiceUser不仅要依赖于服务的接口,还依赖于ServiceContract。 仍然是拿上面那个例子,如果使用Service Locator来解除依赖的话,整个依赖关系应当如下图所示:
正因为上面提到过的ServiceUser对ServiceLocator的依赖性,从提高模块的独立性(比如说,有可能把你构造的ServiceUser或者ServiceProvider给第三方使用)上来说,依赖注入可能更好一些,这恐怕也是为什么大多数的IOC框架都选用了DI的原因。 ServiceLocator最大的优点可能在于实现起来非常简单,如果你开发的应用没有复杂到需要采用一个IOC框架的程度,也许你可以试着采用它。
总结和心得
首先,在完成作业的过程中,大家接触到了最前沿的Web开发理念,扩展了大家的知识面。
虽然许多新语言新技术相继诞生,但是采用新的架构和设计模式后,一度让人觉得不在是Web开发首选技术的Java,仍然表现出惊人的适应能力。虽然纯Web前台开发不再是Java的战场,但是Java相关的技术还是能继续在后台服务开发中大放异彩。
其次,经过项目重构,把单块架构的大型系统,分解成了若干个微型服务来支撑原有业务。更深刻的体会到了SOA模式在企业应用架构中的优点。
再者,采用前后台分离的开发模式,提升了开发效率。因为大家只需要按接口编程,前台和后台开发可以同时进行。前后台分离后,前台开发可以让精通前台技术的开发人员来完成,可以实现更加复杂的交互。
最后,通过学习,大家深刻认识了设计模式在软件设计中所发挥的重要作用。适当的运用设计模式,不仅能使设计清晰明了易于维护,而且还能加快软件的开发速度和偏于日后扩展和维护。虽然通过课程只学到了软件设计方法论的冰山一角,但是大家对于这么学科的兴趣日益剧增。我相信,在今后的工作中,大家会继续学习和研究各种设计模式,为设计出优质的软件而努力。