⽤JavaSPI实现可插拔
前⾔:在软件系统的设计中,可插拔是⼀个重要特性。它意味着给系统添加新功能的时候(或者将原来功能的实现替换成新的实现⽽保持接⼝不变),不改变系统已有功能。这样的可插拔的功能模块被称为插件。插件(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 = Decoder(encodingName);
String[]  result = Content(input);
System.out.println("converted result="+ Arrays.asList(result));
}
}
到Reader⽬录下,执⾏
javac  com\hdp\plugindemo\App.java  -d  classes
decoder再执⾏: 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格式字符串,并转换成字符数组。
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.Token());
}
return  (String[])Array(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 ⼯程
源码如下:
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.Token());
}
return  (String[])Array(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 c
lasses;lib\*  com.hdp.plugindemo.App    text/csv  1,2,3  .这样,任何插件jar都可放进来,主系统重新运⾏或启动就把新功能容纳进来。
总结:1)此例⼦虽然实现了可插拔,但并没实现热插拔,⽐如如何在主系统不重启的情况下把新功能容纳进来,这需要OSGI⼀类热插拔技术。
2)能不能对某些情况下,增加/替换插件时,新插件⽆需编码,紧靠配置就可把新插件容纳进来?
3)如果某些情况下发现需要给主项⽬新增服务定义,还能做到零侵⼊吗(⽽主项⽬⽆需重新编译打包)?
参考:

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。