用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

猜你喜欢

转载自blog.csdn.net/tonyhuang_google_com/article/details/49702291