从.NET调用Java

各位好久不见。由于工作的原因,个人研究时间不多了,而且研究的东西有些也是工作相关的,所以无法在这里记录下来。

今天来记录一下最近在挖的坑里用到的,.NET调用Java的方案。

大家都知道我是.NET教的信徒,并且认为C#是Java的上位替代。不过重复造轮子总是不好的,最近挖的坑需要用到一个jar库,要从C#来调一下它。

排除还在筹划中的.NET Core Java interop,目前从C#调用Java主要有以下几种方案:

把Java库编译成.NET库

没错,说的就是大名鼎鼎的IKVM。IKVM是用.NET实现的Java虚拟机以及Java基础类库,并且其编译器ikvmc可以将Java字节码编译成.NET IL,实现jar库到dll库的直接转换。转换后,可以直接在C#项目里引入该dll并完全直接调用库中的类型和方法,而且运行时不再依赖JVM,用户不装Java也没事,此外调用效率也会比RPC调用高(但转换的代码由于语言和实现差异,有时运行效率变低也是可能的)。这是我非常推崇的一种调用Java库的方式,特别是当Java库本身依赖较少、没有native依赖的时候。

但遗憾的是,IKVM作者已经停止维护,最后一个版本停留在IKVM8(对应JDK1.8)。而且当Java库有native依赖或者一些oracle专属依赖时,IKVM在很多情况下不能很好地处理。

题外话:虽然作者没有解释IKVM是什么意思,不过一种很自然的理解是I代表Iron,Iron家族聚集了各种语言的.NET方言,比如IronPython,IronRuby等,因此当很多人试图将一种语言搬到.NET平台,通常会起名IronXX,比如IronTJS。当然这其中也有些例外,比如Peachpie是目前非常有活力的PHP .NET方言。

通常我会首先尝试使用IKVM,因为它生成的DLL库调用起来实在方便,而且由于已经变成.NET库,还可以方便地对它进行调整。但此次坑中,jar库有大量依赖,特别是native依赖让IKVM栽了,每当运行到native交互时就抛异常,所以不得不放弃它作为首选方案。如果其他方案也都无法胜任,再去考虑对库的native调用进行patch绕过。

生成静态代理库

目前主要有两个方案:jni4net和JNBridgePro。前者是开源项目,但作者弃坑已久,文档还停留在jdk 1.5时期,基本上可以弃了。后者是商业项目但可以试用,而且公司甚至会回复你的email,态度还是不错的。

生成静态代理库方式就是对一个jar库生成一个.NET dll库,这个dll并不像IKVM那样包含了实际逻辑,而是对jar库中每个类型生成的代理类型。当你从C#中引入该dll库开始调用其中的类型的时候,dll库启动一个JVM,并把你的调用转发给真正的jar库,再接收其结果。这样一来,你的C#调用写起来和IKVM方式差别不大,也很方便,同时由于是真正的JVM,兼容性会好很多。

不过试用JNBridgePro之后,发现它对于本次使用的jar库支持不太好,由于这个jar库有巨量的类型和方法,生成出来的dll库不知为何竟然丢了一些方法,导致没法正常调用。(也有可能是因为这个jar库实际上使用kotlin而非java编写的缘故。)而且它生成代理dll库的速度甚至比IKVM编译jar库到dll库还慢(20min vs 5min)。所以还是弃了。不过对于中小型的、可能有native或是oracle专属依赖的Java库来说,还是有一试的价值。

动态转发调用

目前主要有一个方案:Javonet(商业项目,可试用)。该方案一套两头吃,既能Java调.NET,也能.NET调Java,通过一个jar和一个.NET dll实现了一个互相转发平台,从.NET拉起JVM并调用jar,从Java拉起CLR并调用dll。不过公司的侧重点应该是Java调用.NET,Java侧的功能更全,包括事件订阅等都实现了,而.NET侧甚至连Java的基础类型都缺float和double。不过框架整体还是好用的。

与JNB不同的是,它没有搞代理库,需要你用字符串来指明类型,用通用的接口Get/Set/Invoke来取值、设置值、调用方法,因此代码写起来是比前两种难受很多的,调用的多了肯定需要先花时间对它进行二次包装简化调用。

来看看官方的例子:

            //Creating instance of Java class
            var sampleClass = JavonetBridge.Javonet.New("SampleJavaClass");
 
            //Calling instance methods
            String res = sampleClass.Invoke<String>("SayHello", "Student");
            Console.WriteLine("Java method 'SayHello' returned: " + res);
            //Setting fields
            sampleClass.Set("numberA", new JPrimitive(4));
            //Getting fields
            var a = sampleClass.Get("numberA");

要是用IKVM等方案,这个例子就是这样的:

            //Creating instance of Java class
            var sampleClass = new SampleJavaClass();
 
            //Calling instance methods
            String res = sampleClass.SayHello("Student");
            Console.WriteLine("Java method 'SayHello' returned: " + res);
            //Setting fields
            sampleClass.numberA = 4;
            //Getting fields
            var a = sampleClass.numberA;

很显然Javonet的写法是比较难受的。

但是由于它克服了上面其他方案的缺点,初步测试下来能够正常调用这次的jar库,因此我还是决定这次就用它了(如果最后发现有绕不过的坑,就准备再回归IKVM并尝试解决native调用问题。“工程,即妥协。”——我的工作前辈Y老师语)。

改进Javonet

那么首先自然是要对它进行包装简化调用,毕竟我在意识到IKVM不行的时候,已经写了500多行代码,要把它迁移到Javonet并用这套Get/Set/Invoke,我得再写俩月,代码也别想再维护。于是我用C#的dynamic动态类型对javonet做了一套包装DynamicJavonet,把代码简化到了以下程度:

            //Creating instance of Java class
            dynamic sampleClass = DJ.New("SampleJavaClass");
 
            //Calling instance methods
            dynamic res = sampleClass.SayHello("Student");
            Console.WriteLine("Java method 'SayHello' returned: " + res);
            //Setting fields
            sampleClass.numberA = 4;
            //Getting fields
            var a = sampleClass.numberA;

除了New和取Type操作还需要显式通过字符串指定,其他操作均可以通过dynamic大幅简化,实际上内部还是会转化到对Get/Set/Invoke的调用,但写起来已经舒适多了。此外,还对基础类型进行了自动包装。对于一个Java方法 void setValue(int val) ,由于int是基础类型,C#调用时必须采用JPrimitive(val)作为参数,来指明参数是int基础类型;否则则会被当做Integer对象,最后会尝试调用的方法是 void setValue(Integer val) 。显然javonet是一家Java主导的公司,.NET这边舍近求远,明明Integer才是罕见情况,应该采用JInteger(val)来声明,而.NET int直接对应Java int是非常自然的。因此包装里直接把这两种情况取了个反,使用int时帮你自动包装成JPrimitive,实际效果是基础类型;而使用JPrimitive时帮你去掉JPrimitive,实际效果是作为对象。当然这个取反的行为可以设置启用或不启用。

当然是用dynamic是有性能损失的,特别是在循环里频繁大量调用的情况,不过本次不会涉及这种情况,而且就算涉及,也可以把dynamic和原始方式结合使用,在性能攸关的地方使用Get/Set/Invoke,其他地方用dynamic,这种结合写法也不会很痛苦的。

解决了这个问题,随后就是一个刚才提到的巨坑了:javonet在.NET调用Java时,对于基础类型(JPrimitive)只支持了int,bool,long,short四类。显然,至少还缺float和double两类。这是什么概念呢?就是说,当Java有这样一个方法:void setValue(float val) ,你无法通过javonet来调用它,然而,如果方法是 void setValue(Float val) ,那就可以,因为float是基础类型(值)而Float是一个对象(java.number.Float),对象是通用的,你的.NET float传到Java侧默认是Float。当然,正常写方法的程序员没有人会画蛇添足地在能用float的地方用Float,所以你很难绕过这个问题。

恐怕很多人到此已经认定javonet是垃圾可以弃了,不过已经试过很多方案的我不想就这么放弃,而且这个问题看着也不大,这个框架明显具有支持这些类型的能力,只是javonet公司偏重Java调用.NET,对于.NET调用Java没有实现完整罢了。因此接下来就是要patch javonet,使它支持float和double。

 

经过初步分析,可以了解其大致原理,基本上就是RPC调用,.NET dll释放出一个jar(如果是Java调用C#,则是由这个jar释放出.NET dll),并且通过dll里的C++/CLI部分拉起一个JVM,把jar加载进JVM,建立起通信信道,随后你就可以在C#里,通过.NET dll命令这个jar,添加你的jar库到JVM,并进一步调用了。

对于基础类型,RPC调用约定的是类似于“JInt::{数值bytes}”这种形式。遇到不支持的类型(如float)的时候,.NET dll直接提示不支持该类型并拒绝。首先patch dll,增加对float和double的支持,形式如“JFloat::{数值bytes}”。

随后,将dll中的jar解出来,在jar里找到两个类型ParameterBuilder和BinaryStreamProtocol,里面有类似的解析RPC消息的代码,做类似的兼容即可。这里遇到两个Java反编译器造成的坑,不结合具体项目难以描述清楚所以这里只说个大概:

一个是BinaryStreamProtocol中,对KnownTypes枚举有个迷之SwitchMap,一开始我认为.NET侧的KnownTypes枚举的值传到Java侧,也能对上Java侧的枚举值,而由于这个SwitchMap的存在,整个Java侧的枚举值有一个偏移,导致两边的值实际对不上。由于Java反编译器没有正确反编译SwitchMap,导致我在这里卡了一段时间。

另一个也是反编译器的锅,特别是诸如JADX这种反编译出来的语法比较“漂亮”,平时都爱用的。有一段代码在JADX中反编译出来是这样的:

return convertCSharpType(buffer, buffer.getInt(), buffer.get());

而更真实的代码实际上是这样的:

byte b = buffer.get();
int i = buffer.getInt();
return convertCSharpType(buffer, i, b);

JADX编译出来的代码看起来更简洁,但是它的调用顺序却是错的!buffer.get()和buffer.getInt()同样作为这个方法的参数,JADX觉得不如直接合并为一句话。然而这两个函数调用是从流中读取数据,调用顺序错了,读取到的内容也就错了。这个错误很难发现,有多个反编译器的结果都类似于JADX,而能反映正确逻辑的反编译器,出来的代码又比JADX乱很多,让人难以提起精神阅读。在这方面,Java也是远远落后于.NET世界的。(Reflector, JD, ILSpy三家的反编译效果各有特色,但至少可读性都是有保障的,当然JB家的dotPeek还是烂的不行。)

patch完jar后,将它塞回dll。经过测试,包含float和double的Java方法确实可以正常调用了,可以继续挖坑了……

 

后记:在本文写作前,我向官方反映了这个问题。目前我已收到官方答复(和JNB一样回复很快),官方承认此问题并已经发出了修正该问题的测试版本。遗憾的是,官方的修改显得有些naive,竟然将float/double转换成string来传递……这样做的后果,我相信任何一个计算机相关专业的同学都能明确指出,作为商业产品,这种写法实在有失水准。

在修正完float问题后,我又果不其然地发现Javonet不支持float[]。修改越来越多,我不得不在本地建了个git,拉了条自己的分支来维护……

 

 

——————

写完本文已是深夜,顺手点播一首歌。现在是三月,显然我又要老调重弹推荐这首《三月雨》(初版)。推荐初版而不是现在普及的重制版,是因为我是经历了洛天依在中国首次发布的那段时间并因为这首歌入坑,且初版的鼓点深得我心。

念往昔 我急旋慢转你抚琴低吟
到如今 重唱此曲却已无你
莫叹息 我再舞一曲你意乱情迷
空余忆 良辰美景多可惜

 

当然,也是因为最近的事情想起了这几句歌词——
“急旋慢转”的画面还在眼前没有消散,
而明年却没有人“再舞一曲”了。

 

评论 (3) -

  • 还真是很久很久没看到U大的文章了,IP域名换了又换每次都被吓到了OMO
    看到这管文章,能看出究竟有多苦恼和不尽人意233
    辛苦了~
    • 非常感谢一直以来的关注。不过苦恼暂时没有多少。我之前写到,因为在现在的行业中我是菜鸟,所以必定要付出更多的时间和精力,才能维持得了生活这样子。所以,目前本菜鸟表示情绪稳定。当然,累确实还是累的。希望项目上线后能得到好评,努力不会白费吧。

      只要有新的点子了,博客还是会继续写的。已经在酝酿了,咕咕咕
      • 同样地,看到大佬付出的时间和精力,我也更有动力去加油努力了。
        喜欢你的文章是因为可以看到一个过程的演变,流程化当中提供的经验心得让我更容易理解👍
        期待你的新文章

添加评论

Loading