首页 > 代码库 > 一次简单的重构经验

一次简单的重构经验

背景

曾经为一家律师事务所做的案件信息管理工作,使用的是Playframework 2.3.x / Java。由于是外包项目,原来就只是一个工程,也没有打算再拆分子模块。

后来这家公司继续为系统考虑添加功能,要增加一系列的CRM中的销售管理的功能,问题慢慢浮现。

我发现问题有几个:

  • Playframework本来就能进行代码修改、编译、加载、运行,一直以来都非常方便,但是开始CRM部分工作,这个修改到运行的周期开始慢了。平时修改完代码保存后,到运行有结果,只需要几秒钟的时间。到最近CRM开始了,修改一个源代码档案,编译器会帮我编译几十个,甚至上百个代码档案,我开始怀疑依赖关系混乱了,导致编译器很难判断那些档案需要重新编译。
  • 由于没有模块的概念,我在项目中养成了一个小小的坏习惯,就是把我认为是[一般]的功能函数放在Utils对象里。本来如日期的格式化之类的功能放在Utils里无可厚非,但是如果把一个Contact的名字(Salutation FirstName,LastName)都用Utils来格式化,结果是Utils庞大而且分工不清晰。其实理论上,这些函数可能是属于CRM的特有的功能,因为Contact在CRM功能改造才浮现的一个名词,用CRMUtils对象会明确一点。
  • 打开一个工程,我开始无法简单的关注我需要改动的部分,虽然可以用Eclipse的Filter等来选择掩盖package等,这个对于未来的改变不利,会增加未来可能加入的新人的学习成本。

我需要改变,改变思路是模块化。

以下部分不仅仅限于Playframework,理念基本上是通用的。无论你用的是Java/Maven,还是.NET

重构策略

由于是已有的旧工程,要保证重构后功能正常,也要保持重构成本不要过高,策略如下:

  1. 先建立几个模块文件夹(子工程),设定好子工程的依赖关系,然后把相关代码适当地移到到它应该处于的子工程中
  2. 如上描述的Util对象,因为会涉及到不同模块,先重复拷贝到各个模块中,后续再重新定义
  3. 尽量先让编译器帮我做校验,重构失败等于编译失败,这样降低操作成本

这些策略看来不错,于是我分开了几个子工程:

  1. base:把框架性的代码放到这里,其中包括员工登入等权限控制,Utils等
  2. caseman:案件管理,依赖base
  3. crm:新的CRM工程,依赖caseman和base

但是我预期需要处理的不仅仅是把代码各就各位那么简单。其他需要处理的重要部分还有:

  • 当分开了模块后,主功能菜单属于那一个模块呢?
  • 注入框架的配置(我用了Guice),在那个模块启动呢?

原来这两个问题才是核心问题。

重构过程

整个代码搬迁过程看来都比较顺利,直到要把主菜单归到相应的项目的时候。

主菜单

深入想想:主菜单是什么?当一个功能模块加载后,主菜单会发生什么变化?

我希望做到的是:子模块可以[贡献]部分主菜单的显示项。贡献这个字,来自英文Contribute。当我将来要写一个会计模块的时候,我只需要添加模块,主菜单就会自动添加了会计功能项。

(顺带一提:业务方可能永远无法提出这样的动态菜单的需求,因为这个不是业务需求,大概只能在重构过程中又技术团队发现这个需求来。)

主菜单我把它放到base工程,当没有其他模块的时候,空空如也。

每一个模块如果需要添加一个主菜单项的时候,需要实现一个主菜单贡献类:INavigationProvider(接口定义在base,实现定义在各个模块),模块实现方把菜单部分的HTML定义好,由base去调用获取。

public interface INavigationProvider {	/**	 * Define the position of this menu item.	 */	Integer getOrder();	/**	 * Actual HTML fragment of the menu item.	 */	Html getFragment(NavigationItemLocation location);}

当然,由于刚才定义的base也是管登入的权限,base因为也是一个模块,也可以贡献一个登入功能菜单。

由于我们用的是Guice,用Guice的Multibindings就最适合了。

模块加载

一个模块不仅仅是一些对象的集合,还可能包括一些配置,如上说的主菜单的Multibindings的配置,和一些启动需要初始化的东西。

这些都需要定义模块启动时候的约定,如定义一个模块接口,有:onStart()、onStop()。定义了这两个应用层面的生命周期方法,感觉就对了。

public interface IModule {	/**	 * Define the Guice Module used for configuring this Application Module.	 */	Module getModule(Application application);	/**	 * Multibindings for contribution to the main menu.	 * 	 */	void config(Multibinder<INavigationProvider> navBinder);	/**	 * Called on application start, after module initialization.	 */	void onStart(Application app, Injector injector);	/**	 * Called on application stop	 */	void onStop(Application app, Injector injector);}

模块需要实现这个启动接口,作为相当于整个模块的[入口]。

启动代码

回顾一下,我们有什么东西:

  • base,caseman,crm模块,和相应的 IModule 实现
  • IModule需要实现Guice的菜单使用的Multibindings配置,主菜单搞定

任何应用都有启动的步骤和结构,在Playframework,这个在Global.java。启动部分可以看到,相当简洁:

	IModule[] modules = new IModule[] {                        new BaseModule(),			new CaseManModule(),			new CRMModule()	};	public void onStart(Application app) {		List<Module> guiceModules = new LinkedList<Module>();		for (IModule m : modules) {			guiceModules.add(m.getModule(app));		}		guiceModules.add(new AbstractModule() {			protected void configure() {				Multibinder<INavigationProvider> nav = Multibinder						.newSetBinder(binder(), INavigationProvider.class);				for (IModule m : modules) {					m.config(nav);				}			}		});		// initializing injector		this.injector = Guice.createInjector(guiceModules);		for (IModule m : modules) {			m.onStart(app, injector);		}        }

启动代码就只有一个对象,现在在主项目的对象,严格来说只有这一个对象,其他对象都散落在各个模块中。  

如果要再能动态一点,把modules的定义通过其他方式动态加载进来,如:Java Service Provider Interface之类,把一个JAR文件掉到classpath就行了。

不过这样子我已经非常满意了。

后记

在整个重构过程中,代码迁移到跑又跑不动,一个高层对象被它的底层对象应用,对象的循环依赖,不能编译,一切一切...... 我想过放弃 (只是一个 git branch -d 是多么的简单,外包项目,客户都没要求,我要求来干嘛)。但是,只要凭着信念坚持到最后,最终你可以看得到光明。

这类重构绝对是磨练意志力的练习。