用Java SPI实现可插拔
前言: 在软件系统的设计中,可插拔是一个重要特性。它意味着给系统添加新功能的时候(或者将原来功能的实现替换成新的实现而保持接口不变),不改变系统已有功能。这样的可插拔的功能模块被称为插件。插件(plugin)的出现可以很好地支持系统的可扩展性(Extensibility). 一个扩展性好的系统意味着很容易替换或者增加某些功能。
本文的目的是使用JDK6(或以上)的SPI(Service Provider Interface)来演示如何做一个简单插件系统。
回想一下,一般情况下我们怎么在已有系统上修改或者新增一个功能?常见的做法是把系统的源码拿出来,修改代码或增加代码,然后重新编译打包再发布,这样新功能就被容纳进来。 但是这样做有2个弊端:1)原来的系统被重新编译打包(应该避免这一点,而把新功能容纳进来) 2)如果原来你使用的系统是闭源的,那么拿到它的代码是不可能的。因此好的做法是使得系统支持可插拔的特性,要加入新功能只需把新的模块实现放置进来,而对原来的系统不做任何改变。这样一来,无论我们是原有系统的使用者还是开发者,可以用较小的代价和风险扩展系统。利用Java的Service Provider Interface就可以实现。SPI中有Service(服务),通常是一组接口(interface)或者抽象类(abstract class).还有ServicePovider,就是一些实现了服务接口的具体类(插件)。在使用中,主系统提供接口,各个插件模块来提供实现类,每个插件有个服务配置文件指明要实现的接口和具体类,在运行时将服务配置文件放到主系统的classpath,主系统即可使用各个插件。
我们打算用3个工程来演示SPI(Service Provider Interface)的原理。
Reader: 是我们的主工程。用来模拟已经发布或存在的软件系统。它的作用是提供service provider(也即我们这里所指的插件)需要实现的接口。并使用这些接口实现自己的功能。
csv: 解析CSV(comma seperated value,逗号分隔)输入格式的插件工程。来模拟要插入csv解析功能到主工程的csv插件。提供实现服务接口的具体类。
tsv:解析TSV(tab seperated value,tab键分割)输入格式的插件工程。来模拟要插入tsv解析功能到主工程的tsv插件。提供实现服务接口的具体类。
在运行主工程时,可以解析2种格式的输入。并将输入转化成字符串数组。比如 java App text/csv 1,2,3 输出[1,2,3] ;或者java App text/tsv 1 [tab] 2 [tab] 3 输出[1,2,3] ;
1. 目录结构
-----workDir
---csv : csv工程目录
---src
---classes
---Reader: 主工程目录
---classes
---tsv: tsv工程目录
---src
---classes
src是放源代码的目录;classes是编译后的class文件或jar的目录。
2. 代码示例
2.1 主工程Reader
2.1.1 定义需要的服务
在Reader\com\hdp\plugindemo目录下新建一个文件Decoder.java (com.hdp.plugindemo充当java包) 。定义服务接口,其他插件类只要实现此接口,就可能被装载到Reader的运行时。
package com.hdp.plugindemo;
public interface Decoder {
boolean isEncodingSupported(String encodingName);
String[] getContent(String input);
}
2.1.2 创建使用服务的主类
在Reader\com\hdp\plugindemo目录下创建一个文件DecoderFactory.java (com.hdp.plugindemo是java包)。利用java.util.ServiceLoader来装载服务接口的实现,但在Reader工程不提供任何实现类。
package com.hdp.plugindemo;
import java.util.ServiceLoader;
public class DecoderFactory {
public static Decoder getDecoder(String encodingName) throws Exception{
ServiceLoader<Decoder> sl = ServiceLoader.load(Decoder.class);
for ( Decoder decode :sl ) {
if ( decode.isEncodingSupported(encodingName) )
return decode;
}
throw new Exception("Not supported encoding:"+encodingName);
}
}
在Reader\com\hdp\plugindemo目录下创建 一个文件App.java (com.hdp.plugindemo是java包).使用插件提供的功能来输出结果。
package com.hdp.plugindemo;
import java.util.Arrays;
public class App {
public static void main(String[] args) throws Exception{
String encodingName = args[0];
String input = args[1];
Decoder decoder = DecoderFactory.getDecoder(encodingName);
String[] result = decoder.getContent(input);
System.out.println("converted result="+ Arrays.asList(result));
}
}
到Reader目录下,执行
javac com\hdp\plugindemo\App.java -d classes
再执行: java -cp classes com.hdp.plugindemo.App text/csv 1,2,3可以看到,抛出了异常 "Not supported encoding:text/csv"
2.2 csv工程
2.2.1实现服务的接口
在csv\src\decoder目录下新建一个文件CSVDecoder.java (decoder是java包) ,它实现主工程的Decoder接口,可以解析csv格式字符串,并转换成字符数组。
package decoder;
import java.util.*;
import com.hdp.plugindemo.Decoder;
public class CSVDecoder implements Decoder {
public boolean isEncodingSupported(String encodingName) {
if ( encodingName.equalsIgnoreCase("text/csv") ) {
return true;
}
else return false;
}
public String[] getContent(String input) {
List<String> values = new LinkedList<String> ();
StringTokenizer parser = new StringTokenizer(input, ",");
while(parser.hasMoreTokens()) {
values.add(parser.nextToken());
}
return (String[])values.toArray(new String[values.size()]);
}
}
在csv目录下,执行 javac -cp ../Reader/classes src\decoder\CSVDecoder.java -d classes 将实现类CSVDecoder编译到src\classes下.
2.2.2 编写服务配置文件
在 csv\classes目录下创建一个META-INF\services目录层级,在csv\classes\META-INF\services目录下创建一个文件com.hdp.plugindemo.Decoder(此文件名必须是主工程Reader里定义的接口java全名),里面加上这么一行: decoder.CSVDecoder(即插件工程里的具体实现类java全名)。注意文件保存时,编码必须指定为UTF-8 without BOM.如果是Windows,不要用记事本来编辑保存,可以用UltradEdit或者notePad++等工具软件来保存(Windows记事本不能保存为UTF-8 without BOM,它保存的UTF-8文件是带BOM的UTF-8).
这时候,回到主工程Reader目录下,执行 java -cp classes;..\csv\classes com.hdp.plugindemo.App text/csv 1,2,3 ,可以看到屏幕输出
converted result=[1, 2, 3] ,这正是我们期望的结果。
2.3 tsv 工程
源码如下:
package decoder;
import java.util.*;
import com.hdp.plugindemo.Decoder;
public class TSVDecoder implements Decoder {
public boolean isEncodingSupported(String encodingName) {
if ( encodingName.equalsIgnoreCase("text/tsv") ) {
return true;
}
else return false;
}
public String[] getContent(String input) {
List<String> values = new LinkedList<String> ();
StringTokenizer parser = new StringTokenizer(input, "\t");
while(parser.hasMoreTokens()) {
values.add(parser.nextToken());
}
return (String[])values.toArray(new String[values.size()]);
}
}
其他步骤与2.2相同。只是在写服务配置文件时候其tsv\classes\META-INF\services\com.hdp.plugindemo.Decoder内容应是decoder.TSVDecoder
回顾一下2.2.2运行主程序,我们发现Reader主工程在发布后没有改任何东西(源码和配置)也无需重新打包编译,就把csv插件的输入解析功能成功引入。这样一来,新插件对原有系统的侵入性为0. 改变或增加新插件只是对新插件进行编码和配置,对已有系统的代码和配置无任何影响。这显然是比较好的一种做法。
主工程Reader目录下,执行 java -cp classes;..\csv\classes com.hdp.plugindemo.App text/csv 1,2,3 你会注意到-cp classes;..\csv\classes,其中..\csv\classes是为了将csv插件被装载到主工程运行时的classpath.这样META-INF\services\com.hdp.plugindemo.CSVDecoder才有可能被搜到,从而其包含的实现类才能被加到主工程运行时。否则,会报错ServiceConfigurationError .
需要注意的是1)加载配置文件(META-INF\services\XXX)的classloader和加载provider实现类的classloader可以不同,但要保证加载配置文件的classloader能访问到provider的实现类。2)每个provider实现类必须有无参构造子(在我们例子中是靠java自动提供的) 。3)每个provider是被ServiceLoader延迟加载的,也即用到的时候才被加载到内存。这是个比较好的特性。
在我们的例子中,java -cp classes;..\csv\classes com.hdp.plugindemo.App text/csv 1,2,3仅是为了举例说明原理,不是个好的做法(主工程不应看到..\csv\classes)。改进的做法是把csv工程打包成jar ( 在csv\classes 下执行 jar -cvf csvdecoder.jar decoder META-INF 将类文件和配置文件打包到jar). 给Reader工程下建个目录叫lib或ext,将csvdecoder.jar放到Reader\lib或Reader\ext, 然后执行 java -cp classes;lib\* com.hdp.plugindemo.App text/csv 1,2,3 .这样,任何插件jar都可放进来,主系统重新运行或启动就把新功能容纳进来。
总结:1)此例子虽然实现了可插拔,但并没实现热插拔,比如如何在主系统不重启的情况下把新功能容纳进来,这需要OSGI一类热插拔技术。
2)能不能对某些情况下,增加/替换插件时,新插件无需编码,紧靠配置就可把新插件容纳进来?
3)如果某些情况下发现需要给主项目新增服务定义,还能做到零侵入吗(而主项目无需重新编译打包)?
参考:
http://docs.oracle.com/javase/6/docs/api/java/util/ServiceLoader.html
http://blog.csdn.net/fenglibing/article/details/7083526