Изучаю Scala: Часть 2 — Todo лист с возможностью загрузки картинок


    Привет, Хабр! Следующих этап изучения нового языка это старый добрый todo list только не простой а с загрузкой и скачиванием картинок чтобы научится работе с базой данных и файловой системой. За подробностями добро пожаловать под кат.

    Содержание



    Ссылки


    Исходники
    Образы 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]
    
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 1

      0

      А в чем смысл подключать sbt-native-packager и писать Dockerfile? Если можно просто вызвать комаду:


      sbt docker:publishLocal

      или


      sbt docker:publish

      Все что оставалось — дописать в build.sbt строку с указаением выставляемого порта:


      dockerExposedUdpPorts += 8080

      Можно было просто подключить sbt-assembly и на это ограничится, если по каким-то причинам сами хотите писать Dockerfile сами. Да и к тому же и sbt-assembly и sbt-native-packager уже сами вызывают запуск тестов, зачем отдельно это еще делать?


      И зачем запускать тесты, если их нет?

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

      Самое читаемое