null

Пишем CRUD REST API на Kotlin с помощью Vert.x и PostgreSQL

Вступление

В предыдущей статье мы рассмотрели пример создания простого REST API с помощью Vert.x. 
Если Вы еще её не читали, то я рекомендую ознакомиться с ней. Вот ссылка :)

Текущая статья является логическим продолжением.
Мы рассмотрим как реализовать сохранение данных в базе данных PostgreSQL. 
В качестве основы мы будем использовать приложение, созданное в предыдущей статье.

И так, приступим.

Добавляем поддержку реактивного клиента PostgreSQL 

Сначала мы должны добавить зависимость клиента PostgreSQL в наш файл build.gradle, чтобы иметь возможность работать с базой данных в коде приложения.

Файл build.gradle:

buildscript {
    ext.kotlin_version = '1.7.0'
    ext.vertx_version = '4.3.1'

    repositories {
        mavenCentral()
    }

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }

    group = 'CrudRestApi'
    version = '1.0.0'
}

plugins {
    id 'org.jetbrains.kotlin.jvm' version "$kotlin_version"
    id 'java'
}

repositories {
    mavenCentral()
}

sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11

dependencies {
    // Kotlin
    implementation 'org.jetbrains.kotlin:kotlin-stdlib'

    // Vertx Core
    implementation "io.vertx:vertx-core:$vertx_version"
    implementation "io.vertx:vertx-lang-kotlin:$vertx_version"

    // Vertx Web
    implementation "io.vertx:vertx-web:$vertx_version"

    // Vertx Rxjava
    implementation "io.vertx:vertx-rx-java3:$vertx_version"
    implementation "io.vertx:vertx-rx-java3-gen:$vertx_version"

    // Vertx Postgresql
    implementation "io.vertx:vertx-pg-client:$vertx_version"
    // Scram SASL SCRAM-SHA-256
    // This is optional
    implementation 'com.ongres.scram:client:2.1'
}

jar {
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE

    manifest {
        attributes 'Implementation-Title': rootProject.name
        attributes 'Implementation-Version': archiveVersion
        attributes 'Main-Class': 'AppKt'
    }

    from {
        configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
    }
}

task cleanAndJar {
    group = 'build'
    description = 'Clean and create jar'

    dependsOn clean
    dependsOn jar
}

compileKotlin {
    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_11
    }
}

Как Вы могли заметить, зависимость добавляется строчкой:

implementation "io.vertx:vertx-pg-client:$vertx_version"

А вот строчка: 

implementation 'com.ongres.scram:client:2.1'

является необязательной. 
Оставьте её если необходима поддержка аутентификации SASL SCRAM-SHA-256.

Изменяем файл HttpServerVerticle.kt для реализации клиента PostgreSQL

Мы сделаем несколько доработок в этом файле, - добавим настройку соединения (pgConnectOptions) и новый экземпляр пула клиентов (pgPoolClient).
Ну и конечно же переделаем все функции эндпоинта, чтобы они взаимодействовали с PostgreSQL.

В итоге файл HttpServerVerticle.kt будет выглядеть следующим образом:

package verticle

import io.vertx.core.Promise
import io.vertx.core.json.JsonArray
import io.vertx.core.json.JsonObject
import io.vertx.pgclient.PgConnectOptions
import io.vertx.rxjava3.core.AbstractVerticle
import io.vertx.rxjava3.ext.web.Router
import io.vertx.rxjava3.ext.web.RoutingContext
import io.vertx.rxjava3.ext.web.handler.BodyHandler
import io.vertx.rxjava3.pgclient.PgPool
import io.vertx.rxjava3.sqlclient.Tuple
import io.vertx.sqlclient.PoolOptions

class HttpServerVerticle : AbstractVerticle() {
    private lateinit var pgPoolClient: PgPool

    override fun start(promise: Promise<Void>) {
        val pgConnectOptions = PgConnectOptions()
            .setPort(5432)
            .setHost("localhost")
            .setDatabase("marvel_universe")
            .setUser("groot")
            .setPassword("donttellothers")

        val poolOptions = PoolOptions().setMaxSize(5)

        pgPoolClient = PgPool.newInstance(io.vertx.pgclient.PgPool.pool(
            vertx.delegate,
            pgConnectOptions,
            poolOptions))

        val router = Router.router(vertx).apply {
            get("/api/users").handler(this@HttpServerVerticle::getUsers)
            post("/api/users").handler(BodyHandler.create()).handler(this@HttpServerVerticle::setUser)
            put("/api/users").handler(BodyHandler.create()).handler(this@HttpServerVerticle::updateUser)
            delete("/api/users").handler(this@HttpServerVerticle::deleteUser)
        }

        vertx
            .createHttpServer()
            .requestHandler(router)
            .rxListen(8282)
            .subscribe(
                { promise.complete() },
                { failure -> promise.fail(failure.cause) })
    }

    private fun getUsers(context: RoutingContext) {
        var response: JsonObject

        pgPoolClient.rxGetConnection()
            .flatMap { sqlConnection ->
                sqlConnection.query("SELECT * FROM heroes")
                    .rxExecute()
                    .map { rows ->
                        JsonArray().apply {
                            rows.forEach { row ->
                                add(JsonObject().apply {
                                    put("user_id", row.getString("user_id"))
                                    put("user_name", row.getString("user_name"))
                                    put("name_alias", row.getString("name_alias"))
                                    put("company", row.getString("company"))
                                })
                            }
                        }
                    }
                    .doFinally {
                        sqlConnection.close()
                    }
            }
            .subscribe(
                { data ->
                    response = JsonObject().apply {
                        put("success", true)
                        put("data", data)
                    }

                    putResponse(context, 200, response)
                },
                {
                    response = JsonObject().apply {
                        put("success", false)
                        put("error", it.message)
                    }

                    putResponse(context, 500, response)
                }
            )
    }

    private fun setUser(context: RoutingContext) {
        var response: JsonObject

        val userId = context.request().getParam("user_id")
        val userName = context.request().getParam("user_name")
        val nameAlias = context.request().getParam("name_alias")
        val company = context.request().getParam("company")

        pgPoolClient.rxGetConnection()
            .flatMap { sqlConnection ->
                sqlConnection
                    .preparedQuery("INSERT INTO heroes(user_id, user_name, name_alias, company) " +
                        "VALUES ($1, $2, $3, $4)")
                    .rxExecute(Tuple.of(userId, userName, nameAlias, company))
                    .doFinally {
                        sqlConnection.close()
                    }
            }
            .subscribe(
                {
                    response = JsonObject().apply {
                        put("success", true)
                        put("action", "insert")
                    }

                    putResponse(context, 200, response)
                },
                {
                    response = JsonObject().apply {
                        put("success", false)
                        put("error", it.message)
                    }

                    putResponse(context, 500, response)
                }
            )
    }

    private fun updateUser(context: RoutingContext) {
        var response: JsonObject

        val userId = context.request().getParam("user_id")
        val userName = context.request().getParam("user_name")
        val nameAlias = context.request().getParam("name_alias")
        val company = context.request().getParam("company")

        pgPoolClient.rxGetConnection()
            .flatMap { sqlConnection ->
                sqlConnection.preparedQuery("UPDATE heroes " +
                        "SET user_name=$1, name_alias=$2, company=$3 " +
                        "WHERE user_id=$4")
                    .rxExecute(Tuple.of(userName, nameAlias, company, userId))
                    .doFinally {
                        sqlConnection.close()
                    }
            }
            .subscribe(
                {
                    response = JsonObject().apply {
                        put("success", true)
                        put("action", "update")
                    }

                    putResponse(context, 200, response)
                },
                {
                    response = JsonObject().apply {
                        put("success", false)
                        put("error", it.message)
                    }

                    putResponse(context, 500, response)
                }
            )
    }

    private fun deleteUser(context: RoutingContext) {
        var response: JsonObject

        val userId = context.request().getParam("user_id")

        pgPoolClient.rxGetConnection()
            .flatMap { sqlConnection ->
                sqlConnection.preparedQuery("DELETE FROM heroes WHERE user_id=$1")
                    .rxExecute(Tuple.of(userId))
                    .doFinally {
                        sqlConnection.close()
                    }
            }
            .subscribe(
                {
                    response = JsonObject().apply {
                        put("success", true)
                        put("action", "delete")
                    }

                    putResponse(context, 200, response)
                },
                {
                    response = JsonObject().apply {
                        put("success", false)
                        put("error", it.message)
                    }

                    putResponse(context, 500, response)
                })
    }

    private fun putResponse(context: RoutingContext, statuscode: Int, response: JsonObject) {
        context.response().statusCode = statuscode

        context.response().putHeader("Content-Type", "application/json")
        context.response().end(response.encode())
    }
}

 

Рассмотрим этот код.

С помощью PgConnectOptions() мы задаем параметры соединения к базе данных.
Затем мы вызываем PgPool.newInstance() для создания нового экземпляра пула клиентов.
Максимальный размер пула (то есть кол-во параллельных независимых друг от друга соединений к базе данных в рамках одного пула) мы задаем равным пяти. Однако Вы можете задать любое нужное Вам значение. 

В каждой из функций эндпоинта, запрос к базе данных начинается с получения sqlConnection - доступного соединения из пула. Достигается это с помощью вызова pgPoolClient.rxGetConnection().
Далее мы формируем sql-запрос (в рамках методов query/preparedQuery). И наконец, вызывая rxExecute(), мы осуществляем выполнение запроса.    

В случае, если выполняется запрос на выборку данных SELECT, метод rxExecute() возвратит набор строк (rows) из базы данных. 
Для того, чтобы преобразовать эти строки в массив Json, мы сначала используем .map(), а затем rows.forEach() в сочетании с JsonArray().apply().

Если в запрос необходимо передать какие-либо параметры, например, значения переменных в коде, то используется метод SqlConnection.preparedQuery().

Например как здесь:

     .preparedQuery("INSERT INTO heroes(user_id, user_name, name_alias, company) " +
                        "VALUES ($1, $2, $3, $4)")
                    .rxExecute(Tuple.of(userId, userName, nameAlias, company))

В SQL-строке мы указываем места для размещения (placeholders) параметров: $1, $2 и т.д, а в методе rxExecute передаем сами эти параметры в соответствующем порядке.

После выполнения каждого из запросов мы вызываем .doFinally() и в нём закрываем соединение с базой.
Это очень важный момент.
Если не закрывать соединение, то может случиться так, что при попытке получить доступное соединение у пула (.rxGetConnection()) мы столкнемся с ошибкой из-за того, что весь ресурс пула уже занят. Также возможен вариант, когда запрос на соединение зависнет до тех пор, пока не дождётся свободного ресурса.

Вместо заключения

Выражаю благодарность автору Mei Rizal F, так как данная статья является моим отредактированным переводом его статьи "CRUD REST API Using Vert.x and PostgreSQL in Kotlin".

Полный исходный код рассмотренного нами приложения, Вы можете найти на странице автора в GitHub по ссылке https://github.com/merizrizal/vertx-simple-rest-api/tree/crud-postgresql