用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