G'day:
Not sure what this article is going to end up being about. However I am hovering over the "New Project" button in IntelliJ, and am gonna attempt to at least get to a "G'day world" sort of situation with a Ktor-driven web service today.
Why Ktor
We need to port our monolithic CFML/CFWheels app to a more… erm… forward-thinking and well-designed solution. The existing app got us to where we are, and pays our salaries, but its design reflects a very "CFML-dev" approach to application design. We've decided to shift to Kotlin, as you know. We also need to adopt some sort of framework to implement the application atop-of, and we've chosen Ktor for a few reasons:
- It's focus is micro-services and small footprint.
- From what I've read, it focuses on being a framework instead of being an opinion-mill, how other frameworks can tend to be.
- It's written for Kotlin; unlike say Spring, which is written for Java and it shows. We're using Kotlin to have the benefits of the JVM, but to steer clear the Java Way™ of doing things.
- It's created by JetBrains, who created Kotlin, so hopefully the Ktor design team with be aligned with the Kotlin design team, so it should be a pretty Kotlin-idiomatic way of doing things.
- Support for it is baked-in to IntelliJ, so it's a "first class citizen" in the IDE.
Also basically we need to pick something, so we're cracking on with it. If we do some quick investigation and it turns our Ktor ain't for us: I'd rather know sooner rather than later.
Let's get on with it.
Project
One can create a new Ktor project via IntelliJ ("New Project"):
I've only filled in the situation-specific stuff here, and left everything else as default. I've clicked the "Create Git repository" option: I hope if gives me the option to provide a name for it before it charges off and does it, cos I don't want it just to be called "gdayworld". So I might back out of that choice if it doesn't work for me:
Let's press "Next"…
Argh! I have to make decisions! I haven't even finished my first coffee of the day yet!
There are roughly one million plug-ins on offer here, and I don't even know what most of them are. For now, all I need this thing to do is to have testing for a greeting endpoint that says "G'day world" or something, so I doubt I'll need most of this stuff. Let's have a scan through.
OK, I've selected these ones:
- Routing
- DefaultHeaders
- CallLogging
- CallId
- kotlinx.serialization - this also required the ContentNegotiation plug-in
After clicking "create" it got on with it, downloaded some stuff, built the project and declared everything was fine. I now have this lot:
Baseline checks
Right, let's see what tests it installed by default:
package me.adamcameron
import io.ktor.server.routing.*
import io.ktor.http.*
import io.ktor.server.plugins.callloging.*
import org.slf4j.event.*
import io.ktor.server.request.*
import io.ktor.server.plugins.callid.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlin.test.*
import io.ktor.server.testing.*
import me.adamcameron.plugins.*
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
configureRouting()
}
client.get("/").apply {
assertEquals(HttpStatusCode.OK, status)
assertEquals("Hello World!", bodyAsText())
}
}
}
Most of those imports aren't necessary btw, that's a wee bit sloppy. It only claims to need these ones:
import io.ktor.http.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlin.test.*
import io.ktor.server.testing.*
import me.adamcameron.plugins.*
I'll leave it as-is for now. The test looks sound actually. Well: I've purposely not looked at the code yet, but a test that tests that a GET to / returns "Hello World!" seems reasonable. Let's run it:
Cool. OK, let's run the app then, given it looks like it'll work:
C:\Users\camer\.jdks\semeru-11.0.17\bin\java.exe […]
2022-10-16 11:01:49.119 [main] INFO ktor.application - Autoreload is disabled because the development mode is off.
2022-10-16 11:01:49.205 [main] INFO ktor.application - Application started in 0.148 seconds.
2022-10-16 11:01:49.205 [main] INFO ktor.application - Application started: io.ktor.server.application.Application@57312fcd
2022-10-16 11:01:50.448 [DefaultDispatcher-worker-1] INFO ktor.application - Responding at http://127.0.0.1:8080
It ran. Does it actually respond on http://127.0.0.1:8080?
Cool. OK, so I have a test that passes an an app that works. Gonna push that to GitHub as v0.2 (v0.1 was the empty repo). And I'm gonna have a shufti around the files it's created and see what's what.
Tweaking
OK, I'm not gonna look at those old-school xUnit-style tests. I'm gonna adapt them to use the more declarative BDD style I've been using so far when testing stuff with Kotlin. So this means I'm going to add some Kotest dependencies. The test is now:
@DisplayName("Tests of the / route")
class ApplicationTest {
@Test
fun `Tests the root route responds with the correct status and message`() = testApplication {
application {
configureRouting()
}
client.get("/").apply {
status shouldBe HttpStatusCode.OK
bodyAsText() shouldBe "Hello World!"
}
}
}
I'm also refactoring the class name and location to src/test/kotlin/acceptance/IndexRouteTest.kt. It's not testring the app, it's testing the route. Plus it's an acceptance test, and I wanna keep those separate from unit tests / integration tests etc (poss premature optimisation here I guess). I've also lost the subdirectory structure from /src/main/kotlin/me/adamcameron/Application.kt to be just /src/main/kotlin/Application.kt. Kotlin's own style guide recommends this:
In pure Kotlin projects, the recommended directory structure follows the package structure with the common root package omitted. For example, if all the code in the project is in the org.example.kotlin package and its subpackages, files with the org.example.kotlin package should be placed directly under the source root, and files in org.example.kotlin.network.socket should be in the network/socket subdirectory of the source root.
Next I feel there's a design bug in the index route, but I'm gonna push my current tweaks first, and sort that out in the next section.
Giving control to a controller
This design bug: here's the entirety of the implementation of that index route and its handling:
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
That's in /src/main/kotlin/plugins/Routing.kt
Routing should limit itself to what it says ion the tin: routing. It should not be providing the response. It should route the request to a controller which should control how the response is handled. I know this is only example code, but example could should still follow appropriate design practices. So erm: now I have to work out how to create a controller in Ktor. I'm pleased I have a green test on that index route though, cos this is all pretty much a refactoring exercise, so whatever I do: in the end I'll know I have done good if the test still passes.
Hrm. Having not found any examples in the Ktor docs of how to extract controller code out of the routing class, I found myself reading Application structure, specifically these paras:
Different to many other server-side frameworks, it doesn't force us into a specific pattern such as having to place all cohesive routes in a single class name CustomerController for instance. While it is certainly possible, it's not required.
Frameworks such as ASP.NET MVC or Ruby on Rails, have the concept of structuring applications using three folders - Model, View, and Controllers (Routes).
My emphasis. I see. Ktor does not separate-out the idea of routing from the idea of controllers, I see. To me they're different things, but I guess I can see there's overlap. I'm not hugely enamoured with their thinking that "despite the rest of the world using the term MVC, we know better: we're gonna think of it as MVR". Just… why. If you wanna conflate routing and controllers, yeah fine. But in that case they conflate into the controller part of MVC. You don't just go "ah nah it's MVR, trust me". Remember what I said before about opinionated frameworks? This is why I don't like it when frameworks have opinions.
But anyway.
We can still separate out groups of "route-handlers" (sigh) into separate functions. ow I have this:
package routes
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.indexRouting() {
route("/") {
get {
call.respondText("Hello World!")
}
}
}
And my original configureRouting function is just this:
fun Application.configureRouting() {
routing {
indexRouting()
}
}
That's good enough.
One good thing my RTFMing about controllers lead me to was how to get my app to rebuild / reload when I make code changes. By default every time I changed my code I had to shut down the app (remember it's serving a web app now), rebuild, and then re-run the app. That was not the end of the world, but was pretty manual.
Ktor have thought about this, and the solution is easy.
First, I tell my app it's in development mode (in gradle.properties):
junitJupiterVersion=5.9.0
kotestVersion=5.5.0
kotlinVersion=1.7.20
ktorVersion=2.1.2
logbackVersion=1.2.11
kotlin.code.style=official
org.gradle.warning.mode=all
development=true
This in turn is picked up by code in build.gradle.kts
application {
mainClass.set("ApplicationKt")
val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
}
(that code was already there).
Then I needed to tell the app what to pay attention to for reloading (in Application.kt):
fun main() {
embeddedServer(Netty, port = 8080, host = "0.0.0.0", watchPaths = listOf("classes")) {
configureMonitoring()
configureSerialization()
configureRouting()
}.start(wait = true)
}
classes there is a reference to build/classes in the project file system.
Then get Gradle to rebuild when any source code changes:
PS C:\src\kotlin\ktor\gdayworld> ./gradlew --continuous :build
BUILD SUCCESSFUL in 1s
13 actionable tasks: 13 up-to-date
Waiting for changes to input files... (ctrl-d then enter to exit)
<-------------> 0% WAITING
> IDLE
And another instance of Gradle to run the app with it watching the build results:
PS C:\src\kotlin\ktor\gdayworld> ./gradlew :run
> Task :run
2022-10-16 14:21:19.374 [main] DEBUG ktor.application - Java Home: C:\apps\openjdk\EclipseAdoptium
2022-10-16 14:21:19.374 [main] DEBUG ktor.application - Class Loader: jdk.internal.loader.ClassLoaders$AppClassLoader@73d16e93:...]
2022-10-16 14:21:19.390 [main] DEBUG ktor.application - Watching C:\src\kotlin\ktor\gdayworld\build\classes\kotlin\main\me\adamcameron\plugins for changes.
2022-10-16 14:21:19.390 [main] DEBUG ktor.application - Watching C:\src\kotlin\ktor\gdayworld\build\classes\kotlin\main\routes for changes.
2022-10-16 14:21:19.390 [main] DEBUG ktor.application - Watching C:\src\kotlin\ktor\gdayworld\build\classes\kotlin\main\me for changes.
2022-10-16 14:21:19.390 [main] DEBUG ktor.application - Watching C:\src\kotlin\ktor\gdayworld\build\classes\kotlin\main for changes.
2022-10-16 14:21:19.390 [main] DEBUG ktor.application - Watching C:\src\kotlin\ktor\gdayworld\build\classes\kotlin\main\META-INF for changes.
2022-10-16 14:21:19.390 [main] DEBUG ktor.application - Watching C:\src\kotlin\ktor\gdayworld\build\classes\kotlin\main\me\adamcameron for changes.
2022-10-16 14:21:19.390 [main] DEBUG ktor.application - Watching C:\src\kotlin\ktor\gdayworld\build\classes\kotlin\main\plugins for changes.
2022-10-16 14:21:19.562 [main] INFO ktor.application - Application started in 0.298 seconds.
2022-10-16 14:21:19.562 [main] INFO ktor.application - Application started: io.ktor.server.application.Application@5a45133e
2022-10-16 14:21:19.937 [main] INFO ktor.application - Responding at http://127.0.0.1:8080
<===========--> 85% EXECUTING [15s]
> :run
When I change any source code now, the project rebuilds, and the app notices the recompiled classes, and restarts itself:
modified: C:\src\kotlin\ktor\gdayworld\src\main\kotlin\routes\IndexRoutes.kt
Change detected, executing build...
BUILD SUCCESSFUL in 6s
13 actionable tasks: 12 executed, 1 up-to-date
Waiting for changes to input files... (ctrl-d then enter to exit)
<=============> 100% EXECUTING [8m 55s]
2022-10-16 14:26:20.438 [eventLoopGroupProxy-4-2] INFO ktor.application - 200 OK: GET - /
2022-10-16 14:26:34.861 [eventLoopGroupProxy-3-1] INFO ktor.application - Changes in application detected.
2022-10-16 14:26:35.073 [eventLoopGroupProxy-3-1] DEBUG ktor.application - Changes to 18 files caused application restart.
[...]
2022-10-16 14:26:35.106 [eventLoopGroupProxy-3-1] INFO ktor.application - Application auto-reloaded in 0.012 seconds.
2022-10-16 14:26:35.106 [eventLoopGroupProxy-3-1] INFO ktor.application - Application started: io.ktor.server.application.Application@33747fec
2022-10-16 14:26:35.107 [eventLoopGroupProxy-4-2] INFO ktor.application - 200 OK: GET - /
<===========--> 85% EXECUTING [5m 32s]
> :run
Note how the app doesn't restart until I actually use it, which is good thinking.
One might as why I have dropped down to a shell to do this autoload stuff? As far as I can tell it's not baked into IntelliJ yet, so needs to be handled directly by Gradle for now. It's not a hardship. I mean: the shells I am running there are being run from within IntelliJ, it's just slightly more complicated than a key-combo or some mouseclicks.
OK. That's all good progress. I'm gonna take a break and come back and create my own controller / response / etc, which is what the object of the exercise was today.
Docker
Ktor's way
I was not expecting this to be the next step, but I just spotted some stuff about Docker in the Ktor docs ("Docker"), so I decided to see what they said.
[time passes whilst I do battle with the docs]
OK, screw that. It's a very perfunctory handling of it. I can build a jar and create an image that will run it, and then run the container - and it all works - but it's… a bit… "proof of concept". From reading the docs and the code snippets that link from the docs (Deployment - Ktor plugin › Build and run a Docker image).
I had to add this to my build.gradle.kts file:
ktor {
fatJar {
archiveFileName.set("gday-world-ktor.jar")
}
docker {
jreVersion.set(io.ktor.plugin.features.JreVersion.JRE_17)
localImageName.set("gday-world-ktor")
imageTag.set("${project.version}-preview")
portMappings.set(listOf(
io.ktor.plugin.features.DockerPortMapping(
8080,
8080,
io.ktor.plugin.features.DockerPortMappingProtocol.TCP
)
))
}
}
And then from the shell I could run this lot:
PS C:\src\kotlin\ktor\gdayworld> ./gradlew :buildFatJar
[…]
PS C:\src\kotlin\ktor\gdayworld> ./gradlew :runDocker
And I would indeed end up with a running Docker container. Which is handy, but I had no control over what params were passed to docker run, so I couldn't even give the container a name, so I just ended up with one of Docker's random ones. That's a bit amateurish. I checked to see if I was missing anything with the plugin, but this is the code (from Ktor's repo on GitHub):
private abstract class RunDockerTask : DefaultTask() {
@get:Inject
abstract val execOperations: ExecOperations
@get:Input
abstract val fullImageName: Property<String>
@TaskAction
fun execute() {
val dockerExtension = project.getKtorExtension<DockerExtension>()
execOperations.exec {
it.commandLine(buildList {
add("docker")
add("run")
for (portMapping in dockerExtension.portMappings.get()) {
add("-p")
with(portMapping) {
add("${outsideDocker}:${insideDocker}/${protocol.name.lowercase()}")
}
}
add(fullImageName.get())
})
}
}
}
It looks to me like it simply builds a string docker run [port mappings] [image name], and that's it. No scope for me to specify any other of docker run's parameters in my build config.
So: nah, not doing that; I'll DIY. It's at least shown me what I need to do in a DockerFile and I can organise my own docker-compose.yml file.
My way
[…]
I have this docker/Dockerfile:
FROM gradle:7-jdk17 AS build
COPY --chown=gradle:gradle .. /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle test --no-daemon
RUN gradle buildFatJar --no-daemon
FROM openjdk:17
EXPOSE 8080:8080
RUN mkdir /app
COPY --from=build /home/gradle/src/build/libs/*.jar /app/gday-world-ktor.jar
ENTRYPOINT ["java","-jar","/app/gday-world-ktor.jar"]
This is pretty much lifted from the Ktor Docker › Prepare Docker image docs I linked to above, I've just added the test-round in first.
And this docker/docker-compose.yml:
version: '3'
services:
gday-world-ktor:
build:
context: ..
dockerfile: docker/Dockerfile
ports:
- "8080:8080"
stdin_open: true
tty: true
And when I run docker-compose up --build --detach, after a couple of minutes, I get an up and running container with my app in it. Bonus: it halts if my tests don't first pass.
I'm not enamoured with the "after a couple of minutes" part of this: seems really slow for what it needs to do. I am "sure" there must be a way of telling Gradle to do the build, test-run and jar-build all on one operation. However I'm over googling things starting with "gradle" today, so I'm gonna leave it for now.
I'm pretty happy with the progress I made today.
Righto.
--
Adam