Привет, Хабр! Следующих этап изучения нового языка это старый добрый todo list только не простой а с загрузкой и скачиванием картинок чтобы научится работе с базой данных и файловой системой. За подробностями добро пожаловать под кат.
Содержание
- Изучаю Scala: Часть 1 — Игра змейка
- Изучаю Scala: Часть 2 — Todo лист с возможностью загрузки картинок
Ссылки
Исходники
Образы docker image
API
Описание апи в swagger и эндпойнты я сделал с помощью Tapir. Он позволяет своим DSL описать API которое мы хотим реализовать.
//Пытаемся выполнить IO и если случается ошибка то бросаем http код 500
def withStatus[A](f: IO[A]): IO[Either[(StatusCode, String), A]] =
f.attempt.map(x => x match {
case Right(value) => Right(value)
case Left(value) => Left(StatusCode.InternalServerError, value.getMessage)
})
//Описываем что все наши эндпоинты буду начинаться с api/v1 и возвращать в случае ошибки http код и текст ошибки
val baseEndpoint = endpoint
.in("api" / "v1")
.errorOut(statusCode.and(stringBody))
//Описываем что все эндпоинты для изображений будут по адресу api/v1/images
//и иметь тег Imgage.
//По тегам идет группировка в Swagger. Например на КДПВ эндпойнты собраны в две группы
//Images и Todos
private val baseImageEndpoint = baseEndpoint
.in("images")
.tag("Images")
private val download = baseImageEndpoint
.summary("Скачать картинку")
.description("Скачивает картинку по ее идентификатору")
//Эндпоинт будет реагировать на GET запрос
.get
// Эндпоинт будет брать переменную id из пути т.е для lacalhost:8080/api/v1/images/2 он передаст в id = 2
.in(path[Long]("id"))
//Запрос будет возвращать в ответе хедер ContentLengsh
.out(header[Long](HeaderNames.ContentLength))
//Запрос будет возвращать в ответ бинарный файл
.out(streamBody[Stream[IO, Byte]](schemaFor[File], CodecFormat.OctetStream()))
//Логика обработки запроса. Тут мы просто вызываем метод нашего сервиса
.serverLogic(x => withStatus(imagesService.download(x)))
на основе коллекции таких эндпойнтов создаются роуты, а на основе них документация Swagger
endpoints = todosController.endpoints ::: imagesController.endpoints
routes = endpoints.toRoutes;
docs = endpoints.toOpenAPI("The Scala Todo List", "0.0.1")
yml: String = docs.toYaml
appRoutes = routes <+> new SwaggerHttp4s(yml, "swagger").routes[IO]
Server
В качестве сервера Tapir поддерживает несколько бекендов. Я использовал http4s
httpApp = Router(
"/" -> appRoutes
).orNotFound
blazeServer <- BlazeServerBuilder[IO](serverEc)
.bindHttp(settings.host.port, settings.host.host)
.withHttpApp(httpApp)
.resource
Работа с файлами и стримы
Для работы с файлами я использовал стримы из fs2
import fs2.{Stream, io}
def get(path: Path): Stream[IO, Byte] =
io.file.readAll[IO](path, blocker, 4096)
Работа с базой данных
Для работы с БД я использовал doobie и он мне чертовски понравился потому что напомнил старый добрый Dapper ORM. Позволяет маппить DTO и выполнять SQL запросы.
def add(image: Image): IO[Long] = sql"""
INSERT INTO images (hash, file_path)
VALUES (${image.hash}, ${image.filePath})""".update
//Запрос будет возвращать созданный id новой записи в БД
.withUniqueGeneratedKeys[Long]("id")
.transact(xa)
Для миграций подключил Flyway
val flyway = Flyway
.configure()
.dataSource(settings.db.url, settings.db.user, settings.db.password)
.load()
flyway.migrate()
CREATE TABLE IF NOT EXISTS IMAGES (
id SERIAL PRIMARY KEY,
hash VARCHAR NOT NULL UNIQUE,
file_path VARCHAR NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS TODOS (
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL UNIQUE,
image_id BIGINT REFERENCES images,
created TIMESTAMP
);
Сборка и упаковка в образ Docker
Я захотел собрать все в один единственный файл как например делает это Go или .NET Core с нужными настройками поэтому использовал sbt-native-packager и плагин к нему sbt-assembly. Собранный файл можно запустить с помощью команды
java -jar <имя файла>
Потом сделал DockerFile для запуска этого образа в контейнере
FROM hseeberger/scala-sbt:11.0.2-oraclelinux7_1.3.12_2.13.3 AS base
COPY . /root
WORKDIR /root
RUN sbt universal:packageZipTarball
RUN sbt test
FROM openjdk:15-alpine as final
COPY --from=base /root/target/scala-2.13/scala-todo-api.jar /root
WORKDIR /root
EXPOSE 8080
ENTRYPOINT ["java","-jar","scala-todo-api.jar"]
Собранный образ автоматом отправляется в Registry гитлаба через его встроенный CI/CD
Настройки
Настройки сервера загружаю с помощью библиотеки PureConfig и потом так как я использую Docker дополняю их из переменных окружения. Файл application.conf:
db {
//Два раза повторяем переменную потому что если в переменной окружения TODO_API_DB_URL будет пусто то будет использован первый вариант иначе его перезапишет переменная окружения.
url = "jdbc:postgresql://localhost:5432/todos_db"
url = ${?TODO_API_DB_URL}
user = "postgres"
user = ${?TODO_API_DB_USER}
password = "postgres"
password = ${?TODO_API_DB_PASSWORD}
}
host {
port = 8080
port = ${?TODO_API_HOSTING_PORT}
host = "0.0.0.0"
host = ${?TODO_API_HOSTING_HOST}
}
val config = ConfigSource.default.load[AppSettings]
Hokum
А в чем смысл подключать sbt-native-packager и писать Dockerfile? Если можно просто вызвать комаду:
или
Все что оставалось — дописать в build.sbt строку с указаением выставляемого порта:
Можно было просто подключить sbt-assembly и на это ограничится, если по каким-то причинам сами хотите писать Dockerfile сами. Да и к тому же и sbt-assembly и sbt-native-packager уже сами вызывают запуск тестов, зачем отдельно это еще делать?
И зачем запускать тесты, если их нет?