Nasza strona używa cookies. Korzystając ze strony, wyrażasz zgodę na używanie cookies, zgodnie z aktualnymi ustawieniami przeglądarki. Rozumiem

Scala: REST API w mniej niż godzinę

Dominik Zduńczyk Java Software Engineer / Taxus IT
Sprawdź, jak łatwo i szybko zbudować REST API w języku Scala.
Scala: REST API w mniej niż godzinę

W świecie zdominowanym przez deadline’y oraz rosnące koszty utrzymania aplikacji, potrzebne jest narzędzie, które w szybki i łatwy sposób może być rozwijane, a także zapewnia skalowalność projektów. Jednym z tych rozwiązań jest framework Play obsługujący protokół HTTP. Dzięki temu, że projekt może być oparty na języku Scala, oszczędza czas w tworzeniu nowych funkcjonalności względem innych rozwiązań, np. Spring. Jednak oferuje także możliwość pisania kodu w Javie.

Artykuł skierowany jest przede wszystkim do osób tworzących projekty oparte na wirtualnej maszynie Javy (JVM), które chciałyby rozszerzyć swoją wiedzę o znajomość nowych narzędzi.


Framework Play 

Play jest frameworkiem aplikacji sieciowych opartym na open-source. Napisany jest w języku Scala. Platforma programistyczna oparta jest na lekkiej, bezstanowej i przyjaznej dla sieci architekturze.  Użyty wzór architektoniczny wdraża model (MVC).

Reaktywny model oparty na Akka Streams, zapoczątkowany w 2007 roku jako wewnętrzny projekt w firmie Zengularity SA (dawniej Zenexity), oferuje przewidywalne i minimalne zużycie zasobów (procesora, pamięci, wątków) w aplikacjach o dużej skalowalności. Ma to na celu optymalizację wydajności programistów dzięki konwencjom dotyczącym konfiguracji, ponownemu ładowaniu kodu i wyświetlaniu błędów w przeglądarce. Został. W 2009 roku projekt stał się otwartoźródłowy i jest rozwijany po dziś dzień.

Obsługa języka programowania Scala jest dostępna od wersji 1.1 architektury. W wersji 2.0 szkielet podstawowy został przepisany w Scali. Zbudowano i wdrożono migrację do SBT, a szablony wykorzystują Scalę zamiast Apache Groovy.

Wszelkie instrukcje dotyczące uruchomienia frameworku znajdują się na jego oficjalnej stronie. SBT (Simple Build Tool) odpowiada za proces zarządzania zależnościami, użytkownicy Gradle oraz Mavena zaobserwują wiele podobieństw.


Potęga otwartoźródłowego kodu

Ze względu na to, że większość projektów (bibliotek) napisanych w Scali jest otwartoźródłowa, pozwala to na dobór wielu rozwiązań do manipulacji danych, które są rozwijane i wspierane. Popularne biblioteki to np. Slick, Quill oraz ScalikeJDBC.

ScalikeJDBC pozwala na mapowanie tabel na obiekty odwrotnie jak np. w Hibernate mapowanie obiektów na tabele w bazie danych. Proces ten nazywa się reverse engineering oraz generowany jest kod odpowiadający za pobieranie i zapisywanie danych. Na temat pierwszego uruchomienia narzędzia można przeczytać na oficjalnej stronie biblioteki. Według dokumentacji, do pliku build.sbt należy dodać następujące linijki:

libraryDependencies ++= Seq(
 "org.scalikejdbc" %% "scalikejdbc"       % "3.4.0",
 "org.scalikejdbc" %% "scalikejdbc-config"  % "3.4.0",
"org.scalikejdbc" %% "scalikejdbc-play-initializer" % "2.7.1-scalikejdbc-3.4",
 "ch.qos.logback"  %  "logback-classic"   % "1.2.3"
)


W pliku conf/application.conf trzeba skonfigurować połączenie z naszą bazą danych. Aby uruchomić tryb reverse engineeringu do pliku project/plugins.sbt, należy dodać następującą linijki: 

libraryDependencies += "org.postgresql" % "postgresql" % "42.2.5"
addSbtPlugin("org.scalikejdbc%% "scalikejdbc-mapper-generator" % "3.2.1")
play.modules.enabled += "scalikejdbc.PlayModule"


W moim przypadku używam postgresa jako silnika bazy danych tutaj dobór jest dowolny. Ostatnim krokiem jest stworzenie pliku z konfiguracją project/scalikejdbc.properties, gdzie przechowujemy połączenie do bazy danych:

jdbc.driver=org.postgresql.Driver
jdbc.url=jdbc:postgresql://localhost:5432/demo
jdbc.username=postgres
jdbc.password=postgres
jdbc.schema=public
generator.packageName=models
generator.lineBreak=LF
generator.template=queryDsl
generator.testTemplate=specs2unit
generator.encoding=UTF-8


Jeżeli wszystko skonfigurowaliśmy poprawnie, powinniśmy na porcie 9000 otrzymać ekran startowy. Przykładowy kod napisany w odpowiedzialny za pobieranie danych:

object Product {
      def create(name: String, price: Long)(implicit s: DBSession = AutoSession): Long = {                                             
   sql"insert into products values (${name}, ${price})"
     .updateAndReturnGeneratedKey.apply() // returns auto-incremeneted id
 }
 def findById(id: Long)(implicit s: DBSession = AutoSession): Option[Product] = {
   sql"select id, name, price, created_at from products where id = ${id}"
     .map { rs => Product(rs) }.single.apply()
 }
}
Product.findById(123) // borrows connection from pool and gives it back after execution
DB localTx { implicit session => // transactional session
 val id = Product.create("ScalikeJDBC Cookbook", 200) // within transaction
 val product = Product.findById(id) // within transaction
}


Potrzebne są obiekty, obiekty i obiekty

Każdy backend potrzebuje bazy danych. Moja baza wygląda następująco:

Na potrzeby artykułu posłużę się jedynie tabelami products oraz categories. Aby wygenerować modele bazodanowe, należy w konsoli sbt wykonać następujące polecenie 

[IJ][demo] $ scalikejdbcGen products Product
"models.Product" created.
"models.ProductSpec" created.
[success] Total time: 1 s, completed 2020-02-04 15:56:54
[IJ][demo] $ scalikejdbcGen categories Category
"models.Category" created.
"models.CategorySpec" created.
[success] Total time: 1 s, completed 2020-02-04 15:58:49


Jak możemy zauważyć, zostały wygenerowane modele, a ich kod wygląda następująco:

case class Product(
 id: Long,
 name: Option[String] = None,
 brand: Option[String] = None,
 price: Option[BigDecimal] = None,
 categoryId: Option[Long] = None,
 createdAt: ZonedDateTime,
 updatedAt: ZonedDateTime,
 depotPrice: Option[BigDecimal] = None) {...}

object Product extends SQLSyntaxSupport[Product] {
 override val schemaName = Some("public")
 override val tableName = "products"
 override val columns = Seq("id", "name", "brand", "price", "category_id", "created_at", "updated_at", "depot_price")

 def apply(p: SyntaxProvider[Product])(rs: WrappedResultSet): Product = apply(p.resultName)(rs)
 def apply(p: ResultName[Product])(rs: WrappedResultSet): Product = new Product(...)
 val p = Product.syntax("p")
 override val autoSession = AutoSession
 def find(id: Long)(implicit session: DBSession = autoSession): Option[Product] = {...}
 def findAll()(implicit session: DBSession = autoSession): List[Product] = {...}
 def countAll()(implicit session: DBSession = autoSession): Long = {...}
 def findBy(where: SQLSyntax)(implicit session: DBSession = autoSession): Option[Product] = {...}
 def findAllBy(where: SQLSyntax)(implicit session: DBSession = autoSession): List[Product] = {...}
 def countBy(where: SQLSyntax)(implicit session: DBSession = autoSession): Long = {...}

 def create(
   name: Option[String] = None,
   brand: Option[String] = None,
   price: Option[BigDecimal] = None,
   categoryId: Option[Long] = None,
   createdAt: ZonedDateTime,
   updatedAt: ZonedDateTime,
   depotPrice: Option[BigDecimal] = None)(implicit session: DBSession = autoSession): Product = {...
 }
 def batchInsert(entities: Seq[Product])(implicit session: DBSession = autoSession): List[Int] = {...}
 def save(entity: Product)(implicit session: DBSession = autoSession): Product = {...}
 def destroy(entity: Product)(implicit session: DBSession = autoSession): Int = {...}
}


:Następnie tworzymy dwa obiekty DTO dla produktu oraz kategorii:

case class ProductDTO(id: Long, name: Option[String] = None, brand: Option[String] = None, price: Option[BigDecimal] = None,
                       category: Option[CategoryDTO] = None, createdAt: ZonedDateTime, updatedAt: ZonedDateTime, depotPrice: Option[BigDecimal] = None)

case class CategoryDTO(category: Option[Category]) {
 val id: Option[Long] = category.map(_.id)
 val name: Option[String] = category.map(_.name.orNull)
}


Niezbędne jest też mapowanie obiektów DTO na obiekty JSON:

object JsonWriter {

   implicit val categoryWrites: Writes[CategoryDTO] = (category: CategoryDTO) => Json.obj(
     "id" -> category.id,
     "name" -> category.name
   )

   implicit val productWrites: Writes[ProductDTO] = (product: ProductDTO) => Json.obj(
     "id" -> product.id,
     "name" -> product.name,
     "brand" -> product.brand,
     "price" -> product.price,
     "category" -> product.category.map(category =>
       Json.obj(
         "id" -> category.id,
         "name" -> category.name
       )
     ),
     "created_at" -> product.createdAt,
     "updated_at" -> product.updatedAt,
     "depot_price" -> product.depotPrice
   )


Ostatnią rzeczą, jakiej potrzebujemy, jest napisanie kontrolera w sposób funkcyjny:

@Singleton
class DemoController @Inject()(cc: ControllerComponents) extends AbstractController(cc) {

def getProducts = Action { implicit request =>
 val products = Product.findAll()

 Ok(Json.obj("products" -> products.map ( product =>
   JsonWriter.productWrites.writes(ProductDTO(product.id, product.name, product.brand, product.price,
     Some(CategoryDTO(Category.findBy(sqls"id = ${product.categoryId}"))),
     product.createdAt, product.updatedAt, product.depotPrice))
 )))
}


i dodanie odpowiedniego endpointa w pliku routes:

GET     /products                   
controllers.DemoController.getProducts


Po uruchomieniu aplikacji i przejściu do endpointa /products, powinniśmy otrzymać listę z dostępnymi produktami, a wygląda ona następująco:


Podsumowanie

Tym sposobem w mniej niż godzinę otrzymaliśmy proste API zwracającą listę produktów. Jest to jedynie jeden z przykładów zastosowania Play'a. Projekt można rozszerzyć o inne endpointy np. o zapisywanie produktów. Zachęcam cię zatem drogi czytelniku o głębsze poznanie tego narzędzia jak i języka Scala, co pozwoli ci zaoszczędzić czas w tworzeniu nowych rozwiązań. 

Link do projektu: scala-rest-api-demo

Lubisz dzielić się wiedzą i chcesz zostać autorem?

Podziel się wiedzą z 160 tysiącami naszych czytelników

Dowiedz się więcej