为什么Corda要集成springboot
因为Corda内置的Corda Webserver已经被标记成弃用了,一般不再提供支持;再者,springboot的生态明显占优。
太长不读篇
- 独立的module依赖corda和cordapps
- Connection RPC
- Run server task
- Integration test
精读篇
1. 独立的module依赖corda和cordapps
在build.gradle文件添加corda和自行编写的cordapps的依赖,以及对于springboot的依赖
// build.gradle in your-api module
...
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-jre8:$kotlin_version"
testCompile "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
testCompile "junit:junit:$junit_version"
testCompile "io.rest-assured:rest-assured:$rest_assured_version"
testCompile "$corda_release_group:corda-node-driver:$corda_release_version"
// corda dependency
cordaCompile "$corda_release_group:corda-core:$corda_release_version"
cordaCompile "$corda_release_group:corda-rpc:$corda_release_version"
cordaRuntime "$corda_release_group:corda:$corda_release_version"
// springboot dependency
compile("org.springframework.boot:spring-boot-starter-websocket:$spring_boot_version") {
exclude group: "org.springframework.boot", module: "spring-boot-starter-logging"
}
cordapp project(":your-cordapps")
}
除了上述核心的依赖之外,为了进行集成测试,特别加入了RestAssured
的依赖,用于Restful风格的API测试。
2. 编写spring组件Connection RPC
Corda Webserver模块也是通过RPC的方式和Corda节点进行交互的,所以需要使用springboot的@Bean
封装对于Corda RPC的Connection,然后通过依赖注入的方式启动springboot容器,进而编写API。如下:
// App.kt
@SpringBootApplication
open class App {
@Value("\${config.rpc.host:localhost:10006}")
private
lateinit var cordaHost: String
@Value("\${config.rpc.username:user1}")
private
lateinit var cordaUser: String
@Value("\${config.rpc.password:test}")
private
lateinit var cordaPassword: String
@Bean
open fun rpcClient(): CordaRPCOps {
log.info("Connecting to Corda on $cordaHost using username $cordaUser and password $cordaPassword")
var maxRetries = 100
do {
try {
return CordaRPCClient(NetworkHostAndPort.parse(cordaHost)).start(cordaUser, cordaPassword).proxy
} catch (ex: ActiveMQNotConnectedException) {
if (maxRetries-- > 0) {
Thread.sleep(1000)
} else {
throw ex
}
}
} while (true)
}
companion object {
private val log = LoggerFactory.getLogger(this::class.java)
@JvmStatic
fun main(args: Array) {
SpringApplication.run(App::class.java, *args)
}
}
}
基于Kotlin和springboot,然后配置了一个连接Corda结对的rpc client CordaRPCOps
Bean对象。一旦springboot启动完成,CordaRPCOps
将作为一个实例化好的对象注入到其它的组件当中。这里,它将被注入到Controller对象中,使用方式如下:
// GoodController.kt
@RestController
@RequestMapping("/api/")
open class GoodController {
@Autowired
lateinit var rpcOps: CordaRPCOps
...
val stateAndRef = rpcOps.vaultQueryByCriteria(
criteria = QueryCriteria.LinearStateQueryCriteria(externalId = listOf(id)),
contractStateType = Good::class.java).states.singleOrNull()
...
}
3. Gradle中添加 Run Server Task
组件定义好之后,需要注入相应的参数,整个springboot容器才能启动成功,所以在your-api module的build.gradle中配置如下任务:
// build.gradle in your-api module
task runPartyA(type: JavaExec) {
classpath = sourceSets.main.runtimeClasspath
main = 'com.good.App'
environment "server.port", "10007"
environment "config.rpc.username", "user1"
environment "config.rpc.password", "test"
environment "config.rpc.host", "localhost:10006"
environment "spring.profiles.active", "dev"
}
当corda的节点启动之后,运行./gradlew runPartyA
就可以启动springboot,一旦通过rpc连接成功,整个springboot的web server就算启动成功了。这时,你可以通过postman等工具访问。
4. Integration test
虽然springboot容器可以通过gradle启动运行,但是如何通过API测试的方式来保证API的准确和稳定呢?
如果按照以前使用springboot开发web应用的方式,集成测试是非常好写的,只需要加上@SpringBootTest
等注解即可。但是Corda当中,这样的方式并不可行,因为本质上Corda节点和springboot应用是两个独立的项目,而且springboot能否运行是依赖于提前启动的Corda节点的。所以使用@SpringBootTest
启动整个应用,并没有办法控制底层的Corda节点。
Corda测试包下的Node Driver给了一种测试方式,但是却无法支撑springboot的测试,所以需要增加辅助测试代码,以支持这种方式的测试。如下:
// src/test/kotlin/spring/SpringDriver.kt
fun springDriver(
defaultParameters: DriverParameters = DriverParameters(),
dsl: SpringBootDriverDSL.() -> A
): A {
return genericDriver(
defaultParameters = defaultParameters,
driverDslWrapper = { driverDSL: DriverDSLImpl -> SpringBootDriverDSL(driverDSL) },
coerce = { it }, dsl = dsl
)
}
@Suppress("DEPRECATION")
data class SpringBootDriverDSL(private val driverDSL: DriverDSLImpl) : InternalDriverDSL by driverDSL {
companion object {
private val log = contextLogger()
}
fun startSpringBootWebapp(clazz: Class<*>, handle: NodeHandle, checkUrl: String): CordaFuture {
val debugPort = if (driverDSL.isDebug) driverDSL.debugPortAllocation.nextPort() else null
val process = startApplication(handle, debugPort, clazz)
driverDSL.shutdownManager.registerProcessShutdown(process)
val webReadyFuture = addressMustBeBoundFuture(driverDSL.executorService, (handle as NodeHandleInternal).webAddress, process)
return webReadyFuture.map { queryWebserver(handle, process, checkUrl) }
}
private fun queryWebserver(handle: NodeHandle, process: Process, checkUrl: String): WebserverHandle {
val protocol = if ((handle as NodeHandleInternal).useHTTPS) "https://" else "http://"
val url = URL(URL("$protocol${handle.webAddress}"), checkUrl)
val client = OkHttpClient.Builder().connectTimeout(5, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).build()
var maxRetries = 30
while (process.isAlive && maxRetries > 0) try {
val response = client.newCall(Request.Builder().url(url).build()).execute()
response.use {
if (response.isSuccessful) {
return WebserverHandle(handle.webAddress, process)
}
}
TimeUnit.SECONDS.sleep(2)
maxRetries--
} catch (e: ConnectException) {
log.debug("Retrying webserver info at ${handle.webAddress}")
}
throw IllegalStateException("Webserver at ${handle.webAddress} has died or was not reachable at URL $url")
}
private fun startApplication(handle: NodeHandle, debugPort: Int?, clazz: Class<*>): Process {
val className = clazz.canonicalName
return ProcessUtilities.startJavaProcessImpl(
className = className, // cannot directly get class for this, so just use string
jdwpPort = debugPort,
extraJvmArguments = listOf(
"-Dname=node-${handle.p2pAddress}-webserver",
"-Djava.io.tmpdir=${System.getProperty("java.io.tmpdir")}"
),
classpath = ProcessUtilities.defaultClassPath,
workingDirectory = handle.baseDirectory,
arguments = listOf(
"--base-directory", handle.baseDirectory.toString(),
"--server.port=${(handle as NodeHandleInternal).webAddress.port}",
"--config.rpc.host=${handle.rpcAddress}",
"--config.rpc.username=${handle.rpcUsers.first().username}",
"--config.rpc.password=${handle.rpcUsers.first().password}",
"--spring.profiles.active=mock"
),
maximumHeapSize = "200m",
errorLogPath = Paths.get("error.$className.log"))
}
}
重写了一个SpringDriver类,然后通过这个辅助类,就可以按照Corda原来的Driver方式运行集成测试了。测试逻辑很简单,就是先通过springDriver
提前启动节点,然后启动springboot应用,连接上节点暴露出的地址和端口,然后就可以测试API了。
// IntegrationTest.kt
class IntegrationTest {
companion object {
private val log = contextLogger()
}
val walmart = TestIdentity(CordaX500Name("walmart", "", "CN"))
@Test
fun `api test`() {
springDriver(DriverParameters(isDebug = true, startNodesInProcess = true, extraCordappPackagesToScan = listOf("com.walmart.contracts"))) {
val nodeHandles = listOf(startNode(providedName = walmart.name)).map { it.getOrThrow() }
log.info("All nodes started")
nodeHandles.forEach { node ->
val handler = startSpringBootWebapp(App::class.java, node, "/api/walmart/status")
val address = handler.getOrThrow().listenAddress
log.info("webserver started on $address")
given()
.port(address.port)
.body("""{ "code": "00001111", "issuer": "Walmart"}""")
.with()
.contentType(ContentType.JSON)
.`when`()
.post("/api/goods")
.then()
.statusCode(201)
}
}
}
完毕。
-- 于 2018-05-06