Scala - Przykład Hexagonal Architecture i dobre praktyki w Akka HTTP

2

Cześć!

Po pooglądaniu paru wystąpień @jarekr000000 na temat wad beanów, Springa, AOP oraz faktu, że można obejść się bez kontenera DI w pisaniu serwisów, w ramach nauki Scali i FP tworzę sobie prostą apkę z wykorzystaniem Scali, MongoDB, Akka HTTP jako REST API.
Wykorzystuję tu architekturę hexagonalną/porty adaptery oraz brak kontenera DI. Zdaję sobie sprawę, że nie jest konieczna na projekt takiej małej wielkości, ale robię w ramach nauki :D

Mam kilka pytań do doświadczonych kolegów, którzy piszą funkcyjnie @jarekr000000 @KamilAdam @0xmarcin @Wibowit odnośnie dobrych praktyk w tego typu podejściu funkcyjnym oraz już w samej Akkce HTTP, bo dość ciężko znaleźć informacje :D

Z góry przepraszam za nierfaktoryzowany kod i za brak obsługi walidacji, zdefiniowanie aktorów w main component i brak osobnych objectów dla bazy danych itd, ale robię to w ramach nauki, potem będę sprzątał ;)
Nie dodawałem tu również Entity object jak w DDD, bo przy takim CRUD po prostu nie ma sensu :D

Do komuniakcji z Mongo używam asynchronicznego MongoDB Scala Driver, który jest oficjalnym driverem dla Scali: https://mongodb.github.io/mongo-java-driver/4.2/driver-scala/

Pytania:

  1. LinkMongoAdapter jest adapterem wychodzącym i komunikuje się z MongoDB. Jak widzicie, używam tutaj funkcji transformWith, która tworzy Future w zależności od tego czy dokument został znaleziony. Czy takie podejście jest ok, czy lepiej może do Future użyć Either, gdzie Left będzie reprezentować błąd a Right zwrócony case class?
    Następnie oczywiście zwracam to do LinkFacade a potem do LinkController

  2. Gdzie powinien być umiesczony error handling? Czy dozwolone jest zrobienie tego w LinkMongoAdapter, czy lepiej zwracać zwykły Future do LinkFacade, a w fasadzie obsługiwać error handling czyli to co robię teraz w LinkMongoAdapter przez transformWith z Success/Failure albo z wykorzystaniem Option/Either?

  3. Gdzie w Akka HTTP umieszać defininicje Routes? Obecnie trzymam je w LinkController, co moim zdaniem pasuje według SRP, bo tam są też funkcje obsługujące konkretne Route'y (create i findOne), czy lepiej będzie je trzymać w osobnym Object/klasie i tym samym pliku?

  4. Obsługa błędów wejścia np. pusty url, description itd powinna być realizowana standardowo poprzez zwrócenie np. case class Response z polami statusCode, message itp?
    Najlepiej umieścić ją w kontrolerze np. LinkController? Pytam bo w Spring Boot używam do tego osobnego kontrolera z adnotacją @ControllerAdvice :D

Z góry dzięki za pomoc! Poniżej kod:

LinkskapApplication (Reprezentuje koncepcję main component z Clean Architecture, gdzie są inicjalizacje wszystkich modułów itd)

object LinkskapApplication {

  implicit val system: ActorSystem = ActorSystem()
  implicit val executionContext: ExecutionContextExecutor = system.dispatcher

  def main(args: Array[String]): Unit = {
    val linkPersistenceAdapter: LinkPersistenceAdapter = new LinkMongoAdapter()
    val linkFacade: LinkFacade = new LinkFacade(linkPersistenceAdapter)
    val linkController: LinkController = new LinkController(linkFacade)

    val config: Config = ConfigFactory.load()
    val interface: String = config.getString("http.interface")
    val port: Int = config.getInt("http.port")

    val futureBinding = Http().newServerAt(interface, port).bind(linkController.routes)

    StdIn.readLine()
    futureBinding
      .flatMap(_.unbind())
      .onComplete(_ => system.terminate())
  }
}

LinkController

class LinkController(val linkFacade: LinkFacade) {

  private val logger: Logger = LoggerFactory.getLogger(getClass)
  private implicit val createLinkRequestFormat: RootJsonFormat[CreateLinkRequest] = jsonFormat3(CreateLinkRequest)
  private implicit val createLinkResponseFormat: RootJsonFormat[CreateLinkResponse] = jsonFormat4(CreateLinkResponse)
  private implicit val getLinkResponseFormat: RootJsonFormat[GetLinkResponse] = jsonFormat4(GetLinkResponse)

  val routes: Route = {
    pathPrefix(LinkController.LINK) {
      concat(
        pathEnd {
          concat(
            create()
          )
        },
        path(Segment) { id:String =>
          concat(
            findOne(id)
          )
        }
      )
    }
  }

  private def create():Route = post {
    entity(as[CreateLinkRequest]) { createLinkRequest: CreateLinkRequest =>
      onComplete(linkFacade.create(createLinkRequest)) {
        case Success(createLinkResponse) => complete(StatusCodes.Created, createLinkResponse)
        case Failure(exception) => failWith(exception)
      }
    }
  }

  private def findOne(id: String):Route = get {
    onComplete(linkFacade.findOne(id)) {
      case Success(getLinkResponse) => complete(StatusCodes.OK, getLinkResponse)
      case Failure(exception) => failWith(exception)
    }
  }
}

object LinkController {
  val LINK: String = "links"
}

LinkFacade

class LinkFacade(linkPersistenceAdapter: LinkPersistenceAdapter) {

  def create(createLinkRequest: CreateLinkRequest): Future[CreateLinkResponse] = {
    linkPersistenceAdapter.create(createLinkRequest)
  }

  def findOne(id: String): Future[GetLinkResponse] = linkPersistenceAdapter.findOne(id)
}

LinkPersistenceAdapter

trait LinkPersistenceAdapter {
  def create(createLinkRequest: CreateLinkRequest): Future[CreateLinkResponse]
  def findOne(id: String): Future[GetLinkResponse]
}

LinkMongoAdapter

class LinkMongoAdapter extends LinkPersistenceAdapter {

  private val codecRegistry = fromRegistries(fromProviders(classOf[MongoLink]), DEFAULT_CODEC_REGISTRY)
  private val mongoClient: MongoClient = MongoClient("mongodb://localhost:27017")
  private val database: MongoDatabase = mongoClient.getDatabase("linkskap").withCodecRegistry(codecRegistry)
  private val collection: MongoCollection[MongoLink] = database.getCollection("links")

  override def create(createLinkRequest: CreateLinkRequest): Future[CreateLinkResponse] = {
    val createdLink: MongoLink = MongoLink(createLinkRequest.url, createLinkRequest.title, createLinkRequest.description)
    collection.insertOne(createdLink).toFuture().transformWith {
      case Success(result) =>
        val id: String = result.getInsertedId.asObjectId().getValue.toString
        Future(CreateLinkResponse(id, createLinkRequest.url, createLinkRequest.title, createLinkRequest.description))
      case Failure(exception) => Future.failed(exception)
    }
  }

  override def findOne(id: String): Future[GetLinkResponse] = {
    val linkFuture: Future[Seq[MongoLink]] = collection.find(Filters.eq("_id", new ObjectId(id))).toFuture()
    linkFuture.transformWith {
      case Success(result) =>
        val mongoLink: MongoLink = result.head
        Future(GetLinkResponse(mongoLink._id.toString, mongoLink.url, mongoLink.title, mongoLink.description))
      case Failure(exception) => Future.failed(exception)
    }
  }
}

MongoLink

case class MongoLink(_id: ObjectId, url: String, title: String, description: String)

object MongoLink {
  def apply(url: String, title: String, description: String): MongoLink = {
    MongoLink(new ObjectId(), url: String, title: String, description: String)
  }
}

DTOsy

CreateLinkRequest

case class CreateLinkRequest(url: String, title: String, description: String)

CreateLinkResponse

case class CreateLinkResponse(id: String, url: String, title: String, description: String)

GetLinkResponse

case class GetLinkResponse(id: String, url: String, title: String, description: String)
2

Na szybko (sorry, mam kilka minut wolnych na pisanie w ten weekend).
W scali chyba nigdy nie użyłbym Future - tylko czegoś w stylu Task (Monix, ZIO, ScalaZ).
Nie mogłem znaleźć dobrego artykułu why - ale ten to dobry początek:
https://alvinalexander.com/scala/differences-scalaz-task-scala-future-referential-lazy/

0

@jarekr000000: Dzięki Jarek za pomoc! Alvina kojarzę, zwłaszcza zajebistą książkę Scala Cookbook i Functional Programming Simplified, którą teraz czytam i jest o niebo lepsza niż czerwona Functional Programming Scala, którą ciężko mi się ostatnio czytało :D

Gdybyście mieli koledzy z oznaczonego postu czas po weekendzie pomóc to bardzo cieszyłbym się, co tu dalej wyprawiać w kodzie bez kontenera IoC i jak dobrze zaimplementować takie głupie API :D

Dzięki za pomoc!

1
  1. To zależy.

Nie tak dawno, popularne było podejście żeby wszystkie możliwe błędy modelować jako ADT. Czyli w Twoim przypadku, zwracałbyś Future[Either[SomeError, CreateLinkResponse]]. Takie rozwiązanie jest fajne bo wprost pokazuje co może pójść nie tak ale ma wadę jak masz masę funkcji, gdzie każda zwraca jakiś tam błąd i musisz to łączyć. Przez to Twój kod zaczyna wyglądać tak, że 80% linii to obsługa błędów, a gdzieś pomiędzy tym logika biznesowa. Innymi słowy - czyta się do d**y, maintanence też średni ale za to bardzo duża pewność że wszystkie błędy poobsługiwałeś i że będzie działało.
Aktualnie, częściej widzę podejście "hybrydowe". Czyli tam gdzie nie ma możliwości na sensowną obsługę błędów (np. logika biznesowa wymaga, że znajdziesz użytkownika po danym ID) to rzuca się błędem - czyli tak jak masz aktualnie to zaimplementowane. Tam, gdzie da się dać jakiś fallback albo inne zachowanie biznesowe to tam zwracać ADT. W tym pryzpadku, często metody które potencjalnie mogą rzucać błędy prefixuje się z np. unsafe.

Jak dla mnie warto spróbować i jednego i drugiego, a na końcu zdecydować co bardziej pasuje ;)

  1. Jeśli ścieżek jest mało to zazwyczaj trzymam w controllerze. Wydzielam do osbnych plików jeśli:
  • Ścieżek jest za dużo i zaczynają zaciemniać kod
  • Z jakiegoś powodu jest milion implicitów i spowalnia to pracę w IDE w controllerach
  1. W tym przypadku pewnie chodzi Ci o customowy RejectionHandler. To co będziesz zwracać to już zależy od Twojej aplikacji. Do prostej walidacji requestów "per route" możesz używać dyrektywy validate
0
  1. Można by wszystkie błędy trzymać w Future, ale niestety Future obsługuje jako błędy tylko wyjątki. Ale jak weźmiesz jedną z bibliotek polecanych przez Jarka to tam zwykle oprócz Tasta jest jakieś uniwersalne IO obsługujące dowolne błędy. W zasadzie to Task jest definiowany na podstawie IO i np w ZIO wygląda to tak type Task[+A] = ZIO[Any, Throwable, A] , gdzie A jest typem zwracanym przez Task/ZIO, a Any zajmuje miejsce dla środowisk/kontekstu (można o tym nie myśleć na poczatek lub wziąć bardziej klasyczną bibliotekę)
  2. Nie lubię kontrolerów, kojarzą mi się ze springiem :D robię taki sam plik tylko nazywam go LinkRoute a w firmie nazywany go LinkAPI
  3. Wszystko trzymam w Routingach przez co są długie i wyklądają trochę na skrytpy :(

1 użytkowników online, w tym zalogowanych: 0, gości: 1