Java开拓2.0: 利用Hibernate Shards举办切分
副标题#e#
当干系数据库试图在一个单一表中存储数 TB 的数据时,总机能常常会低落。显然,对所有数据编索引不只对付读并且对付写都很耗时。因为 NoSQL 数据商店尤其适合存储大型数据(如 Google 的 Bigtable),显然 NoSQL 是一种非干系数据库要领。对付倾向于利用 ACID-ity 和实体布局干系数据库的开拓人员及需要这种布局的项目来说,切分是一个令人振奋的可选要领。
切分 是数据库分区的一个分支,可是它不是当地数据库技能 — 切分产生在应用措施级别。在各类切分实现中,Hibernate Shards 是 Java™ 技能世界中最受接待的一个。这个机动绝妙的项目可以让您利用映射至逻辑数据库的 POJO 对切分数据集举办险些无缝操纵(我将在下文扼要先容 “险些” 的原因)。利用 Hibernate Shards 时,您无须将您的 POJO 出格映射至切分 — 您可以像利用 Hibernate 要领对任何常见干系数据库举办映射时一样对其举办映射。Hibernate Shards 可觉得您打点初级此外切分任务。
到今朝为止,在本 系列 中,我已经利用了一个基于角逐和参赛者类推干系的简朴域展示了各类数据库存储技能。本月,我将继承利用这个熟悉的示例先容一种实用的切分技能,然后在 Hibernate Shards 中对其举办实现。留意:与切分相关的主要事情与 Hibernate 没有太大干系;事实上,Hibernate Shards 的编码事情较量简朴。个中要害的部门在于判定 如何举办切分以及对什么举办切分。
关于本系列
自 Java 技能首次降生以来,Java 开拓名堂已产生了雷霆万钧的变革。得益于成熟的开源框架和靠得住的租赁陈设基本设施,此刻可以迅速而经济地组装、测试、运行和维护 Java 应用措施。在 本系列 中,Andrew Glover 摸索使这种新的 Java 开拓气势气魄成为大概的各类技能和东西。
切分简介
数据库切分 是一个固有的干系流程,可以通过一些逻辑数据块将一个表的行分为差异的小组。譬喻,假如您正在按照时间戳对一个名为 foo 的超大型表举办分区,2010 年 8 月之前的所有数据都将进入分区 A,而之后的数据则全部进入分区 B。分区可以加速读写速度,因为它们的方针是单独分区中的较小型数据集。
分区成果并不老是可用的(MySQL 直到 5.1 版本后才支持),并且其需要的贸易系统的本钱也让人望而却步。更重要的是,大部门分区实此刻同一个物理机上存储数据,所以受到硬件基本的影响。除此之外,分区也不能辨别硬件的靠得住性可能说缺乏靠得住性。因此,许多伶俐的人们开始寻找举办伸缩的新要领。
切分 实质上是数据库级此外分区:它不是通过数据块支解数据表的行,而是通过一些逻辑数据元素对数据库自己举办支解(凡是跨差异的计较机)。也就是说,切分不是将数据表 支解成小块,而是将整个数据库 支解成小块。
切分的一个典范示例是基于按照区域对一个存储世界范畴客户数据的大型数据库举办支解:切分 A 用于存储美国的客户信息,切分 B 用户存储亚洲的客户信息,切分 C 欧洲,等。这些切划分离处于差异的计较机上,且每个切分将存储所有相关数据,如客户爱好或订购汗青。
切分的长处(如分区一样)在于它可以压缩大型数据:单独的数据表在每个切分中相对较小,这样就可以支持更快速的读写速度,从而提高机能。切分还可以改进靠得住性,因为即便一个切分意外失效,其他切分仍然可以处事数据。并且因为切分是在应用措施层面举办的,您可以对不支持通例分区的数据库举办切分处理惩罚。资金本钱较低同样也是一个潜在优势。
切分和计策
像许多其他技能一样,举办切分时也需要作出部门妥协。因为切分不是一项当地数据库技能 — 也就是说,必需在应用措施中实现 —在开始切分之前需要拟定出您的切分计策。举办切分时主键和跨切分查询都饰演重要脚色,主要通过界说您不行以做什么实现。
主键
切分操作多个数据库,个中所有数据库都独立起浸染,不过问干与其他切分。因此,假如您依赖于数据库序列(如自动主键生成),很有大概在一个数据库会合将呈现同一个主键。可以跨漫衍式数据库协调序列,可是这样会增加系统的庞洪水平。制止沟通主键最安详的要领就是让应用措施(应用措施将打点切分系统)生成主键。
跨切分查询
大部门切分实现(包罗 Hibernate Shards)不支持跨切分查询,这就意味着,假如您想操作差异切分的两个数据集,就必需处理惩罚特另外长度。(有趣的是,Amazon 的 SimpleDB 也克制跨域查询)譬喻,假如将美国客户信息存储在切分 1 中,还需要将所有相关数据存储在此。假如您实验将那些数据存储在切分 2 中,环境就会变得巨大,系统机能也大概受影响。这种环境还与之前提到的一点有关 — 假如您因为某种原因需要举办跨切分毗连,最好回收一种可以消除反复的方法打点键!
#p#分页标题#e#
很明明,在成立数据库前必需全面思量切分计策。一旦选择了一个特定的偏向之后,您差不多就被它绑定了 — 举办切分后很难随便移动数据了。
制止不成熟切分
切分最亏得后期实现。如不成熟优化一样,基于预期数据增长的切分大概是劫难的温床。乐成的切分实现基于对付应用措施数据随时间增长的领略,以及之后对付将来的揣度。一旦对数据举办切分后,移动数据会很是坚苦。
#p#副标题#e#
一个计策示例
因为切分将您绑定在一个线型数据模子中(也就是说,您无法轻松毗连差异切分中的数据),您必需对如安在每个切分中对数据举办逻辑组织有一个清晰的观念。这可以通过聚焦域中的主要节点实现。如在一个电子商务系统中,主要节点可以是一个订单可能一个客户。因此,假如您选择 “客户” 作为切分计策的节点,那么与客户有关的所有数据将移动至各自的切分中,可是您仍然必需选择将这些数据移动至哪个切分。
对付客户来说,您可以按照地址地(欧洲、亚洲、非洲等)切分,可能您也可以按照其他元素举办切分。这由您抉择。可是,您的切分计接应该包括将数据匀称漫衍至所有切分的要领。切分的总体观念是将大型数据集支解为小型数据集;因此,假如一个特定的电子商务域包括一个大型的欧洲客户集以及一个相对小的美国客户集,那么基于客户地址地的切分大概没有什么意义。
回到角逐 — 利用切分!
此刻让我们回到我常常提到的赛跑应用措施示例中,我可以按照角逐或参赛者举办切分。在本示例中,我将按照角逐举办切分,因为我看到域是按照介入差异角逐的参赛者举办组织的。因此,角逐是域的根。我也将按照角逐间隔举办切分,因为角逐应用措施包括差异长度和差异参赛者的多项角逐。
请留意:在举办上述决按时,我已经接管了一个妥协:假如一个参赛者介入了不止一项角逐,他们分属差异的切分,那该怎么办呢?Hibernate Shards (像大大都切分实现一样)不支持跨切分毗连。我必需忍受这些轻微未便,答允参赛者被包括在多个切分中 — 也就是说,我将在参赛者介入的多个角逐切分中重建该参赛者。
为了轻便起见,我将建设两个切分:一个用于 10 英里以下的角逐;另一个用于 10 英里以上的角逐。
实现 Hibernate Shards
Hibernate Shards 险些可以与现有 Hibernate 项目无缝团结利用。独一问题是 Hibernate Shards 需要一些特定信息和行为。好比,需要一个切分会见计策、一个切分选择计策和一个切分处理惩罚计策。这些是您必需实现的接口,固然部门环境下,您可以利用默认计策。我们将在后头的部门逐个相识各个接口。
ShardAccessStrategy
执行查询时,Hibernate Shards 需要一个抉择首个切分、第二个切分及后续切分的机制。Hibernate Shards 无需确定查询什么(这是 Hibernate Core 和基本数据库需要做的),可是它确实意识到,在得到谜底之前大概需要对多个切分举办查询。因此,Hibernate Shards 提供了两种极具创意的逻辑实现要领:一种要领是按照序列机制(一次一个)对切分举办查询,直到得到谜底为止;另一种要领是并行会见计策,这种要领利用一个线程模子一次对所有切分举办查询。
为了使问题简朴,我将利用序列计策,名称为 SequentialShardAccessStrategy。我们将稍后对其举办设置。
ShardSelectionStrategy
当建设一个新工具时(譬喻,当通过 Hibernate 建设一个新 Race 或 Runner 时),Hibernate Shards 需要知道需将对应的数据写入至哪些切分。因此,您必需实现该接口并对切分逻辑举办编码。假如您想举办默认实现,有一个名为 RoundRobinShardSelectionStrategy 的计策,它利用一个轮回计策将数据输入切分中。
对付赛跑应用措施,我需要提供按照角逐间隔举办切分的行为。因此,我们需要实现 ShardSelectionStrategy 接口并提供依据 Race 工具的 distance 回收 selectShardIdForNewObject 要领举办切分的浅易逻辑。(我将稍候在 Race 工具中展示。)
运行时,当在我的域工具上挪用某一雷同 save 的要领时,该接口的行为将被深层用于 Hibernate 的焦点。
清单 1. 一个简朴的切分选择计策
#p#分页标题#e#
import org.hibernate.shards.ShardId;
import org.hibernate.shards.strategy.selection.ShardSelectionStrategy;
public class RacerShardSelectionStrategy implements ShardSelectionStrategy {
public ShardId selectShardIdForNewObject(Object obj) {
if (obj instanceof Race) {
Race rce = (Race) obj;
return this.determineShardId(rce.getDistance());
} else if (obj instanceof Runner) {
Runner runnr = (Runner) obj;
if (runnr.getRaces().isEmpty()) {
throw new IllegalArgumentException("runners must have at least one race");
} else {
double dist = 0.0;
for (Race rce : runnr.getRaces()) {
dist = rce.getDistance();
break;
}
return this.determineShardId(dist);
}
} else {
throw new IllegalArgumentException("a non-shardable object is being created");
}
}
private ShardId determineShardId(double distance){
if (distance > 10.0) {
return new ShardId(1);
} else {
return new ShardId(0);
}
}
}
如您在 清单 1 中所看到的,假如耐久化工具是一场 Race,那么其间隔被确定,并且(因此)选择了一个切分。在这种环境下,有两个切分:0 和 1,个中切分 1 中包括 10 英里以上的角逐,切分 0 中包括所有其他角逐。
假如耐久化一个 Runner 或其他工具,环境会稍微巨大一些。我已经编码了一个逻辑法则,个中有三个原则:
一名 Runner 在没有对应的 Race 时无法存在。
假如 Runner 被建设时介入了多场 Races,这名 Runner 将被耐久化到寻找到的首场 Race 所属的切分中。(顺便说一句,该原则对将来有负面影响。)
假如还生存了其他域工具,此刻将激发一个异常。
然后,您就可以擦掉额头的热汗了,因为大部门艰巨的事情已经搞定了。跟着角逐应用措施的增长,我所利用的逻辑大概会显得不足机动,可是它完全可以顺利地完成这次演示!
ShardResolutionStrategy
当通过键搜索一个工具时,Hibernate Shards 需要一种可以抉择首个切分的要领。将需要利用 SharedResolutionStrategy 接口对其举办指引。
如我之前提到的那样,切分迫使您重视主键,因为您将需要亲自打点这些主键。幸运的是,Hibernate 在提供键或 UUID 生成方面表示精采。因此 Hibernate Shards 缔造性地提供一个 ID 生成器,名为 ShardedUUIDGenerator,它可以机动地将切分 ID 信息嵌入到 UUID 中。
假如您最后利用 ShardedUUIDGenerator 举办键生成(我在本文中也将采纳这种要领),那么您也可以利用 Hibernate Shards 提供的创新 ShardResolutionStrategy 实现,名为 AllShardsShardResolutionStrategy,这可以抉择依据一个特定工具的 ID 搜索什么切分。
设置好 Hibernate Shards 事情所需的三个接口后,我们就可以对切分示例应用措施的第二步举办实现了。此刻应该启动 Hibernate 的 SessionFactory 了。
设置 Hibernate Shards
Hibernate 的个中一个焦点接口工具是它的 SessionFactory。Hibernate 的所有神奇都是在其设置 Hibernate 应用措施进程中通过这个小工具实现的,譬喻,通过加载映射文件和设置。假如您利用了注释或 Hibernate 贵重的 .hbm 文件,那么您还需要一个 SessionFactory 来让 Hibernate 知道哪些工具是可以耐久化的,以及将它们耐久化到 那边。
因此,利用 Hibernate Shards 时,您必需利用一个加强的 SessionFactory 范例来设置多个数据库。它可以被定名为 ShardedSessionFactory,并且它虽然是 SessionFactory 范例的。当建设一个 ShardedSessionFactory 时,您必需提供之前设置好的三个切分实现范例(ShardAccessStrategy、ShardSelectionStrategy 和 ShardResolutionStrategy)。您还需提供 POJO 所需的所有映射文件。(假如您利用一个基于备注的 Hibernate POJO 设置,环境大概会有所差异。)最后,一个 ShardedSessionFactory 示例需要每个切分都对应多个 Hibernate 设置文件。
建设一个 Hibernate 设置
我已经建设了一个 ShardedSessionFactoryBuilder 范例,它有一个主要要领 createSessionFactory,可以建设一个设置公道的 SessionFactory。之后,我将将所有的一切都与 Spring 毗连在一起(此刻谁不利用一个 IOC 容器?)。此刻,清单 2 显示了 ShardedSessionFactoryBuilder 的主要浸染:建设一个 Hibernate 设置:
清单 2. 建设一个 Hibernate 设置
#p#分页标题#e#
private Configuration getPrototypeConfig(String hibernateFile, List<String>
resourceFiles) {
Configuration config = new Configuration().configure(hibernateFile);
for (String res : resourceFiles) {
configs.addResource(res);
}
return config;
}
如您在 清单 2 中所看到的,可以从 Hibernate 设置文件中建设了一个简朴的 Configuration。该文件包括如下信息,如利用的是什么范例的数据库、用户名和暗码等,以及所有必需的资源文件,如 POJO 所用的 .hbm 文件。在举办切分的环境下,您凡是需要利用多个数据库设置,可是 Hibernate Shards 支持您仅利用一个 hibernate.cfg.xml 文件,从而简化了整个进程(可是,如您在 清单 4 中所看到的,您将需要对利用的每一个切分筹备一个 hibernate.cfg.xml 文件)。
下一步,在清单 3 中,我将所有的切分派置都收集到了一个 List 中:
清单 3. 切分派置列表
List<ShardConfiguration> shardConfigs = new ArrayList<ShardConfiguration>();
for (String hibconfig : this.hibernateConfigurations) {
shardConfigs.add(buildShardConfig(hibconfig));
}
Spring 设置
在 清单 3 中,对 hibernateConfigurations 的引用指向了 Strings List,个中每个 String 都包括了 Hibernate 设置文件的名字。该 List 通过 Spring 自动毗连。清单 4 是我的 Spring 设置文件中的一段摘录:
清单 4. Spring 设置文件中的一部门
<bean id="shardedSessionFactoryBuilder"
class="org.disco.racer.shardsupport.ShardedSessionFactoryBuilder">
<property name="resourceConfigurations">
<list>
<value>racer.hbm.xml</value>
</list>
</property>
<property name="hibernateConfigurations">
<list>
<value>shard0.hibernate.cfg.xml</value>
<value>shard1.hibernate.cfg.xml</value>
</list>
</property>
</bean>
如您在 清单 4 中所看到的,ShardedSessionFactoryBuilder 正在与一个 POJO 映射文件和两个切分派置文件毗连。清单 5 中是 POJO 文件的一段摘录:
清单 5. 角逐 POJO 映射
<class name="org.disco.racer.domain.Race" table="race"dynamic-update="true"
dynamic-insert="true">
<id name="id" column="RACE_ID" unsaved-value="-1">
<generator class="org.hibernate.shards.id.ShardedUUIDGenerator"/>
</id>
<set name="participants" cascade="save-update" inverse="false" table="race_participants"
lazy="false">
<key column="race_id"/>
<many-to-many column="runner_id" class="org.disco.racer.domain.Runner"/>
</set>
<set name="results" inverse="true" table="race_results" lazy="false">
<key column="race_id"/>
<one-to-many class="org.disco.racer.domain.Result"/>
</set>
<property name="name" column="NAME" type="string"/>
<property name="distance" column="DISTANCE" type="double"/>
<property name="date" column="DATE" type="date"/>
<property name="description" column="DESCRIPTION" type="string"/>
</class>
请留意,清单 5 中的 POJO 映射的独一奇特方面是 ID 的生成器类 — 这就是 ShardedUUIDGenerator,它(如您想象的一样)将切分 ID 信息嵌入到 UUID 中。这就是我的 POJO 映射中切分的独一奇特方面。
切分派置文件
下一步,如清单 6 中所示,我已经设置了一个切分 — 在本示例中,除切分 ID 和毗连信息外,切分 0 和切分 1 的文件是一样的。
清单 6. Hibernate Shards 设置文件
<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD//EN"
"http://hibernate.sourceforge.net/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory name="HibernateSessionFactory0">
<property name="dialect">org.hibernate.dialect.HSQLDialect</property>
<property name="connection.driver_class">org.hsqldb.jdbcDriver</property>
<property name="connection.url">
jdbc:hsqldb:file:/.../db01/db01
</property>
<property name="connection.username">SA</property>
<property name="connection.password"></property>
<property name="hibernate.connection.shard_id">0</property>
<property name="hibernate.shard.enable_cross_shard_relationship_checks">true
</property>
</session-factory>
</hibernate-configuration>
#p#分页标题#e#
如其名字所示,enable_cross_shard_relationship_checks 属性对跨切分干系举办了查抄。按照 Hibernate Shards 文档记录,该属性很是耗时,在生成情况中应该封锁。
最后,ShardedSessionFactoryBuilder 通过建设 ShardStrategyFactory ,然后添加三个范例(包罗 清单 1 中的 RacerShardSelectionStrategy),将一切都整合到了一起,如清单 7 中所示:
清单 7. 建设 ShardStrategyFactory
private ShardStrategyFactory buildShardStrategyFactory() {
ShardStrategyFactory shardStrategyFactory = new ShardStrategyFactory() {
public ShardStrategy newShardStrategy(List<ShardId> shardIds) {
ShardSelectionStrategy pss = new RacerShardSelectionStrategy();
ShardResolutionStrategy prs = new AllShardsShardResolutionStrategy(shardIds);
ShardAccessStrategy pas = new SequentialShardAccessStrategy();
return new ShardStrategyImpl(pss, prs, pas);
}
};
return shardStrategyFactory;
}
最后,我执行了谁人名为 createSessionFactory 的绝妙要领,在本示例中建设了一个 ShardedSessionFactory,如清单 8 所示:
清单 8. 建设 ShardedSessionFactory
public SessionFactory createSessionFactory() {
Configuration prototypeConfig = this.getPrototypeConfig
(this.hibernateConfigurations.get(0), this.resourceConfigurations);
List<ShardConfiguration> shardConfigs = new ArrayList<ShardConfiguration>();
for (String hibconfig : this.hibernateConfigurations) {
shardConfigs.add(buildShardConfig(hibconfig));
}
ShardStrategyFactory shardStrategyFactory = buildShardStrategyFactory();
ShardedConfiguration shardedConfig = new ShardedConfiguration(
prototypeConfig, shardConfigs,shardStrategyFactory);
return shardedConfig.buildShardedSessionFactory();
}
利用 Spring 毗连域工具
此刻可以深呼吸一下了,因为我们顿时就乐成了。到今朝为止,我已经建设一个可以公道设置 ShardedSessionFactory 的生成器类,其实就是实现 Hibernate 无处不在的 SessionFactory 范例。ShardedSessionFactory 完成了切分中所有的神奇。它操作我在 清单 1 中所陈设的切分选择计策,并从我设置的两个切分中读写数据。(清单 6 显示了切分 0 和切分 1 的设置险些沟通。)
此刻我需要做的就是毗连我的域工具,在本示例中,因为它们依赖于 Hibernate,需要一个 SessionFactory 范例举办事情。我将仅利用我的 ShardedSessionFactoryBuilder 提供一种 SessionFactory 范例,如清单 9 中所示:
清单 9. 在 Spring 中毗连 POJO
<bean id="mySessionFactory"
factory-bean="shardedSessionFactoryBuilder"
factory-method="createSessionFactory">
</bean>
<bean id="race_dao" class="org.disco.racer.domain.RaceDAOImpl">
<property name="sessionFactory">
<ref bean="mySessionFactory"/>
</property>
</bean>
如您在 清单 9 中所看到的,我首先在 Spring 中建设了一个雷同工场的 bean;也就是说,我的 RaceDAOImpl 范例有一个名为 sessionFactory 的属性,是 SessionFactory 范例。之后,mySessionFactory 引用通过在 ShardedSessionFactoryBuilder 上挪用 createSessionFactory 要领建设了一个 SessionFactory 示例,如 清单 4 中所示。
当我为我的 Race 工具示例利用 Spring(我主要将其作为一个巨型工场利用,以返回预设置的工具)时,一切工作就都搞定了。固然没有展示,RaceDAOImpl 范例是一个操作 Hibernate 模板举办数据存储和检索的工具。我的 Race 范例包括一个 RaceDAOImpl 示例,它将所有与数据商店相关的勾当都推迟至此。很默契,不是吗?
#p#分页标题#e#
请留意,我的 DAO 与 Hibernate Shards 在代码方面并没有绑定,而是通过设置举办了绑定。设置(如 清单 5 中的)将它们绑定在一个特定切分 UUID 生成方案中,也就是说了我可以在需要切分时从已有 Hibernate 实现中从头利用域工具。
切分:利用 easyb 的测试驱动
接下来,我需要验证我的切分实现可以事情。我有两个数据库并通过间隔举办切分,所以当我建设一场马拉松时(10 英里以上的角逐),该 Race 示例应在切分 1 中找到。一个小型的角逐,如 5 公里的角逐(3.1 英里),将在切分 0 中找到。建设一场 Race 后,我可以查抄单个数据库的记录。
在清单 10 中,我已经建设了一场马拉松,然后继承验证记录确实是在切分 1 中而非切分 0 中。使工作越发有趣(和简朴)的是,我利用了 easyb,这是一个基于 Groovy 的行为驱动开拓架构,操作自然语言验证。easyb 也可以轻松处理惩罚 Java 代码。即便不相识 Groovy 或 easyb,您也可以通过查察清单 10 中的代码,看到一切如期举办。(请留意,我辅佐建设了 easyb,而且在 developerWorks 中对这个话题颁发过文章。)
清单 10. 一个验证切分正确性的 easyb 故事中一段摘录
scenario "races greater than 10.0 miles should be in shard 1 or db02", {
given "a newly created race that is over 10.0 miles", {
new Race("Leesburg Marathon", new Date(), 26.2,
"Race the beautiful streets of Leesburg!").create()
}
then "everything should work fine w/respect to Hibernate", {
rce = Race.findByName("Leesburg Marathon")
rce.distance.shouldBe 26.2
}
and "the race should be stored in shard 1 or db02", {
sql = Sql.newInstance(db02url, name, psswrd, driver)
sql.eachRow("select race_id, distance, name from race where name=?",
["Leesburg Marathon"]) { row ->
row.distance.shouldBe 26.2
}
sql.close()
}
and "the race should NOT be stored in shard 0 or db01", {
sql = Sql.newInstance(db01url, name, psswrd, driver)
sql.eachRow("select race_id, distance, name from race where name=?",
["Leesburg Marathon"]) { row ->
fail "shard 0 contains a marathon!"
}
sql.close()
}
}
虽然,我的事情还没有完 — 我还需要建设一个短程角逐,并验证其位于切分 0 中而非切分 1 中。您可以在本文提供的 代码下载 中看到该验证操纵!
切分的利弊
切分可以增加应用措施的读写速度,尤其是假如您的应用措施包括大量数据 — 如数 TB — 可能您的域处于无限制成长中,如 Google 或 Facebook。
在举办切分之前,必然要确定应用措施的局限和增长对其有利。切分的本钱(可能说缺点)包罗对如何存储和检索数据的特定应用措施逻辑举办编码的本钱。举办切分后,您多几几何都被锁定在您的切分模子中,因为从头切分并非易事。
假如可以或许正确实现,切分可以用于办理传统 RDBMS 局限和速度问题。切分对付绑定于干系基本架构、无法继承进级硬件以满意大量可伸缩数据存储要求的组织来说是一个很是本钱高效的决定。
下载
描写 | 名字 | 巨细 | 下载要领 |
---|---|---|---|
本文样例代码 | j-javadev2-11.zip | 15KB | HTTP |