陈颂光
全栈工程师,承接从编译器到网站的各类软件开发与咨询,也可以聊历史哲学。
关注我的 GitHub

用OSGi打造可热拔插的插件系统

OSGi是Java平台上一个始于1999年的动态模块化系统,已经用于包括Eclipse在内的各种规模的软件。以下我们演示如何用OSGi框架的一个实现Apache felix来构建一个文件查看器,它用插件的方式支持各种文件类型。

急不及待的话可以马上试验:

  1. git clone https://gitlab.com/chungkwong/superviewer.git克隆本例代码
  2. cd superviewer进入项目目录
  3. mvn package构建项目
  4. cd distribution/target/superviewer.distribution-1.0-SNAPSHOT-dist进入成品目录
  5. ./startup.sh运行项目
  6. 打开按钮后选择文件,如文本文件或常见格式的图片,则文件内容会显示到一个新标签页中

基本概念

软件开发的关键在于控制软件的认知复杂度,而模块化正是控制认知复杂度的有效方法,有利于重用、测试、部署和维护。模块化本质上就是把软件分解为一些高内聚、低耦合的模块,这样开发一个模块时便不用过于关注其它模块,或者说每个模块只被尽可能少的人关注。仅靠自律显然不能保障低耦合性,必需有强制性措施禁止访问内部实现而只允许访问接口。Java的访问控制机制可以控制包间的访问,但由于现在应用程序中一般用JAR包为重用的单位,我们更需要JAR包为粒度的访问控制,而OSGi框架通过定制类加载器只让明确导出的包对其它模块可见。这甚至为同一虚拟机中运行不同版本的库提供了可能。

与Java 9引入的Jigsaw静态模块化系统被相比,OSGi是动态模块化系统,它把绑定推迟到运行时,从而适合用于可热拔插的插件系统,例如浏览器可在被要求打开一个未知格式时从网络自动下载并安装所需查看器然后马上打开文件,而不必重启程序。类似地,在接入设备时安装驱动程序而在设备离开后卸载可能是好主意。现实世界的动态性意味着建立动态的模块模型有时更为自然。

在OSGi框架中,应用程序由一些称为捆(bundle)的模块组成,每个捆中包含若干组件,不同组件通过服务协作。每个捆在JAR文件中的META-INF/MANIFEST.MF明确指出它要求从环境获得什么和向环境提供什么。简单来说,提供对象的捆创建对象并把它注册到OSGi服务注册表或从其中撤回,而使用对象的捆则从注册表按接口或类查找对象(必要时可再限制属性),甚至可以侦听注册表的变动。

虽然自行实现OSGi中你需要的部分并不难,但使用OSGi意味着可借助它丰富的生态系统,如现有的OSGi包和有关工具,包括可以声明式地用标注注入服务。

模块

首先创建一个空白的Maven项目(在Netbeans中项目类型MavenPOM项目)再加入以下样子的pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.github.chungkwong</groupId>
	<artifactId>superviewer</artifactId>
	<version>1.0-SNAPSHOT</version>
	<packaging>pom</packaging>
	<modules>
		<module>launcher</module><!-- 用于启动OSGi的程序 -->
		<module>api</module><!-- 提供服务接口的模块 -->
		<module>image</module><!-- 提供服务实现的模块 -->
		<module>media</module><!-- 提供服务实现的模块 -->
		<module>text</module><!-- 提供服务实现的模块 -->
		<module>application</module><!-- 提供服务用户的模块 -->
		<module>distribution</module><!-- 用于打包 -->
	</modules>
	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	</properties>
</project>

下面分别说明这项目的各个子项目。

服务接口模块

为了在编码时应当避免对实现类的引用,应该对接口编程。在本例子中我们需要表示文件查看器的接口,因此我们在api目录中子项目编写了以下接口:

package com.github.chungkwong.superviewer.api;
import java.io.*;
import javafx.scene.*;
/**
 * 文件查看器工厂
 */
public interface ViewerFactory{
	/**
	 * 打开指定文件
	 * @param file 要打开的文件
	 * @return 查看器
	 * @throws Exception
	 */
	Node getViewer(File file)throws Exception;
	/**
	 * 判断本工厂是否可能打开指定文件
	 * @param file 要打开的文件
	 * @return 判断
	 */
	boolean isViewable(File file);
}

接着我们编写用于管理捆生命周期的BundleActivator实现,它的start方法在捆启动时被调用以便初始化,stop方法在捆停用时被调用以便清理。对于我们的接口捆,并不需要做什么。

package com.github.chungkwong.superviewer.api;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
public class Activator implements BundleActivator{
	@Override
	public void start(BundleContext context) throws Exception{
	}
	@Override
	public void stop(BundleContext context) throws Exception{
	}
}

最后,我们完成pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<artifactId>superviewer</artifactId>
		<groupId>com.github.chungkwong</groupId>
		<version>1.0-SNAPSHOT</version>
	</parent>

	<groupId>com.github.chungkwong</groupId>
	<artifactId>superviewer.api</artifactId>
	<version>1.0-SNAPSHOT</version>
	<packaging>bundle</packaging>

	<name>viewer-api OSGi Bundle</name>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.osgi</groupId>
			<artifactId>org.osgi.core</artifactId>
			<version>6.0.0</version>
			<scope>provided</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.felix</groupId>
				<artifactId>maven-bundle-plugin</artifactId>
				<version>2.3.7</version>
				<extensions>true</extensions>
				<configuration>
					<instructions>
						<!-- BundleActivator实现 -->
						<Bundle-Activator>com.github.chungkwong.superviewer.api.Activator</Bundle-Activator>
						<!-- 容许在捆外访问的包 -->
						<Export-Package>com.github.chungkwong.superviewer.api</Export-Package>
						<!-- 不容许在捆外访问的包 -->
						<Private-Package>com.github.chungkwong.superviewer.api.*</Private-Package>
					</instructions>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>

服务实现模块

现在我们提供一个服务实现,因此我们在image目录中子项目编写中实现了一个用于打开图片文件的文件查看器工厂类:

package com.github.chungkwong.superviewer.image;
import com.github.chungkwong.superviewer.api.*;
import java.io.*;
import java.nio.file.*;
import java.util.logging.*;
import javafx.scene.*;
import javafx.scene.image.*;
public class ImageViewerFactory implements ViewerFactory{
	@Override
	public Node getViewer(File file) throws Exception{
		return new ImageView(file.toURI().toURL().toString());
	}
	@Override
	public boolean isViewable(File file){
		try{
			String type=Files.probeContentType(file.toPath());
			return type!=null&&type.startsWith("image/");
		}catch(IOException ex){
			Logger.getLogger(ImageViewerFactory.class.getName()).log(Level.INFO,null,ex);
			return false;
		}
	}
}

接着我们编写用于管理捆生命周期的BundleActivator实现,它的start方法在捆启动时在服务注册表中注册上述工厂到对应接口ViewerFactory的条目,在需要区分对应同一接口的不同服务时可以附加一个表示属性的映射表。因停用时框架能自动从注册表撤回捆之前注册的服务和侦听器,stop方法并不需要做什么。

package com.github.chungkwong.superviewer.image;
import com.github.chungkwong.superviewer.api.*;
import java.util.*;
import org.osgi.framework.*;
public class Activator implements BundleActivator{
	@Override
	public void start(BundleContext context) throws Exception{
		context.registerService(ViewerFactory.class,new ImageViewerFactory(),new Hashtable<String,Object>());
	}
	@Override
	public void stop(BundleContext context) throws Exception{
	}
}

最后,我们完成pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<artifactId>superviewer</artifactId>
		<groupId>com.github.chungkwong</groupId>
		<version>1.0-SNAPSHOT</version>
	</parent>

	<groupId>com.github.chungkwong</groupId>
	<artifactId>superviewer.image</artifactId>
	<version>1.0-SNAPSHOT</version>
	<packaging>bundle</packaging>

	<name>image-viewer OSGi Bundle</name>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<maven.compiler.source>1.8</maven.compiler.source>
		<maven.compiler.target>1.8</maven.compiler.target>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.osgi</groupId>
			<artifactId>org.osgi.core</artifactId>
			<version>6.0.0</version>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>${project.groupId}</groupId>
			<artifactId>superviewer.api</artifactId>
			<version>${project.version}</version>
			<scope>provided</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.felix</groupId>
				<artifactId>maven-bundle-plugin</artifactId>
				<version>3.5.0</version>
				<extensions>true</extensions>
				<configuration>
					<instructions>
						<Bundle-Activator>com.github.chungkwong.superviewer.image.Activator</Bundle-Activator>
						<Export-Package ></Export-Package>
						<Private-Package>com.github.chungkwong.superviewer.image.*</Private-Package>
					</instructions>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>

类似地可以编写其它服务实现,例如用于打开文本文件和视频文件的,这里就不重复了。

服务使用者模块

现在我们编写一个JavaFX客户端使用服务:

package com.github.chungkwong.superviewer.application;
import com.github.chungkwong.superviewer.api.*;
import java.io.*;
import java.util.*;
import java.util.logging.*;
import javafx.application.*;
import javafx.scene.*;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.*;
import org.osgi.framework.*;
import org.osgi.framework.launch.*;
public class SuperViewer extends Application{
	@Override
	public void start(Stage stage) throws Exception{
		stage.setScene(new Scene(getPane()));
		stage.setMaximized(true);
		stage.show();
	}
	private BorderPane getPane(){
		BorderPane pane=new BorderPane();
		Button open=new Button("打开");
		pane.setTop(open);
		Label status=new Label();
		pane.setBottom(status);
		TabPane content=new TabPane();
		open.setOnAction((e)->{
			open(new FileChooser().showOpenDialog(null),content,status);
		});
		pane.setCenter(content);
		return pane;
	}
	private void open(File file,TabPane pane,Label status){
		if(file!=null){
			try{
				//以下获取对各已注册ViewerFactory的引用
				Collection<ServiceReference<ViewerFactory>> serviceReferences=Activator.bundleContext.getServiceReferences(ViewerFactory.class,null);
				Iterator<ServiceReference<ViewerFactory>> applicatable=serviceReferences.stream().filter((ref)->{
					//以下获取对已注册的ViewerFactory
					ViewerFactory factory=Activator.bundleContext.getService(ref);
					boolean supported=factory.isViewable(file);
					//使用完的引用应当释放
					Activator.bundleContext.ungetService(ref);
					return supported;
				}).iterator();
				while(applicatable.hasNext()){
					ServiceReference<ViewerFactory> ref=applicatable.next();
					try{
						Tab tab=new Tab(file.getName(),Activator.bundleContext.getService(ref).getViewer(file));
						pane.getTabs().add(tab);
						pane.getSelectionModel().select(tab);
						status.setText("成功打开"+file);
						return;
					}catch(Exception ex){
						Logger.getLogger(SuperViewer.class.getName()).log(Level.SEVERE,null,ex);
					}finally{
						Activator.bundleContext.ungetService(ref);
					}
				}
				status.setText("无法打开"+file);
			}catch(InvalidSyntaxException ex){
				Logger.getLogger(SuperViewer.class.getName()).log(Level.SEVERE,null,ex);
			}
		}
	}
	@Override
	public void stop() throws Exception{
		//在客户端被关闭时关闭框架从而让程序能退出
		for(Bundle bundle:Activator.bundleContext.getBundles()){
			if(bundle instanceof Framework){
				bundle.stop();
			}
		}
	}
}

接着我们编写用于管理捆生命周期的BundleActivator实现,它的start方法在捆启动时启动客户端。

package com.github.chungkwong.superviewer.application;
import org.osgi.framework.BundleActivator;
import org.osgi.framework.BundleContext;
public class Activator implements BundleActivator{
	static BundleContext bundleContext;
	@Override
	public void start(BundleContext context) throws Exception{
		bundleContext=context;
		new Thread(()->SuperViewer.launch(SuperViewer.class)).start();
	}
	@Override
	public void stop(BundleContext context) throws Exception{
	}
}

最后,我们完成pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<artifactId>superviewer</artifactId>
		<groupId>com.github.chungkwong</groupId>
		<version>1.0-SNAPSHOT</version>
	</parent>

	<groupId>com.github.chungkwong</groupId>
	<artifactId>superviewer.application</artifactId>
	<version>1.0-SNAPSHOT</version>
	<packaging>bundle</packaging>

	<name>application OSGi Bundle</name>

	<properties>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<maven.compiler.source>1.8</maven.compiler.source>
		<maven.compiler.target>1.8</maven.compiler.target>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.osgi</groupId>
			<artifactId>org.osgi.core</artifactId>
			<version>6.0.0</version>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>${project.groupId}</groupId>
			<artifactId>superviewer.api</artifactId>
			<version>${project.version}</version>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.felix</groupId>
				<artifactId>maven-bundle-plugin</artifactId>
				<version>3.5.0</version>
				<extensions>true</extensions>
				<configuration>
					<instructions>
						<Bundle-Activator>com.github.chungkwong.superviewer.application.Activator</Bundle-Activator>
						<Export-Package ></Export-Package>
						<Private-Package>com.github.chungkwong.superviewer.application.*</Private-Package>
					</instructions>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>

类似地可以编写其它服务实现,例如用于打开文本文件和视频文件的,这里就不重复了。

连接模块

使用启动器

为了运行OSGi程序,在用mvn package构建各模块的JAR包后,你可以:

  1. 下载Felix发行版
  2. 解压后进入目录中
  3. 编辑conf/config.properties文件,加上一行org.osgi.framework.system.packages.extra=javafx.application,javafx.collections,javafx.event,javafx.scene,javafx.scene.control,javafx.scene.layout,javafx.stage,javafx.scene.image 以便导出JavaFX相关的包
  4. 运行java -jar bin/felix.jar
  5. 输入install 路径以安装指定JAR文件对应的OSGi捆
  6. 输入start 路径以启动指定JAR文件对应的OSGi捆

在启动上述三个模块后,你应该能得到一个能打开图片文件的程序。值得注意的是,它们的启动顺序是无关紧要的。

当然每次手动启动模块通常不是好主意,所以Felix提供了自动部署功能,只要把模块JAR放到bundle目录下,则模块会自动安装和启动。如果还想在JAR文件被删除时自动卸载和在更新时自动重新安装和启动,在conf/config.properties中设置felix.auto.deploy.action=install,start,update,uninstall

更多的选项参见conf/config.properties配置文件。

嵌入框架

有时候我们希望让OSGi框架成为程序的一部分而不是让整个程序受OSGi控制。这时可以把OSGi框架嵌入到我们的程序中:

package com.github.chungkwong.superviewer;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.logging.*;
import org.apache.felix.framework.FrameworkFactory;
import org.apache.felix.main.*;
import org.osgi.framework.launch.*;
public class Main{
	public static void main(String[] args) throws Exception{
		Map config=getDefaultConfig();
		Framework framework=new FrameworkFactory().newFramework(config);
		stopFrameOnExit(framework);
		framework.init();
		syncBundles(config,framework);
		framework.start();
		framework.waitForStop(0);
		System.exit(0);
	}
	private static Map<String,String> getDefaultConfig(){
		Map<String,String> config=new HashMap<>();
		config.put("org.osgi.framework.system.packages.extra","javafx.application,javafx.collections,javafx.event,javafx.scene,javafx.scene.control,javafx.scene.layout,javafx.stage,javafx.scene.image,javafx.scene.media");
		config.put("felix.auto.deploy.action","uninstall,install,update,start");
		config.put("felix.auto.deploy.dir","bundle");
		config.put("org.osgi.framework.storage.clean","onFirstInit");
		config.put("felix.log.level","4");
		return config;
	}
	private static void stopFrameOnExit(Framework framework){
		Runtime.getRuntime().addShutdownHook(new Thread(()->{
			try{
				framework.stop();
				framework.waitForStop(0);
			}catch(Exception ex){
				System.err.println("Error stopping framework: "+ex);
			}
		},"Felix Shutdown Hook"));
	}
	private static void syncBundles(Map config,Framework framework){
		AutoProcessor.process(config,framework.getBundleContext());
		try{
			Path path=new File("bundle").toPath();
			FileSystem fileSystem=path.getFileSystem();
			WatchService watchService=fileSystem.newWatchService();
			path.register(watchService,StandardWatchEventKinds.ENTRY_CREATE,
					StandardWatchEventKinds.ENTRY_DELETE,
					StandardWatchEventKinds.ENTRY_MODIFY,
					StandardWatchEventKinds.OVERFLOW);
			new Thread(()->{
				while(true){
					try{
						WatchKey key=watchService.take();
						AutoProcessor.process(config,framework.getBundleContext());
						key.reset();
					}catch(InterruptedException ex){
						Logger.getLogger(Main.class.getName()).log(Level.SEVERE,null,ex);
					}
				}
			},"Check bundle directory");
		}catch(Exception ex){
			Logger.getLogger(Main.class.getName()).log(Level.SEVERE,null,ex);
			ex.printStackTrace();
		}
	}
}

当然还要在pom.xml指出依赖:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>com.github.chungkwong</groupId>
		<artifactId>superviewer</artifactId>
		<version>1.0-SNAPSHOT</version>
	</parent>
	<artifactId>superviewer.launcher</artifactId>
	<packaging>jar</packaging>
	<properties>
		<maven.compiler.source>1.8</maven.compiler.source>
		<maven.compiler.target>1.8</maven.compiler.target>
	</properties>

	<dependencies>
		<dependency>
			<groupId>org.apache.felix</groupId>
			<artifactId>org.apache.felix.framework</artifactId>
			<version>5.6.10</version>
		</dependency>
		<dependency>
			<groupId>org.apache.felix</groupId>
			<artifactId>org.apache.felix.main</artifactId>
			<version>5.6.10</version>
		</dependency>
	</dependencies>
	<build>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-jar-plugin</artifactId>
				<configuration>
					<archive>
						<manifest>
							<mainClass>com.github.chungkwong.superviewer.Main</mainClass>
						</manifest>
					</archive>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>

打包

当要发布如上嵌入OSGi的程序时,我们可以制作一个类似Felix发行包的目录结构。为此在Maven子项目distribution中的pom.xml中使用maven-assembly-plugin

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>com.github.chungkwong</groupId>
		<artifactId>superviewer</artifactId>
		<version>1.0-SNAPSHOT</version>
	</parent>
	<artifactId>superviewer.distribution</artifactId>
	<packaging>pom</packaging>
	<build>
		<plugins>
			<plugin>
				<artifactId>maven-assembly-plugin</artifactId>
				<version>3.1.0</version>
				<executions>
					<execution>
						<id>distro-assembly</id>
						<phase>package</phase>
						<goals>
							<goal>single</goal>
						</goals>
						<configuration>
							<descriptors>
								<descriptor>assembly.xml</descriptor>
							</descriptors>
						</configuration>
					</execution>
				</executions>
			</plugin>
		</plugins>
	</build>
</project>

组合规则assembly.xml如下:

<?xml version="1.0" encoding="UTF-8"?>
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.0.0"
		  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
		  xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.0.0 http://maven.apache.org/xsd/assembly-2.0.0.xsd">
	<id>dist</id>
	<formats>
		<format>dir</format>
	</formats>
	<includeBaseDirectory>false</includeBaseDirectory>
	<fileSets>
		<fileSet>
			<fileMode>777</fileMode>
			<directory>${project.basedir}</directory>
			<includes>
				<include>startup.sh</include>
				<include>startup.bat</include>
			</includes>
		</fileSet>
		<fileSet>
			<directory>${project.basedir}</directory>
			<includes>
				<include>README</include>
				<include>LICENSE</include>
			</includes>
		</fileSet>
	</fileSets>
	<moduleSets>
		<moduleSet>
			<useAllReactorProjects>true</useAllReactorProjects>
			<excludes>
				<exclude>com.github.chungkwong:superviewer.distribution</exclude>
				<exclude>com.github.chungkwong:superviewer.launcher</exclude>
			</excludes>
			<binaries>
				<outputDirectory>bundle</outputDirectory>
				<unpack>false</unpack>
			</binaries>
		</moduleSet>
		<moduleSet>
			<useAllReactorProjects>true</useAllReactorProjects>
			<includes>
				<include>com.github.chungkwong:superviewer.launcher</include>
			</includes>
			<binaries>
				<outputDirectory>bin</outputDirectory>
				<unpack>false</unpack>
			</binaries>
		</moduleSet>
	</moduleSets>
</assembly>

另外,我们提供了startup.sh启动脚本(startup.bat类似):

#!/bin/sh
java -cp "bin/*" com.github.chungkwong.superviewer.Main

这样在构建主项目后就可像文章开始时说的方法运行程序。

下一步

关于OSGi框架的更多信息请参考OSGi 规范

关键词 java