今天上午终于完成了网站的整体迁移，历时不到5天。不知道这期间有多少朋友注意到了迁移导致的服务中断呢？本文将讨论这次迁移的全过程，以及我的一些想法。迁移数据库在迁移公告这篇文章里我提到的这次迁移的主要目的：使用Linode的托管数据库试图切换到WordPress来避开Typecho部分不成熟的功能实际上在发出这篇公告之前，网站就已经迁移到Linode的托管数据库了，这个过程还是相对无痛的：将现有的数据库导出成sql格式连上Linode的托管数据库，导入sql文件修改Typecho设置，让Typecho使用新的数据库配置从原理上来说这个过程不应该遇到任何麻烦，因为数据库的内容是完全没变的，只是相当于换了个地方。但是托管数据库和自建数据库不同，自建数据库运行在127.0.0.1上，通常只需要设置好密码就行了，不需要传输层加密，类似于以前的HTTP。但是托管数据库不一样，数据库位于公网的其他位置，如果继续使用明文协议，很容易会受到中间人攻击，因此需要传输层加密，也就是使用SSL连接。但是目前主流的博客平台似乎都没有考虑到这一点。在安装过程中WordPress不支持设置SSL证书，但安装完成后可以通过设置一个变量来启用相应的支持。而Typecho就不一样——压根儿不支持。好在我看到GitHub上有人提了pr，我把对应部分的修改应用到1.2.0版本上，完美可用。截至1.2.1-rc版本发出时，这个pr还没有合并，但截至本文发布时这个pr已经并入主线，不知道能不能赶上1.2.1的发布。迁移完数据库，我注意到了公告中提到的this is incompatible with sql_mode=only_full_group_by，变萌生了迁移网站的想法。迁移服务器为了避免网站服务不受中断，所以我决定暂且将旧的网站搬迁到一个临时服务器上，以便我在这个服务器上尝试新的东西。这个过程也比较顺畅：开一台新的服务器，装好lnmp之类的软件使用rsync把网站复制过去，改好权限保持nginx配置不变，原封不动把网站配置和证书复制过去修改DNS记录等待旧的DNS记录失效后，验证网站是否还在如期工作我印象中这部分好像没有遇到太多问题。在等文件迁移的过程中，我发现Typecho的作者已经声称修复了那个聚合查询的问题，但我还是遇到了。本着科学的原则，我打算深入研究一下这个问题。我新装了一个干净的Typecho，建立了一个测试分类，又在这个分类里面创建了一个子分类。在旧的网站中，查看测试分类的页面会抛出聚合查询错误，而新装的typecho就没有这个问题。我很是费解，但是我又不会PHP，这怎么办呢？本着自己动手丰衣足食的原则，我一边查谷歌，一边用记事本改PHP文件，开启了Typecho的debug模式，找到了报错的点，尝试使用var_dump查看出错的查询，我发现出错的点在于查询中使用了无关于内容的表。在新的Typecho中，查询分类的select部分只选择了table.contents这个表的内容，这十分合理——分类页面会列出这个分类下的文章，而文章都存储在contents表里。但是旧的Typecho则直接变成了select *，而后面用到了join，就会导致这个查询不光select文章表的内容，还会select到后面join进来的表的内容，这就导致了聚合查询的错误。找到了问题所在，那我就要看看为什么这两个select的不一样。经过一番查找，最后发现是一个插件修改了select语句。这个插件年代久远，是统计文章阅读量的。工作原理就是访问文章的时候给数据库字段加1，查询文章的时候会查询数据，一并把阅读量作为结果返回。而又经过一番查找，我发现这个插件的作者后来提供了一个不需要数据库字段的实现：Typecho文章都可以设置自定义字段，如果把数据库字段改成文章的这个字段，岂不美哉？只可惜我目前使用的主题依赖这个插件，不知道以后作者会不会修改。不过就算作者修改了，这样统计阅读量也有问题：这种统计方法是建立在执行PHP代码的基础上实现的，如果使用了CDN，那么页面就会被缓存，下次请求的时候就不会再触发PHP代码了。这么看还是基于JS的解决方案比较好，但我对于阅读量也没什么执念，我只管写的开心，别人爱看不看。找到了问题所在，禁用了插件，问题解决，这下不用非得换WordPress了。评估新的博客系统当然，我还是想试试看WordPress的，于是就安装了。结果我大受震撼，不禁发表了如下感想：这一回，是我自己发昏，竟想着换到 WordPress 去了。我装好了 WordPress 一看，什么都要钱。好不容易看上一个主题，要价 59 美元我就不说了，好东西值好价，但是他妈的 59 美元竟然不支持 Dark mode？以及什么评论同步 IFTTT 和 Pushover 竟然也都是要钱的，我真的麻了。更让我不理解的是，插件要钱，竟然还是按月收费。WP Reset这个插件的作用是把WordPress给你回复出厂设置，但是要按月订阅，我他妈每个月都给我的博客重置一下，我是不是有病？作者是不是有病？更何况，WordPress程序运行在我自己花钱租用的服务器上，你这代码也运行在我的服务器上，一次结清我尚且还能够理解，他妈的我自己花钱租服务器，还得花钱买软件？这不大冤种吗？这软件我要是用得上，还则罢了。他妈的一个恢复出厂设置的插件，按月收费？还是他们挣钱狠啊是的，WordPress作为一个强大的CMS，大家似乎更看重将其打造为电商平台。比如说使用Pushover和IFTTT推送通知，这个是针对订单的。想要通知评论？没门。这么看我还是继续用我的Typecho吧。配置服务器在把网站迁回来之前，我想重新配置一下服务器。这个服务器之前用的系统是Debian 10，虽然很干净，但升级的时候总是不太方便，而我喜欢的OpenSUSE似乎又太冷门，服务商没有提供这个系统。最后我选择了Ubuntu 2204 LTS，虽然我不太喜欢Ubuntu背后的商业公司，但是Ubuntu在易用性上还是挺好的，至少大版本升级的时候不会那么痛苦，而且和Debian一样可以共享类似的软件源。设置好之后，为了保证安全性，我安装了fail2ban，这个软件的功能比较强大，但我这里的主要用途是防止ssh被破解。如果一个人在短时间内连续多次登录ssh失败，这个软件就会把这个人的ip封禁一段时间，阻止暴力破解。在安装这个软件的过程中有两个问题：fail2ban抓不到ssh失败的记录，后来发现要设置ssh-backend为systemd，默认backend是不行的fail2ban没办法封禁ip，后来发现是非标准ssh端口导致的，不要写什么port = ssh, 1234，直接写port = 1234，然后在action里面也写port=1234，重启之后就万事大吉了。最后为了防止我自己忘记密码，我还设置了Tailscale SSH。一开始我以为这东西就是通过Tailscale访问SSH，结果尝试之后才发现不是这样的——使用Tailscale SSH不再需要密码或密钥认证了，只要Tailscale控制台允许你访问对方的ssh，那么不需要任何密钥，可以直接登录。这也太爽了吧！但是为了避免出现安全问题，我在访问控制里设置了如下规则：一共有两个标签：device和server我的个人设备被列入device我的服务器列入serverdevice访问server无需认证其他成员可以互访，但访问之前需要使用我的tailscale账号认证这样一来，即便我的服务器被黑了，它也没办法通过tailscale访问到我其他的设备。如果我的设备丢了，我只要在控制台上关闭这台电脑的访问权限就可以了。最后，关于PHP，我选用的版本是PHP 8.1，开启了JIT。我并没有直观的感受到JIT带来的性能提升，但看网上评论好像都还不错，就先开着吧。迁移网站迁移网站并不只是原封不动的把网站迁走在迁回来。这里做了两个修改：取消www.skyblond.info：在旧的网站中，很久以前博客使用了诸如www.skyblond.info/2022/10/23/678.html的格式，但后来变成了skyblond.info/archives/678.html的格式，但为了兼容原来的url，特意设置了一个www.skyblond.info的vhost，让nginx把旧的url重写成新的url。这一次迁移的时候，在迁移数据库的过程中就把原来所有的www.skyblond.info都改成了对应的新的skyblond.info额外增设statics.skyblond.info：在旧的网站中，诸如图片、随机背景这些静态资源全都是和网站挤在一起，我觉得这样不是很优雅。于是将图片这些静态资源单独做了一个vhost，便于管理。在安装好对应软件和插件之后，我一边复制数据库一边进行上述修改，按道理来说应该不会有什么问题。但是问题偏偏就发生了：我自己写的评论通知IFTTT和Pushover都没有问题，唯独那个评论通知邮件的插件坏了。无论我怎么调试，都不太行。最后折腾了两天，实在搞不定，我一想，我可去他妈的吧。新的插件虽然话是这么说，但是没有邮件通知不行啊。我左思右想，有了一个好办法：ITFFF和Pushover都是同POST的方式把评论推送到对应的平台，那我也可以用类似的方式把评论推给Java，然后再让Java去发邮件后来我简单验证了一下我这个想法，发现还挺可行的。于是写了一个简单的插件，来将评论数据推送到指定的URL上。然后再由Java程序进行处理，无论是发邮件还是通知其他平台，都比PHP自由多了。这里有如下几点考虑：PHP插件方面，使用了Joyqi在新版本引入的异步服务，可以保证评论通知的处理不拖慢系统处理评论的速度Java方面，收到评论数据后尽快结束请求，不占用过多的PHP资源关于部署，Java这边使用了Docker，配合GitHub Action的自动构建镜像，可以简化推送代码到使用成本的流程；同时为了减小Java的内存占用，这里用了IBM semeru JRE（OpenJ9），这个JRE的主要特点就是节约内存因为本篇的重点在于迁移网站总结，而不是插件开发介绍，所以在代码方面就不多介绍了。Java发邮件是真方便，但是邮件系统不支持utf-8是真闹心。总结这次搬迁网站虽然看起来好像没有什么实质性的大变化，更像是把网站搬走再搬回来，不过这个过程中确实整理出来了一些资源。例如把数据库挪走了，我就可以把大部分软件迁回到这个CN2线路的服务器上了，而不用再忍受德国机房的渣网络。总体来说这次迁移好像也没什么太大的问题，那就先这样吧。
【代码札记】使用JNA在Windows上创建并操作TUN设备
虽然大家一致认为Java不适合做底层，但我觉得不适合不代表不能做。本篇文章比起札记，其实更像是一篇show off。背景最近我在看去中心系统相关的内容，看到了Yggdrasil，且不说它的原理如何，它在使用方式上就让我耳目一新。传统的overlay network都需要通过某种应用层的接口来让用户访问，比如Tor和I2P都需要浏览器支持，后者更是提供了诸如socks这种代理协议来提供访问，这就要求应用程序必须专门考虑并为此设计，才能获得一定程度的兼容性。对于不支持socks协议的程序，很遗憾，它没法接入I2P网络。Yggdrasil不同：它在运行时创建了一个TUN设备。TUN设备是一种虚拟网络适配器，类似TAP。TAP设备工作在二层，换言之它能够得到的是数据链路层的数据包（更细致地说，其实是媒体访问控制MAC包），而TUN设备则工作在三层，它能够得到的数据是IP包。Yggdrasil将网络中的每一个节点的公钥映射成IPv6地址中被废弃的一段0020::0/7，这段地址在可观的未来不会被使用（但Yggdrasil网络中节点的公钥是256位，而这个地址段只有121位，我还不太清楚他们是怎么解决地址冲突的），每个节点分配一个段内的IPv6地址，这样一来，只要在操作系统中访问这个段内的地址，不管你是ICMP还是TCP还是UDP，操作系统都封装成IP包，交给这个TUN设备。而Yggdrasil从这个TUN设备中获取封装好的IP号，看一下目的地地址，在网络内路由，到达对方节点后，直接通过TUN设备写入操作系统的IP栈，之后无论是什么协议，全部由操作系统负责处理和分发。多么优雅的解决方案啊！但是只有一个遗憾，这个软件是Go写的，而我是写Java的。我翻遍了Google和GitHub，也没有发现支持Java在Windows上创建和操作TUN设备的库。我很不爽，于是就自己写了一个。自己造轮子经过一番搜索，我发现了一个叫做wintun的C++库，这个库将win32 API的调用封装成了简单的函数，诸如WintunCreateAdapter这种。要知道，让Java调用C就已经很困难了，你需要处理好Java对象和C结构体之间的映射，要处理好指针、类型的问题。而微软的Win32为了让事情变得更加复杂（其实是历史遗留问题），他们使用了自己的一套类型系统，并且有者自己的一套API设计理念，用起来更加麻烦。看到有前人栽树，现在后人就得想怎么乘凉。好在Java调用C并不是什么稀奇事情：除了原生的JNI之外，我们还有JNA和JNR，还有未来的Project Panama。就目前而言，对于这个项目来讲，JNA是最好的选择。关于编写mapping的过程就不再赘述了，无非就是参照JNA文档，遇到问题了Google一下。不过其中有个小插曲，就是无论怎么调试，一开始得到的结果返回的都是null，我百思不得其解啊。最后无意中想到，这个玩意儿是不是需要管理员权限？但是IDEA并没有提供单独将程序作为管理员运行的选项，不得已，我只能将整个IDEA作为管理员运行。对于来路不明的软件，这个动作其实非常危险：不光是IDEA，由IDEA启动的Gradle也是作为管理员运行的，如果构建脚本里有一些恶意代码，使用管理员权限运行刚好给了他们可乘之机。不过考虑到这个是我自己写的软件，顶多蓝屏，能怎么样？编写好了Mapping，我发现一个问题：这个软件只能创建和操作TUN设备，但是没办法给TUN设备分配IP。虽然我可以在TUN设备中模拟DHCP包，但是这未免有些复杂且太不灵活了。查阅了一大堆文档，最后发现win32 API还是绕不开了。于是开始想办法编写相关的win32 API。具体内容可以参考这个项目的GitHub Repo。因为是C，免不了很多面向过程编程。与其一开始考虑怎么把这个东西做的尽可能OOP，我的建议是每次针对一个功能（例如列出IP，插入MIB ROW等），写一个demo，确保能用之后，再将这个功能封装成对象的成员方法。不然一边设计对象，一边考虑C的过程，实属给自己找不痛快。展示经过了一周的艰苦奋斗，这个库终于是成了。为了验证效果，我借助pcap4j实现了ICMP ECHO（也就是ping）响应。效果就是，运行程序，创建一个TUN设备，程序为这个TUN设备分配IP为0020::100/7，并开始处理IP包。这个时候在系统内ping任意一个同网段的ip，都可以得到相对应的ping响应。总体流程还是简单明了的：package info.skyblond.jna import com.sun.jna.platform.win32.Guid import info.skyblond.jna.wintun.* import org.pcap4j.packet.* import org.pcap4j.packet.namednumber.IcmpV6Code import org.pcap4j.packet.namednumber.IcmpV6Type import org.pcap4j.packet.namednumber.IpNumber import org.pcap4j.packet.namednumber.IpVersion import java.io.EOFException import java.net.Inet6Address import kotlin.concurrent.thread import kotlin.experimental.and import kotlin.random.Random fun main(args: Array<String>) { println("Current wintun version: ${WintunLib.INSTANCE.WintunGetRunningDriverVersion()}") val guid = Guid.GUID.newGuid().toGuidString() val adapter = WintunAdapter("Wintun Demo Adapter", "Wintun", guid) // Ring size: 8MB val session = adapter.newSession(0x800000) try { // clean up old ip adapter.dissociateIp(ip) } catch (t: Throwable) { t.printStackTrace() } val ip = Inet6Address.getByName("0020::100") println("Set ip to: $ip") adapter.associateIp( AdapterIPAddress(ip = ip, prefixLength = 7u) ) TODO("Handle IP packets") println("Closing!") session.close() adapter.close() }整体流程就是创建网卡、启动Session、设定IP、处理IP包，最后关闭资源。处理IP包的部分可以单独放在一个线程里： val t = thread { try { while (true) { val result = session.readPacket() if (result != null) thread { handlePacket(session, result) } } } catch (e: EOFException) { e.printStackTrace() } catch (e: NativeException) { e.printStackTrace() } } while (t.isAlive) { Thread.sleep(1000) }由于读取的时候不一定总会读出数据，因此需要判断一下读出的内容是否为空，不为空再处理。处理起来也相对简单，无非是解析包，拿到目的地IP，然后创建一个回复的包，写回操作系统： private fun handlePacket(session: WintunSession, packet: ByteArray) { val isV6 = packet[0].and(0xf0.toByte()) == 0x60.toByte() println( "Get IPv${if (isV6) "6" else "4"} packet from OS\n" + "\tSize: ${packet.size} bytes\n" ) if (isV6) { val v6Packet = IpV6Packet.newPacket(packet, 0, packet.size) println(v6Packet) (v6Packet.payload as? IcmpV6CommonPacket)?.let { icmpV6Common -> (icmpV6Common.payload as? IcmpV6EchoRequestPacket)?.let { request -> val reply = IpV6Packet.Builder() .version(IpVersion.IPV6) .trafficClass(IpV6SimpleTrafficClass.newInstance(0x00)) .flowLabel(IpV6SimpleFlowLabel.newInstance(0)) .srcAddr(v6Packet.header.dstAddr) .dstAddr(v6Packet.header.srcAddr) .nextHeader(IpNumber.ICMPV6) .hopLimit(127) .correctLengthAtBuild(true) .payloadBuilder( IcmpV6CommonPacket.Builder() .srcAddr(v6Packet.header.dstAddr) .dstAddr(v6Packet.header.srcAddr) .type(IcmpV6Type.ECHO_REPLY) .code(IcmpV6Code.NO_CODE) .correctChecksumAtBuild(true) .payloadBuilder( IcmpV6EchoReplyPacket.Builder() .identifier(request.header.identifier) .sequenceNumber(request.header.sequenceNumber) .payloadBuilder(request.payload.builder) ) ) .build() println("Reply: \n$reply") session.writePacket(reply.rawData) } } } else { val v4Packet = IpV4Packet.newPacket(packet, 0, packet.size) println(v4Packet) } }程序运行起来之后ping 0020::300，效果如下：关于完整的程序和这个库，可以在GitHub上找到。-全文完-
本文将记述如何在JVM上使用Lua脚本。众所周知，JVM上运行的都是字节码，而要得到字节码，就离不开编译。而脚本的目的就在于不进行事先编译的情况下提供一个灵活的控制系统。谈及脚本语言，JavaScript和Python算是两个比较火的编程语言了，但是我认为他们都太复杂了，不够优雅。我心目中比较理想的脚本语言是Lua，它比较简单，而且能够面向特定领域。如果说Python是为了解决任何可以通过编程解决的问题而设计的，那么Lua可能就是为了解决某个特定领域中的问而设计的。例如大名鼎鼎的Cheat Engine，他就支持用户使用Lua脚本来操作程序的内存，或者将Lua脚本编译成机器指令插入到源程序中，这可比写汇编容易不少。除了Lua，似乎也可以为某一个特定领域创建一门语言，来自JetBrains的MSP工具就是为此而生。但是为此你需要自定义一套语法规则，相当于为了你的领域而单独创造一门编程语言，即便它非常简单。这对我来说有些因小失大了——引入脚本最初是为了简化开发，同时提供足够的灵活性，而为了达到这个目的要设计一个全新的语言，我想这对开发者、对用户的心智负担都不小。本文将记述我在JVM平台上使用Lua脚本的过程。LuaKLuaK是隶属于KorGE的Lua库，其旨在将LuaJ迁移到Kotlin上。其中KorGE是一个基于Kotlin的游戏引擎。但这个库看起来还在开发中，暂时没有可以使用的文档与制品。我个人比较期待这个库，但是目前还没有完工，只能寻求别的代替品了。Luaj经过一番搜索，我找到了一个有年头的库，LuaJ，有年头到什么程度呢？就是最后一次更新的时候（2019年3月，3.0.2发布），社区开发者没能联系上原作者，进而无法为Maven仓库上的制品进行更新。于是使用时只得将库下载到硬盘上进行使用。这让我想起了早年间我学习Java，还不会用maven、gradle这些工具时，就从网络上下载依赖的jar，让IDEA帮我管理依赖。总之，通过类似implementation(files("lib/luaj-jse-3.0.2.jar"))的语句就可以让gradle使用本地的jar依赖了。这个库在功能上还是比较丰富的：例如执行Lua脚本（使用库的API），提供脚本执行引擎（使用Java Scripting API），Lua解释器（将Lua脚本处理为语法树，使用Java访问语法树），Lua编译器（将Lua脚本编译成LuaVM的字节码），Lua到Java编译器（将LuaVM的字节码翻译成JVM字节码）。而以上所有内容都跟C代码没有任何关系——纯Java实现。当然了，本文的目标很简单：让Java能够为Lua脚本提供新的能力，并且在Lua脚本中调用Java提供的能力。经过一番查阅，LuaJ为扩展提供了较为方便的接口，其中比较重要的就是TwoArgFunction。从名字来看，这个抽象类似乎是描述了一个具有两个参数的函数，但LuaJ将其用作加载库的方式。具体来说，当我们在Lua脚本中require 'MyCustomLib'时，LuaJ会搜索classpath中所有实现了LuaFunction、并且具有默认构造函数（即无参数的构造函数）的类，如果找到了name为MyCustomLib，就把这个类当作一个函数来执行，其中第一个参数是模块名（modname，也就是MyCustomLib），第二个参数就是调用者的环境。通过操作环境可以在环境中注册库，之后的代码便可以调用我们的库了。需要注意的是，前文说的name并不是传统意义上的类名，而是Class.java中getName()返回的类名。因此对于info.skyblond.demo.lua.luaj包中的MyCustomLib类，他的name是info.skyblond.demo.lua.luaj.MyCustomLib。如果仅用MyCustomLib，LuaJ是找不到对应类的。这里我编写了一个简单的例子，展示了LuaJ提供的无参函数、单参函数、双参函数、三参函数和可变参函数：package info.skyblond.demo.lua.luaj import org.luaj.vm2.LuaFunction import org.luaj.vm2.LuaTable import org.luaj.vm2.LuaValue import org.luaj.vm2.Varargs import org.luaj.vm2.lib.* import java.math.BigDecimal import java.math.BigInteger import java.time.Duration class MyCustomLib : TwoArgFunction() { /** * Will be called when executing `require 'MyCustomLib'`. * Then the "MyCustomLib" will be the moduleName. * The env is the environment of that env. * And this func needs to return a [LuaTable]. * The key is func name, the value is [LuaFunction] * */ override fun call(modname: LuaValue, env: LuaValue): LuaTable { val library = tableOf() library.set("getMilli", GetMilliSecondFunc()) library.set("addOne", AddOneFunc()) library.set("longestStr", LongestStringFunc()) library.set("futureMilli", FutureMilliFunc()) library.set("hello", TestFunc()) env.set("MyCustomLib", library) return library } class GetMilliSecondFunc : ZeroArgFunction() { override fun call(): LuaValue { return LuaValue.valueOf(System.currentTimeMillis().toString()) } } class AddOneFunc : OneArgFunction() { override fun call(p0: LuaValue): LuaValue { // in Lua, int, double, long are all "number" return when (p0.typename()) { "number" -> { val number = p0.checknumber() number.add(1) } "string" -> { val str = p0.checkstring().tojstring().lowercase() if (str.contains(".") || str.contains("e")) { // decimal val result = str.toBigDecimal().add(BigDecimal.ONE) val er = result.toEngineeringString() val pr = result.toPlainString() if (er.length < pr.length) { LuaValue.valueOf(er) } else { LuaValue.valueOf(pr) } } else { // integer val result = str.toBigInteger().add(BigInteger.ONE) LuaValue.valueOf(result.toString()) } } else -> error("Invalid arg type: ${p0.typename()}") } } } class LongestStringFunc : TwoArgFunction() { override fun call(p1: LuaValue, p2: LuaValue): LuaValue { val str1 = p1.checkstring().length() val str2 = p2.checkstring().length() return if (str1 >= str2) p1 else p2 } } class FutureMilliFunc : ThreeArgFunction() { override fun call(p0: LuaValue, p1: LuaValue, p2: LuaValue): LuaValue { val currentMilli = p0.checkstring().tojstring().toLong() val number = p2.checkstring().tojstring().toLong() val offset = when (p1.checkstring().tojstring().lowercase()) { "millis" -> Duration.ofMillis(number) "seconds" -> Duration.ofSeconds(number) "minutes" -> Duration.ofMinutes(number) "hours" -> Duration.ofHours(number) "days" -> Duration.ofDays(number) else -> null }?.toMillis() return if (offset != null) { LuaValue.valueOf((currentMilli + offset).toString()) } else { error("Invalid duration type: $p1") } } } class TestFunc : VarArgFunction() { override fun invoke(args: Varargs): Varargs { // note: Lua start index from 1 for (i in 1..args.narg()) { val arg = args.arg(i) println("Arg#$i: $arg (${arg.typename()})") } return LuaValue.valueOf("Vararg result!") } } }这些代码都可以在Lua中被调用。测试用的Lua脚本如下：-- get Java lib loading in the env require 'info.skyblond.demo.lua.luaj.MyCustomLib' -- test normal print print('Hello world!') -- zero arg func print('GetMilli', MyCustomLib.getMilli()) -- one arg with multiple arg types print('AddOne', MyCustomLib.addOne(123)) print('AddOne', MyCustomLib.addOne(128.5)) print('AddOne', MyCustomLib.addOne('999999999999999999')) print('AddOne', MyCustomLib.addOne('1e-6')) -- two arg func print('LongestStr', MyCustomLib.longestStr('aa', 'bbb')) -- three arg func print('FutureMilli', MyCustomLib.futureMilli(MyCustomLib.getMilli(), 'days', 1)) -- var arg func print('VarArg', MyCustomLib.hello('str', 222, true))为了方便起见，我直接从字符串加载Lua脚本了：package info.skyblond.demo.lua.luaj import org.luaj.vm2.lib.jse.JsePlatform object TestLuajBasic { @JvmStatic fun main(args: Array<String>) { val globals = JsePlatform.standardGlobals() globals.load(MyCustomLib()) println(MyCustomLib::class.java.name) val chunk = globals.load("...Lua脚本...") chunk.call() } }这里为了确认类的名字，我打印了MyCustomLib::class.java.name。这一行println并不是执行代码必须的。执行结果如下：info.skyblond.demo.lua.luaj.MyCustomLib Hello world! GetMilli 1659595842222 AddOne 124 AddOne 129.5 AddOne 1000000000000000000 AddOne 1.000001 LongestStr bbb FutureMilli 1659682242233 Arg#1: str (string) Arg#2: 222 (number) Arg#3: true (boolean) VarArg Vararg result!嗯，目的达成。不过有些老代码确实让我耿耿于怀，这个库支持的Lua版本是5.2，而最新版已经到了5.4。虽然说功能很强大，但是通过GitHub来看，大部分代码都已经大约七八年没有更新了，最老的代码已经有12年之久。如果是业务代码到还无所谓，我比较担心的是将LuaVM字节码翻译成JVM字节码时，能否利用到最新的字节码（这部分相关的代码是2012年10月更新的，那时候Java7刚问世一年）。不过抛开这部分功能，Lua 5.2还是够用的，并且Java的一个好处就是能够保持代码不变，而仅通过升级JVM版本就能获得性能提升。并且得益于Java API的稳定性，这样的老代码能够完全兼容Kotlin并运行在Java17上。-全文完-
大学四年过得真快，转眼间就毕业了。趁着做完毕设、毕业之前，和同学搞了个Minecraft服务器。然而2核4G对于140多个Fabric模组来说还是太多了。本文记述我如何对JVM调优。前言Java和JVM一直是一个很庞大的系统。Java语言在JVM的基础上隐藏了很多细节，从而让程序员更关注功能而非性能。而JVM的作用则是对程序员编写的代码进行优化，因此JVM中引入了垃圾回收、即时编译等一系列先进而复杂的子系统。这种复杂度也使得JVM的性能并不像C++、Go或者Rust这样值观：你以为一段循环即可测量某个操作的性能，实际上这个操作可能随着循环的进行被即时编译机制优化。总之，本文希望以一种尽量量化的方式说明如何对JVM进行调优，从而避免「摆弄开关」和「按照坊间传闻」调优。本文的测试环境是一个4核心8GB内存的虚拟机，安装了openSUSE Leap 15.4，Linux内核5.14.21-150400.22-default，带有2GB交换空间。测试程序为MCPLUS整合包（版本1.18.1 Beta4），额外加入了Chunky用于生成区块。选择最佳JVM这里准备了5个候选JDK，三个HotSpot，一个OpenJ9，还有一个来自Azul的Zing：OpenJDK（Java 17.0.2+8 Linux/x64 Hotspot）Adoptium Temurin（原AdoptOpenJDK，Java 17.0.3+7 Linux/x64 Hotspot）IBM Semeru Runtime Open Edition（Java 17.0.3+7 Linux/x64 OpenJ9 0.32.0）Azul zulu jdk（Java 17.0.3+7 Linux/x64 Hotspot）Azul zing（Java 17.0.3+7 Linux/x64 Tiered VM Zing22.05.0.0+2）测试时使用的世界Seed为-3566672805144844787。测试流程为：删除logs和worlds文件夹启动服务端线程设置Chunky在主世界，以（0，0）为中心，半径为500生成区块（总计4225个区块）完成后停止服务端保存logs文件夹测量途中除了收集Minecraft服务端日志（Chunky日志）外，还收集JVM的垃圾回收日志。测试时使用如下脚本：#! /bin/bash JVM_OPENJDK=openjdk-17.0.2 JVM_TEMURIN=temurin-jdk-17.0.3+7 JVM_SEMERU=ibm-semeru-open-jdk-17.0.3+7 JVM_ZULU=zulu17.34.19-ca-jdk17.0.3-linux_x64 JVM_ZING=zing22.05.0.0-3-jdk17.0.3-linux_x64 sudo rm -rf /home/skyblond/jdks/test/logs* for JVM_NAME in $JVM_OPENJDK $JVM_TEMURIN $JVM_SEMERU $JVM_ZULU $JVM_ZING; do echo '========================================' echo "Start testing $JVM_NAME" echo '========================================' sudo rm -rf /home/skyblond/jdks/test/world mkdir logs sudo nice -n -20 /home/skyblond/jdks/$JVM_NAME/bin/java \ -Xmx6G -Xms6G -Xlog:gc:logs/gc.log -Xlog:gc* \ -jar fabric-server-launch.jar nogui | tee logs/full.log chmod 777 -R /home/skyblond/jdks/test/logs mv -v /home/skyblond/jdks/test/logs /home/skyblond/jdks/test/logs-$JVM_NAME echo '========================================' echo "Finish testing $JVM_NAME" echo '========================================' done该脚本定义了5个JVM的路径，然后再循环中依次使用这些JVM启动MC服务端，并在服务端退出后保存日志。其中启动MC服务端的时候，设置堆大小为6GB内存，开启垃圾回收日志并存储于logs中（MC服务端的日志文件夹）。最终logs文件夹中有三个我们需要的文件：gc.log：垃圾回收日志latest.log：MC服务端的输出full.log：控制台输出，混合了垃圾回收日志和MC服务端输出，由于GC日志的时间从JVM启动那一刻开始计算，而MC的日志则以现实时间计算，因此需要利用该文件校对时间。具体做法是在启动时找到两条相邻的MC日志输出，看夹在中间的gc日志时间。这个方法要比看第一条日志的时间精确许多，因为JVM预热/启动的时候MC还不会输出日志由于时间有限，跑一次测试基本上要50分钟到60分钟，因此我只测量了一次。虚拟机内的进程相对稳定，而虚拟机外启动了众多程序，例如微信、Chrome、Spotify等，这些程序对于测量结果的影响尚不明确。因此测量结果将辅以GC日志分析进行说明： OpenJDKTemurinIBM SemeruAzul Zulu JDKAzul ZingChunky用时8分59秒9分15秒11分20秒9分17秒8分51秒平均暂停 / ms51.349.686255.38-平均GC间隔 / s883.557-注1：OpenJ9产生的GC日志使用IBM PMAT工具分析，Azul Zing产生的GC日志使用GC Log Analyzer工具分析，其他使用G1的HotSpot虚拟机产生的日志使用GCViewer分析，版本为提交fd90b9d492121b2f5251d8555fd0e89cd73d53ea，本次提交中优化了对Java 17中G1日志的解析。对于使用G1的三个HotSpot虚拟机，我们更关注Young GC（Normal）和Mixed GC的占比，这类似以前的新生代和老年代——一般来说大部分GC应当发生在新生代，即Young GC，有时候也叫Normal GC。当新生代也不够用了，传统的垃圾收集器开始进行Full GC以回收老年代，而G1则对老年代进行部分回收，直到有足够用的空域内存。因此使用G1收集器同样也要注意是否有太多的新生代对象漏到老年代，从而因此过多的Mixed GC拖慢速度。OpenJDK：114次GC暂停中有8次是因为Mixed GCTemurin：115次GC暂停中有8次是因为Mixed GCAzul Zulu JDK：120次GC暂停中有2次是因为Mixed GC从Mixed GC上来看，我更青睐Azul Zulu JDK，因为频繁的Mixed GC带来的就是MC卡顿（掉TPS），但从生成区块的性能来说，Zulu JDK是HotSpot中最差的一个，也许它的策略是用略低的性能换取稳定的表现。对于OpenJ9，虽然它性能最差，但是在测试时发现它的内存占用非常好：别的虚拟机已经开始Swap了，它的6GB堆刚用了大约三分之二。此外我看Minecraft社区也对OpenJ9有所讨论，并认为可能是最简单的获得性能提升的方法。此外，Minecraft社区普遍反馈使用OpenJ9在各方面都获得了提升（我个人猜测是内存占用量减少，从而整体减少的进入Swap的内存，因此整体系统表现为「不卡了」），同时也有针对OpenJ9的调优参数，这里为了方便起见，所有虚拟机使用默认参数：即不修改GC，不做针对性的修改。此外除了总体耗时外，生成区块过程中的速度变化也很有意思：纵轴是每秒生成的区块数量，横轴是生成任务开始后的秒数。可以看到最开始Azul Zing的速度是最慢的，但随着循环运行，Zing的即时编译器开始介入进行优化，到最后Zing的速度竟然是最快的了。最后说说我的JDK选择吧，这五种JDK都是可以免费获得的，其中只有Azul Zing是商业使用付费，开发和评测使用无需付费，其他的JDK基本上都是开源的。抛开性能，我认为相关的工具也很重要。对于HotSpot虚拟机，它的GC日志可视化软件靠社区用爱发电，毕竟是开源软件嘛，Oracle都把Java开源了，还不许人家留点做咨询的家伙事儿？但是Azul和IBM可是两家商业公司，其中IBM还是百年老店。这里我要吐槽一下IBM的陈年工具，最后一次更新在2014年，而且图表也不够直观，总之就是难上手。在这种我需要快速对比不同JVM的场景下，难上手反直观的工具，即便其功能再强大，我也会很嫌弃。反而Azul在工具方面做的非常好，除了缺乏必要的统计之外，图标和各种信息都非常详尽、直观。所以我的选择是Azul Zing，如果内存实在是紧缺的话，可以考虑IBM Semeru，使用OpenJ9。如果受限于Azul Zing的许可/商用限制，可以考虑Azul Zulu JDK，他们的Mixed GC更少，也许能够避免一些突然掉TPS的情况，但是整体TPS可能稍低一些。当然，如果嫌麻烦，用Linux发行版本自带的OpenJDK也不是不行，在性能测试中可以看出随着各种优化机制的接入，性能第二的就是OpenJDK。至于Temurin，他们号称自己的JDK经过各种兼容性测试，但是对我而言好像影响不大。在工具方面，我首推Azul Zing，在性能上我也推荐Azul Zing，虽然他很慢热，但这并不是要害。至于调优，所有HotSpot系在Java 17上都是用G1作为默认的垃圾收集器，有些JDK也编译了ZGC。对于G1，Oracle官方的建议是使用默认值，在默认值（平衡）情况下G1会尝试在高吞吐下获得较小、统一的暂停。如果想要追求高吞吐低暂停，那么可以设置-XX:MaxGCPauseMillis来降低目标GC停顿时间，同时为了能够让G1达成这个目标，最好配合-Xmx多给他一些内存。至于ZGC，在宣传上它的性能要比G1更好（3TB堆上世界暂停小于1ms），但实际测试时，由于扫描、标记等操作是与用户线程并行的，因此反而挤占了Minecraft的线程，导致性能降低。实际测试中，OpenJDK使用ZGC后，生成区块的时间从8分59秒延长到了10分45秒。内存调优接下来我们看一下使用默认参数运行的时候，Azul Zing虚拟机的瓶颈。一般来说JVM的瓶颈在于垃圾回收，因为JIT编译或者其他优化都可以通过多线程的形式与用户线程一起跑，而唯独垃圾回收不得不暂停用户线程。在现代垃圾回收器中，一般都按照分代理论（ZGC除外），优先考虑来的快去的快的新生代/年轻代。然而当新生代垃圾回收跟不上新生代的分配速度时，那么多出来的对象就只能被迫晋升到老年代，这个代的对象一般被认为比较长寿，即不会轻易丢弃。然而现实是这些来的快去的快的对象进入老年代之后，仍然改变不了他们去的快的命运。因此它们将很快成为老年代的垃圾。对于传统的CMS等回收算法，老年代回收的代价比较高（通常意味着更高的暂停时间），所以一般在内存调优的时候会尽量避免老年代出现锯齿状。比如默认参数下的内存分布：可以看出来每次GC时老年代出现锯齿状，而通过查阅文档，我发现Zing并不支持手动设置新生代或老年代的大小，但有两个参数可以间接控制对象晋升和垃圾回收：-XX:GPGCTimeStampPromotionThresholdMS=10000：这个参数控制新生代对象晋升到老年代前等待的时间。堆大于2GB时默认为2000毫秒，小于等于2GB时为500毫秒。考虑到区块生成任务不需要在内存中持久保留过多对象，因此我将这个参数设置成了10秒。-XX:GPGCTargetPeakNewGenOccupancyPercent=70：这个参数为堆内存中新生代占用设置了一个软限制，当新生代占用堆达到了这个百分比时，将自动触发一次GC。这个参数默认是0，也就是说软上限和硬上限一致。考虑到所有区块生成完即可回收，因此我将这个参数设置为了75%，当新生代对象占用了超过75%的堆内存时将会触发一次GC。这两个参数对于性能的影响并不是线性缩放的。例如第一个参数在整体上限定了晋升老年代的条件，对于游戏来说，如果一个玩家正在跑图，那么玩家大约需要5~6秒来走过一个平坦的区块；如果玩家在自己的家或者其他长期停留的区块，如果这个参数设置过长将导致每次GC都尝试释放这个区块，反而增大了GC的压力。第二参数也是类似，该参数对于突发负载比较有用，即平时预留出足够的内存，当遇到突发负载时可以直接分配内存，而不用等待GC释放资源。但如果该值较低，则大量玩家同时在线时，内存用量降不下来，可能会导致频繁GC。我认为在正常运行服务器的时候，这个值可以设置为90%或95%，当玩家登录和跑图的时候会分配大量内存，为这类场景留出余量能够极大改善用户登录和加载区块的体验。修改完这些参数后，我得到的最快耗时是8分56秒，分布图如下：Azul Zing ReadNow!为了解决JVM启动慢热的问题，Azul Zing提出了“ReadyNow!”。这个技术实际上就是利用文件保存相关信息，让未来的JVM启动时可以直接根据以前的信息对相关代码进行优化，从而避免预热的问题。要使用这项技术，需要指定两个JVM参数用于保存文件：-XX:ProfileLogIn=<file> -XX:ProfileLogOut=<file>，在开启这两个开关之后，官方建议将所有重要的函数都执行至少5万次。于是我就让他生成5万个区块，然后删掉世界文件重新运行测试。启动JVM后需要等待一段时间，这个时候虽然Minecraft服务端启动完毕了，但是Zing在后台进行即时编译，需要等CPU使用率平缓之后再开始测试。耗时从8分51秒下降到了。。。emmmm。。。。9分21秒？通过GC日志可以看到，在使用默认设置时，C2编译线程稳定在2个活跃线程，而开启ReadyNow后一上来C2就有3个线程，并且在生成区块的时候，活动线程数量介于2到3个之间。我推断编译线程占用了用户线程的CPU时间，从而导致生成地图反而变慢。因为2022年只有4个CPU核心确实不叫多，而且生成区块这个任务并不是单线程的，所以任何需要额外CPU时间的优化其实都是负优化。除非CPU核心特别多，Minecraft用不过来，这个时候C2可以编译，我推断在这种情况下应当会有性能提升。上图是默认参数下的编译线程统计，下图是使用了ReadyNow的统计：4核8G都这样，那腾讯云的2核4G就更拉倒了。透明大页（THP）然而优化还不止于此，我们还有最后一个手段：优化系统调用。HotSpot虚拟机管理内存时不使用系统调用，但是Azul Zing不一样。因此我们还可以开启内核的透明大页（Transparent Huge Pages）。Linux内存页面的默认大小是4KB，这个是历史遗留问题，因为早期内存少，而现在内存大了，程序需要的内存也变多了，对应的，页面也就多了。页面多了，页表也就跟着大了，带来的影响就是缺页中断次数增加，拖慢了系统效率。如果能够分配较大的内存页面的话，一次缺页中断就可以获得大量内存，岂不美哉？通过设置透明大页，Linux内核将尝试分配2MB的页面，如果满足不了则退回4KB。但是注意，对于数据库等针对4KB页面做出优化的软件，大页面可能反而会降低程序性能。索性我们的JVM能够从中受益。我使用的是openSUSE，四舍五入算RedHat系列，按照Azul官网的说明开启大页后，使用调优后的参数运行一下测试（10秒晋升，新生代软限制90%），耗时为8分52秒，比优化参数后快了一些，我预期在内存分配率较高的场景下，玩家的体验能够有所上升。当然了，如果你是付费用户的话，可以直接安装Azul Zulu Prime System Tools，这一套工具包含了针对Azul Zing的各种内核优化核工具。总结JVM调优可能是Java编程中最麻烦的一部分，这和其他深奥且复杂的知识一样，大部分时间用不上（回想刚学Java的时候，JVM就像是一个神奇的黑盒子，编译一次，到任何平台上都能运行，多神奇），但知道了会很有用（当面对大负载或者大流量时，你的程序可能并不会如你所愿的运行，而你的代码没有问题，问题在于Java的其他子系统上）。总而言之，本文是在《Java性能优化实践》这本书的启发下撰写的，最终调优的参数就只有两个，额外开启了透明大页：mkdir logs sudo nice -n -20 /home/skyblond/jdks/$JVM_NAME/bin/java \ -Xmx6G -Xms6G -Xlog:gc:logs/gc.log -Xlog:gc* \ -XX:GPGCTimeStampPromotionThresholdMS=10000 \ -XX:GPGCTargetPeakNewGenOccupancyPercent=90 \ -jar fabric-server-launch.jar nogui | tee logs/full.log另外书上说开启GC日志对于性能没有影响，并且十分有助于调优和排查问题，因此这里也开了gc日志。对于ReadyNow技术，我认为没有8核或者16核就不要想了吧，虽然Minecraft的并行程度不高，但是2核和4核真的算是非常资源紧缺了。-全文完-
整个人类社会都在因为疫情开倒车：各国政府出于好意或恶意，无不以疫情为名试图拿走我们曾经有的权力。虽然我个人对此无能为力，但我认为，个体还是应当掌握技术以保护自己的。所以本文来介绍一下如何使用X25519密钥交换算法实现加密通信。当然了，由于我并不是信安专业的，对于密码学这块并没有那么熟，所以这里只是简单的说一说原理。若是哪里说错了，还请读者友善的在评论区中提出。加密的两大阵营首先我们来说说加密算法的两大阵营吧，这是从密钥的结构来说的。对于我们常规使用的加密算法，例如打压缩包的时候，我们可以选择对压缩包加密。这种加密就是对称加密，因为加密解密使用同一个密钥，加密和解密互为逆过程，是对称的。这种加密算法的好处是速度比较快，但坏处是，如果你想把加密后的数据分享给别人，那么你就必须要将密码也一起分享给别人。如果途中这个密码泄露了，那么这个加密就形同虚设了。在实践中，常用的加密算法有AES系列，ChaCha系列等，本文使用我较为习惯的AES-256-GCM。而另一个阵营就是非对称加密，相比对称加密，这种加密算法的加密过程和解密过程是不同的。这种加密算法的钥匙有两种：私钥和公钥。它们成对出现，私钥是必须保护好的秘密数据，而公钥则由私钥计算得出，与私钥对应，但不会泄露私钥，因此可以随意公开出去。当别人想要给你发送数据时，对方首先需要使用你的公钥加密数据，而你接收到数据后，使用自己的私钥解密数据。这样一来，你的秘密私钥压根不需要发送出去，就大大减少了私钥泄露的可能性。但是非对称加密有一个问题：性能非常慢。于是人们想出了一个好办法：先用非对称加密算法传输/协商一个比较短的、用于对称加密的密钥，再使用这个密钥进行对称加密。由于一开始的非对称加密不需要泄露私钥，是安全的，从而保证交换的加密密钥也是安全的，再使用这个密钥进行对称加密，通信的内容还是安全的，而且性能更高！非对称加密的两大阵营在说密钥交换之前，这里还想横插一杠，介绍一下非对称加密的两大主要阵营。它们分别是基于素数的RSA，和基于椭圆曲线的ECC。RSA是一个非常老牌的非对称加密算法了，到现在（2022年）我的网站证书使用的仍然是RSA算法。RSA算法的基本原理就是利用大整数难以在多项式时间内被分解出因子来保证安全，因此，只要RSA密钥的长度足够长，别人就拿你没办法（目前推荐使用2048位以上的长度，我个人倾向于4096位）。并且在对抗量子攻击上（使用量子计算机破解密钥），RSA做得更好（可能是因为量子计算机也对着素数和分解因子发愁）。不过从另一个角度来说，现阶段而言，量子计算机只有国家或商业资助的大学和研究所才有，并且用起来也比较繁琐（IBM曾经提供过免费的量子计算试用，但我不会用）。国家或商业巨人来搞我，我配吗？我不配。所以抗量子攻击并不在我的主要威胁模型里。而另一个阵营则是ECC，椭圆曲线加密算法。这种算法根据选择的曲线不同，各有各的特点。本文所使用的25519曲线，是由著名密码学家Daniel J. Bernstein设计的，据说设计的初衷是因为由美国国家机构设计的曲线在参数选取上不透明，可能存在后门，因此这位密码学家一气之下自己设计了一条公开透明而且还性能好的25519曲线。只不过这条曲线用起来比RSA稍微麻烦一些。对于加密和签名，RSA可以使用一套密钥，而25519则不行，基于25519的密钥交换算法称为X25519，而使用25519曲线的签名算法称为Ed25519，二者密钥长度相同，但要求却不同：Ed25519密钥来者不拒，只要够长就行，但X25519对密钥的特定位有要求，并且公钥的计算方式与Ed25519不同，使得二者无法相互替代。也就是说，如果同时需要加密和签名的话，使用25519曲线需要有两套私钥和公钥，但好在本文只用加密。本文青睐ECC的另一个原因是，不考虑量子攻击的情况下，ECC的密钥长度通常比RSA的短，并能够提供相似的安全性。例如25519曲线使用256位的密钥（不可能更改）提供的安全强度（Security bits）接近128位（实际为126位），而要达到这种强度，RSA需要使用3072位；而448曲线使用448位密钥就可以提供224位的安全强度，而要想达到此强度，RSA的密钥长度需要达到一万多位（7680位RSA密钥的强度时192位，15360位RSA密钥的强度是256位）。因此当你想要交换公钥时，你肯定想要尽快结束这个过程，否则一旦被其他人注意到，交换过程就可能会受阻，因此考虑同等安全性，ECC的密钥更短，交换更快，更适合实际使用。密钥交换算法关于密钥交换算法，最简单的可以从Diffie-Hellman算法说起，该算法基于RSA密钥，利用素数的整数模n乘法群及其原根实现。所谓“整数模n乘法群”，就是一堆数（a,b,c,d...），他们与n互质，并且在模n后结果相同，这些数就是整数模n乘法群的成员。这里就不详细说明具体是怎么算的了，简单地说，可以通过互换RSA公钥实现“用自己的私钥和对方的公钥计算出相同的结果”。还是以Alice和Bob为例，它们在安全的环境下（例如现实中阴暗无人的小巷）交换了双方的公钥，回家后，Alice使用自己的私钥和Bob的公钥，通过DH算法计算出了一个数，而Bob回家后用自己的私钥和Alice的公钥也计算出了一个数，DH算法保证他们俩算出来的数是相同的，又因为这个过程涉及到无人知晓的对方私钥，因此一旦公钥安全的交换了，Alice和Bob就具备了安全通信的能力（使用计算出来的数作为对称加密的密钥）。如果把这种算法推广到椭圆曲线上，我们就得到了所谓的ECDH，椭圆曲线密钥交换算法。使用25519曲线的密钥交换算法称作X25519，这里我们使用Java的BouncyCastle库来实现。实现这里我们想做一个完整的程序，即从生成密钥到加密通信，步骤如下：双方生成私钥根据私钥计算X25519公钥双方交换X25519公钥计算公共秘密（Shared secret）使用这个公共密钥进行加密通信使用BouncyCastle的话，一个缺点就是如果你想直接使用API，那文档就会比较少，使用JCE（Java加密扩展），你就必须忍受用字符串传参的丑陋代码，但文档确实多，而且好用。然而我比较固执，我宁愿自己研究，也要代码好看。所以本文使用纯BouncyCastle实现，除了java.security.SecureRandom之外就没有任何Java密码学的东西了。编程语言使用Kotlin，但是Java的话问题也不大，偏爱Kotlin就是因为语法糖非常舒服。首先当然是定一个简单的框架：使用org.bouncycastle.crypto.params.X25519PrivateKeyParameters生成X25519私钥并计算公钥，提取成字节数组方便保存、传输使用org.bouncycastle.crypto.params.X25519PublicKeyParameters从字节数组中还原别人的公钥使用org.bouncycastle.crypto.agreement.X25519Agreement进行X25519密钥交换使用org.bouncycastle.crypto.modes.GCMSIVBlockCipher和org.bouncycastle.crypto.engines.AESEngine进行AES-256-GCM加密解密，其中使用某种方法产生不会重复的nonce作为AEAD的参数生成私钥计算公钥当然好办： private val secureRandom = SecureRandom() private fun generatePrivateKey(): ByteArray = X25519PrivateKeyParameters(secureRandom).encoded private fun generatePublicKey(privateKeyBytes: ByteArray): ByteArray = X25519PrivateKeyParameters(privateKeyBytes).generatePublicKey().encoded生成私钥后直接用getEncoded()把公钥编码成字节数组，公钥也是类似，由私钥计算出来后以字节数组的形式表示。有了公钥私钥，就可以考虑怎么协商X25519了： private fun doECDH(selfPrivateKeyBytes: ByteArray, remotePublicKeyBytes: ByteArray): ByteArray { val agreement = X25519Agreement() val result = ByteArray(agreement.agreementSize) agreement.init(X25519PrivateKeyParameters(selfPrivateKeyBytes)) agreement.calculateAgreement(X25519PublicKeyParameters(remotePublicKeyBytes), result, 0) return result }这里先用自己的私钥初始化X25519Agreement，然后使用别人的公钥计算协商的秘密，结果是32字节（256位），正好可以用来做AES 256位加密： private fun encryptMessage(key: ByteArray, message: ByteArray): Pair<ByteArray, ByteArray> { val cipher = GCMSIVBlockCipher(AESEngine()) // mac size is ranged from 32~128 bits, for message integrity check // nonce can be any size, but must not be reused // the SIV will have more security when nonce is reused // but the max message it can handle is 2^31 - 24 bytes val nonce = ByteArray(12) secureRandom.nextBytes(nonce) cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce)) val result = ByteArray(cipher.getOutputSize(message.size)) val resultSize = cipher.processBytes(message, 0, message.size, result, 0) cipher.doFinal(result, resultSize) return result to nonce }这里选择的加密方式是AES-256-GCM，因为GCM是一个AEAD加密，而AEAD可以同时用于加密和完整性验证。换言之如果消息在传输过程中被篡改，这种加密可以探测到这种篡改。这种加密方式有三个参数：加密用的密钥，keyMAC（Message Authentication Code）大小，一般取最大值128，从而尽可能检测消息被篡改nonce，由于GCM是一种计数器模式的加密算法，因此需要一个值（偏移量）来确保同一个密钥在加密不同块时有所变化。重复使用nonce可能会导致算法的安全性受到破坏为了减小重复使用nonce带来的安全隐患，这里我使用了GCM-SIV变体，该变体在nonce被重用时提供了更好的抵抗力，但代价是BouncyCastle的GCM-SIV实现最大只能支持$2^{31}-24$字节，如果超出这个大小，则必须分多次加密，但好在这个就是个演示，单次发送并不会有那么大的数据量，有的话，限制一下就是了。接下来是解密： private fun decryptMessage(key: ByteArray, encryptedMessage: ByteArray, nonce: ByteArray): ByteArray { val cipher = GCMSIVBlockCipher(AESEngine()) cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce)) val result = ByteArray(cipher.getOutputSize(encryptedMessage.size)) val resultSize = cipher.processBytes(encryptedMessage, 0, encryptedMessage.size, result, 0) cipher.doFinal(result, resultSize) return result }最后方便打印字节数组，我们把它用Hex表示出来： private fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }最后的最后，我们把这些功能串起来做一个演示： @JvmStatic fun main(args: Array<String>) { val serverPrivateKeyBytes = generatePrivateKey() println("Server x25519 private key: " + serverPrivateKeyBytes.toHex()) val clientPrivateKeyBytes = generatePrivateKey() println("Client x25519 private key: " + clientPrivateKeyBytes.toHex()) // test key exchange val serverECDHPublicKey = generatePublicKey(serverPrivateKeyBytes) println("Server x25519 public key: " + serverECDHPublicKey.toHex()) val clientECDHPublicKey = generatePublicKey(clientPrivateKeyBytes) println("Client x25519 public key: " + clientECDHPublicKey.toHex()) val serverECDHResult = doECDH(serverPrivateKeyBytes, clientECDHPublicKey) println("Server x25519 result: " + serverECDHResult.toHex()) val clientECDHResult = doECDH(clientPrivateKeyBytes, serverECDHPublicKey) println("Client x25519 result: " + clientECDHResult.toHex()) require(serverECDHResult.contentEquals(clientECDHResult)) // test encryption val message = "This is a message. 中文消息。".encodeToByteArray() println("Message: " + message.toHex()) val (serverSendEncryptedMessage, serverSendNonce) = encryptMessage(serverECDHResult, message) println("Server sent message: " + serverSendEncryptedMessage.toHex()) println("Server sent nonce: " + serverSendNonce.toHex()) val clientDecrypt = decryptMessage(clientECDHResult, serverSendEncryptedMessage, serverSendNonce) println("Client decrypt: " + clientDecrypt.toHex()) require(message.contentEquals(clientDecrypt)) }输出如下：Server x25519 private key: f068efa402d7e8b539ad7964c5f065cd0310f48b49716580c6d4201e3ed6bc45 Client x25519 private key: 9035a47bf3b071ff26519a1dfe48a2b0bd61fdea625e8c02069422bfc1c3bc75 Server x25519 public key: 68466086a2c7481f084e138ea6e6310c19b9bcf39b0362443e0f91ed6123996f Client x25519 public key: d13382775b7288e4bbddac313237231eeacead8abaa16efcbf49f8d332d27c48 Server x25519 result: b88bb584f2970e04369dbfc8f745cf8d0c0631684ef237a4b32131ccb061e65c Client x25519 result: b88bb584f2970e04369dbfc8f745cf8d0c0631684ef237a4b32131ccb061e65c Message: 546869732069732061206d6573736167652e20e4b8ade69687e6b688e681afe38082 Server sent message: f54f6f67fac24535bcf8da3b0a61997375e49ba444ffe5cf0b3bb2a3d6a46870a9072214b178e298be94cb825ad0d22f0f36 Server sent nonce: 6fe6165722959386b03885e6 Client decrypt: 546869732069732061206d6573736167652e20e4b8ade69687e6b688e681afe38082可以看到，服务器和客户端只用自己的私钥和对方的公钥就实现了X25519协商，并以此进行对称加密的通信。完整代码package info.skyblond.crypto import org.bouncycastle.crypto.agreement.X25519Agreement import org.bouncycastle.crypto.engines.AESEngine import org.bouncycastle.crypto.modes.GCMSIVBlockCipher import org.bouncycastle.crypto.params.AEADParameters import org.bouncycastle.crypto.params.KeyParameter import org.bouncycastle.crypto.params.X25519PrivateKeyParameters import org.bouncycastle.crypto.params.X25519PublicKeyParameters import java.security.SecureRandom /** * This class demonstrate how to perform a X25519 key exchange with pure BouncyCastle. * */ object X25519WithPureBC { private val secureRandom = SecureRandom() /** * Generate private key depends on curve. * */ private fun generatePrivateKey(): ByteArray = X25519PrivateKeyParameters(secureRandom).encoded /** * Generate the public key for ECDH key exchange. * */ private fun generatePublicKey(privateKeyBytes: ByteArray): ByteArray = X25519PrivateKeyParameters(privateKeyBytes).generatePublicKey().encoded private fun doECDH(selfPrivateKeyBytes: ByteArray, remotePublicKeyBytes: ByteArray): ByteArray { val agreement = X25519Agreement() val result = ByteArray(agreement.agreementSize) agreement.init(X25519PrivateKeyParameters(selfPrivateKeyBytes)) agreement.calculateAgreement(X25519PublicKeyParameters(remotePublicKeyBytes), result, 0) return result } /** * Return: (Encrypted message, nonce) * */ private fun encryptMessage(key: ByteArray, message: ByteArray): Pair<ByteArray, ByteArray> { val cipher = GCMSIVBlockCipher(AESEngine()) // mac size is ranged from 32~128 bits, for message integrity check // nonce can be any size, but must not be reused // the SIV will have more security when nonce is reused // but the max message it can handle is 2^31 - 24 bytes val nonce = ByteArray(12) secureRandom.nextBytes(nonce) cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce)) val result = ByteArray(cipher.getOutputSize(message.size)) val resultSize = cipher.processBytes(message, 0, message.size, result, 0) cipher.doFinal(result, resultSize) return result to nonce } private fun decryptMessage(key: ByteArray, encryptedMessage: ByteArray, nonce: ByteArray): ByteArray { val cipher = GCMSIVBlockCipher(AESEngine()) cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce)) val result = ByteArray(cipher.getOutputSize(encryptedMessage.size)) val resultSize = cipher.processBytes(encryptedMessage, 0, encryptedMessage.size, result, 0) cipher.doFinal(result, resultSize) return result } private fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } @JvmStatic fun main(args: Array<String>) { val serverPrivateKeyBytes = generatePrivateKey() println("Server x25519 private key: " + serverPrivateKeyBytes.toHex()) val clientPrivateKeyBytes = generatePrivateKey() println("Client x25519 private key: " + clientPrivateKeyBytes.toHex()) // test key exchange val serverECDHPublicKey = generatePublicKey(serverPrivateKeyBytes) println("Server x25519 public key: " + serverECDHPublicKey.toHex()) val clientECDHPublicKey = generatePublicKey(clientPrivateKeyBytes) println("Client x25519 public key: " + clientECDHPublicKey.toHex()) val serverECDHResult = doECDH(serverPrivateKeyBytes, clientECDHPublicKey) println("Server x25519 result: " + serverECDHResult.toHex()) val clientECDHResult = doECDH(clientPrivateKeyBytes, serverECDHPublicKey) println("Client x25519 result: " + clientECDHResult.toHex()) require(serverECDHResult.contentEquals(clientECDHResult)) // test encryption val message = "This is a message. 中文消息。".encodeToByteArray() println("Message: " + message.toHex()) val (serverSendEncryptedMessage, serverSendNonce) = encryptMessage(serverECDHResult, message) println("Server sent message: " + serverSendEncryptedMessage.toHex()) println("Server sent nonce: " + serverSendNonce.toHex()) val clientDecrypt = decryptMessage(clientECDHResult, serverSendEncryptedMessage, serverSendNonce) println("Client decrypt: " + clientDecrypt.toHex()) require(message.contentEquals(clientDecrypt)) } }-全文完-
最近在用Java写智能合约，其中有一个静态字段是合约的管理员地址，这个地址用于鉴权，确保只有该地址能够对合约进行管理操作（升级、销毁等）。但是为了做到自动化测试，这个管理员地址在develop分支上是私链的，而实际部署的时候得换成公链上的另一个地址。前几天升级合约的时候忘记替换地址，直接把私链地址部署到了公开的测试网了。好在是测试网，问题不大，这要是主网就别玩了，重开吧。为了杜绝这样的后顾之忧，本文决定探索一种方法，能够一份代码按照使用情况填入不同的地址来生成合约，避免将地址写死在代码里产生问题。背景具体来说，这个区块链是Neo N3区块链，其中Java的语言支持由Neow3j这个库提供。这个库允许开发者使用任意JVM语言开发合约，并根据最终编译的Java字节码生成Neo的可执行合约。因此上文提到的合约就是使用Java开发的，没有用Kotlin的原因就是怕翻车。典型的合约管理员地址是将其作为一个静态字段实现的：public class TestContract { static Hash160 CONTRACT_OWNER = addressToScriptHash("NR9pwfToFHHYti4RY1dLP3wGr4waSGn7NH"); }其中NR9pwfToFHHYti4RY1dLP3wGr4waSGn7NH是用于测试的地址，其私钥随着代码一并公开到GitHub仓库，显然这种公开的钱包地址并不适宜作为公链的管理地址使用，但对于自动化测试来说，让程序获取到地址的私钥并进行自动调用，这可比手工测试轻松多了。其中的addressToScriptHash函数是Neow3j编译器提供的一个本地方法，它没有具体实现，而是在编译为合约的时候，由编译器将里面的字符串解读为NeoVM能够理解的钱包地址。这种方法最直观，官方的教程也是这样写的。看起来没什么问题，但涉及到实际使用和开发，就如同我所说的，自动化测试要求密钥开放，而实际部署要求密钥必须保密，如果在开发和部署两个分支上频繁修改这个地址，不美观且麻烦，最重要的是一旦忘记，会导致很大的安全隐患。另一个解决方法是在合约中获取当前网络的MagicNumber来判断网络类型，由合约自动决定使用哪一个地址。由于每个网络的魔数都是固定的，并且公共测试网和主网的数字都是固定不变的，这种方法相对可行，但额外的代码会给执行阶段带来额外的手续费（NeoN3的智能合约是按指令收费的），并且同时写两个地址，并不美观，也不优雅。解决为了美观优雅，我决定探索ObjectWeb ASM库，这个库用于直接操作底层的Java字节码。谈及Java，我的印象总是诸如“抽象”、“不关心底层硬件”、“面向Java虚拟机”之类的，所以我们在编写Java的时候只需要关注逻辑上的实现，而不必关心这套代码是否会在不同架构的CPU上有不同的行为。而能够支持这种功能得，正是Java虚拟机和Java字节码的配合，所有运行在Java虚拟机上的语言，包括Java、Kotlin，最终都要编译成Class文件。而这个Class文件中就包含了即将执行的Java字节码，可以理解为面向Java虚拟机的汇编语言。字节码初探以如下类为例：package info.skyblond.jvm.asm; public class ASMTargetClass { static Pair<String, String> target = genPair("Something", "Nothing"); String local = "local"; static String staticDirect = "staticDirect"; private static Pair<String, String> genPair(String s1, String s2) { return new Pair<>(s1, s2); } }其中的Pair<>是这个类：package info.skyblond.jvm.asm; public class Pair <T, U>{ private final T first; private final U second; public Pair(T first, U second) { this.first = first; this.second = second; } public T getFirst() { return first; } public U getSecond() { return second; } }编译ASMTargetClass会得到文件ASMTargetClass.class，和其他可执行文件一样，我们可以直接用16进制编辑器打开它看内容，这种方法可以但是并不推荐。在Linux下我们会使用readelf之类的工具来查看可执行文件的信息。对应的，我们可以使用javap命令查看编译好的class文件：javap -c path/to/class/file其中-c表示我们要反汇编，这样我们就能在控制台中看到结果了，另外如果使用IDEA的话，可以安装ASM Bytecode Viewer这个插件，直接在IDEA里面查看。除了字节码之外，插件还会进行额外的处理，比如查询字节码版本，字段的访问控制，将常量拼接到字节码中。这里我们只关心字节码：public class info.skyblond.jvm.asm.ASMTargetClass { static info.skyblond.jvm.asm.Pair<java.lang.String, java.lang.String> target; java.lang.String local; static java.lang.String staticDirect; public info.skyblond.jvm.asm.ASMTargetClass(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/O bject."<init>":()V 4: aload_0 5: ldc #2 // String local 7: putfield #3 // Field local:Ljava/lang/String; 10: return static {}; Code: 0: ldc #6 // String Something 2: ldc #7 // String Nothing 4: invokestatic #8 // Method genPair:(Ljava/lang/String;Ljava/lang/String;)Linfo/skyblond/jvm/asm/Pair; 7: putstatic #9 // Field target:Linfo/skyblond/jvm/asm/Pair; 10: ldc #10 // String staticDirect 12: putstatic #11 // Field staticDirect:Ljava/lang/String; 15: return }可以看到，反编译给出了三个变量和两个函数。三个变量正是我们在代码中定义的，而两个函数，好像都不太像我们定义的那个genPair()，仔细观瞧得话，可以发现Code那一节正是对象的<init>方法，也就是在JVM创建对象后真正用来初始化对象的代码。首先它调用了Object的<init>方法，毕竟Object是万物之父嘛。然后通过ldc将常量池中的#2压入栈（在此之前还通过指令aload_0将this压入了栈），随后putfield就将常量池中#3代表的局部变量赋值为刚刚压入栈的#2。而后面的static {}，就是初始化类的代码，这个代码只有在类被加载的时候才会执行。比如先从常量池加载了#6和#7，然后调用了一个static方法，之后又把这个栈顶（也就是刚刚调用的那个方法的返回值）赋值给#9，再从常量池中加载#10，付给静态变量#11，最后返回。结合后面的注释来看，这个注释是javap自动生成的，在ldc指令后面它标出了加载的值，比如最后一个ldc #10，加载的正是我们写在代码里的字符串staticDirect。但是我们写的genPair方法还是没有找到。如果我们在javap命令中添加-private标志，就可以看到这个方法了：public class info.skyblond.jvm.asm.ASMTargetClass { // ... private static info.skyblond.jvm.asm.Pair<java.lang.String, java.lang.String> genPair(java.lang.String, java.lang.String); Code: 0: new #4 // class info/skyblond/jvm/asm/Pair 3: dup 4: aload_0 5: aload_1 6: invokespecial #5 // Method info/skyblond/jvm/asm/Pair."<init>":(Ljava/lang/Object;Ljava/lang/Object;)V 9: areturn // ... }实际上，如果我们使用刚刚提到的插件，会得到更友好的输出：// class version 55.0 (55) // access flags 0x21 public class info/skyblond/jvm/asm/ASMTargetClass { // compiled from: ASMTargetClass.java // access flags 0x8 // signature Linfo/skyblond/jvm/asm/Pair<Ljava/lang/String;Ljava/lang/String;>; // declaration: target extends info.skyblond.jvm.asm.Pair<java.lang.String, java.lang.String> static Linfo/skyblond/jvm/asm/Pair; target // access flags 0x0 Ljava/lang/String; local // access flags 0x8 static Ljava/lang/String; staticDirect // access flags 0x1 public <init>()V L0 LINENUMBER 3 L0 ALOAD 0 INVOKESPECIAL java/lang/Object.<init> ()V L1 LINENUMBER 5 L1 ALOAD 0 LDC "local" PUTFIELD info/skyblond/jvm/asm/ASMTargetClass.local : Ljava/lang/String; RETURN L2 LOCALVARIABLE this Linfo/skyblond/jvm/asm/ASMTargetClass; L0 L2 0 MAXSTACK = 2 MAXLOCALS = 1 // access flags 0xA // signature (Ljava/lang/String;Ljava/lang/String;)Linfo/skyblond/jvm/asm/Pair<Ljava/lang/String;Ljava/lang/String;>; // declaration: info.skyblond.jvm.asm.Pair<java.lang.String, java.lang.String> genPair(java.lang.String, java.lang.String) private static genPair(Ljava/lang/String;Ljava/lang/String;)Linfo/skyblond/jvm/asm/Pair; L0 LINENUMBER 8 L0 NEW info/skyblond/jvm/asm/Pair DUP ALOAD 0 ALOAD 1 INVOKESPECIAL info/skyblond/jvm/asm/Pair.<init> (Ljava/lang/Object;Ljava/lang/Object;)V ARETURN L1 LOCALVARIABLE s1 Ljava/lang/String; L0 L1 0 LOCALVARIABLE s2 Ljava/lang/String; L0 L1 1 MAXSTACK = 4 MAXLOCALS = 2 // access flags 0x8 static <clinit>()V L0 LINENUMBER 4 L0 LDC "Something" LDC "Nothing" INVOKESTATIC info/skyblond/jvm/asm/ASMTargetClass.genPair (Ljava/lang/String;Ljava/lang/String;)Linfo/skyblond/jvm/asm/Pair; PUTSTATIC info/skyblond/jvm/asm/ASMTargetClass.target : Linfo/skyblond/jvm/asm/Pair; L1 LINENUMBER 6 L1 LDC "staticDirect" PUTSTATIC info/skyblond/jvm/asm/ASMTargetClass.staticDirect : Ljava/lang/String; RETURN MAXSTACK = 2 MAXLOCALS = 0 }除了字节码之外，插件还会帮我们添加上对应的行号、栈最大大小和局部变量最大个数等信息，同时还会将常量池中#x的引用替换成实际的内容，比如LDC #10变成了LDC "staticDirect"。ASM入门这样一来我们就能够对应上了，如果要修改某一个常量的值，我们只需要修改对象初始化，或者类初始化时的代码，让ldc加载一个不同的常量就可以了。改动的方法有两种：一种是修改常量池，指令不变；另一种是新增一个常量，然后让指令加载新的常量。对于ASM库来说，它提供了两种面向对象的访问方式，一种叫做Visitor API，另一种叫做Tree API。两种API各有优劣，而对于我们的需求来说，两种API都可以胜任。Visitor APIVisitor API以遍历的思想实现：例如我们要将一个class中的字符串常量替换，产生一个新的class，那么我们需要定义一个Visitor，决定在遇到各种事件时执行什么动作，而新的class就在遍历时产生了。例如遇到普通的方法或函数，我们原封不动的复述这个方法或函数，而一旦遇到我们要修改的函数，我们就替换成自己的Visitor；当遇到其他指令的时候我们复述这个指令，而一旦遇到LDC指令，我们就检查原来加载的值是不是我们要替换的，是就替换成新的值，不是就复述这个指令，写到新的class中去。代码如下：package info.skyblond.jvm.asm; import org.objectweb.asm.*; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import static org.objectweb.asm.Opcodes.ASM9; public class VisitorDemo extends ClassVisitor { public VisitorDemo(ClassVisitor cv) { super(ASM9, cv); this.cv = cv; } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions); if (name.equals("<clinit>")) { return new MethodVisitor(ASM9, mv) { @Override public void visitLdcInsn(Object value) { if ("Something".equals(value)) value = "Replace!"; super.visitLdcInsn(value); } }; } return mv; } public static void main(String[] args) throws IOException, InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { String className = ASMTargetClass.class.getCanonicalName(); ClassReader reader = new ClassReader(className); ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); VisitorDemo visitor = new VisitorDemo(writer); reader.accept(visitor, ClassReader.EXPAND_FRAMES); byte[] classData = writer.toByteArray(); Class clazz = new MyClassLoader().defineClass(className, classData); Object personObj = clazz.getDeclaredConstructor().newInstance(); Field targetField = clazz.getDeclaredFields()[0]; targetField.setAccessible(true); Pair<String, String> p = (Pair<String, String>) targetField.get(personObj); System.out.println(p.getFirst()); System.out.println(p.getSecond()); System.out.println(ASMTargetClass.target.getFirst()); System.out.println(ASMTargetClass.target.getSecond()); } }首先我们通过继承ClassVisitor来实现我们自己的Visitor。这个Visitor使用ASM9标准，这里我用的库是org.ow2.asm:asm:9.2。ClassVisitor提供了许多可以覆盖的方法，比如VisitField、visitMethod之类的方法，他们分别在访问成员和方法时被调用，由于我们的目标是<clinit>方法，也就是类初始化时的代码，因此这里只覆盖了visitMethod。 @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { System.out.println("Visit method: " + name); MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions); if (name.equals("<clinit>")) { return new MethodVisitor(ASM9, mv) { @Override public void visitLdcInsn(Object value) { if ("Something".equals(value)) value = "Replace!"; super.visitLdcInsn(value); } }; } return mv; }在这个方法中，首先我们先用外部的ClassVisitor进行访问，访问结果存在mv变量里，随后判断，如果方法的名字是<clinit>，那我们就返回一个自己创建的MethodVisitor，这个Visitor的默认行为由刚刚的mv提供，我们只覆盖处理Ldc指令的逻辑，即覆盖了visitLdcInsn方法，在这个方法中，我们判断value和字符串“Something”是否一致，如果一致就把它的值就改为“Replaced!”，然后调用父类的默认实现（这里的默认实现是ClassWriter，稍后讲解main的时候会说，它的默认行为就是在访问时把访问的指令写到新的class里，即复述）。随后就是主函数。 String className = ASMTargetClass.class.getCanonicalName(); ClassReader reader = new ClassReader(className); ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); VisitorDemo visitor = new VisitorDemo(writer); reader.accept(visitor, ClassReader.EXPAND_FRAMES); byte[] classData = writer.toByteArray(); Class clazz = new MyClassLoader().defineClass(className, classData); Object personObj = clazz.getDeclaredConstructor().newInstance(); Field targetField = clazz.getDeclaredFields()[0]; targetField.setAccessible(true); Pair<String, String> p = (Pair<String, String>) targetField.get(personObj); System.out.println(p.getFirst()); System.out.println(p.getSecond()); System.out.println(ASMTargetClass.target.getFirst()); System.out.println(ASMTargetClass.target.getSecond());这里我们获取到了要替换的类的全名，然后用它创造了一个ClassReader。之后我们还创建了一个ClassWriter，它类似于我们平时用的Writer，能够向其中写入数据。最后我们实例化我们自定义的Visitor，因为他要以writer作为默认实现，就是说我们没有动的地方，就按照ClassWriter的实现原封不动的写入到新的class中。对于我们覆盖了的方法，按照我们写的代码执行，这样我们就能够复述原来的Class，并在我们想要的地方做出修改。之后使用了一个自定义的ClassLoader将写好的新class加载进来，因为我们这个Class和原来的Class是一个名字，并且没有对应的文件，所以不能直接让默认的加载器干活。其实现如下：package info.skyblond.jvm.asm; public class MyClassLoader extends ClassLoader { public final Class<?> defineClass(String name, byte[] b) throws ClassFormatError { return defineClass(name, b, 0, b.length); } }其实就是把字节数组转换成一个Class对象，之后利用反射即可拿到我们声明的第一个变量（也就是静态的target变量）的值，将他转换为Pair<String, String>类型后就可以正常操作了。最后四行代码打印了修改前后的不同：Replace! Nothing Something Nothing可以看到通过反射获取到的修改的类，它的Something已经被替换成了Replace!，而后面的Nothing和原来的一致。对于合约来说，我们不需要自定义类加载器，因为合约的编译器允许我们给他一个Class文件的InputStream，它会通过这个InputStream读取class并编译成合约，因此我们只需要将新的Class用ByteArrayInputStream包装一下即可。Tree APITree API则是将Class文件转换成一颗树，根节点ClassNode指向了整个树的根节点，而MethodNode则表示某一个方法的根节点，最后还有InsnNode，每一个node表示一条指令。我们可以在程序中找到不同的节点进行不同的操作以实现目的。使用Tree API写的程序比起Visitor API的更为简洁，只需要一个主函数即可：package info.skyblond.jvm.asm; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.MethodNode; import java.lang.reflect.Field; public class TreeDemo { public static void main(String[] args) throws Exception { String className = ASMTargetClass.class.getCanonicalName(); ClassReader reader = new ClassReader(className); ClassNode classNode = new ClassNode(); reader.accept(classNode, ClassReader.EXPAND_FRAMES); for (MethodNode methodNode : classNode.methods) { if (methodNode.name.equals("<clinit>")) { for (AbstractInsnNode insnNode : methodNode.instructions) { if (insnNode.getType() == AbstractInsnNode.LDC_INSN) { LdcInsnNode node = (LdcInsnNode) insnNode; if (node.cst.equals("Something")) { node.cst = "Replace!"; } } } } } ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); classNode.accept(writer); byte[] classData = writer.toByteArray(); Class clazz = new MyClassLoader().defineClass(className, classData); Object personObj = clazz.getDeclaredConstructor().newInstance(); Field targetField = clazz.getDeclaredFields()[0]; targetField.setAccessible(true); Pair<String, String> p = (Pair<String, String>) targetField.get(personObj); System.out.println(p.getFirst()); System.out.println(p.getSecond()); System.out.println(ASMTargetClass.target.getFirst()); System.out.println(ASMTargetClass.target.getSecond()); } }可以看到主函数的后半部分几乎Visitor API的一模一样，不同指出在于前半部分： ClassReader reader = new ClassReader(className); ClassNode classNode = new ClassNode(); reader.accept(classNode, ClassReader.EXPAND_FRAMES); for (MethodNode methodNode : classNode.methods) { if (methodNode.name.equals("<clinit>")) { for (AbstractInsnNode insnNode : methodNode.instructions) { if (insnNode.getType() == AbstractInsnNode.LDC_INSN) { LdcInsnNode node = (LdcInsnNode) insnNode; if (node.cst.equals("Something")) { node.cst = "Replace!"; } } } } }首先通过ClassReader读取要修改的类，然后创建一个ClassNode，让reader将类读取的结果转换成ClassNode，然后我们遍历classNode的方法节点，如果找到了名为<clinit>的节点，那么我们就开始遍历这个方法的指令节点。通过判别每一个insnNode的类型（注意是Type而不是OpCode），如果是LDC_INSN，即LDC指令，我们需要将他的类型转换为LdcInsnNode，而这种node的cst字段即是该字段载入的常量的值，它可以是String，可以是Integer，也可以是其他类型。总之我们找到我们想要修改的值之后，可以直接修改成目标值，然后利用如下代码将修改后的classNode写成class即可： ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); classNode.accept(writer); byte[] classData = writer.toByteArray();后面的验证代码和Visitor API的一样，而效果也是一样的。-全文完-
10月用一整月写完了一篇论文，因此也没能兑现月更的承诺，实属丢人现眼。本文将总结10月以及最近这几天的心得与体会，并希望能够给更多遭遇同样境况的人一些方向。序尽管现在在深度学习方面最火热的编程语言是Python，但作为高贵的Java的程序员，使用全动态的Python语言有害于我的纯粹性。因此在前些日子赶论文的时候，一大部分时间都花在了使用JVM进行深度学习上。从一开始的无数次Python入门失败，到今天能够写成文章总结JVM上进行深度学习的经验，我认为这比写论文更有意思，毕竟Java天下第一，在应用领域我还是最支持JVM。 警告 本文对Python持有强烈的偏见，如有不适请及时关闭网页。 引言尽管序和引言在中文颇有重复的嫌疑，但即便如此我还是要将他们分开。在本节我将试图阐述为什么Python做机器学习很火，而Java上的深度学习一直不温不火。这里需要强调的是本文所说的深度学习，是通常需要显卡进行训练的任务，而不是传统的机器学习。在传统机器学习领域，我认为Java的Weka库和Oracle的tribuo都已经做的非常优秀了。关于Python在机器学习领域的火爆，网络上有那么几种说法，我听到的一种说法是CPython对于C系的库调用起来很方便，因为机器学习库底层都是C/C++写的，比较快，而Java通过JavaCPP或者JNI调用普遍比较慢。但是我觉得这种说法并不稳妥，我认为更实际的原因可能是Python好学，毕竟深度学习的核心在于数学，而搞数学的人在以前能写个Matlab就算不错了，而Python又没有什么严格的工程结构，王八蛋都能学会，故因为便于入门，许多搞理论研究的人使用Python构建他们通往实践的路。另一方面Java则没有这么好入门，各种工程设计的模式以及强类型的束缚，以及JVM的深不可测，使得一些Java程序员写了十几年代码也不敢说自己真正了解Java，因此Java才在深度学习领域不温不火吧。另外值得一说的是Python本身只是一个编程语言规范，而本文所指的Python实际上是其基于C/C++的实现，即CPython，在以前也曾经有过JVM实现的Python，但是其版本止步于2.7，而后来Oracle的GraaleVM也试图打通Python与JVM的隔阂，但就现阶段来说GraalVM上通过Python调用C的API还仍然在开发中。Support for more extension modules is high priority for us. We are actively building out our support for the Python C API to make extensions such as NumPy, SciPy, Scikit-learn, Pandas, Tensorflow and the like work fully. This work means that some other extensions might also already work, but we're not actively testing other extensions right now and cannot promise anything. Note that to try other extensions on this implementation, you have to download, build, and install them manually for now.支持更过拓展模块是我们最先要做的。我们正在努力开发Python的C语言API，使得诸如NumPy、SciPy、Scikit-learn、Pandas、Tensorflow之类的框架完全正常工作。这些工作意味着其他一些扩展也可能已经起作用，但是我们现在不积极测试这些扩展，也无法做出任何保证或承诺。 请注意，要尝试此实现的其他扩展，您现在必须手动下载，构建和安装它们。来源：Github - GraalVM翻译过来就是GraalVM的Python支持在做，也许能用，但是不稳定。尽管现阶段没有任何应用的可能（我不希望在代码中引入任何不稳定性），但是我还是很看好GraalVM，除了Python，它还有一个Native Image，能够将Java等其支持的语言编译为静态镜像，十分值得期待。但话说回来，DDL不等人，我们没法等着GraalVM开发完毕再去使用Java调用Python的代码搞深度学习。因此本文将就现阶段可用的，且好用的、符合Java程序员习惯的一些深度学习框架进行介绍与对比。最后还需要提一点，Python上面的深度学习库依赖于其底层的C++库，原则上可以通过JNI调用他们去做JVM的深度学习，但是很显然这有不是很优雅，我也不认同他是JVM上的深度学习。但后续介绍的一些库也使用，甚至是直接使用的Python深度学习库的C++库，但那样我认为可以是JVM上的深度学习。至于这二者之间的区别，还请读者自行拿捏。不适用的选项本节介绍一些对我现有环境不可用的选项，这些选项或许可以在其他环境中正常工作。我的环境是Windows 10.MXNet Scala EditionApache的MXNet在主要面向Python语言之外，还额外的支持了7种语言，其中JVM语言占了3种，分别是Java、Scala和Clojure。其中Java语言的API只能用于现有模型的部署，不能用于训练，Scala语言的API可以进行训练，但只支持CPU，而Clojure版本的API则是对Scala API的再封装。我选择使用Scala版本，尽管我对SBT一无所知，但还是在学习之初（在Hello World之前）给官方提了一个Issue。后来发现Scala版本的API不支持Windows，只好作罢。Tensorflow Scala接着Scala的热度，自然想到的就是Tensorflow在Scala上的非官方封装。对于封装的Scala API本身并没有什么平台之分，而关键在于Tensorflow的动态库，对于预编译版本，并没有Windows的选项，所以只能从Windows上本地编译Tensorflow。而Windows上编译，反正我是不看好。倘若真的需要在Windows上去编译库，我宁愿再装一个Linux双系统也不要安装微软的Visual Studio。所以这个选项也只好作罢。Tensorflow Java 0.2如果Scala的路走不通，那就剩下官方的Java API了。Tensorflow的Java API最近发布了0.2版，该版本支持使用Java进行模型训练。但是目前来说文档很少，只有官方的一页，而且Tensorflow在2.0版本全面拥抱Keras，使得Java版本的Tensorflow几乎只能从最底层的做起。尽管有tensorflow-framework起到类似Keras的作用，但目前来说还十分不完善。或许还得等它发展一些时间吧。于是Tensorflow Java也只能作罢。适用的选项DeepLearning4JDL4J是目前我实践最多的一个库，这个库完全不依赖Python库（但底层还是C++实现）的、存粹的商业深度学习库。是的，它是一个商业库，背后的公司曾经是SkyMind，后来变更为konduit.ai。它具有所有其他可以商业使用的库的全部特点：诸如完善的文档，规范的代码组织结构，积极的社区支持等，总的来说用于开发项目十分顺手，十分符合一个Java程序员的使用习惯。但另一方面，该库对于自定义拓展并不是很友好。目前该框架中有一个SameDiff，旨在模型中引入你自定义的行为，相对于PyTorch之类的针对普通函数的自动微分，Java实现中最困难的还是类型系统。但是好在这种困难并非只针对于这个框架，回想一下Spring框架中为了迎合MVC架构，一个Hello World的网页都要写5个或者更多的类才能实现，在深度学习上只不过是把论文里的公式实现柔和进了继承与覆盖的过程中罢了。即便如此，我还是很开心能够用这个库完成我的论文，比使用Python快乐多了。为了自定义一个Attention层，首先你需要声明WorkSpace，告诉框架你这个层都需要哪些变量作为参数，这样框架能够根据环境自动在内存或显存种帮你管理这些变量。之后定义运算过程，这个定义只能使用框架提供的有限数学操作，但是你可以使用循环或分支等语句动态的根据参数内容变化最终组合而成的表达式，之后反向传播的时候，框架会根据已知的操作和代码给出的组合自动计算导数等参数。所以其中最困难的地方就是如何面对抽象的对象，将其想象为实际的数据，并利用有限的操作来实现你所要的功能。在这方面我还不是太行，所以至今尚未能实现CRF层（大约半年前），转而使用了Attention。但是随着一篇论文的实践，之后我或许可以做到了。对于这个框架，如果需要出于研究目的使用的话，其自带的一些东西很可能几乎不能够覆盖到你的需求，你需要自己实现。而实现的过程又极度依赖于Java的开发模式，虽然使用Kotlin在语法上简化了相当一部分工作，但如果对于Java本身并不熟练，要拓展出自己的函数还是十分困难的。而且就目前来说，如何在这个框架上实现一个Transformer Block我还毫无头绪。因此我大概是不会选择使用这个框架进行研究性工作的。但如果需要开发商业或工业的项目，这些项目的重点不在于模型有多新，而是要求程序有多稳，那么这个框架绝对会是我的首选。Deep Java LibraryDJL是亚马逊开发的深度学习库。由于其稀少的文档，使得我在写论文之初选择了DL4J，直到论文完结后我才开始重新审视这个库。就这个库本身的设计来说，其底层依赖于四个Python深度学习库之一，可以任选Apache MXNet、PyTorch、TensorFlow和ONNX Runtime之一作为底层，而DJL则提供了一个统一的抽象，我选择PyTorch。在使用时只需要配置好对应的maven依赖，完全不需要自己构建什么，maven依赖中会包含对应底层所需要的C++实现库，因此和DL4J一样无痛使用。当然了，前提是你能找到官方的这些maven包的名称。是的，文档是目前为止这个库最痛苦的地方。你以为在Setup一节中会告诉你如何在Maven或者Gradle中使用这个库？想都别想，只有一段这样的文字：(Optional) Import using Gradle/Maven wrappersYou use Gradle and Maven wrappers to build the project, so you don't need to install Gradle or Maven. However, you should have basic knowledge about the Gradle or Maven build system.嗯？那些依赖的包名呢？？？好在这网页有搜索功能，直接搜索maven，找到了相关页面。总之使用这个库还是挺重度依赖搜索功能的，无论是搜索文档还是搜索谷歌，总归还是有些困难。但是对于库本身来说，因为其底层直接使用各种Python库的依赖，因此在使用上更倾向于已有深度学习库的模样，当然在抽象的过程中会有一些改变，但整体而言不像DL4J那样另起炉灶。在扩展自定义操作方面，可以直接使用LambdaBlock，虽然一样要和DL4J操作一个抽象的对象，但至少在工作量上轻松很多。另外值得一提的是，这个库将模型表示为Block的组合，用起来会和Keras的Sequential十分相似。昨天摸索了一下午，终于拼凑出来了该库的MNIST程序。依赖如下（使用Gradle 6.7）：implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.4.10" implementation group: 'ch.qos.logback', name: 'logback-classic', version: '1.2.3' implementation platform("ai.djl:bom:0.8.0") implementation 'ai.djl:api:0.8.0' implementation "ai.djl:basicdataset:0.8.0" implementation "ai.djl:model-zoo:0.8.0" runtimeOnly 'ai.djl.pytorch:pytorch-engine:0.8.0' // runtimeOnly 'ai.djl.pytorch:pytorch-native-auto:1.6.0' runtimeOnly 'ai.djl.pytorch:pytorch-native-cu102:1.6.0:win-x86_64'训练程序如下（使用Kotlin）：package info.skyblond.djl.demo import ai.djl.Model import ai.djl.basicdataset.Mnist import ai.djl.ndarray.types.Shape import ai.djl.nn.Activation import ai.djl.nn.Blocks import ai.djl.nn.SequentialBlock import ai.djl.nn.core.Linear import ai.djl.training.DefaultTrainingConfig import ai.djl.training.EasyTrain import ai.djl.training.evaluator.Accuracy import ai.djl.training.listener.TrainingListener import ai.djl.training.loss.Loss import java.nio.file.Files import java.nio.file.Paths fun main() { Model.newInstance("MNIST").use { model -> val batchSize = 32 val mnist = Mnist.builder() .setSampling(batchSize, true) .build() val input = 28 * 28 val output = 10 val hiddenLayerSizes = intArrayOf(128, 64) val sequentialBlock = SequentialBlock() sequentialBlock.add(Blocks.batchFlattenBlock(input.toLong())) for (hiddenSize in hiddenLayerSizes) { sequentialBlock.add(Linear.builder().setUnits(hiddenSize.toLong()).build()) sequentialBlock.add(Activation::relu) } sequentialBlock.add(Linear.builder().setUnits(output.toLong()).build()) model.block = sequentialBlock val config = DefaultTrainingConfig(Loss.softmaxCrossEntropyLoss()) .addEvaluator(Accuracy()) .addTrainingListeners(*TrainingListener.Defaults.logging()) val trainer = model.newTrainer(config) trainer.initialize(Shape(1, 28 * 28)) val epoch = 2 for (i in 0 until epoch) { for (batch in trainer.iterateDataset(mnist)) { // During trainBatch, we update the loss and evaluators with the results for the training batch. EasyTrain.trainBatch(trainer, batch) // Now, we update the model parameters based on the results of the latest trainBatch trainer.step() // We must make sure to close the batch to ensure all the memory associated with the batch is cleared quickly. // If the memory isn't closed after each batch, you will very quickly run out of memory on your GPU batch.close() } // Call the end epoch event for the training listeners now that we are done trainer.notifyListeners { listener: TrainingListener -> listener.onEpoch(trainer) } } val modelDir = Paths.get("build/mlp") Files.createDirectories(modelDir) model.setProperty("Epoch", epoch.toString()) model.save(modelDir, "mlp") } }推理（即利用模型做预测）程序（使用Kotlin）：package info.skyblond.djl.demo import ai.djl.Model import ai.djl.basicmodelzoo.basic.Mlp import ai.djl.modality.Classifications import ai.djl.modality.cv.Image import ai.djl.modality.cv.ImageFactory import ai.djl.modality.cv.util.NDImageUtils import ai.djl.ndarray.NDArray import ai.djl.ndarray.NDList import ai.djl.translate.Batchifier import ai.djl.translate.Translator import ai.djl.translate.TranslatorContext import java.nio.file.Paths import java.util.stream.Collectors import java.util.stream.IntStream fun main() { val img = ImageFactory.getInstance().fromUrl("https://djl-ai.s3.amazonaws.com/resources/images/0.png"); val modelDir = Paths.get("build/mlp") val model = Model.newInstance("mlp") model.block = Mlp(28 * 28, 10, intArrayOf(128, 64)) model.load(modelDir) val translator: Translator<Image, Classifications> = object : Translator<Image, Classifications> { override fun processInput(ctx: TranslatorContext, input: Image): NDList { // Convert Image to NDArray val array: NDArray = input.toNDArray(ctx.ndManager, Image.Flag.GRAYSCALE) return NDList(NDImageUtils.toTensor(array)) } override fun processOutput(ctx: TranslatorContext?, list: NDList): Classifications? { // Create a Classifications with the output probabilities val probabilities = list.singletonOrThrow().softmax(0) val classNames = IntStream.range(0, 10).mapToObj { i: Int -> i.toString() }.collect(Collectors.toList()) return Classifications(classNames, probabilities) } // The Batchifier describes how to combine a batch together // Stacking, the most common batchifier, takes N [X1, X2, ...] arrays to a single [N, X1, X2, ...] array override fun getBatchifier(): Batchifier { return Batchifier.STACK } } val predictor = model.newPredictor(translator) val classifications = predictor.predict(img) println(classifications.toString()) }之后我会更倾向于使用这个库去做一些研究性的东西，从结构上看它会比DL4J更灵活一些。尽管在使用的过程中会有一些阻碍，但好在并不耽误使用，而且代码本身拥有详细的注释，因此一旦引用了Maven依赖，便可以在IDE中通过阅读注释等方法解决半数以上的问题，余下的一半几乎都可以在搜索引擎中得到解决。小结DL4J和DJL都能够很方便的通过Maven或Gradle构建深度学习环境，而不需要依赖Python运行环境。至此我认为这两个库已经能够很好的满足在JVM上进行深度学习的需求了，但是除了深度学习本身，关于数据可视化以及深度学习附属的一些领域还差一些。文中没有提到的是DL4J可以通过官方支持使用网页来查看训练过程中的一些数据，诸如当前迭代的分数、更新参数的比例等，有助于调整超参。并且DL4J对于训练和评估的过程十分完备，除了最普通的准确率（Accuracy）之外，还有精确度（Precision）、召回率和F1的计算，而DJL尚未发现有网页UI，而训练过程中的评估，官方也只给了Accuracy一种。但好在DJL也有规范的接口，可以通过继承Evaluator来实现自己的评估器。尽管还需要一些时间才能更加成熟，无论如何，能够在JVM进行深度学习都是一件很值得高兴的事情。
今天在利用Java处理语料的时候发现随着时间的推移，Java进程需要的内存越来越多，虽然过程中有往下掉的环节，但是总体而言还是增长的趋势。由此我推断是发生了内存泄漏。本文将记述排查及解决的过程。运行的程序本身代码很简单：import ch.qos.logback.classic.Level import info.skyblond.fiona.helper.HanLPHelper import org.slf4j.LoggerFactory import java.io.File import java.io.ObjectOutputStream import java.nio.charset.StandardCharsets fun main(args: Array<String>) { val corpus = File("").bufferedReader(StandardCharsets.UTF_8) val structuralizedSentence = File("") val objectOutputStream = ObjectOutputStream(structuralizedSentence.outputStream()) corpus.lineSequence().shuffled().map { // 分词 }.map { sentence -> // 其他处理 }.forEachIndexed { index, it -> objectOutputStream.writeObject(it) if (index % 1000 == 0) { objectOutputStream.flush() System.gc() } } objectOutputStream.close() }可以看出来基本上就是对语料库进行处理，处理的过程中并没有将循环内的变量存储到循环外部，内部变量也没有和外部长生命周期的变量交互的机会。而循环内部的变量与外部唯一交互的机会就是最后forEachIndexed中利用ObjectOutputStream写入文件的过程。为了科学严谨，我注释了与ObjectOutputStream相关的语句，只在循环内部执行同样的操作，唯独不写入文件。利用Jconsole查看内存发现并没有内存泄漏。因此根据控制变量的原理，可以确定就是ObjectOutputStream导致了这次内存泄漏。确定了问题的位置，之后就好办了。原本为了方便存储处理结果，Json显得十分冗余，而使用其他编码方式又相当麻烦，于是我直接用ObjectOutputStream存入文件，十分优雅。可结果没想到这东西不仅没优雅成，反倒还给我弄了个内存泄漏出来，但即便如此，Java仍然比Python优秀十万倍。经过网上的一番搜索（关键字ObjectOutputStream memory leak），总结起来是这样的：ObjectOutputStream在工作的时候会对已经发送（调用writeObject(obj)的obj）对象记录在一个表中（引用形式），为了节省带宽和内存，如果之后发送过同样的数据，就可以直接告诉接收端使用之前发送过的内容，从而避免发送重复的数据。在搜索的过程中还看到有人提到writeUnshared函数，通过说明来看应该是无论之前是否发送过同样的数据，经由这个函数发送的数据总是会被传输。看起来是解决了内存泄漏的问题，但是在发送的时候还是会记录被发送对象的引用，从而导致GC认为这不是带回收的对象，引起内存泄漏。这个问题真正的解法在于经常调用reset方法，这个方法会将对应的ObjectOutputStream重置，效果就是和新new出来的对象一样。这样之前持有的发送过的对象的引用就会被清零，同时也会在流上输出一个reset标志，告诉（未来的）接收者对方也要清空这个引用表。这样之前没能被GC收集并清理的对象就可以成功的为后来者腾出内存了。而关于writeUnshared，我认为不必要，出于两方面考虑：一方面是默认的机制可以节省带宽，本文中我要写入文件，这样可以避免在文件中写入相同的数据，能够减小文件大小；另一方面考虑如果要常规调用reset()，那么调用这个还是writeObject()我觉得可能区别不大。因此我选择看上去更优雅的writeObject()。于是对forEachIndexed块修改如下：.forEachIndexed { index, it -> objectOutputStream.writeObject(it) if (index % 10000 == 0) { objectOutputStream.flush() objectOutputStream.reset() System.gc() } }修改过代码之后重新跑，终于看到了正常的内存曲线，针不戳。-全文完-
最近在学习Clojure，我认为学习一门编程语言，只看书不实践是不行的。故决定使用Clojure写一个类似Base64的卢恩符文编码。这里使用Elder Futhark字母表，关于此字母表的更多信息还请移步维基百科。这篇文章是Proof of Concept性质的，本文将使用Java/Kotlin编写相同功能的代码，以确定具体的实现细节和编码方式。原理Base64关于Base64详细的编解码方式，请移步维基百科。简单来说就是将要编码的字节按照顺序摆好，然后选定6位为一组算出来对应的数字，再以预先选定好的方式将数字替换成字母。解码时反向操作，通过字母找到对应的数字，按顺序将数字翻译回二进制，按顺序放好便是原来的被编码字节。卢恩符文同样按照Base64的思路，将字母换成卢恩符文，并修改一下位数，可以达到目的。这里选用的Elder Futhark字母表具有如下字母：ᚠᚢᚦᚨᚱᚲᚷᚹᚺᚾᛁᛃᛇᛈᛉᛊᛏᛒᛖᛗᛚᛝᛟᛞ对应Unicode编码：\u16A0 \u16A2 \u16A6 \u16A8 \u16B1 \u16B2\u16B7 \u16B9 \u16BA \u16BE \u16C1 \u16C3\u16C7 \u16C8 \u16C9 \u16CA \u16CF \u16D2\u16D6 \u16D7 \u16DA \u16DD \u16DF \u16DE一共是24个字母。它还具有三种标点：᛫᛬᛭对应Unicode编码：\u16EB \u16EC \u16ED第一个标点是空格，即单词间的分隔符。中间的是句号，即句子间的分隔符。最后一个不知道，我也没能查到相关的说明。 以下方式只是我选定的一个方式，不唯一，而且很灵活 卢恩符文与Base64首先不一样之处在于字符数量，Base64选了64个字符作为编码字符，他正好是2的6次幂，刚好可以用6个二进制位来表示，这也是为什么要把字节摆好，每六个位一组翻译成数字的原因。而卢恩符文只有24个，加上标点符号也才27个，正好卡在5位和6位二进制数能表达的范围之间，很尴尬，因此这套系统涉及非二进制运算，所以远不及Base64这些利用二进制位操作的方式高效迅速。当然，我写这个东西就是奔着好玩去的，能用就行。如果换一个角度想，这实际上就是进制转换的问题。Base64是利用64进制表示数据，那么卢恩符文也可以用24进制来表示数据。对于每一块数据（可以是任意字节），按照字节顺序放好，翻译成数的形式，再按照数制转换的方式操作即可。得到数字之后按照表进行翻译便可得到卢恩符文编码。解码时则反向操作。以上便是基本原理，当实际实现时还需要有些调整或技巧。下面将分解代码进行介绍。代码分解定义常量首先是定义字母表：val rawElderFutharkCharacters = charArrayOf( 'ᚠ', 'ᚢ', 'ᚦ', 'ᚨ', 'ᚱ', 'ᚲ', 'ᚷ', 'ᚹ', 'ᚺ', 'ᚾ', 'ᛁ', 'ᛃ', 'ᛇ', 'ᛈ', 'ᛉ', 'ᛊ', 'ᛏ', 'ᛒ', 'ᛖ', 'ᛗ', 'ᛚ', 'ᛝ', 'ᛟ', 'ᛞ' ).sortedArray()这里要保证一定是个有序数组，其中原数组的最后两个字母是字母表正确的顺序，但是Unicode编码却是相反的。因为后面解码时要用二分查找确定字符在数组中的位置，所以一定要保证被查找的数组是有序的，否则将不能正确解码。然后是定义块长度。这里我选了三种不同的长度，实际上大家可以随意选择，只要电脑能跑得动就可以。const val minBlockSize = 128 const val middleBlockSize = 256 const val maxBlockSize = 512然后是一些趁手的工具函数。fun randomBlockSize(): Int { return when (Random.nextInt(0, 3)) { 0 -> minBlockSize 1 -> middleBlockSize 2 -> maxBlockSize else -> throw IllegalStateException() } } fun getSpace(blockSize: Int): Char { return when (blockSize) { minBlockSize -> '᛫' // \u16EB middleBlockSize -> '᛬' // \u16EC maxBlockSize -> '᛭'// \u16ED else -> throw IllegalArgumentException() } } fun getBlockSize(c: Char): Int { return when (c) { '᛫' -> minBlockSize // \u16EB '᛬' -> middleBlockSize // \u16EC '᛭' -> maxBlockSize// \u16ED else -> throw IllegalArgumentException() } }以上分别是随机取一个块长度、按照块长度取一个分隔符和通过分隔符确定块长度。前二者供编码时使用，最后一个则是解码时使用。编码函数为了近最大可能保证灵活性，编码函数接收一个字节数组作为参数。fun encodeRunes(byteArray: ByteArray): String { var bytes = byteArray.toList() var encodeResult = "" while (bytes.isNotEmpty()) { ...... } encodeResult += "ᚠᚢᚦᚨᚱᚲ" return encodeResult }编码函数大体的结构如上。bytes是一个临时变量，encodeResult用来存放编码结果。while循环是对传入数据的循环处理，每次处理一个块的数据量。最后传出结果时，我加了个后缀，这个是随意选的，我使用了字母表的前五个字母。对于块的处理如下。首先要确定块长度。val blockSize = randomBlockSize()随后是按照长度取出足够数量的字节并进行处理：//abcdef in raw, use fold right to put in, there will be: //fedcba, when take out, you take right side bits first var remainder = bytes.take(blockSize).map { it.toInt() - Byte.MIN_VALUE }.foldRight(BigInteger.ZERO) { t, r -> r.shiftLeft(Byte.SIZE_BITS).add(t.toBigInteger())}先从bytes中取处定长的数据（.take(length)），然后将他们处理为正数，因为Byte类型范围是-128 ~ +127，如果不做处理，假设第一个字节是+16，第二个字节是-16，由于Java没有无符号类型，所以当进行加法运算时，后面的负数回对前面的正数造成影响。因此这里将每一字节处理成0 ~ 255的范围，随后再进行叠加就是了（map中的部分）。最后要将这些字节按顺序放好翻译成数字。这里使用了Java的BigInteger，它底层是利用整型数组来存储大数，最大范围是整形数最大范围和内存容量二者取最小，通常而言都是内存容量限制住了BigInteger的发挥。这里使用foldRight来进行求和操作。将字节放进去，左移8位（一个字节的位数），再放另一个，以此类推。而foldRight则是从右向左依次进行处理。例如有如下字节：a b c d e f那么处理后存起来的是：f e d c b a其中左为高位，右为地位。我们取余数时，位于前面的字节先编码，后来的后编码：do { encodeResult += rawElderFutharkCharacters[remainder.rem((rawElderFutharkCharacters.size).toBigInteger()).intValueExact()] remainder = remainder.div((rawElderFutharkCharacters.size).toBigInteger()) } while (remainder.compareTo(BigInteger.ZERO) != 0)将编码的字符直接写到结果里，然后更新余数，直到除干净。这里要考虑一个问题：如果当前块待编码的字节是a b c 0 0 0，那么求和后就是0 0 0 c b a，如果处理完前三个有效的之后，后面三个0还没有处理，就提前结束了，如果后面块还有数据，这里丢了三位那不就乱了？这个问题先放着，解码的时候自然就明白了。主要的处理完成了，还差一个小尾巴：随机加上分隔符并从bytes里移除处理完的数据：encodeResult += getSpace(blockSize) bytes = bytes.drop(blockSize)解码函数首先我们要重新定义小米，不是，重新定义一个工具函数：inline fun CharSequence.indexOfFirst(predicate: (Char) -> Boolean): Int { for (index in indices) { if (predicate(this[index])) { return index } } return Int.MAX_VALUE }这个是字符串查找函数，他返回第一个为真的字符，否则返回-1，但是为了方便处理，我们要让他在找不到时返回整型最大值。原因稍后揭晓。正经的解码函数接收一个编码后的字符串，返回解码的字节数组。fun decodeRunes(encodedString: String): ByteArray { require(encodedString.endsWith("ᚠᚢᚦᚨᚱᚲ")) { "Encoded string should end with ᚠᚢᚦᚨᚱᚲ" } var temp = encodedString val bytes = mutableListOf<Byte>() while (temp != "ᚠᚢᚦᚨᚱᚲ") { ...... } return bytes.toByteArray() }首先保证编码后的字符串是正确的结尾，然后定义一个临时变量和保存结果的变量。每一次循环处理一个块，直到字符串只剩下一个我们加进去的结尾标记。每次解码一个块的步骤如下，首先确定这个块的基本信息：val pivot = minOf( temp.indexOfFirst { c -> c == '᛫' }, temp.indexOfFirst { c -> c == '᛬' }, temp.indexOfFirst { c -> c == '᛭' } ) val counter = bytes.size val blockSize = getBlockSize(temp[pivot])这里考察这三个分隔符哪个第一个出现，然后根据最先出现的分隔符确定块的长度。同时还记录处理块之前的结果长度，以便后续确定已经解码了多少个字节。然后按照字符串还原出该块对应的数字：var accumulator = temp.substring(0, pivot).map { rawElderFutharkCharacters.binarySearch(it) }.foldRightIndexed(BigInteger.ZERO) { index, t, r -> r.add(t.toBigInteger().multiply(rawElderFutharkCharacters.size.toBigInteger().pow(index)))}这里先取出对应的字符串，然后搜索每个字符在数组中的位置来还原出编码数字，然后按照位置求和。这里提前更新余下的字符串来判定是不是最后一块：temp = temp.substring(pivot + 1) val isLastBlock = temp == "ᚠᚢᚦᚨᚱᚲ"然后就可以对数字进行处理了：while (bytes.size - counter < blockSize && (!isLastBlock || accumulator.compareTo(BigInteger.ZERO) != 0)) { bytes.add((accumulator.remainder((2.0.pow(Byte.SIZE_BITS).toInt()).toBigInteger()).intValueExact() + Byte.MIN_VALUE).toByte()) accumulator = accumulator.shiftRight(Byte.SIZE_BITS) }循环有两个终止条件：第一个是本块解码出的字节数达到块编码的字节数量，则停止；第二个条件是如果该块是最后一块，则不要求达到数量，只要到0就停，因为后面都是0和后面截止没有区别，反而多余的0会成为脏数据。这样就不必担心中间块有0提前结束编码，会导致数据混乱的问题了。循环里的内容是取一个字节的数据，还原回字节的范围（还记得一开始我们把字节值从-128 ~ +127移到0 ~ 225吗）并加到结果集里面。然后更新余数。代码全览全部代码如下：import java.lang.IllegalArgumentException import java.lang.Math.pow import java.math.BigInteger import java.nio.charset.StandardCharsets import kotlin.math.pow import kotlin.random.Random // \u16A0 \u16A2 \u16A6 \u16A8 \u16B1 \u16B2 // \u16B7 \u16B9 \u16BA \u16BE \u16C1 \u16C3 // \u16C7 \u16C8 \u16C9 \u16CA \u16CF \u16D2 // \u16D6 \u16D7 \u16DA \u16DD \u16DF \u16DE // 24 in total val rawElderFutharkCharacters = charArrayOf( 'ᚠ', 'ᚢ', 'ᚦ', 'ᚨ', 'ᚱ', 'ᚲ', 'ᚷ', 'ᚹ', 'ᚺ', 'ᚾ', 'ᛁ', 'ᛃ', 'ᛇ', 'ᛈ', 'ᛉ', 'ᛊ', 'ᛏ', 'ᛒ', 'ᛖ', 'ᛗ', 'ᛚ', 'ᛝ', 'ᛟ', 'ᛞ' ).sortedArray() //note the last two char is reversed in unicode, direct apply binarySearch won't work well with ᛞ const val minBlockSize = 128 const val middleBlockSize = 256 const val maxBlockSize = 512 fun randomBlockSize(): Int { return when (Random.nextInt(0, 3)) { 0 -> minBlockSize 1 -> middleBlockSize 2 -> maxBlockSize else -> throw IllegalStateException() } } fun getSpace(blockSize: Int): Char { return when (blockSize) { minBlockSize -> '᛫' // \u16EB middleBlockSize -> '᛬' // \u16EC maxBlockSize -> '᛭'// \u16ED else -> throw IllegalArgumentException() } } fun getBlockSize(c: Char): Int { return when (c) { '᛫' -> minBlockSize // \u16EB '᛬' -> middleBlockSize // \u16EC '᛭' -> maxBlockSize// \u16ED else -> throw IllegalArgumentException() } } fun encodeRunes(byteArray: ByteArray): String { var bytes = byteArray.toList() var encodeResult = "" while (bytes.isNotEmpty()) { val blockSize = randomBlockSize() //abcdef in raw, use fold right to put in, there will be: //fedcba, when take out, you take right side bits first var remainder = bytes.take(blockSize).map { it.toInt() - Byte.MIN_VALUE }.foldRight(BigInteger.ZERO) { t, r -> r.shiftLeft(Byte.SIZE_BITS).add(t.toBigInteger()) } do { encodeResult += rawElderFutharkCharacters[remainder.rem((rawElderFutharkCharacters.size).toBigInteger()).intValueExact()] remainder = remainder.div((rawElderFutharkCharacters.size).toBigInteger()) } while (remainder.compareTo(BigInteger.ZERO) != 0) encodeResult += getSpace(blockSize) bytes = bytes.drop(blockSize) } encodeResult += "ᚠᚢᚦᚨᚱᚲ" return encodeResult } inline fun CharSequence.indexOfFirst(predicate: (Char) -> Boolean): Int { for (index in indices) { if (predicate(this[index])) { return index } } return Int.MAX_VALUE } fun decodeRunes(encodedString: String): ByteArray { require(encodedString.endsWith("ᚠᚢᚦᚨᚱᚲ")) { "Encoded string should end with ᚠᚢᚦᚨᚱᚲ" } var temp = encodedString val bytes = mutableListOf<Byte>() while (temp != "ᚠᚢᚦᚨᚱᚲ") { val pivot = minOf( temp.indexOfFirst { c -> c == '᛫' }, temp.indexOfFirst { c -> c == '᛬' }, temp.indexOfFirst { c -> c == '᛭' } ) val counter = bytes.size val blockSize = getBlockSize(temp[pivot]) var accumulator = temp.substring(0, pivot).map { rawElderFutharkCharacters.binarySearch(it) }.foldRightIndexed(BigInteger.ZERO) { index, t, r -> r.add(t.toBigInteger().multiply(rawElderFutharkCharacters.size.toBigInteger().pow(index))) } temp = temp.substring(pivot + 1) val isLastBlock = temp == "ᚠᚢᚦᚨᚱᚲ" //if not the last block, then full of blockSize byte should be filled. //if is the last block, then consider the end is 0 => xxx,xxx,0,0,0,0 would be xxx,xxx ,0 after this would be ignore while (bytes.size - counter < blockSize && (!isLastBlock || accumulator.compareTo(BigInteger.ZERO) != 0)) { bytes.add((accumulator.remainder((2.0.pow(Byte.SIZE_BITS).toInt()).toBigInteger()).intValueExact() + Byte.MIN_VALUE).toByte()) accumulator = accumulator.shiftRight(Byte.SIZE_BITS) } } return bytes.toByteArray() } fun main() { val rawString = "天匠染青红，花腰呈嬝娜。袅娜的娜作女子名的时候念钠。\n我看到一片广阔的海岸，在我们面前展现的事物超越头脑所能理解的范畴，沙滩上的每一粒沙子，每一滴水和空气分子都是在讲述一个故事。每个都是要被唱响的歌。他们每个人都充满生机，笑声，苦难和仇恨。他们都是一样的，即使他们都是不同的。" println("Raw: " + rawString.length + " character(s).") var startTime = System.currentTimeMillis() val encodeResult = encodeRunes(rawString.toByteArray(StandardCharsets.UTF_8)) println("Encode: ${System.currentTimeMillis() - startTime}ms and get " + encodeResult.length + " character(s).") startTime = System.currentTimeMillis() val decodeResult = decodeRunes(encodeResult) val result = (rawString == String(decodeResult, StandardCharsets.UTF_8)) println("Decode: ${System.currentTimeMillis() - startTime}ms and result is " + result) println() println("Raw String:") println(rawString) println() println("Encoded:") println(encodeResult) println() println("Decoded:") println(String(decodeResult, StandardCharsets.UTF_8)) }这里有一个例子：天匠染青红，花腰呈嬝娜。袅娜的娜作女子名的时候念钠。 我看到一片广阔的海岸，在我们面前展现的事物超越头脑所能理解的范畴，沙滩上的每一粒沙子，每一滴水和空气分子都是在讲述一个故事。每个都是要被唱响的歌。他们每个人都充满生机，笑声，苦难和仇恨。他们都是一样的，即使他们都是不同的。对这段文字编码耗时32毫秒，编码结果如下：ᚲᚱᚺᛃᛚᛒᛚᚲᚦᚱᛖᛈᛇᚠᛃᚺᛗᛉᚢᛟᛟᛖᛚᚢᛞᛟᛖᚢᚺᚷᛁᛃᛗᛞᛒᛝᚨᛝᚠᛁᛒᛖᛗᛞᚢᛗᚦᚷᚺᚹᚠᛚᚢᛇᚢᚹᛖᛝᚾᛞᚲᛏᚾᚺᚹᛞᚷᛇᛈᛉᛖᛟᛇᛏᛇᛊᛗᛁᛈᛟᚷᛁᛞᚲᚢᚦᛗᛏᛞᛃᛏᚷᛏᚦᛖᛃᚲᚠᛝᛗᚢᛊᛒᛉᛃᛈᚲᛏᛖᛖᚱᚦᛁᛒᚦᛟᚺᛒᛝᛒᛊᛚᛊᚹᛞᚷᛇᚹᛁᚲᛈᚨᛇᚨᛚᚦᛞᚱᛈᛒᚨᚾᚱᚨᛒᛞᚲᚢᛞᚦᚦᚷᛊᚷᛒᛏᚷᛈᛝᛗᛟᚹᛖᚱᚨᛞᚺᛚᛉᛏᚲᛏᚾᛃᚨᛖᚷᛖᚱᛒᚲᚲᚱᛞᚢᛊᛗᚷᚢᛚᛟᛒᛁᛉᚦᚷᛈᚨᚨᚠᛝᛝᚨᛟᚠᛚᛁᚾᛒᛏᛃᛟᛈᚹᛒᛇᛈᛝᛞᛈᚹᛗᚨᚢ᛫ᚾᚹᚦᚠᚢᛇᛚᛝᚺᛁᛃᛞᛝᛃᚱᛟᛚᛖᚹᛖᛈᛟᛈᚨᛟᛞᛁᛃᚢᚠᚠᚱᛇᛝᛟᛟᚲᚨᛒᛃᛏᛞᛁᛗᚢᛒᛖᚦᛝᛗᛒᛞᚨᛈᛖᛁᛗᛟᛉᚠᛈᛗᚦᛉᚲᚾᛟᚦᛟᚦᛉᚨᚾᚢᚦᛗᚠᚾᛊᛝᛒᚺᛗᛇᚨᚺᚹᚦᚾᚹᛊᛏᚲᚠᚢᛇᛊᛟᛈᚢᚠᛇᚱᚱᚨᛗᛖᚾᚢᛊᛗᛖᚾᛏᚠᛁᛊᚠᛝᛚᚷᛏᛒᛊᛏᚠᛊᛒᛒᛊᚹᛇᛗᛊᛟᛒᚺᛇᛖᚹᛊᛉᚦᛈᚾᛝᛝᚨᛃᛒᚲᛃᛞᚢᛏᚦᚱᚲᚦᚾᛇᚦᛏᛇᛖᛉᛟᛏᛁᚨᚷᛒᚲᚾᚷᛒᛉᛊᚱᚠᚢᛚᛟᛗᚱᛃᛁᛞᛒᚺᚢᚠᚨᛏᛇᛗᚲᛉᛏᚺᚢᛃᚱᚱᛞᛃᛗᛟᚨᛈᛞᛗᛏᛈᛈᛊᛈᚠᚠᛇᛗᛉᛃ᛫ᚷᛚᚷᚾᚦᚷᚺᛏᚠᛁᚺᛏᛊᚨᚷᚾᛟᛉᛉᚺᛖᚾᚢᛟᚨᛇᚱᛇᛇᛏᚾᛃᚠᚢᚠᚹᛁᛖᛁᛁᛒᚺᛊᚠᛊᛈᚱᚷᚨᛒᛇᚲᚷᚲᛚᚲᚾᚲᛏᛞᚢᚾᛁᚨᛝᛈᛞᚲᚢᚺᚱᚷᚨᚹᛃᛝᚢᛒᚷᛊᚠᛊᛖᛖᛗᛟᚠᚢᛈᛇᛝᚠᚱᚷᛈᚦᚾᛏᚹᛃᛞᛞᚨᛉᚷᛁᚷᚢᛝᛁᚦᚾᛇᛏᛉᛝᛃᚢᛏᛒᚨᛁᛗᛏᛃᛃᚾᛇᚺᚾᚲᚲᚨᚠᚨᛁᚦᚠᛇᛉᛉᛖᛈᛈᚠᚨᚢᛟᛞᚨᛉᚲᚾᚱᛃᛒᛊᛇᛏᛉᛖᚦᛃᛈᚨᛁᚠᚺᛏᛇᚱᚠᛝᚲᛞᚹᚱᛊᛞᚢᚦᛏᛞᚨᛒᛗᛇᛈᚹᛊᚹᚦᚷᛖᛇᛇᛝᚨᛖᛉᛏᚠᚺᛊᚨᚷᚺᛃᛒᚱᛟᚹᚹᛗᛚᛉᛇᛒᛁᚨᚢᚦᛟᛗᚷᛚᛉᛃᛞᚹᚱᛚᛚᛏᚷᛁᛝᚦᛈᛚᛒᛈᚾᛃᛈᚱᛃᛞᛒᚨᛉᛉᚨᛒᛚᚱᛁᛃᛒᛚᛊᚠᚦᛃᛏᛒᛁᛒᚹᚺᚺ᛬ᚠᚢᚦᚨᚱᚲ解码上面的卢恩符文耗时12毫秒，解码结果与原文一致。-全文完-
我现在日常开发的主力语言是Java和Kotlin，其中后者可能占比更加多一些，如果可以的话我尽可能的会选择Kotlin来做我熟悉的事情，但是另一种情况，比如尝试我以前未曾涉足过的非Kotlin原生框架（例如DeepLearning4J），我就比较偏好于使用Java，因为通常情况下我可以从框架的文档处很方便的复制粘贴来一些代码跑跑看。今天闲来无事，决定试试看基于JVM的其他语言。本文将记述我今天搭建Clojure运行和编译环境的一些事宜。杂谈在函数式这方面，JVM上Scala勉强算吧，比较纯粹的就是Eta-lang和Clojure了，前者类似Haskell，后者类似Lisp。我是比较偏好Haskell的，但是前者实在是太新了，而且有风评传它对Java原生的调用不是很美，而且为了贴近GHC又不得不做出一些牺牲，但最终又不是原生的GHC。等于它处在了一个JVM和GHC之间很尴尬的位置。不过目前他们成立了一个公司来开发Eta-lang，我决定在等几个版本，万一就好了呢。而且说起Haskell，我是真喜欢Haskell的写法和理念，不用担心括号叠罗汉，Monad也很酷（虽然我不会用吧，可我乐意学），但是迫使我不得不弃坑（我试图入门过Haskell，有博文为证）的一个最主要的原因就是Haskell的包管理工具stack，遇到一个要求了不同版本GHC的依赖，就要下载一整套GHC，真的用起来的时候代码还没写多少，光是引用了几个依赖，一个200GB的分区就红了，我这笔记本真是撑不住啊。而且看了好久，我发现社区可能还想把这个当作一个特性，遂不得已弃坑了。如果Eta-lang能做好，它是基于JVM的，虽然也兼容与Haskell互相调用，但是我认为这样子就完全可以避开stack来节省我的硬盘空间，毕竟我认为Java没有什么做不到的，编程思想不一样可以自己写接口包装一下嘛。对于Lisp的印象，我觉得就是一个远古魔法。我确实没有深入了解过Lisp，听闻远古Lisp支持的运算/函数/关键字屈指可数，但是能达到有限状态机那种境界，我觉得是真厉害。但是现在的Lisp，真就是括号叠罗汉。同样是一个2加2，在Haskell里：2 + 2加号是中缀函数，可以加括号变为普通函数调用，可是Lisp就不行：(+ 2 2)非得用括号包起来才是一个表达式，不然编译器就骂街。且还没有中缀函数的概念。我可能还是不懂Lisp的精髓吧。我买了一本书叫「Living Clojure」，我决定深入的学一学，没事儿用这个做点儿小玩意儿也是好的嘛。但前提还是得先把环境搭起来。客观条件我的电脑是Win10，常用的IDE就是Intellij家的IDEA，因为在读大学，所以可以申请到免费的教育版许可。关于Clojure的IDE，我决定在IDEA的基础上安装插件。我选择的插件是Cursive，他们非常人性的提供了几种不同的许可：个人许可 - 99刀一年企业许可 - 不知道多少钱一年，我没细看非商业许可 - 免费我选的是后者，因为我还到敢在商业项目中使用Clojure的程度，而且我也保证我不会用非商业许可写用来赚钱的代码。所以我申请的最后一种，有效期是六个月，可以随时申请新的。【警告】请务必根据你的工作性质购买合适的许可。这个插件也是别人日夜操劳写出来的，大家都不容易，花点钱支持正版没毛病。坑其实安装过程挺简单的，直接装上插件输入密钥就可以了。但是我装完了建立好工程准备进REPL耍一耍的时候，IDEA突然提示我：Dependency resolution error Error synchronising learning:0.1.0-SNAPSHOT: Failed to collect dependencies at org.clojure:clojure:jar:1.10.0我：？？？后来我发现这个插件是用的Maven获取的jar文件，m2文件夹中有对应的文件夹了，可是文件夹里还是空的。在其他项目中我的Maven好好的，没有出现过类似的情况。经过一顿排查，确定是网络的问题。遂一通更改settings.xml，又是挂代理又是换Repo的，折腾一溜够。最终在代理软件中发现端倪：unknown Socks version: 67我：？？？我明明用的http协议啊？最终发现IDEA的设置里是Socks5协议，而IDEA在调用Maven下载Jar文件时默认把代理设置传给了Maven，后者似乎是不认socks5的样子，反正就是没谈好，导致网络始终不通。最终在IDEA中改回Http代理，再刷新工程就完美解决问题了。至此Cursive插件的全部功能都能得以正常运作，等书到了就可以学起来了。-全文完-