如果你看过我最近发表关于 Kotlin 的文章,你可能会注意到我曾经提到过 DSL(Domain Specific Languages,领域专用语言)。Kotlin 是一门提供了强大特性支持 DSL 的编程语言。这些特性中,我曾经介绍过具有接收者的函数字面量(Function Literals with Receiver),以及调用约定和中缀表达式。
这篇文章中,我们会看到 DSL 的概念,当然还有如何使用 Kotlin 创建一个相对简单的 DSL 示例。
举例来说,我常常在需要 HTTPS 通讯的情况下,艰难地使用 Java API 建立 SSL/TLS 连接。就在最近,我还不得不在我们的应用程序中实现了一个不同类型的 SSL/TLS。为了做这个事情,我再次想写一个小型库来支持类似的任务,以样板的方式避开所有困难。
领域专用语言 (DSL)
领域专用语言这个术语现在使用得非常广泛,但就所要谈论的情况而言,它指的是某种“微型语言”。它以半陈述的方式描述特定领域对象的构造。用于创建 XML、HTML 或 UI 数据的 Groovy builders 就是一个例子。在我看来,最好的例子是 Gradle,它也是使用基于 Grovvy 的 DSL 来描述软件构建自动化。(顺便提一下,还有一个 Gradle-Script-Kotlin,是针对 Gradle 的 Kotlin DSL。)
把目标简化一下,DSL 是一种提供 API 的方式,这种 API 更清晰、更具可读性,最重要的是,它比传统 API 结构更明确。DSL 使用嵌入的描述而不是用一种命令的方式调用各个功能,这种方式会创建清晰的结构,我们甚至可以称之为“语法”。DSL 定义可以合并不同构造,应用于各个作用域,并在其中使用不同的功能。
为什么 Kotlin 特别适用于 DSL
大家都知道 Kotlin 是静态类型语言,它拥有像 Groovy 这样的动态类型语言所不具备的能力。最重要的是,静态类型允许在编译期检查错误,而且一般情况下会得到 IDE 更好的支持。
好了,别再浪费时间在理论上,我们来感受 DSL 的乐趣吧,有很多嵌入的 Lambda 哦!因此,你最好先搞懂如何在 Kotlin 中使用 Lambda!
Kotlin DSL 的示例
本文的引言部分就说过我们会使用 Java API 建立 SSL/TLS 连接来作为示例。如果你对此并不熟悉,我们先来简单的介绍一下。
Java 安全套接字扩展
Java 安全套接字扩展 (Java Secure Socket Extension, JSSE) 是 Java SE 1.4 就引入的库,它提供通过 SSL/TLS 创建安全连接的功能,包括客户端/服务器认证、数据加密以及保证消息完整性。和许多其他人一样,我发现安全问题相当棘手,哪怕在日常工作中我们经常用到这些功能。原因之一可能就是需要组合大量 API。另一个原因建立这样的连接非常繁琐。来看看类层次结构:
相当多的类,不是吗?你通常从创建一个信息的密钥存储开始,然后配合一个随机数生成器建立 SSLContext。这可以用于工厂模式,用来创建你的 Socket。老实说,听起来并不难,不过我们来看看实现呢 —— 用 Java ...
使用 Java 设置 TLS 连接
我需要100多行代码来做到这一点。它展示了一个函数,可用于连接到具有可选相互身份验证的TLS服务器,如果这是双方的需要,客户端和服务器都需要彼此信任。
JSSE Java:
public class TLSConfiguration { ... }
public class StoreType { ... }
public void connectSSL(String host, int port,
TLSConfiguration tlsConfiguration) throws IOException {
String tlsVersion = tlsConfiguration.getProtocol();
StoreType keystore = tlsConfiguration.getKeystore();
StoreType trustStore = tlsConfiguration.getTruststore();
try {
SSLContext ctx = SSLContext.getInstance(tlsVersion);
TrustManager[] tm = null;
KeyManager[] km = null;
if (trustStore != null) {
tm = getTrustManagers(trustStore.getFilename(),
trustStore.getPassword().toCharArray(),
trustStore.getStoretype(), trustStore.getAlgorithm());
}
if (keystore != null) {
km = createKeyManagers(keystore.getFilename(),
keystore.getPassword(),
keystore.getStoretype(), keystore.getAlgorithm());
}
ctx.init(km, tm, new SecureRandom());
SSLSocketFactory sslSocketFactory = ctx.getSocketFactory();
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(
host, port);
sslSocket.startHandshake();
} catch (Exception e) {
throw new IllegalStateException("Not working :-( ", e);
}
}
private static TrustManager[] getTrustManagers(
final String path, final char[] password,
final String storeType, final String algorithm) throws Exception {
TrustManagerFactory fac = TrustManagerFactory.getInstance(
algorithm == null ? "SunX509 " : algorithm);
KeyStore ks = KeyStore.getInstance(
storeType == null ? "JKS " : storeType);
Path storeFile = Paths.get(path);
ks.load(new FileInputStream(storeFile.toFile()), password);
fac.init(ks);
return fac.getTrustManagers();
}
private static KeyManager[] createKeyManagers(
final String filename, final String password,
final String keyStoreType, final String algorithm) throws Exception {
KeyStore ks = KeyStore.getInstance(
keyStoreType == null ? "PKCS12 " : keyStoreType);
ks.load(new FileInputStream(filename), password.toCharArray());
KeyManagerFactory kmf = KeyManagerFactory.getInstance(
algorithm == null ? "SunX509 " : algorithm);
kmf.init(ks, password.toCharArray());
return kmf.getKeyManagers();
}
好的,这是Java,对吧?嗯,代码相当的冗长 - 有许多被检查的异常和资源被处理,为简洁起见,我已经在这里简化了。
下一步,我们将这些代码转换成简明的Kotlin代码,然后为愿意建立TLS连接的客户端提供DSL。
使用 Kotlin 设置 TLS 连接
Kotlin 的 SSLSocketFactory:
fun connectSSL(host: String, port: Int, protocols: List<String>, kmConfig: Store?, tmConfig: Store?){
val context = createSSLContext(protocols, kmConfig, tmConfig)
val sslSocket = context.socketFactory.createSocket(host, port) as SSLSocket
sslSocket.startHandshake()
}
fun createSSLContext(protocols: List<String>, kmConfig: Store?, tmConfig: Store?): SSLContext {
if (protocols.isEmpty()) {
throw IllegalArgumentException("At least one protocol must be provided. ")
}
return SSLContext.getInstance(protocols[0]).apply {
val keyManagerFactory = kmConfig?.let { conf ->
val defaultAlgorithm = KeyManagerFactory.getDefaultAlgorithm()
KeyManagerFactory.getInstance(conf.algorithm ?: defaultAlgorithm).apply {
init(loadKeyStore(conf), conf.password)
}
}
val trustManagerFactory = tmConfig?.let { conf ->
val defaultAlgorithm = TrustManagerFactory.getDefaultAlgorithm()
TrustManagerFactory.getInstance(conf.algorithm ?: defaultAlgorithm).apply {
init(loadKeyStore(conf))
}
}
init(keyManagerFactory?.keyManagers, trustManagerFactory?.trustManagers,
SecureRandom())
}
}
fun loadKeyStore(store: Store) = KeyStore.getInstance(store.fileType).apply {
load(FileInputStream(store.name), store.password)
}
您可能会注意到,我没有在这里进行一对一转换,这是因为在Kotlin的stdlib中提供了一些函数,这在许多情况下有很多帮助。这一小段源代码包含四种apply的用法,一种利用扩展函数对象的方法。它允许我们通过在创建时传递上下文给它的lambda的语句块内重用,就像DSL一样,我们将在稍后看到。
被apply的对象成为函数的receiver,然后可以通过这个receiver在lambda中使用,即可以调用成员,而不需要任何额外的前缀。如果仍然不明白,可以看看我的博客文章关于这些扩展函数对象的部分。
我们已经看到,Kotlin可以比Java更简洁,但这是常识。我们现在想把这个代码包装在一个DSL中,然后客户端可以用它来进行TSL连接。
使用 Kotlin 创建 DSL
在创建 API 时要考虑的第一件事 —— 这也适用于 DSL,即客户端会被问到的:我们需要哪些配置参数。
在我们的例子中,这是非常简单的。我们需要分别为 keystore 和 truststore 提供零个或一个描述。另外,重要的是要知道接受的密码套件和套接字链接超时。最后同样重要的是,必须要为我们的连接提供一组协议,例如 TLSv1.2。对于每一个配置的值,缺省值都是可用的,必要时将需要使用。
这可以很容易地封装在配置类中,我们称之为 ProviderConfiguration,因为它稍后将会配置在我们的 TLSSocketFactoryProvider 中。
配置
DSL 配置类:
class ProviderConfiguration {
var kmConfig: Store? = null
var tmConfig: Store? = null
var socketConfig: SocketConfiguration? = null
fun open(name: String) = Store(name)
fun sockets(configInit: SocketConfiguration.() -> Unit) {
this.socketConfig = SocketConfiguration().apply(configInit)
}
fun keyManager(store: () -> Store) {
this.kmConfig = store()
}
fun trustManager(store: () -> Store) {
this.tmConfig = store()
}
}
这里有三个可空属性,默认情况下,它们都为 null,因为客户端可能不希望配置连接的所有内容。这里的重要方法是 sockets, keyManager, 和 trustManager,它们拥有一个带有函数类型的参数。第一个 SocketConfiguration 是通过定义一个 receiver 的函数显式声明。这使得客户端可以传入一个
lambda 以访问 SocketConfiguration 中的所有成员,正如我们从扩展函数知道的这一点。
socket 方法通过创建一个新的实例来提供 receiver,然后通过 apply 来调用传递的函数。然后将生成的配置实例用作内部属性的值。另外两个函数比较简单,因为它们定义了简单的函数类型,没有 receiver。他们只是期望一个函数被传递,返回一个 Store 的一个实例,然后被置于内部属性上。
现在再来看看 Store 和 SocketConfiguration 类。
DSL 配置类(2):
data class SocketConfiguration(
var cipherSuites: List<String>? = null, var timeout: Int? = null,
var clientAuth: Boolean = false)
class Store(val name: String) {
var algorithm: String? = null
var password: CharArray? = null
var fileType: String = "JKS "
infix fun withPass(pass: String) = apply {
password = pass.toCharArray()
}
infix fun beingA(type: String) = apply {
fileType = type
}
infix fun using(algo: String) = apply {
algorithm = algo
}
}
第一个类是一个简单的数据类,而且属性又是可空的。Store 有点独特,因为它只定义了三个 infix 函数,实际上这上是属性的简单设置器。我们在这里使用 apply,因为它之后会返回应用的对象。这使我们能够轻松地链接到设置器。目前尚未提及的一件事是 ProviderConfiguration 中的函数 open(name: String)。很快就会看到这可以用作 Store 的工厂。这一切都结合在一起,可以定义我们的配置。但是在这之前,可以先看看客户端,先来看一下 TLSSocketFactoryProvider,它需要配置我们刚刚看到的类。
DSL 核心类
TLSSocketFactoryProvider
class TLSSocketFactoryProvider(init: ProviderConfiguration.() -> Unit) {
private val config: ProviderConfiguration = ProviderConfiguration().apply(init)
fun createSocketFactory(protocols: List<String>)
: SSLSocketFactory = with(createSSLContext(protocols)) {
return ExtendedSSLSocketFactory(
socketFactory, protocols.toTypedArray(),
getOptionalCipherSuites() ?: socketFactory.defaultCipherSuites)
}
fun createServerSocketFactory(protocols: List<String>)
: SSLServerSocketFactory = with(createSSLContext(protocols)) {
return ExtendedSSLServerSocketFactory(
serverSocketFactory, protocols.toTypedArray(),
getOptionalCipherSuites() ?: serverSocketFactory.defaultCipherSuites)
}
private fun getOptionalCipherSuites() =
config.socketConfig?.cipherSuites?.toTypedArray()
private fun createSSLContext(protocols: List<String>): SSLContext {
//... already known
}
}
这个类也不难理解,它的大部分内容都不显示在这里,因为我们已经从使用 Kotlin 的 SSLSocketFactory 已获知,特别是 createSSLContext。
这个列表中最重要的是构造函数。它期望一个具有 ProviderConfiguration 的函数对象作为 receiver。在内部,它创建一个新的实例,并调用此函数来初始化配置。该配置用于 TLSSocketFactoryProvider 的其他函数,一旦调用了一个公共方法,即分别是 createSocketFactory 和 createServerSocketFactory,就可以设置
SocketFactory。
为了将这些组合在一起,必须创建一个顶级函数,这将是客户端与 DSL 的接入点。