数据工厂设计与实现
##1. 数据工厂的作用 ##
在日常的测试过程中,测试人员(或者开发人员)总是需要构造各种各样的测试数据来满足自己的需求。数据工厂的作用就是提供统一的 UI,让测试人员或者开发人员能够快速、简单地生成测试数据,提高测试效率。
所谓快速、简单,是指对于其他的(不是这条业务线的)测试、开发人员来说,都能通过简单的输入,生成自己需要的测试数据,而不用去了解接口或者数据库的设计,通过调用接口或者直接写数据库来构造数据。
##2. 设计原则 ##
数据工厂只是一个框架,其构造测试数据的业务逻辑(模块)需要各个业务线的测试人员开发,所以数据工厂的设计遵循以下原则:
- 数据工厂的模块开发只需关注业务逻辑,前端采用注册的形式,也就是不需要写 Web 前端的代码;
- 模块的开发人员不需要考虑 Web 容器的部署等问题,只需要提供实现了业务逻辑的 Jar 包即可;
- 数据工厂动态装载业务逻辑的 Jar 包,提供动态扩展的功能(即插件化);
- 考虑到不同的业务逻辑的 Jar 包可能会使用相同的第三方 Jar 包,但是版本不同,所以数据工厂要对各个业务逻辑的 Jar 包进行容器隔离,避免所依赖的第三方 Jar 包版本冲突的问题。
##3. Java 类装载及容器隔离 ##
类装载器是 Java 语言的一个创新,也是 Java 语言流行的重要原因之一,它使得 Java 类可以被动态地加载到 Java 虚拟机中并执行。关于类装载器,本文不打算做详细介绍,感兴趣的读者请参阅本文的参考文档:深入探讨 Java 类装载器。
Java 的容器隔离以前有 OSGI,但是 OSGI 显得过于沉重,已经基本被 Java 社区抛弃。其实通过 Java 的类装载器就能实现轻量级的容器隔离,因为 Java 中判定两个类是否相同,看的是类全名和其对应的类装载器,两者同时相同才表示相等;也就是说,通过不同的类装载器装载相同的类,在 Java 虚拟机中,这两个类其实是不相等的,是隔离的,是不能相互访问的。
关于 Java 中容器隔离的概念和实现,请参阅本文的参考文档:Java 中隔离容器的实现。
##4. 数据工厂的设计 ##
###4.1 系统框架图 ###
系统的整体框架图如下:
系统的主要流程如下:
- 开发者实现注册(Register)接口,定义前端表单的结构,同时实现业务逻辑处理(Handler)接口,负责处理前端用户提交的数据,并返回处理结果;
- 开发者使用Apache Maven Shade Plugin将工程打包成 Jar 包(Maven Shade Plugin 可以将依赖的 Jar 包全部解压,然后将得到的 class 文件连同当前项目的 class 文件一起合并到最终的 Jar 包中,这样最终的 Jar 包就包含所有依赖的类了,具体请参阅本文的参考文档:Maven 实战(九)——打包的技巧);
- 开发者上传 Jar 包,数据工厂扫描 Jar 包,找到实现注册(Register)接口的类,调用注册方法 register,生成前端菜单和表单信息,存入数据库;
- 前端读取数据库,生成菜单和表单;
- 用户输入数据,提交表单,数据工厂从数据库读取业务逻辑处理类信息,装载该类,调用业务逻辑处理方法 handle,传入用户输入的数据,并将处理结果返回给前端。
###4.2 注册(Register)接口 ###
注册接口定义了一个注册用的方法,主要的作用是注册数据工厂服务,定义 Web 前端表单的结构:
public interface Register {
Module register(); // 注册数据工厂服务, 可以有多个类实现这个接口
}
```
register方法返回的Module类定义如下:
```java
public class Module {
// 前端和http服务可以二选一,或者都注册
private Class<? extends Handler> handlerClass; // 处理业务请求的类,实现Handler接口,必选
/***** 注册前端需要 *****/
private String groupName; // 模块隶属的业务group,前端体现为一级菜单,注册前端必选,不能超过16个中文字符
private String moduleName; // 展示在前端的模块名称,前端体现为二级菜单,注册前端必选,不能超过16个中文字符
private List<Widget> widgets; // 前端需要展示的控件,注册前端必选
/**********************/
/***** 注册http服务需要 *****/
private String actionSpace; // 提供web service的namespace, 注册http服务必选
private String actionName; // 提供web service的actionname, 要求在同一namespace下唯一, 注册http服务必选
/**********************/
private String helpMsg; // 帮助信息(可以介绍该模块的功能,如何使用等),可选
private String author; // 模块的作者, 请使用中文名,可选
// getter和setter方法
}
```
这里需要说明的是,数据工厂的模块不仅可以注册前端,还可以注册Http服务。如果要注册Http服务的话,只需提供actionSpace和actionName,其他的代码不需要做任何改变。用户可以通过 http://qa.qima-inc.com/dmm/${actionSpace}/${actionName} 即可访问该Http服务(数据工厂解析用户提供的URL,通过actionSpace和actionName在数据库里找到对应的Handler类信息)。当然,这里用户提供的数据(不论是通过Http Body或者URL的参数)需要遵循约定的格式。
Module类里使用到的Widget类定义如下:
```java
public class Widget {
private String label; // 前端控件标签, 展示给用户,必选
private String name; // 控件名称, 也就是最终传给业务处理类Handler的 json串中的key,必选
private WidgetType widgetType; // 控件类型,必选
private List<SelectOption> options; // 如果控件是select, 显示的options,可选
private String placeHolder; // input或者textarea的placeholder, 可选
private int maxLength = 0; // input或者textarea最大可输入的字符数, 0表示不限制
private boolean required = false; // input或者textarea是否是必填项
private String defaultValue; // 控件的默认值,可选
private String pattern; // 校验控件输入值的正则表达式,可选
private String title; // 与pattern结合使用,不符合格式要求时提示给用户的信息,可选
private String text; // 控件类型是PARAGRAPH时,控件显示的文本信息
// getter和setter方法
}
```
###4.3 业务逻辑处理(Handler)接口###
业务逻辑处理接口定义了一个处理业务逻辑的方法handle:
```java
public interface Handler {
Result handle(String json); // 调用业务线的接口,或者直接操作数据库,处理传入的json字符串
}
```
该方法接收用户的输入(数据工厂包装好的JSON格式的字符串),通过调用业务线的接口,或者直接操作数据库,处理业务逻辑,并返回处理的结果。
返回结果Result类定义如下:
```java
public class Result {
private boolean success = false; // 接口调用是否成功
private String result; // 如果成功,返回的结果,可以是JSON字符串;如果不成功,返回的错误信息
// getter和setter方法
}
```
##5. 模块开发示例##
本示例展示如何实现前端为下面图片所显示的菜单和表单的数据工厂模块:
![模块开发示例菜单图](/content/images/2017/08/dmm-module-menu.png)
![模块开发示例表单图](/content/images/2017/08/dmm-module-table.png)
实现注册(Register)接口的代码主体如下:
```java
Module module = new Module().setHandlerClass(DmmDemoHandler.class);
/***** 定义前端显示的控件 *****/
module.setGroupName("Demo") // 前端一级菜单
.setModuleName("演示模块") // 前端二级菜单
.setHelpMsg("这是一个数据工厂的演示模块\n这是帮助的第二行") // 可选
.setAuthor("方金和"); // 可选
// 输入框
module.addWidget(new Widget()
.setLabel("姓名:") // 控件的label
.setName("name") // 控件的name
.setWidgetType(WidgetType.INPUT) // 控件的类型,输入框
.setPlaceHolder("请输入您的姓名") // 控件的placeholder,可选
.setMaxLength(10) // 控件接受的最大输入长度,可选
.setRequired(true) // 控件是否是必填项,可选
.setPattern("^[a-zA-Z]+$") // 校验控件的输入的正则表达式,可选
.setTitle("请使用英文名")); // 与pattern结合使用,输入校验不通过时提示给用户的信息,可选
// 选择框
Widget select = new Widget()
.setLabel("性别:")
.setName("gender")
.setWidgetType(WidgetType.SELECT);
// 选择框的option
SelectOption option1 = new SelectOption()
.setDisplayName("女") // 选择框里显示的文本
.setValue("1"); // 传递给后端的值
SelectOption option2 = new SelectOption()
.setDisplayName("男")
.setValue("2");
select.addOption(option1).addOption(option2);
module.addWidget(select);
// textarea
module.addWidget(new Widget()
.setLabel("个性说明:")
.setName("description")
.setWidgetType(WidgetType.TEXTAREA));
// 定义上述控件后,前端会显示一个输入框、一个选择框和一个textarea。
// 假设用户前端输入:姓名输入"xxx",性别选择"女",个性说明输入"这里是个性说明"
// 则用户提交表单后,传递给业务逻辑处理类DmmDemoHandler的json字符串为:
// {"name": "xxx", "gender": "1", "description": "这里是个性说明"}
```
如果要注册Http服务,则代码如下:
```java
/***** 注册 Http 服务 *****/
module.setActionSpace("user").setActionName("profile");
// 注册上述Http服务后, 用户调用http的url为(支持POST或者GET方法):
// http://qa.qima-inc.com/dmm/user/profile
// 如果使用POST方法,http body请使用json格式,http body将传递给业务逻辑处理类DmmDemoHandler
// 如果使用GET方法,url后附加的http参数将被包装成json格式字符串传递给业务逻辑处理类DmmDemoHandler
/*************************/
```
业务逻辑处理类DmmDemoHandler的代码主体如下:
```java
public Result handle(String json) { // 传入的用户输入为json格式的字符串
JSONObject input = JSON.parseObject(json);
String name = input.getString("name");
if (name == null) { // success设置为false,则前端会提示结果为失败
return new Result().setSuccess(false).setResult("必须输入姓名");
}
Map<String, String> result = new HashMap<>();
result.put("姓名", name);
result.put("性别", input.getString("gender").equals("1") ? "美眉" : "帅哥");
result.put("个性说明", input.getString("description"));
// result一般使用json格式的字符串
return new Result().setSuccess(true).setResult(JSON.toJSONString(result));
}
```
使用上述业务逻辑处理类,用户提交表单后,显示的结果为:
![数据工厂调用结果图](/content/images/2017/08/dmm-call-result.png)
##6. 数据工厂的实现##
数据工厂的实现其实比较简单,主要集中在扫描Jar包和调用业务逻辑处理方法上(本文不展示前端的实现细节)。
###6.1 扫描Jar包###
扫描Jar包,调用注册类方法register的代码如下(Groovy语言):
```groovy
List<Module> modules = []
def filePath = new File(jarFilePath) // jarFilePath:jar包的文件路径
URLClassLoader loader = new URLClassLoader([new URL('file:' + filePath.absolutePath)] as URL[]) // 使用不同的类装载器装载不用的jar包
Class registerClazz = loader.loadClass('com.youzan.test.dmm.register.Register') // 注册接口的class
Enumeration<JarEntry> files = new JarFile(filePath).entries()
while (files.hasMoreElements()) {
def className = files.nextElement().name
if (className.endsWith('.class')) {
className = className.replaceAll('/', '.').replaceAll('.class', '')
} else {
continue
}
Class clazz = loader.loadClass(className)
if (clazz.isInterface()) {
continue
}
if (registerClazz.isAssignableFrom(clazz)) { // 如果类实现了Register接口
def module = clazz.newInstance().register() // 调用register方法,获取module信息
if (module) {
modules.add(module)
}
}
}
// 同时保存该jar包的装载器,供后续的方法路由使用
```
###6.2 调用业务逻辑处理方法###
调用业务逻辑处理方法的代码如下(Groovy语言):
```groovy
def classLoader = JarParseService.getClassLoader(jarFilePath) // 使用扫描jar包时已经创建的类装载器
Thread.currentThread().setContextClassLoader(classLoader) // 将当前线程的类装载器设置为上面的类装载器
def clazz = classLoader.loadClass(handlerClassName) // 装载业务逻辑处理类
try {
def result = clazz.newInstance().handle(json) // 调用handle方法,传入用户的输入(JSON格式的字符串)
return [success: result.success, result: result.result]
} catch (Throwable e) {
// 如果有异常,返回异常堆栈信息
return [success: false, result: CommonUtils.getStackTrace(e)]
}
```