阅读视图

发现新文章,点击刷新页面。

Java7 HashMap 源码分析

链表和数组可以按照人们意愿排列元素的次序,但是,如果想要查看某个指定的元素,却又忘记了它的位置,就需要访问所有的元素,直到找到为止。如果集合中元素很多,将会消耗很多时间。有一种数据结构可以快速查找所需要查找的对象,这个就是哈希表(hash table).

HashMap是基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

1. HashMap的数据结构:

HashMap使用数组和链表来共同组成的。可以看出底层是一个数组,而数组的每个元素都是一个链表头。

enter image description here

1
2
3
4
5
6
7
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
...
}

Entry是HashMap中的一个内部静态类,包级私有,实现了Map中的接口Entey<K,V>。可以看出来它内部含有一个指向下一个元素的指针。

2.构造函数

HashMap的构造函数有四个:

  1. HashMap() — 构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
  2. HashMap(int initialCapacity) — 构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
  3. HashMap(int initialCapacity, float loadFactor) — 构造一个带指定初始容量和加载因子的空 HashMap。
  4. HashMap(Map<? extends K,? extends V> m) — 构造一个映射关系与指定 Map 相同的新 HashMap

实际上就两种,一个是指定初始容量和加载因子,一个是用一个给定的映射关系生成一个新的HashMap。说一下第一种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* Constructs an empty <tt>HashMap </tt> with the specified initial
* capacity and load factor.
*
* @param initialCapacity the initial capacity
* @param loadFactor the load factor
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
public HashMap( int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException( "Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float. isNaN(loadFactor))
throw new IllegalArgumentException( "Illegal load factor: " +
loadFactor);

// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;

this.loadFactor = loadFactor;
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
useAltHashing = sun.misc.VM. isBooted() &&
(capacity >= Holder. ALTERNATIVE_HASHING_THRESHOLD);
init();
}

参数很简单,初始容量,和加载因子。初始容量定义了初识数组的大小,加载因子和初始容量的乘积确定了一个阈值。阈值最大是(1<<30) + 1。初始容量一定是2的N次方,而且刚刚比要设置的值大。默认初始容量是16,默认加载因子是0.75。当表中的元素数量大于等于阈值时,数组的容量会翻倍,并重新插入元素到新的数组中,所以HashMap不保证顺序恒久不变。

当输入的加载因子小于零或者不是浮点数时会抛出异常(IllegalArgumentException)。

3.put操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key </tt>, or
* <tt>null </tt> if there was no mapping for <tt> key</tt> .
* (A <tt>null </tt> return can also indicate that the map
* previously associated <tt>null </tt> with <tt> key</tt> .)
*/
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table .length );
for (Entry<K,V> e = table[i]; e != null; e = e. next) {
Object k;
if (e. hash == hash && ((k = e. key) == key || key.equals(k))) {
V oldValue = e. value;
e. value = value;
e.recordAccess( this);
return oldValue;
}
}

modCount++;
addEntry(hash, key, value, i);
return null;
}

由于HashMap只是key值为null,所以首先要判断key值是不是为null,是则进行特殊处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Offloaded version of put for null keys
*/
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e. next) {
if (e. key == null) {
V oldValue = e. value;
e. value = value;
e.recordAccess( this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}

可以看出key值为null则会插入到数组的第一个位置。如果第一个位置存在,则替代,不存在则添加一个新的。稍后会看到addEntry函数。

** PS:考虑一个问题,key值为null会插入到table[0],那为什么还要遍历整个链表呢?**

回到put函数中。在判断key不为null后,会求key的hash值,并通过indexFor函数找出这个key应该存在table中的位置。

1
2
3
4
5
6
/**
* Returns index for hash code h.
*/
static int indexFor (int h, int length) {
return h & (length-1);
}

indexFor函数很简短,但是却实现的很巧妙。一般来说我们把一个数映射到一个固定的长度会用取余(%)运算,也就是h % length,但里巧妙地运用了table.length的特性。还记得前面说了数组的容量都是很特殊的数,是2的N次方。用二进制表示也就是一个1后面N个0,(length-1)就是N个1了。这里直接用与运算,运算速度快,效率高。但是这是是利用了length的特殊性,如果length不是2的N次方的话可能会增加冲突。

前面的问题在这里就有答案了。因为indexFor函数返回值的范围是0到(length-1),所以可能会有key值不是null的Entry存到table[0]中,所以前面还是需要遍历链表的。

得到key值对应在table中的位置,就可以对链表进行遍历,如果存在该key则,替换value,并把旧的value返回,modCount++代表操作数加1。这个属性用于Fail-Fast机制,后面讲到。如果遍历链表后发现key不存在,则要插入一个新的Entry到链表中。这时就会调用addEntry函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Adds a new entry with the specified key, value and hash code to
* the specified bucket. It is the responsibility of this
* method to resize the table if appropriate.
*
* Subclass overrides this to alter the behavior of put method.
*/
void addEntry (int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && ( null != table[bucketIndex])) {
resize(2 * table. length);
hash = ( null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}

createEntry(hash, key, value, bucketIndex);
}

这个函数有四个参数,第一个是key的hash值,第二个第三个分别是key和value,最后一个是这个key在table中的位置,也就是indexFor(hash(key), table.length-1)。首先会判断size(当前表中的元素个数)是不是大于或等于阈值。并且判断数组这个位置是不是空。如果条件满足则要resize(2 * table. length),等下我们来看这个操作。超过阈值要resize是为了减少冲突,提高访问效率。判断当前位置不是空时才resize是为了尽可能减少resize次数,因为这个位置是空,放一个元素在这也没有冲突,所以不影响效率,就先不进行resize了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* Rehashes the contents of this map into a new array with a
* larger capacity. This method is called automatically when the
* number of keys in this map reaches its threshold.
*
* If current capacity is MAXIMUM_CAPACITY, this method does not
* resize the map, but sets threshold to Integer.MAX_VALUE.
* This has the effect of preventing future calls.
*
* @param newCapacity the new capacity, MUST be a power of two;
* must be greater than current capacity unless current
* capacity is MAXIMUM_CAPACITY (in which case value
* is irrelevant).
*/
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable. length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer. MAX_VALUE;
return;
}

Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM. isBooted() &&
(newCapacity >= Holder. ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor , MAXIMUM_CAPACITY + 1);
}

resize操作先要判断当前table的长度是不是已经等于最大容量(1<<30)了,如果是则把阈值调到整数的最大值((1<<31) - 1),就没有再拓展table的必要了。如果没有到达最大容量,就要生成一个新的空数组,长度是原来的两倍。这时候可能要问了,如果oldTable. length不等于MAXIMUM_CAPACITY,但是(2 * oldTable. length)也就是newCapacity大于MAXIMUM_CAPACITY怎么办?这个是不可能的,因为数组长度是2的N次方,而MAXIMUM_CAPACITY = 1<<30。
生成新的数组后要执行transfer函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable. length;
for (Entry<K,V> e : table) {
while( null != e) {
Entry<K,V> next = e. next;
if ( rehash) {
e. hash = null == e. key ? 0 : hash(e. key);
}
int i = indexFor(e.hash, newCapacity);
e. next = newTable[i];
newTable[i] = e;
e = next;
}
}
}

这个函数要做的就是把原来table中的值挨个拿出来插到新数组中,由于数组长度发生了改变,所以元素的位置肯定发生变化,所以HashMap不能保证该顺序恒久不变。回到resize函数,这时新的数组已经生成了,只需要替换原来数组就好了。并且要更新一下阈值。可以看出来resize是个比较消耗资源的函数,所以能减少resize的次数就尽量减少。

回到函数addEntry 中,判断完是不是需要resize后就需要创建一个新的Entry了。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Like addEntry except that this version is used when creating entries
* as part of Map construction or "pseudo -construction" (cloning,
* deserialization). This version needn't worry about resizing the table.
*
* Subclass overrides this to alter the behavior of HashMap(Map),
* clone, and readObject.
*/
void createEntry( int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}

调用createEntry函数,参数跟addEntry一样,第一个是key的hash值,第二个第三个分别是key和value,最后一个是这个key在table中的位置。这里的操作与Entry的构造函数有关系。

1
2
3
4
5
6
7
8
9
/**
* Creates new entry.
*/
Entry (int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}

构造函数中传入一个Entry对象,并把它当做这个新生成的Entry的next。所以createEntry函数中的操作相当于把table[bucketIndex]上的链表拿下来,放在新的Entry后面,然后再把新的Entry放到table[bucketIndex]上。

enter image description here

到这里整个put函数算是结束了。如果新插入的K,V则会返回null。

4.get操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Returns the value to which the specified key is mapped,
* or {@code null} if this map contains no mapping for the key.
*
* <p>More formally, if this map contains a mapping from a key
* {@code k} to a value {@code v} such that {@code (key==null ? k==null :
* key.equals(k))}, then this method returns {@code v}; otherwise
* it returns {@code null}. (There can be at most one such mapping.)
*
* <p>A return value of {@code null} does not <i>necessarily </i>
* indicate that the map contains no mapping for the key; it's also
* possible that the map explicitly maps the key to {@code null}.
* The {@link #containsKey containsKey} operation may be used to
* distinguish these two cases.
*
* @see #put(Object, Object)
*/
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);

return null == entry ? null : entry.getValue();
}

也是先判断key是不是null,做特殊处理。直接上代码,不赘述。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Offloaded version of get() to look up null keys. Null keys map
* to index 0. This null case is split out into separate methods
* for the sake of performance in the two most commonly used
* operations (get and put), but incorporated with conditionals in
* others.
*/
private V getForNullKey() {
for (Entry<K,V> e = table[0]; e != null; e = e. next) {
if (e. key == null)
return e. value;
}
return null;
}

key不是null则会调用getEntry函数,并返回一个Entry对象,如果不是null,就返回entry的value。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Returns the entry associated with the specified key in the
* HashMap. Returns null if the HashMap contains no mapping
* for the key.
*/
final Entry<K,V> getEntry(Object key) {
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[ indexFor(hash, table.length)];
e != null;
e = e. next) {
Object k;
if (e. hash == hash &&
((k = e. key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}

直接求key值hash值,然后求table中的位置,遍历链表。有返回entry对象,没有返回null。

5. Fail-Fast机制

1
2
3
4
5
6
7
8
/**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail -fast. (See ConcurrentModificationException).
*/
transient int modCount;

我们知道java.util.HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。

这一策略在源码中的实现是通过modCount域,保证线程之间修改的可见性。,modCount顾名思义就是修改次数,对HashMap内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。

注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测程序错误。

参考

  • 《Core JAVA》
  • 《JAVA API》

关于我 && 博客

下面是个人的介绍和相关的链接,期望与同行的各位多多交流,三人行,则必有我师!

  1. 博主个人介绍 :里面有个人的微信和微信群链接。
  2. 本博客内容导航 :个人博客内容的一个导航。
  3. 个人整理和搜集的优秀博客文章 - Android 性能优化必知必会 :欢迎大家自荐和推荐 (微信私聊即可)
  4. Android性能优化知识星球 : 欢迎加入,多谢支持~

一个人可以走的更快 , 一群人可以走的更远

微信扫一扫

细说 Java 单例模式

单例模式也叫单子模式,是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。本文就从单例模式的两种构建方式来带大家了解一下单例,最后介绍一种高级且简洁的单例模式。

什么是单例模式

单例模式,也叫单子模式,是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。

中心原则就是:单例对象的类必须保证只有一个实例存在

单例模式的构建

在java中主要有两种构建方式

  1. 懒汉方式。指全局的单例实例在第一次被使用时构建。
  2. 饿汉方式。指全局的单例实例在类装载时构建。

简单的说就是一个需要延迟初始化,一个则不需要。

比较简单的构建方式有:

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
private final static Singleton INSTANCE = new Singleton();

// Private constructor suppresses
private Singleton() {
}

// default public constructor
public static Singleton getInstance() {
return INSTANCE;
}
}

这种方式实现简单,实例在类装载时构建,如果想要实现一种实例在第一次被使用时构建应该怎么做?

有一种叫做 双重检查锁(double-checked locking)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Singleton {
private static volatile Singleton INSTANCE = null;

// Private constructor suppresses
// default public constructor
private Singleton() {
}

//thread safe and performance promote
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
//when more than two threads run into the first null check same time, to avoid instanced more than one time, it needs to be checked again.
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

此种方法只能用在JDK5及以后版本(注意 INSTANCE 被声明为 volatile),之前的版本使用“双重检查锁”会发生非预期行为.

另一种单例模式

在第一条推荐阅读里提到了另一种实现单例的方式 lazy initialization holder class idiom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {

// Private constructor suppresses
private Singleton() {
}

private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}

public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}

JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。相比其他实现方案(如double-checked locking等),该技术方案的实现代码较为简洁,并且在所有版本的编译器中都是可行的。

关于 static final Singleton INSTANCE 域的访问权限为什么时包级私有可以阅读: Initialization On Demand Holder idiom的实现探讨

使用枚举

最后推荐实现最为简洁的一种方式: 使用枚举

代码极其简洁, 使用极其简单:

1
2
3
public enum Singleton {
INSTANCE;
}

回顾一下前面集中单例的实现方式, 都只考虑了常规获取类对象的手段, 然而还可以通过序列化和反射机制获取对象.上面两种方式如果实现了序列化接口 Serializable 就必须重写 readResolve() 方法

1
2
3
private Object readResolve(){
return INSTANCE;
}

即使重写了 readResolve() 方法也会涉及某系域需要关键字 transient 的修饰, 具体讨论不再展开, 总之涉及序列化挺蛋疼.

关于防止反射暂时没有深入了解, 据了解: 因为反射的某些地方绕过了java机制的限制,private只在编译时进行权限的限制,但是在运行时是不存在这种权限的限制的, 此处仅供参考.

但是使用enum实现的单例自带防序列化与防反射功能, 详细参照枚举类反编译后代码(供参考)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public abstract class Singleton extends Enum
{

private Singleton(String s, int i)
{
super(s, i);
}

public static Singleton[] values()
{
Singleton asingleton[];
int i;
Singleton asingleton1[];
System.arraycopy(asingleton = ENUM$VALUES, 0, asingleton1 = new Singleton[i = asingleton.length], 0, i);
return asingleton1;
}

public static Singleton valueOf(String s)
{
return (Singleton)Enum.valueOf(singleton/Singleton, s);
}

Singleton(String s, int i, Singleton singleton)
{
this(s, i);
}

public static final Singleton INSTANCE;
private static final Singleton ENUM$VALUES[];

static
{
INSTANCE = new Singleton("INSTANCE", 0) ;
ENUM$VALUES = (new Singleton[] {
INSTANCE
});
}
}

我们实现的枚举都是继承了 java.lang.Enum 可以看出来单例的实现也是通过关键字 static 修饰的静态初始化块来实现.

那么为什么enum可以防御反射呢…很简单, 因为它是一个抽象类 public abstract class Singleton extends Enum 即使是反射机制也不能实例化了.

有为什么能防御序列化呢…这个要看java源码中对于对象序列化的处理.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
java.io.ObjectOutputStream

private void writeObject0(Object obj, boolean unshared){
...
// remaining cases
if (obj instanceof String) {
writeString((String) obj, unshared);
} else if (cl.isArray()) {
writeArray(obj, desc, unshared);
} else if (obj instanceof Enum) {
writeEnum((Enum) obj, desc, unshared);
} else if (obj instanceof Serializable) {
writeOrdinaryObject(obj, desc, unshared);
} else {
if (extendedDebugInfo) {
throw new NotSerializableException(cl.getName() + "\n" + debugInfoStack.toString());
} else {
throw new NotSerializableException(cl.getName());
}
}
}

/**
* Writes given enum constant to stream.
*/
private void writeEnum(Enum en, ObjectStreamClass desc, boolean unshared) throws IOException {
bout.writeByte(TC_ENUM);
ObjectStreamClass sdesc = desc.getSuperDesc();
writeClassDesc((sdesc.forClass() == Enum.class) ? desc : sdesc, false);
handles.assign(unshared ? null : en);
writeString(en.name(), false);
}

可以看出enum在被序列化时时经过特殊处理的, 被序列化的仅仅是枚举的名字而已.所以可以猜测一下反序列的的代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
 java.io.ObjectOutputStream
private Object readObject0(boolean unshared) throws IOException {
...
switch (tc) {
...
case TC_ENUM:
return checkResolve(readEnum(unshared));
...
}
...
}

/**
* Reads in and returns enum constant, or null if enum type is
* unresolvable. Sets passHandle to enum constant's assigned handle.
*/
private Enum readEnum(boolean unshared) throws IOException {
...
if (cl != null) {
try {
en = Enum.valueOf(cl, name);
} catch (IllegalArgumentException ex) {
throw (IOException) new InvalidObjectException(
"enum constant " + name + " does not exist in " +
cl).initCause(ex);
}
if (!unshared) {
handles.setObject(enumHandle, en);
}
}
...
return en;
}

反序列化也仅仅是通过name调用了方法

1
Enum.valueOf(Class<T> enumType, String name)

获取了一个枚举实例, 所以枚举也可以防止通过序列化产生新的单例.

友情建议: 在序列化枚举时要特别注意, 枚举的名称一定不能改变, 否则在反序列化时有可能会抛出异常!!!

1
2
3
4
5
6
7
8
9
10
public static <T extends Enum<T>> T valueOf(Class<T> enumType,
String name) {
T result = enumType.enumConstantDirectory().get(name);
if (result != null)
return result;
if (name == null)
throw new NullPointerException("Name is null");
throw new IllegalArgumentException(
"No enum constant " + enumType.getCanonicalName() + "." + name);
}

推荐阅读:

  1. Effective Java 第71条 慎用延迟初始化
  2. Core Java 第一卷 14.5.8 Volatile 域
  3. JSL 17.4
  4. Java 理论与实践: 正确使用 Volatile 变量
  5. 双重检查锁定与延迟初始化

关于我 && 博客

下面是个人的介绍和相关的链接,期望与同行的各位多多交流,三人行,则必有我师!

  1. 博主个人介绍 :里面有个人的微信和微信群链接。
  2. 本博客内容导航 :个人博客内容的一个导航。
  3. 个人整理和搜集的优秀博客文章 - Android 性能优化必知必会 :欢迎大家自荐和推荐 (微信私聊即可)
  4. Android性能优化知识星球 : 欢迎加入,多谢支持~

一个人可以走的更快 , 一群人可以走的更远

微信扫一扫

Java使用JNA访问本地库

JNA(Java Native Access )是一个基于JNI的开源库,能有效提高Java访问调用本地库的效率。

官方介绍

JNA 为 Java 程序提供了对本机共享库的轻松访问,而无需编写除 Java 代码之外的任何内容 - 不需要 JNI 或本机代码。此功能可与Windows的Platform /Invoke和Python的ctype相媲美。

JNA 允许您使用自然 Java 方法调用直接调用本机函数。Java 调用看起来就像本机代码中的调用一样。大多数呼叫不需要特殊处理或配置;不需要样板或生成的代码。

JNA 使用一个小型 JNI 库存根来动态调用本机代码。开发人员使用 Java 接口来描述目标本机库中的函数和结构。这使得利用本机平台功能变得非常容易,而不会产生为多个平台配置和构建 JNI 代码的高开销。阅读此更深入的描述。

使用方法

import com.sun.jna.Library;
import com.sun.jna.Native;
public interface JnaDemo extends Library {
    JnaDemo INSTANCE = (JnaDemo) Native.loadLibrary("demo",JnaDemo.class);
    //demo处写需要调用的动态库名字,不写.so或.dll
    ...
    //此处写需要调用的库函数,注意不同类型的对应关系
}

注意事项:

  • 如果同时加载多个动态库,不要出现同名的函数,否则可能会出现致命错误。
  • C++与Java类型的对应关系一定要写对,否则也会出现致命错误。

待续

Java 的 hashCode 与 equals

Java 的 hashCode 和 equals 是 java.lang.Object 的两个方法,这意味着所有的 Java 对象都存在这两个方法。当我们在使用 Map、Set 数据结构时,都要或多或少的和此方法打交道。亦或使用 Hibernate 在定义 Entity 实体类时,也需要注意这两个方法的重写。掌握 hashCode 与 equals 方法有利于加深对 Java 数据结构的理解,并且还能帮助我们避免一些低级 Bug。

Kotlin 与 Java 类似,但 Kotlin 的一些语法糖使得背后的类转换变得隐晦起来。例如 Kotlin 的比较操作符 == 实际调用的是类的 equals;Kotlin 可以针对列表进行取差操作,而这个操作的背后需要先把对应数据集合类型转成 HashSet ,然后使用 HashSet 的取差方法。

hashCode

hashCode 方法是由 Java 的哈希算法生成的 int 类型哈希值,既然是哈希值,这就意味着两个不同的对象也可能拥有相同的哈希值

哈希算法是把任意长度的输入转换成定长输出。

在 JavaDoc 中有说明此方法主要用于需要哈希表结构的数据结构,如 java.util.HashMapjava.util.HashSet ,hashCode 方法受限于以下三个法则:

  • 在程序运行过程中,hashCode 结果是不变的。
  • 如果两个对象 equals 相等,则 hashCode 方法必须相等。
  • 不要求两个 hashCode 相等的对象,equals 也相等。

这三个法则浓缩成一句话:

在程序运行阶段时,两个 equals 相等的对象,hashCode 结果值必须相等。

为什么这么要求呢?因为在使用哈希结构查询数据时,如使用 containsgetremove 等操作时,都会先使用哈希值匹配对应的 bucket,当多个对象出现哈希冲突时,在一个 bucket 中会存在以链表方式连结的对象列表,然后逐个使用 equals 方法进行匹配,以提高查询效率。

任何对象都有 hashCode 方法,如果没有手动重写,Object 的原生实现则会在某种程度上使用内存地址。

不同的 JVM,对 hashCode 的具体实现是不一样的。

equals

equals 方法相对而言就单纯了一些,这个方法就是用来比较两个对象的逻辑相等。类是编程思想中用于对现实世界建模抽象的工具,对应现实生活中的一类事物,而对象则是对应现实生活中的一个实体。现实生活中的实体都是可区分的,具有可标识性;在面向对象编程中使用对象映射现实生活中的实体,要保证对象的标识性,则是使用 equals 方法进行比对。

所以当我们要重写 equals 方法时,需要遵循的原则就是要让对象具有可区分性,能够和现实实体相对应。

开发中需要注意什么

平时开发过程中,大多数类都会使用内置的 hashCode 和 equals 方法,这对日常的开发过程没有任何影响,这常常会让我们忽略了它的重要性,这会在某些情况下造成难以察觉的Bug。

所以需要加深对此的理解,尤其是使用 ORM 或集合操作时,一定要注意 hashCode 和 equals 方法的重写。只要遵循上面提到的法则,就能够避免很多问题了。

Also See

Rust 与 Java 程序的异步接口互操作

许多语言的高性能程序库都是建立在 C/C++ 的核心实现上的。

例如,著名 Python 科学计算库 Pandas 和 Numpy 的核心是 C++ 实现的,RocksDB 的 Java 接口是对底层 C++ 接口的封装。

Rust 语言的基本目标之一就是替代 C++ 在这些领域的位置,为开发者提供 Rust 具备的安全性和可组合性优势。

Apache OpenDAL (incubating) 是 Databend 工程师 Xuanwo 开发的一个 Rust 语言实现的开放数据访问层。它的核心设计支持通过相同的对象存储 API 访问不同的存储服务(Service),并提供可扩展的中间件(Layer)来支持通用的请求重试、限流和指标上报功能。目前,包括 Databend / RisingWave / GreptimeDB / mozilla sccache 在内的多个软件都选用 OpenDAL 作为其存储访问接口。

OpenDAL 架构概念图

在 Rust 核心实现的基础上,OpenDAL 提供了 Java / Python / Node.js 等不同语言的 API 绑定(Binding),以支持更广泛的生态利用 OpenDAL 已经完成的工作。例如,使用 Python 绑定,诸多大模型应用库能够在不同云厂商的对象存储服务间无缝迁移,支持用户使用任意对象存储服务。而在开发期间,则可以用内存或文件实现来模拟测试相同 API 的语义。

要在 OpenDAL 实现一个特定语言的 API 绑定,涉及到功能实现、程序库打包和发布等多个环节。本文从功能实现的角度出发,以 Java 绑定为例,讨论 OpenDAL 如何在社群力量的支持下实现 opendal-java 库。同时,重点剖析行内首个完整的 Java ↔ Rust 异步接口互操作的最佳实践。

跨语言互操作的基本知识

我的本科毕业论文《多计算机语⾔原理及实现机制分析之初探》当中讨论了三种跨语言互操作的方法:外部函数接口(FFI)、进程间通信(IPC)和多语言运行时。

最常见的是基于 FFI 的方案,即通过一套语言无关的函数调用约定,完成不同语言之间的通信。例如,opendal-java 就是使用 Java 的 FFI 方案 JNI 来完成 Java 和 Rust 之间的互操作的。CPython、Ruby 和 Haskell 等语言实现,则是通过 libffi 来完成和 Native 函数的互操作。

可以看到,FFI 方案基本都是实现了本语言与 Native 函数即遵循 C ABI 的函数之间的互操作,要想使用这样的方案实现 Java 程序调用 CPython 函数是不可能的。这不仅仅是没有人为 Java 和 CPython 之间定义一套调用规则的原因,还有只有 Native 函数才不需要运行时的缘故。要想调用一个 Java 函数,或是一个 CPython 函数,都必须先启动一个对应语言的运行时(JRE 或 CPython 解释器)。如果每次调用都启动一个新的运行时实例,那么这个性能损耗将彻底疯狂,而如果常驻一个目标运行时的进程实例,那么更加成熟的解决方案是进程间通信。

说进程间通信或 IPC 可能还有很多人不知道是什么,举一个例子就很容易理解了:Protobuf + gRPC 的解决方案就是典型的 IPC 方案。

如果说 FFI 是定义了一套语言无关的 Native 函数调用约定,那么 IPC 就是定义了一套语言无关进程接口调用约定。在 gRPC 之外,Apache Thrift / Apache Avro RPC / Apache Arrow Flight RPC 也都定义了各自的语言无关的进程接口调用约定,一般称为接口描述语言(IDL)。

这种方式下,开发者需要首先使用 IDL 定义好想要进行互操作的接口,随后使用对应方案的编译器产生调用方或被调用方语言的数据结构定义和接口存根(stub)对象,接着实现接口逻辑并在进程启动时暴露访问端口。实际调用时,调用方将接口访问及其参数结构编码为字节流,发送到接收方端口,接收方解码请求及其参数,完成请求后回传编码后的结果。

显而易见,IPC 的方式比起 FFI 的方式多了大约两轮数据编解码,加上一个来回网络字节传输的开销。

最后一种跨语言互操作的方案是多语言运行时,这个词汇可能又很陌生。同样举一个实例:JVM 就是一个跨语言运行时。

JVM 上面首先可以运行 Java 语言。然后,它可以运行 Scala / Groovy / Kotlin 等 JVM 族的语言。到这里,JVM 已经可以实现定义上的跨语言互操作了,因为 Java 和后面几个语音确实不是同一个编程语言。进一步地,JVM 上可以运行 Clojure 语言,这意味着 JVM 支持 Java 和 Lisp 之间的互操作。当然,Lisp 比较小众,所以最后我给出百分百令人信服的例子:在 JVM 上可以用 Jython 和 JRuby 实现 Java 和 Python 或 Ruby 的互操作,甚至实现 Python 和 Ruby 的互操作。虽然 Jython 项目凉凉了,但是 JRuby 仍然有很多下游使用,例如 HBase 的 Shell 是 JRuby 实现的,ELK 软件栈中的 Logstash 也是 JRuby 实现的。

此外,在多语言运行时的理论先锋 GraalVM 和 Truffle Framework 的支持下,GraalPy / TruffleRuby / FastR / Sulong (LLVM bitcode) 等等方案接连出现并活跃发展至今。这也是我在毕业论文中重点讨论和研究的对象。

OpenDAL 的多语言 API 绑定最终选择了基于 FFI 的方案。

首先,OpenDAL 根本不启动进程,它被设计为程序直接调用的软件库,所以 IPC 方案从模型上就是不适合的,更不用说调用一个基本的数据访问 API 不应该有多余的网络开销。不过,由于 Golang 自闭的跨语言生态和极力推崇 RPC 的哲学,OpenDAL 支持 Golang 调用的方式可能真的得做一个 service 然后暴露出 RPC 接口。

而多语言运行时的方案,应该说目前还没有支持 Java 和 Rust 或 Native 函数互操作的多语言运行时方案。最接近的是 GraalVM 上的 Sulong 运行时,但是它和它所依赖的 GraalVM 都还不算成熟甚至还未大规模生产使用,且 Sulong 支持的是执行 LLVM bitcode 代码,采用这个方案,就要解决 Rust ↔ LLVM bitcode ↔ Java 三方的沟通和版本适配问题。一言以蔽之,这个方案技术上就很难实现。

opendal-java 的实现

Java 通过 JNI 约定调用 C ABI 函数的一般实现流程如下:

  1. Java 侧定义一个 native 方法;
1
2
3
4
package org.apache.opendal;
public class BlockingOperator extends NativeObject {
private static native long constructor(String schema, Map<String, String> map);
}
  1. C ABI 侧定义一个符合方法编码规则的函数,这里以 opendal-java 中的定义为例;
1
2
3
4
5
6
7
8
9
#[no_mangle]
pub extern "system" fn Java_org_apache_opendal_BlockingOperator_constructor(
mut env: JNIEnv,
_: JClass,
scheme: JString,
map: JObject,
) -> jlong {
// ...
}
  1. Java 程序启动时,调用 System.loadLibrary(libname)System.load(filename) 方法加载 native 库,后续对 native 方法的调用便会转为在 native 库中查找经过编码后的对应 native 函数的调用。

知道了基本的方法映射模式,我们就可以分点来讨论 opendal-java 中的设计要点和技术难点了。

Native Object

从简单的不涉及异步接口互操作的 Blocking Operator 开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class BlockingOperator extends NativeObject {
// ...

public BlockingOperator(String schema, Map<String, String> map) {
super(constructor(schema, map));
}

public String read(String path) {
return read(nativeHandle, path);
}

public Metadata stat(String path) {
return new Metadata(stat(nativeHandle, path));
}

@Override
protected native void disposeInternal(long handle);
private static native long constructor(String schema, Map<String, String> map);
private static native String read(long nativeHandle, String path);
private static native long stat(long nativeHandle, String path);
}

public class Metadata extends NativeObject {
// ...

protected Metadata(long nativeHandle) {
super(nativeHandle);
}
}

public abstract class NativeObject implements AutoCloseable {
// ...

protected final long nativeHandle;

protected NativeObject(long nativeHandle) {
this.nativeHandle = nativeHandle;
}

@Override
public void close() {
disposeInternal(nativeHandle);
}

protected abstract void disposeInternal(long handle);
}

这个代码片段介绍了 Java 侧的主要映射策略:

  1. 每个对应到 Rust 侧结构的类都继承自 NativeObject 类,它持有一个 nativeHandle 字段,指示 Rust 侧对应结构的指针。
  2. 这个指针通过 constructor native 方法获得,通过 disposeInternal native 方法释放。
  3. 每个方法,例如上面的 read 方法,在内部都会被转成 methodName(nativeHandle, args..) 的 native 方法调用,前面可能有一些必要的 marshalling 工作。
  4. 每个返回 Rust 结构的方法,例如上面的 stat 方法,其 native 方法返回对应结构指针的整数,在 Java 侧方法返回前包装成继承自 NativeObject 的类。

NativeObject 包括了一段动态库加载的 static 逻辑,这是一个独立且复杂的话题,这里不做展开。

对应到 Rust 侧,native 方法实现的模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#[no_mangle]
pub extern "system" fn Java_org_apache_opendal_BlockingOperator_constructor(
mut env: JNIEnv,
_: JClass,
scheme: JString,
map: JObject,
) -> jlong {
intern_constructor(&mut env, scheme, map).unwrap_or_else(|e| {
e.throw(&mut env);
0
})
}

fn intern_constructor(env: &mut JNIEnv, scheme: JString, map: JObject) -> Result<jlong> {
let scheme = Scheme::from_str(env.get_string(&scheme)?.to_str()?)?;
let map = jmap_to_hashmap(env, &map)?;
let op = Operator::via_map(scheme, map)?;
Ok(Box::into_raw(Box::new(op.blocking())) as jlong)
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_BlockingOperator_disposeInternal(
_: JNIEnv,
_: JClass,
op: *mut BlockingOperator,
) {
drop(Box::from_raw(op));
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_BlockingOperator_read(
mut env: JNIEnv,
_: JClass,
op: *mut BlockingOperator,
path: JString,
) -> jstring {
intern_read(&mut env, &mut *op, path).unwrap_or_else(|e| {
e.throw(&mut env);
JObject::null().into_raw()
})
}

fn intern_read(env: &mut JNIEnv, op: &mut BlockingOperator, path: JString) -> Result<jstring> {
let path = env.get_string(&path)?;
let content = String::from_utf8(op.read(path.to_str()?)?)?;
Ok(env.new_string(content)?.into_raw())
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_BlockingOperator_stat(
mut env: JNIEnv,
_: JClass,
op: *mut BlockingOperator,
path: JString,
) -> jlong {
intern_stat(&mut env, &mut *op, path).unwrap_or_else(|e| {
e.throw(&mut env);
0
})
}

fn intern_stat(env: &mut JNIEnv, op: &mut BlockingOperator, path: JString) -> Result<jlong> {
let path = env.get_string(&path)?;
let metadata = op.stat(path.to_str()?)?;
Ok(Box::into_raw(Box::new(metadata)) as jlong)
}

这里有三个要点。

第一,虽然 Rust 的 FFI 理论上可以直接对接 JNI 的标准,但是我还是使用了 jni-rs 库来简化开发。这个库的质量很不错,其主要工作是在 FFI 接口上封装了一套 JNI 领域模型的 Rust 结构。例如 JMap 这样的结构在 JNI 里是不存在的,JString 提供的接口也非常方便。注意 String 在这个传递过程中是有可能产生 marshalling 开销的。

第二,每个 JNI 接口函数都实现为调用对应的 intern 函数,然后用一段 unwrap_or_else(|e| {e.throw}) 的模板处理可能的错误。这是因为 JNI 的接口不能返回 Result 类型,所以做了一个错误处理的集中抽象。具体设计实现下一段会谈,这里主要说明的是可以最大程度的避免 unwrap 或对等方法的调用,把错误传递到 Java 侧用 Exception 来处理,而不是 Rust 侧 panic 即等价与 C++ core dump 来处理失败。后者显然是所有 Java 用户都不想处理的问题,也无法在 Java 侧捕捉处理。

第三,可以注意下如何返回 Rust 结构的指针,以及 disposeInternal 时如何释放指针。这是 Rust 内存安全的边界,理解这里面的逻辑对编写内存安全的 Rust FFI 有很大的帮助。

这里有一个潜在的优化点:Metadata 其实是个记录结构(record),如果能做好 marshalling 对应,可以直接编码返回,这样 Java 拿到的就是一个完全自己管理生命周期的数据对象,后续也不用走 JNI 去访问 Metadata 的数据。

错误处理

opendal-java 的一个创新价值是实现了一套 Rust ↔ Java 的错误处理范式。

在 Rust 侧,我们在 intern 系列方法里完成调用 Rust 函数的工作,回传 Result 到外层 FFI 接口处理。如果 Result 是错误结果,那么会走一个 throw 的过程抛出异常。这个过程会从 Rust 侧的错误提取出错误信息和错误码,然后构造 Java 侧的异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
pub(crate) struct Error {
inner: opendal::Error,
}

impl Error {
pub(crate) fn throw(&self, env: &mut JNIEnv) {
if let Err(err) = self.do_throw(env) {
env.fatal_error(err.to_string());
}
}

fn do_throw(&self, env: &mut JNIEnv) -> jni::errors::Result<()> {
let exception = self.to_exception(env)?;
env.throw(exception)
}

pub(crate) fn to_exception<'local>(
&self,
env: &mut JNIEnv<'local>,
) -> jni::errors::Result<JThrowable<'local>> {
let class = env.find_class("org/apache/opendal/OpenDALException")?;
let code = env.new_string(...);
let message = env.new_string(self.inner.to_string())?;
let exception = env.new_object(...);
Ok(JThrowable::from(exception))
}
}

对应 Java 侧 OpenDALException 定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class OpenDALException extends RuntimeException {
private final Code code;
public OpenDALException(String code, String message) {
this(Code.valueOf(code), message);
}
public OpenDALException(Code code, String message) {
super(message);
this.code = code;
}
public Code getCode() {
return code;
}
public enum Code {
// ...
}
}

运用这个范式,我把整个绑定 Rust 侧的 panic 调用控制在了 10 个以内,且全部是在异步接口互操作的范畴里的。其中大部分在 Load 和 Unload 的逻辑里,这是整个程序启动和终止的地方。其他的调用在 Rust 侧完成 Futrue 后回调的上下文里。这两者的共同点是:它们都对应不到一个用户控制的 Java 上下文来抛出异常。

异步接口互操作

opendal-java 的另一个创新价值,也是业内首创的方案,是实现了 Rust ↔ Java 异步接口互操作。

opendal-java 的第一版异步接口互操作实现是基于 Global Reference 的。但这个方案有一个缺陷,那就是 Global Reference 上限是 65535 个。所谓基于 Global Reference 的方案,就是把需要异步完成的 CompletableFuture 对象注册为 JNI 的 Global Reference 并跨线程共享,这意味着整个程序的 API 调用并发上限一定不超过 65535 个。

虽然这个数量对于大部分场景已经够用,但是毕竟是个无谓的开销,且 Global Reference 的访问没有经过特别的优化,很难估计重度使用这个特性会带来怎样的不稳定性。

我曾经构思过基于全局 Future Registry 的解决方案,或者演化成一个类似于跨语言 Actor Model (Dispatcher + Actor with Mailbox) 的方案,但是最终都没有成功写出来。

这里面主要的难点是 JNI 调用所必须的 JNIEnv 不是线程安全的。而要想真正实现 Java 调用 Rust 的异步接口,并在 Rust 异步动作完成后回调,而不是原地阻塞等待,调用过程一定会经历从 JNI 调用线程转移到 Rust 的后台异步线程。Global Reference 能够把 Java 对象提升到全局空间,进而跨线程共享,但是这其实也不解决 JNIEnv 不能移动到另一个线程的问题。

opendal-java 的第一版异步接口互操作实现解决了这个问题,其核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static mut RUNTIME: OnceCell<Runtime> = OnceCell::new();
thread_local! {
static ENV: RefCell<Option<*mut jni::sys::JNIEnv>> = RefCell::new(None);
}

#[no_mangle]
pub unsafe extern "system" fn JNI_OnLoad(vm: JavaVM, _: *mut c_void) -> jint {
RUNTIME
.set(
Builder::new_multi_thread()
.worker_threads(num_cpus::get())
.on_thread_start(move || {
ENV.with(|cell| {
let env = vm.attach_current_thread_as_daemon().unwrap();
*cell.borrow_mut() = Some(env.get_raw());
})
})
.build()
.unwrap(),
)
.unwrap();

JNI_VERSION_1_8
}

#[no_mangle]
pub unsafe extern "system" fn JNI_OnUnload(_: JavaVM, _: *mut c_void) {
if let Some(r) = RUNTIME.take() {
r.shutdown_background()
}
}

unsafe fn get_current_env<'local>() -> JNIEnv<'local> {
let env = ENV.with(|cell| *cell.borrow_mut()).unwrap();
JNIEnv::from_raw(env).unwrap()
}

unsafe fn get_global_runtime<'local>() -> &'local Runtime {
RUNTIME.get_unchecked()
}

其中,RUNTIME 的启动、关闭和获取是常规的使用 tokio 异步框架的方式:虽然可能更多人是简单的 #[tokio::main] 解决,但是其实 tokio 底下大概也是这么一个全局共享的 RUNTIME 的实现。

真正值得注意的是 JNI_OnLoad 传进来了一个线程安全的 JavaVM 对象,我们基于它在每个 tokio RUNTIME 的线程里 attach 了一个 JNIEnv 实例。

上面提到,JNIEnv 不是线程安全的,但是我们现在是在每个 tokio 线程池的线程里各自创建了一个本地的 JNIEnv 实例,这些实例在各自的线程里存活,并不跨线程共享。

JNI_OnLoad 方法就是这里破解难点的关键,它在本动态库被加载(通过 System.load 或者 System.loadLibrary 方法)之后被调用,传递当前 JavaVM 实例以供使用。由于运行当前程序的 JavaVM 全局只有一个,它是线程安全的,并且有一个 attach_current_thread_as_daemon 方法可以把当前线程注册到 JVM 上,获取 JNI 操作必须的 JNIEnv 对象。

突破这个问题以后,我们其实完全就不需要用 Global Reference 来传递 CompletableFuture 对象,而是可以实现我设想过的全局 Future Registry 方案了。其主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private enum AsyncRegistry {
INSTANCE;
private final Map<Long, CompletableFuture<?>> registry = new ConcurrentHashMap<>();
private static long requestId() {
final CompletableFuture<?> f = new CompletableFuture<>();
while (true) {
final long requestId = Math.abs(UUID.randomUUID().getLeastSignificantBits());
final CompletableFuture<?> prev = INSTANCE.registry.putIfAbsent(requestId, f);
if (prev == null) {
return requestId;
}
}
}
private static CompletableFuture<?> get(long requestId) {
return INSTANCE.registry.get(requestId);
}
private static <T> CompletableFuture<T> take(long requestId) {
final CompletableFuture<?> f = get(requestId);
if (f != null) {
f.whenComplete((r, e) -> INSTANCE.registry.remove(requestId));
}
return (CompletableFuture<T>) f;
}
}

public class Operator extends NativeObject {
// ...

public CompletableFuture<Metadata> stat(String path) {
final long requestId = stat(nativeHandle, path);
final CompletableFuture<Long> f = AsyncRegistry.take(requestId);
return f.thenApply(Metadata::new);
}

public CompletableFuture<String> read(String path) {
final long requestId = read(nativeHandle, path);
return AsyncRegistry.take(requestId);
}

private static native long stat(long nativeHandle, String path);
private static native long read(long nativeHandle, String path);
}

这次,所有的 native 方法都返回一个 long 值,它是一个从 AsyncRegistry 中获取结果对应的 CompletableFuture 的凭证。

Rust 侧通过 JNI 调用 AsyncRegistry#requestId 方法注册一个 Future 并取得它的凭证,随后这个凭证(整数)被传递到 tokio RUNTIME 创建的后台线程里,完成 API 调用后,通过后台线程的 JNIEnv 调用 AsyncRegistry#get 方法取得 CompletableFuture 对象,调用 CompletableFuture#complete 方法回填结果,或者 CompletableFuture#completeExceptionally 方法回调异常。

其主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
fn request_id(env: &mut JNIEnv) -> Result<jlong> {
Ok(env
.call_static_method(
"org/apache/opendal/Operator$AsyncRegistry",
"requestId",
"()J",
&[],
)?
.j()?)
}

fn get_future<'local>(env: &mut JNIEnv<'local>, id: jlong) -> Result<JObject<'local>> {
Ok(env
.call_static_method(
"org/apache/opendal/Operator$AsyncRegistry",
"get",
"(J)Ljava/util/concurrent/CompletableFuture;",
&[JValue::Long(id)],
)?
.l()?)
}

fn complete_future(id: jlong, result: Result<JValueOwned>) {
let mut env = unsafe { get_current_env() };
let future = get_future(&mut env, id).unwrap();
match result {
Ok(result) => {
let result = make_object(&mut env, result).unwrap();
env.call_method(
future,
"complete",
"(Ljava/lang/Object;)Z",
&[JValue::Object(&result)],
)
.unwrap()
}
Err(err) => {
let exception = err.to_exception(&mut env).unwrap();
env.call_method(
future,
"completeExceptionally",
"(Ljava/lang/Throwable;)Z",
&[JValue::Object(&exception)],
)
.unwrap()
}
};
}

#[no_mangle]
pub unsafe extern "system" fn Java_org_apache_opendal_Operator_read(
mut env: JNIEnv,
_: JClass,
op: *mut Operator,
path: JString,
) -> jlong {
intern_read(&mut env, op, path).unwrap_or_else(|e| {
e.throw(&mut env);
0
})
}

fn intern_read(env: &mut JNIEnv, op: *mut Operator, path: JString) -> Result<jlong> {
let op = unsafe { &mut *op };
let id = request_id(env)?;

let path = env.get_string(&path)?.to_str()?.to_string();

unsafe { get_global_runtime() }.spawn(async move {
let result = do_read(op, path).await;
complete_future(id, result.map(JValueOwned::Object))
});

Ok(id)
}

async fn do_read<'local>(op: &mut Operator, path: String) -> Result<JObject<'local>> {
let content = op.read(&path).await?;
let content = String::from_utf8(content)?;

let env = unsafe { get_current_env() };
let result = env.new_string(content)?;
Ok(result.into())
}

fn make_object<'local>(
env: &mut JNIEnv<'local>,
value: JValueOwned<'local>,
) -> Result<JObject<'local>> {
let o = match value {
JValueOwned::Object(o) => o,
JValueOwned::Byte(_) => env.new_object("java/lang/Long", "(B)V", &[value.borrow()])?,
JValueOwned::Char(_) => env.new_object("java/lang/Char", "(C)V", &[value.borrow()])?,
JValueOwned::Short(_) => env.new_object("java/lang/Short", "(S)V", &[value.borrow()])?,
JValueOwned::Int(_) => env.new_object("java/lang/Integer", "(I)V", &[value.borrow()])?,
JValueOwned::Long(_) => env.new_object("java/lang/Long", "(J)V", &[value.borrow()])?,
JValueOwned::Bool(_) => env.new_object("java/lang/Boolean", "(Z)V", &[value.borrow()])?,
JValueOwned::Float(_) => env.new_object("java/lang/Float", "(F)V", &[value.borrow()])?,
JValueOwned::Double(_) => env.new_object("java/lang/Double", "(D)V", &[value.borrow()])?,
JValueOwned::Void => JObject::null(),
};
Ok(o)
}

可以看到,我构建了一个实现 API 接口绑定的模式:

  1. 外层 JNI 映射函数和阻塞接口一样,调用 intern 方法并串接 throw 回调,处理同步阶段可能的异常。这主要来自于 String marshalling 和参数合法性检查的步骤。
  2. intern 方法处理参数映射,从 AsyncRegistry 里取得 Future 的凭证,随后调用 unsafe { get_global_runtime() }.spawn(...) 把 API 请求发送到后台线程处理,并返回 Futrue 凭证。Java 侧的 native 方法返回,取得凭证。
  3. do 方法在后台线程执行,得到结果。该结果由 complete_future 方法处理回调 CompletableFuture 的方法回填结果或异常。

其他的细节可以读源码分析,这里再提一下对异常的处理。

可以看到,只要是在 Java 侧调用 JNI 线程里的异常,我都压在 intern 方法的 Result 里抛出去了。JNI Onload 和 Unload 过程没有用户能处理的线程,tokio RUNTIME 的后台线程调用 complete_future 方法的时候也不在用户能处理的线程上,所以这些地方我都用了 unwrap 来处理错误。一方面是用户根本处理不了,另一方面也是这些调用是可以确保一定成功的,如果不成功,一定是代码写错了或者底层的不变式被破坏了,即使用户可以捕获这些异常,也不可能有合理的处理方式。

当然,如果未来发现其中某些异常可以恢复,可以在 Rust 侧从错误里恢复。技术上,do 方法返回的 err 会被 complete_future 回传到 CompletableFuture 的错误结果里,这也是一种不 panic 的 tokio RUNTIME 中的错误处理方式。

社群驱动的开发方式

虽然当前版本的 opendal-java 主要是我的设计,但是它的第一版并不是我写的。

项目作者 Xuanwo 首先开了 Java 绑定的 Issue-1572 提出需求,随后 @kidylee 很快表达了兴趣。由于我此前尝试过构建基于 TiKV Rust client 的 Java client 绑定,我分享了我做过的尝试。

不过,我没能实现一个符合自己期望的 TiKV Java client 绑定,所以在我想清楚之前,我并没有动力去做一个自己不满意的实现。

但是这个时候 @kidylee 很快做出了第一版 blocking operator 的实现。一个月后,来自 RocketMQ 社群的 @ShadowySpirits 也加入了进来。他想实现异步接口的支持,而这就是我之前没想通所以不愿意动手的卡点。

@ShadowySpirits 很快做了一个基于我放弃的 Global Reference 的解决方案,虽然 Global Reference 有上面我提到过的缺陷,但是他构建的 JNI Onload 方法及其全局线程池共享的方式给了我启发,Thread loacal 共享 JNIEnv 的方案打通了我之前面临的 JNIEnv 不 Sync 的难题,我于是得以实现自己就差最后一个技术难点的基于全局 AsyncRegistry 的解决方案,彻底绕过了 Global Reference 的限制。

功能实现以后,出于没有发布的软件就得不到严肃使用的认知,我着手解决了基本的项目打包和发布逻辑问题(Issue-2313)和发布前的其他功能、测试和文档工作(Issue-2339)。

这些工作完成以后,opendal-java 就正式发布到 Maven 中央资源库了。

昨天 @luky116 上报的另一个问题验证了我对软件发布重要性的认知。他凭着直觉使用 opendal-java 库,马上撞上了一个构建问题。这使得我重新思考了之前打包方式对下游用户的不方便之处,并记录了对应的 Issue 追踪。

我的计划是复刻 rocksdbjni 的发布方法,在不同平台编译动态库,最后合并不同平台编译出来的库到 resources 目录下发布,加载逻辑对应处理好平台架构的命名和发现逻辑。这个同时要修改 NativeObject 里的动态库加载逻辑,Maven 的打包逻辑和 GitHub Actions 的构建和发布逻辑。如果你了解 RocksDB 的打包发布方式,可以参与进来。不过这样的人应该很少,所以如果你感兴趣,也可以订阅这个问题,等我下个月找到时间演示一下解法。

此外,我在绕过 @luky116 遇到的构建问题以后,还发现了 opendal-java 对 OpenDAL features 打包的问题,可能会影响下游用户的使用预期。这个问题是个产品问题,我也记了一个 Issue 来讨论。基本上,用户可以自己打包动态库并指定动态库发现路径,这是最终兜底方案。但是这个方案目前没有直接的文档,只是我这个实现的人心里清楚。而且作为上游,有些 features 是适合一揽子打包出去,提供更好的开箱体验的。

最后,如果你也想体验一下开发 OpenDAL 多语言 API 绑定的过程,可以参与到我做了一半的 C# 绑定上来:

基本的项目框架我已经定好了,后续工作的参考材料也列出来了。如果你有足够的背景,我提供的材料应该已经足够作为直接实现的参考。

C# 绑定相较于 Java 绑定的优势在于它有原生的 C ABI repr 支持,这能减少一部分 marshalling 的开销。但是这些技术使用的人比较少,或者说整个 .NET 技术栈的用户都显著少于 JVM 技术栈,更不用说国内几乎没有 .NET 技术栈的企业,也就没有什么中文材料,所以学习新知识的门槛可能会有一些。

生成版本信息正确解析的 POM 文件

本文成文于 2019 年。最近 Apache StreamPark (Incubating) 项目要做第一个 Apache 版本的发布,遇到了类似的发布多 Scala 支持版本时如何正确生成对应 POM 文件,又尽可能复用流水线的问题。由于过往发布记录都被删除,故重新发布。

近日在阅读 FLINK 代码时发现 FLINK 有一个 force-shading 模块,关于这个模块的作用注释在其使用点 maven-shade-plugin 的配置中是这样写的

现在这个模块已经移动到 flink-shaded 仓库下,详见 pom.xml 文件。

1
2
3
4
5
6
7
8
9
10
11
<artifactSet>
<includes>
<!-- Unfortunately, the next line is necessary for now to force the execution
of the Shade plugin upon all sub modules. This will generate effective poms,
i.e. poms which do not contain properties which are derived from this root pom.
In particular, the Scala version properties are defined in the root pom and without
shading, the root pom would have to be Scala suffixed and thereby all other modules.
-->
<include>org.apache.flink:force-shading</include>
</includes>
</artifactSet>

从注释中我们可以看到 force-shading 的作用是强制触发 maven-shade-plugin 的执行,并且提到了这样会生成所谓的 effective pom 文件。这究竟是怎么一回事呢?我们先从一个实例中理解这个问题。

先创建一个任意 MAVEN 工程,将它的 pom.xml 文件中 dependencies 替换为以下内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?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>moe.tison</groupId>
<artifactId>eden_${scala.binary.version}</artifactId>
<version>0.1-SNAPSHOT</version>

<properties>
<scala.binary.version>2.12</scala.binary.version>
</properties>

<dependencies>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>force-shading</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_${scala.binary.version}</artifactId>
<version>2.5.24</version>
</dependency>
</dependencies>
</project>

这里省略了可以配置 scala.binary.version 属性的 profile 部分,我们的意图是根据不同的 profile 来打出适应不同 Scala 版本的 jar 包,这一点可以在 mvn clean install -P<profile-name> 里指定。但是我们看一下在默认 profile 下发出来的 pom 文件的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?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>moe.tison</groupId>
<artifactId>eden_${scala.binary.version}</artifactId>
<version>0.1-SNAPSHOT</version>

<properties>
<scala.binary.version>2.12</scala.binary.version>
</properties>

<dependencies>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>force-shading</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_${scala.binary.version}</artifactId>
<version>2.5.24</version>
</dependency>
</dependencies>
</project>

可以看到,${scala.binary.version} 的部分并没有被解析。这是因为 MAVEN install 的策略是直接复制工程对象的 pom file 字段对应的文件,在这里它直接复制了项目下的 pom.xml 文件。

这样会有什么问题呢?基于简单的复制策略 MAVEN 并不会解析 pom 文件中的 properties,这会导致我们基于不同的 profile 打出来的包的项目描述 pom 文件都是一样的。即使我们分别为 Scala 2.11 和 2.12 版本打了两个不同的 jar 包,由于 ${scala.binary.version} 未解析,在下游应用中引用的使用属性永远是以 <scala.binary.version>2.12</scala.binary.version> 为准,也就丧失了原本分开打包兼容不同版本的初衷了。

明白了问题以后,我们来看一下 force-shading 是怎么解决这个问题的。

我们先往 pom.xml 中添加一个 artifactSet exclude 所有依赖的 maven-shade-plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>shade-eden</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<excludes>
<exclude>org.apache.flink:force-shading</exclude>
<exclude>com.typesafe.akka:akka-actor_*</exclude>
</excludes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

可以看到打出来的 jar 包的 pom 文件依旧不解析相关的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?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>moe.tison</groupId>
<artifactId>eden_${scala.binary.version}</artifactId>
<version>0.1-SNAPSHOT</version>

<properties>
<scala.binary.version>2.12</scala.binary.version>
</properties>

<dependencies>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>force-shading</artifactId>
<version>1.8.1</version>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_${scala.binary.version}</artifactId>
<version>2.5.24</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>shade-eden</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<excludes>
<exclude>org.apache.flink:force-shading</exclude>
<exclude>com.typesafe.akka:akka-actor_*</exclude>
</excludes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

我们试着把 force-shading 像 FLINK 那样 inlcude 到最终的 uber-jar 中。

1
2
3
4
5
<artifactSet>
<includes>
<include>org.apache.flink:force-shading</include>
</includes>
</artifactSet>

可以看到这次打出来的 jar 包中的 pom 文件已经解析了 properties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?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/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>moe.tison</groupId>
<artifactId>eden_2.12</artifactId>
<version>0.1-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>shade-eden</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>org.apache.flink:force-shading</include>
</includes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_2.12</artifactId>
<version>2.5.24</version>
<scope>compile</scope>
</dependency>
</dependencies>
<properties>
<scala.binary.version>2.12</scala.binary.version>
</properties>
</project>

这样,在不同的 profile 下,MAVEN 会把 properties 的使用点全部替换成运行时的值,打出来的包即依赖运行时的值,这个值可以由 profile 指定,就达到了我们打不同的兼容包的需求了。

那么,为什么使用 force-shading 就能达到这样的效果呢?

我们看到 MAVEN install 的时候的一行日志。

1
[INFO] Installing /path/to/eden/dependency-reduced-pom.xml to /home/user/.m2/repository/moe/tison/eden_2.12/0.1-SNAPSHOT/eden_2.12-0.1-SNAPSHOT.pom

可以看到我们是把 dependency-reduced-pom.xml 作为最终安装时复制的 pom file 来使用的,这个文件由 maven-shade-plugin 在对比模块原有依赖和经过 shade 之后的依赖有区别是解析产生,即 FLINK 中注释提到的 effective pom,它会在运行时基于依赖 diff 产生,由于运行时的 properties 本就是被设定的值,因此它巧合的就完成了这个解析 properties 的任务。

这里,依赖的 diff 由 pom.xml 中依赖 force-shading 而在 uber-jar 中打入 force-shading 因此不含这个依赖来达到。由于这个 diff 出现在 flink-parent 中,所有的子模块都会经历这个过程,所以所有子模块都使用了 effective pom 作为最终的 pom 文件。由于 force-shading 本身是一个空模块,只是为了触发 maven-shade-plugin,因此打入 uber-jar 中也不会有问题。

此外还有一点值得一提,即 maven-shade-plugin 在不指定 artifactSetartifactSet/includes 为空时,默认是将所有依赖打入 uber-jar,即不选=全选。force-shadinginclude 恰巧避免了这一出乎意料的情况的发生,保证 shade 时所有的 inlcudeexclude 都显式声明,客观上也减少了潜在的难以分析的漏洞。

关于 FLINK 和 SPARK 使用 force-shading 手段的讨论:

关于 MAVEN 安装时不解析 properties 的讨论:

Protobuf Gradle Plugin 的用例

近日尝试利用 Apache Ratis 这个项目包装一个 Raft 协议驱动的状态机的时候,遇到了需要用 Protobuf 传输数据的场景。由于 Gradle 构建工具的门槛和 Java 语言项目的某些惯例碰到了使用上的问题,这里记录一下我在这个玩具项目当中的用例。

首先介绍一下整个项目的主要目录结构,这里只包含最小复现需要的集合

1
2
3
4
5
project/
project/proto/
project/proto/RMap.proto
project/build.gradle
project/settings.gradle

其中 settings.gradle 只有一行默认生成的 rootProject.name = 'dryad' 信息,RMap.proto 是一个普通的不包含 gRPC 定义的 proto 文件。RMap.proto 文件内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
syntax = "proto3";
option java_package = "org.tisonkun.dryad.proto.rmap";
option java_outer_classname = "RMapProtos";
option java_generate_equals_and_hash = true;

package dryad.rmap;

message GetRequest {
bytes key = 1;
}

message GetResponse {
bool found = 1;
bytes key = 2;
bytes value = 3;
}

message PutRequest {
bytes key = 1;
bytes value = 2;
}

message PutResponse {
}

主要使用 Protobuf Gradle Plugin 的逻辑都在 build.gradle 文件里,文件内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
plugins {
id 'java'
id 'com.google.protobuf' version '0.8.18'
id 'com.github.johnrengelman.shadow' version '7.1.2'
}

repositories {
mavenCentral()
mavenLocal()
}

sourceCompatibility = 17
targetCompatibility = 17

dependencies {
implementation 'com.google.protobuf:protobuf-java:3.19.2'
implementation 'org.apache.ratis:ratis-thirdparty-misc:0.7.0'

protobuf files("proto/")
}

protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.12.0'
}
}

shadowJar {
configurations = []
relocate 'com.google.protobuf', 'org.apache.ratis.thirdparty.com.google.protobuf'
}

编译和构建工具采用的版本信息如下

1
2
3
4
5
6
7
8
9
10
11
12
------------------------------------------------------------
Gradle 7.4
------------------------------------------------------------

Build time: 2022-02-08 09:58:38 UTC
Revision: f0d9291c04b90b59445041eaa75b2ee744162586

Kotlin: 1.5.31
Groovy: 3.0.9
Ant: Apache Ant(TM) version 1.10.11 compiled on July 10 2021
JVM: 17.0.2 (Eclipse Adoptium 17.0.2+8)
OS: Mac OS X 10.15.7 x86_64

这个用例当中有两个注意点。

第一个注意点是 protobuf 的配置方式。

可以看到在 dependencies 配置块中声明了 proto 文件的路径。我不记得是不是有默认的查询路径比如 <project>/src/main/proto 这样的,但是建议还是明确写出来为好,毕竟业界也没有什么公认的标准,每个插件工具的假设不一定采用同一套约定。

另外就是 protobuf 配置块中声明了 protoc 工具的版本。Protobuf Gradle Plugin 的官方文档当中还介绍了如何整合 gRPC 等插件等控制 protoc 编译过程的方式。玩具项目当中不需要,因此略过。

最后是 protocprotobuf-java 的版本不一样,如果还要引入 gRPC 的插件和 JAR 包依赖,还会有其他不一样的版本。这是因为 Protobuf 生态并不是整体同步发布的,而是各个组件很大程度上自主开发和发布的缘故。具体的兼容矩阵我没有研究过,但是一般来说锁定了某个版本就不太会轻易升级了。比如 Apache Hadoop 的 Protobuf 版本一直停留在 2.5.0 版本上。印象中 3.0 版本以后的兼容性还是比较好的,3.10+ 版本之间的升级还算顺滑。

第二个注意点是 Gradle Shadow Plugin 插件的使用。

Gradle Shadow Plugin 很大程度上是 Maven Shade Plugin 的同位替代。也就是说,服务于需要把依赖项一起打成一个大 JAR 包的场景。

通常来说,Maven 或 Gradle 项目打包的时候,依赖项都不会进入到最终产物当中。因为打包就只是对你写的这些代码编译出来的 class 文件打包,而不是像 C / Rust 这种产生二进制可执行文件的思路。Java 语言程序运行起来,是需要程序员把所有的依赖项都写进 classpath 里,再指定要运行的类,执行其 Main 方法启动的。这种情况下打包不需要把依赖项都搭进去。

这种方案对于企业自己管理所有依赖,大部分软件是自包含少依赖的大型软件的场景是比较合理的。但是随着互联网的兴起和合作开发效率提升,一个项目依赖大量其他项目的情形越来越多,这些其他项目也有自己的开发周期,往往会产生多个版本的 JAR 包发布产物。这种情况下再要求程序员自己去管理依赖项,管理 classpath 的内容,在生产上就是既繁琐有不可靠的了。

因此,Gradle Shadow Plugin 和 Maven Shade Plugin 解决的问题就是把所有依赖在打包的时候也打进构建产物当中,产生一个 project-all.jar 文件。用户可以直接把这一个 JAR 包加入 classpath 就能保证所有的依赖都已经就绪。甚至在 MANIFEST 文件中写好默认的 MainClass 信息,就能通过 java -jar 命令将大 JAR 包以一种形如二进制可执行文件的方式运行起来。

不过,我们这里用上的不是打一个大 JAR 包的功能,而是在这个大需求下解决 package relocation 问题的功能。

Java 语言程序依靠全限定名来识别一个类,每个 ClassLoader 都对每个全限定名都只会加载一个类实例。如果 classpath 当中存在两个相同全限定名的类,那么根据 ClassLoader 的实现策略,可能会加载其中任意一个,或者报错。

对于服务端应用例如 Apache Flink 和 Apache Ratis 而言,它们自己需要依赖 protobuf 或 akka 等三方库,同时它们自己的用户也有可能依赖这些三方库,那么用户内部逻辑使用的三方库版本,跟用户逻辑需要跟服务端打交道时使用的三方库版本,就有可能在 classpath 当中同时存在。如果这两个版本不兼容,就会出现运行时错误。

由于服务端应用往往受众更广,通常来说解决方案是用户应用程序采用跟服务端相同的依赖版本。但是如果用户不是直接依赖跟服务端可能冲突的三方库,而是间接依赖,那么这个版本对齐的工作往往就很难做了。

另一种形式是形如 akka 生态当中的 play 框架,直接暴露操作 akka 底层数据结构的接口,用户自己不依赖 akka 而是通过 play 提供的接口使用 akka 的能力。但是这种形式只对 akka 和 play 这样由同一个团队开发的软件是比较合适的,放在更加复杂的开源软件生态当中就很难配合了。

因此从服务端的角度出发,为了避免用户遇到这一难题,一个彻底的解决方法就是 package relocation 更改自己依赖的三方库的全限定名。

比如上面 build.gradle 里配置项显示的

1
2
3
4
shadowJar {
configurations = []
relocate 'com.google.protobuf', 'org.apache.ratis.thirdparty.com.google.protobuf'
}

这意味着把所有 com.google.protobuf 的文本都替换成 org.apache.ratis.thirdparty.com.google.protobuf 的字样,也包括字符串当中的情况,以应对动态加载的用例。

这样,服务端最终打出来的 JAR 包里,使用的类全限定名就不是 com.google.protobuf.Message 而是 org.apache.ratis.thirdparty.com.google.protobuf.Message 了,这也就跟用户依赖的 com.google.protobuf.Message 不同,从而不会起冲突。

当然,这种 package relocation 不仅仅在服务端的使用上会改掉全限定名,也需要类的实现本身也是以新的全限定名来提供的。因此 Apache Ratis 项目提供了 ratis-thirdparty-misc 库,Apache Flink 项目提供了 flink-shaded 库。其中的内容就是把服务端依赖的软件以 relocate 之后的名称重新发布。

对于这个玩具项目来说,它需要的是保持跟 Apache Ratis 服务端一样的 protobuf 依赖的全限定名,保证能够嵌入到 Apache Ratis 的服务端实现当中。对于其中的 proto 定义部分,它并不需要真的把依赖项也打进自己的 JAR 包里,这个打大 JAR 包的工作会交给最终的 dist package 完成。所以我们还需要把 Gradle Shadow Plugin 默认打入所有运行时依赖的行为变掉。这就是 configurations = [] 一行起的作用,把打入最终 JAR 包的依赖项置空,这样就只会包含 proto 文件编译出来的 class 文件了。这样的用例,其实与 Maven Shade Plugin 的惯用法有较大的差别,更像是 Maven Replacer Plugin 的用法。

最后作为小 tip 值得一提的是,上面提到 package relocation “也包括字符串当中的情况,以应对动态加载的用例”。这其实导致了 akka 项目很难利用常规的 package relocation 插件来完成这个工作。惯例上,Java 语言项目的全限定名以域名开头,形如 com.google.protobuforg.apache.ratis 等等。一般而言这种形式的字符串只会出现在类的全限定名当中。然而,akka 作为一个 Scala 项目采用了 akka.actor 形式的全限定名前缀。不幸的是,这种前缀模式跟 akka 的配置项是重叠的。这就导致 package relocation 会同时改变配置项的名称。这其实不是我们想要的,因为这样用户也要跟着改配置项的名称才能跟 relocate 之后的 akka 库交互,这通常来说是非常难做到并且与大部分开发者的直觉和生态项目的假设是冲突的。

20220626 更新

  1. 这样 relocated 以后的结果,只会体现在本仓库再次被依赖时。因为 shadowJar 作业发生在打包阶段,因此如果在同一个包内使用 Protobuf Plugin 生成的类,此时依赖的还是 relocated 前的全限定名。
  2. 多模块的 Gradle 项目中,一个子项目依赖 shadowJar 产生的另一个子项目需要形如 implementation project(path: ':foo-proto', configuration: 'shadow') 的语法。
  3. 如果使用 Intellij IDEA 来开发,需要在 build.gradle 里加载名为 idea 的 Gradle Plugin 才能正确索引 Protobuf Plugin 生成的文件。

具体可以参考在线的完整案例 Dryad 仓库。

❌