LoginSignup
1
0

More than 1 year has passed since last update.

Scala(PlayFrameWork)でNewsアプリのAPIを作ってみた

Last updated at Posted at 2023-04-05

はじめに

ウェブクルーバックエンドエンジニア1年目の久保田です。
今回はScalaの勉強の一環として作ってみたNewsアプリの作成の備忘ログとして本記事を作成しました。
素人がPlayで簡単なアプリを作ってみた話です。
見せられるようなものではありませんが、一応gitのコードの公開もしているので本記事を進めることで同じアプリを作ることができるかと思います。

概要

今回Newsアプリのバックエンド部分をPlayFrameWorkを使って実装しました。
作成前の技術感としてはすこしScalaを勉強した程度です。
苦労した点と実際の実装内容を残しているので、同じようなところで躓いている初学者の方の助けになればうれしいです。
※この記事ではPlayのAPI部分について言及していますが、フロントも作成しているのでよろしければgitをご覧ください。

苦労した点

  • 記事が少ない
    まず他の言語に比べ圧倒的にナレッジが少ないと感じました。
    公式ページはありますが、最新バージョンは日本語対応していないですしPlayフレームワークに関する記事も他の言語のフレームワークに比べてだいぶ少ないように思います。
    Slickやcirceなどのライブラリはさらに少なく、最新の記事を探すのにかなり苦労しました。
    →対応策として・・・
    社内でPlayを使って作られたサービスがあったので参考にしたり、英文をちまちま英訳しました。
    一通り作ってみた所感としては困っている部分に対してピンポイントで解決してくれるような記事はほぼなかったのでいろいろな媒体参考にするのがいいと思います。
    (Scalaに限ったことではないですが・・・)
  • slickやcirceがとっつきにくい
    playのORMであるslickやjsonをいい感じにしてくれるcirceというライブラリを今回使ってみたのですが、初学者がいきなり触るには少しハードルが高いと感じました。
    特にslickはmapを操作するのですが、このあたりのメソッドを知らない状態で触ると何しているか全くわからず進むのに苦労しました。
    →対応策として・・・
    とりあえず難しいことをするのはあきらめました。
    slickを使うにしても単一テーブルの全件取得にとどめたり、circeでの書き方も自分のわかりそうなものをピックアップしてその書き方をとりあえず極められるよう努めました。

アプリの構成

構成はざっくり以下のような形です。
image.png

フロントからのリクエストを受け取り、NewsApiから該当のキーワードに即する記事を取得、JsonをCirceで整形してフロントに渡します。
また、ブックマークに関してはフロント側でvuexでブックマークアイコンのstateを管理し、stateによって取得、削除のリクエストが送られてきます。slickを使ってmysqlを操作し、Circeを使って整形したjsonを返すといった形です。

作成してみる

早速APIを作成していきます。
今回Scalaのバージョン等は以下のようになっています。

  • scala 2.8.18
  • circe 0.14.1
  • slick 5.1.45
  • mysql-connector-java 5.1.41

事前準備

1.Playの環境を作成する

  • tutorial進めると自然と環境は作れます。

※バージョンによってはjavaのバージョンも変更する必要があるので気をつけてください。

2.NewsApiのApiKeyを取得する

  • 以下のURLから登録してApiKeyを取得してください。

  • 1日に1000回までのリクエストが無料です。それ以上のリクエストが送られるとエラーが返ってきます。

APIの作成

まずはフロントから投げられるリクエストを整理します。
今回必要なのは以下です。

type request parameter 備考
GET getNewsApiCategory category: String カテゴリ別のニュースを取得する
GET getHomeNewsApi ホーム画面のtopHeadlinesのニュースを取得する
GET getBookmarkData ブックマークのニュースを取得する
GET insertBookmarkData image_url: String, article_title: String, article_url: String ブックマークを新たに挿入
GET deleteBookmarkData url: String ブックマークから削除

上から順に作成します。
まずはroutesを書いていきます。
routesにはAPIで受け取ったリクエストをどのcontrollerに渡すかを記載したファイルです。
以下の通り記載します。

conf/routes

GET     /getNewsApiCategory         controllers.endpoints.GetNewsCategoryController.getNewsApiCategory(category: String)

GET     /getHomeNewsApi             controllers.endpoints.GetHomeNewsApiController.getHomeNewsApi()

GET     /getBookmarkData            controllers.endpoints.BookmarkController.index()

GET     /insertBookmarkData         controllers.endpoints.BookmarkController.insert(image_url: String, article_title: String, article_url: String)

GET     /deleteBookmarkData         controllers.endpoints.BookmarkController.delete(url: String)

上記のように リクエストの種類  /呼び出し時のURL  controllerのメソッドのように書きます。

続いてroutesから渡されるcontrollerを作っていきます。
まずはgetNewsApiCategory controllerを作ります。
ここではフロントからカテゴリがパラメータとして渡されます。(例:sports)
このパラメータをNewsApiのパラメータに渡してカテゴリに即したjsonを呼び出します。
まずはcontrollerを作成します。
controllersディレクトリの下にendpointsディレクトリを作成しています。

/app/controllers/endpoints/GetNewsCategoryController

package controllers.endpoints/

import javax.inject.Inject
import scala.concurrent.ExecutionContext
import scala.concurrent.ExecutionContext.Implicits.global
import play.api.i18n.I18nSupport
import play.api.libs.ws._
import play.api.mvc.{AbstractController, ControllerComponents}

class GetNewsCategoryController @Inject() (ws: WSClient, val cc: ControllerComponents, ec: ExecutionContext) extends AbstractController(cc) with I18nSupport {
  def getNewsApiCategory(category: String) = Action.async {
    val url: String = "https://newsapi.org/v2/top-headlines?country=jp&category="
    val key: String = "&apiKey= *事前に用意したApiKeyが入ります。*"
    ws.url(url + category + key).get().map { response =>
      Ok(response.body)
    }
  }
}

まず、NewsApiにはいくつかのパラメータの渡し方があります。
今回はあらかじめ用意されているカテゴリを呼び出す方法でAPIを呼び出しています。
詳しくは公式ページに記載がありますが、top-headlinesで最新ニュースを呼び出すことができ、countryで国、categoryでNewsApi側で用意されているカテゴリから好きなものを選べるようになっています。
今回はパラメータに渡したカテゴリのうち、日本の最新ニュースを呼び出しています。
呼び出しに成功した時の処理はOk()内に記載します。今回は返ってきたjsonのbody部分を返しています。
※NewsApiには用意されているcategoryパラメータとは別にsearchパラメータもあります。ここからニュース記事の検索もできるので気になる方は実装してみてください。

続いてGetHomeNewsApiController です。
ここではアプリに入って最初の画面で表示する内容を取得します。
この画面では最新記事をカテゴリ関係なく表示するので、ほとんど先ほどのcontrollerと同じです。

/app/controllers/endpoints/GetHomeNewsApiController

package controllers.endpoints

import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.ExecutionContext.Implicits.global
import play.api.i18n.I18nSupport
import play.api.libs.ws._
import play.api.mvc.{AbstractController, ControllerComponents}

class GetHomeNewsApiController @Inject() (ws: WSClient, val cc: ControllerComponents, ec: ExecutionContext) extends AbstractController(cc) with I18nSupport {
  def getHomeNewsApi() = Action.async {
    val url: String = "https://newsapi.org/v2/top-headlines?country=jp&sortBy=publishedAt&apiKey="
    val key: String = "*事前に用意したApiKeyが入ります。*"
    ws.url(url + key).get().map { response =>
      Ok(response.body)
    }
  }
}

先ほどとの違いはパラメータの有無です。今回はカテゴリ関係なく日本の最新記事を取得するのでパラメータは渡していません。
また、パラメータがないためroutesのメソッド呼び出しの部分もパラメータなしの記述になっています。

/conf/routes

GET     /getNewsApiCategory         controllers.endpoints.GetNewsCategoryController.getNewsApiCategory(category: String)

GET     /getHomeNewsApi             controllers.endpoints.GetHomeNewsApiController.getHomeNewsApi()

これで最低限APIを呼び出せばニュース記事を取得できるようになりました。
続いてブックマーク機能の実装に移ります。
実際に作成する前に新たに使う技術について軽くご説明します。

  • Cierce
    jsonをいい感じにするライブラリです、読み方あんまり知らないんですけどキルケとかサーシーて読むみたいです。
今回はinsert処理の際に使います。 以下の記事に詳しい記述方法など書いてあるので気になる方は読んでみてください。

いくつか書き方のパターンはあるみたいですが、今回はcase classを使う方法で書いてみます。

  • Slick
    playのORMです。RailsのActiveRecordみたいなやつです。
    これについては下の記事が分かりやすかったので引用させていただきます。

とりあえずとっつきにくくてあまり理解できていないので今回は難しいことはしないです。
joinとかそういうのは無しでブックマークを全件取得する、新たにブックマークに挿入、指示された記事をwhereで絞ってブックマークから削除する処理を作ってみることにします。

同じControllerクラスに3つのメソッドを作っていきます。
それぞれブックマークの取得、挿入、削除を担当しています。
※build.sbtにライブラリとそのバージョン記載する必要があります。
書き方についてはgitからコードご覧ください。

まずはDBの環境の用意から始めます。
今回はMySQLを使いました。
テーブルは1つだけです。

table名: bookmark
データ一覧

論理名 制約
BOOKMARK_ID INT AI,NN
IMAGE_URL VARCHAR なし
IMAGE_TITLE VARCHAR NN
ARTICLE_URL VARCHAR NN

続いてplayと繋げます。
conf/application.confに以下のように追記してください

/conf/appliction.conf

#Slick
slick.dbs {
  default {
    profile="slick.jdbc.MySQLProfile$"
    db {
      driver=com.mysql.jdbc.Driver
      //localhost:以下は環境に合わせてください
      url="jdbc:mysql://localhost:3306/スキーマ名?useSSL=false"
      user=設定したID
      password="ご自身のパスワード"
    }
  }
}

これでとりあえずplayからslickを使ってDBの操作ができるようになります。
続いてslickのコードジェネレータを使います。
これをすることでMysqlのスキーマをScalaのコードに落とし込むことができます。
Slickでは基本的にmapを操作することでSQLを叩きます。

参考資料

以下を記述しています。

/app/generator/SlickModelGenerator

package generator


import slick.model.Model
import slick.jdbc.JdbcProfile
import scala.concurrent.{Await, ExecutionContext}
import scala.concurrent.duration.Duration


object SlickModelGenerator {
  //tableNamesをNoneにすると全テーブル出力
  def run(slickDriver: String, jdbcDriver: String, url: String, user: String, password: String,
          tableNames: Option[Seq[String]], outputDir: String = "app", pkg: String = "models", topTraitName: String = "Tables", scalaFileName: String = "Tables.scala") = {
    val driver: JdbcProfile = slick.jdbc.MySQLProfile
    val db = slick.jdbc.MySQLProfile.api.Database.forURL(url, driver = jdbcDriver, user = user, password = password)
    try {
      import scala.concurrent.ExecutionContext.Implicits.global

      val modelAction = driver.createModel(Some(driver.defaultTables), ignoreInvalidDefaults = false)(ExecutionContext.global).withPinnedSession
      val allModel = Await.result(db.run(modelAction),Duration.Inf)
      val modelFiltered=tableNames.fold (allModel){ tableNames =>
        Model(tables = allModel.tables.filter { aTable =>
          tableNames.contains(aTable.name.table)
        })
      }

      new SourceCodeGeneratorEx(modelFiltered).writeToFile(slickDriver, outputDir, pkg, topTraitName, scalaFileName)
    } finally db.close
  }

  def main(args: Array[String]): Unit = {
    //各スキーマの設定でfunction作ってここにいれる
//    exportCommonSchema
    exportNewsAppSchema
  }

  //news_appスキーマの出力
  def exportNewsAppSchema = {
    val slickDriver = "slick.jdbc.MySQLProfile"
    val jdbcDriver = "com.mysql.jdbc.Driver"
    val url = "jdbc:mysql://localhost:3306/news_app?useSSL=false"
    val user = "news_user"
    val password = "Koukih11!"
    val outputDir = "app"
    val pkg = "models"
    val topTraitName = "tables"
    val scalaFileName = "Tables.scala"

    //  対象テーブル
    val tableNames: Option[Seq[String]] = Some(
      //大文字小文字の判定あるため注意
    Seq("BOOKMARK")
    )

    run(slickDriver, jdbcDriver, url, user, password, tableNames, outputDir, pkg, topTraitName, scalaFileName)
  }
}

記述できたらターミナルから新たにrunすることで/app/models/Tablesが生成されます。

terminal
sbt run

下のように生成されていればOKです。

/app/models/Tables
// ※自動生成

package models
// AUTO-GENERATED Slick data model
/** Stand-alone Slick data model for immediate use */
object tables extends {
  val profile = slick.jdbc.MySQLProfile
} with tables

/** Slick data model trait for extension, choice of backend or usage in the cake pattern. (Make sure to initialize this late.) */
trait tables {
  val profile: slick.jdbc.JdbcProfile
  import profile.api._
  import slick.model.ForeignKeyAction
  // NOTE: GetResult mappers for plain SQL are only generated for tables where Slick knows how to map the types of all columns.
  import slick.jdbc.{GetResult => GR}

  /** DDL for all tables. Call .create to execute. */
  lazy val schema: profile.SchemaDescription = Bookmark.schema
  @deprecated("Use .schema instead of .ddl", "3.0")
  def ddl = schema

  /** Entity class storing rows of table Bookmark
   *  @param userId Database column USER_ID SqlType(INT)
   *  @param imageUrl Database column IMAGE_URL SqlType(VARCHAR), Length(400,true), Default(None)
   *  @param articleTitle Database column ARTICLE_TITLE SqlType(VARCHAR), Length(400,true)
   *  @param articleUrl Database column ARTICLE_URL SqlType(VARCHAR), Length(400,true)
   *  @param bookmarkId Database column BOOKMARK_ID SqlType(INT), AutoInc, PrimaryKey */
  case class BookmarkRow(userId: Int, imageUrl: Option[String] = None, articleTitle: String, articleUrl: String, bookmarkId: Option[Int] = None)
  /** GetResult implicit for fetching BookmarkRow objects using plain SQL queries */
  implicit def GetResultBookmarkRow(implicit e0: GR[Int], e1: GR[Option[String]], e2: GR[String], e3: GR[Option[Int]]): GR[BookmarkRow] = GR{
    prs => import prs._
    val r = (<<?[Int], <<[Int], <<?[String], <<[String], <<[String])
    import r._
    BookmarkRow.tupled((_2, _3, _4, _5, _1)) // putting AutoInc last
  }
  /** Table description of table BOOKMARK. Objects of this class serve as prototypes for rows in queries. */
  class Bookmark(_tableTag: Tag) extends profile.api.Table[BookmarkRow](_tableTag, Some("news_app"), "BOOKMARK") {
    def * = (userId, imageUrl, articleTitle, articleUrl, Rep.Some(bookmarkId)) <> (BookmarkRow.tupled, BookmarkRow.unapply)
    /** Maps whole row to an option. Useful for outer joins. */
    def ? = ((Rep.Some(userId), imageUrl, Rep.Some(articleTitle), Rep.Some(articleUrl), Rep.Some(bookmarkId))).shaped.<>({r=>import r._; _1.map(_=> BookmarkRow.tupled((_1.get, _2, _3.get, _4.get, _5)))}, (_:Any) =>  throw new Exception("Inserting into ? projection not supported."))

    /** Database column USER_ID SqlType(INT) */
    val userId: Rep[Int] = column[Int]("USER_ID")
    /** Database column IMAGE_URL SqlType(VARCHAR), Length(400,true), Default(None) */
    val imageUrl: Rep[Option[String]] = column[Option[String]]("IMAGE_URL", O.Length(400,varying=true), O.Default(None))
    /** Database column ARTICLE_TITLE SqlType(VARCHAR), Length(400,true) */
    val articleTitle: Rep[String] = column[String]("ARTICLE_TITLE", O.Length(400,varying=true))
    /** Database column ARTICLE_URL SqlType(VARCHAR), Length(400,true) */
    val articleUrl: Rep[String] = column[String]("ARTICLE_URL", O.Length(400,varying=true))
    /** Database column BOOKMARK_ID SqlType(INT), AutoInc, PrimaryKey */
    val bookmarkId: Rep[Int] = column[Int]("BOOKMARK_ID", O.AutoInc, O.PrimaryKey)
  }
  /** Collection-like TableQuery object for table Bookmark */
  lazy val Bookmark = new TableQuery(tag => new Bookmark(tag))
}

※DBの環境によって多少生成結果が変わるかもしれないです。
早速insertの処理を作ってみます。
まずはControllerです。

app/controllers/endpoints/BookmarkController
 package controllers.endpoints

import dao.BookmarkSlickDao
import dto.BookmarkDto
import play.api.libs.ws._
import play.api.libs.circe.Circe
import io.circe.syntax._
import play.api.mvc.{AbstractController, AnyContent, ControllerComponents, Request}
import javax.inject.Inject
import scala.concurrent.ExecutionContext

class BookmarkController @Inject()(slickDao: BookmarkSlickDao, ws: WSClient, cc: ControllerComponents)(implicit ec: ExecutionContext) extends AbstractController(cc) with Circe {
//  private val logger = Logger(this.getClass)

  //挿入処理
    def insert(image_url: String, article_title: String, article_url: String) = Action { implicit request =>
      slickDao.create(image_url, article_title, article_url)
      Ok("挿入完了")
    }
}

次に呼び出されるDAOを書いていきます。

/app/dao/BookmarlSlickDao
package dao
//import dao.BookmarkDao.BookmarkRow

import models.tables._

import javax.inject.{Inject, Singleton}
import play.api.Logger
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
import slick.jdbc.JdbcProfile
import slick.jdbc.MySQLProfile.api._


import scala.concurrent.{ExecutionContext, Future}
import scala.util.control.NonFatal

@Singleton
class BookmarkSlickDao @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit ec: ExecutionContext) extends HasDatabaseConfigProvider[JdbcProfile]  {

private val logger = Logger(this.getClass)

  //Insertクエリ
  def create(imageUrl: String, articleTitle: String, articleUrl: String): Future[Int] = {
    val action = Bookmark += BookmarkRow(Some(imageUrl), articleTitle, articleUrl)

    //クエリ実行
    db.run(action).recover {
      case NonFatal(e) =>
        logger.error("DB操作で例外が発生しました。", e)
        throw e
    }
  }

※BookmarkIdはautoInclementなので渡していません。
これでデータベースに対してニュース記事を追加できるようになったはずです。試しにAPIを叩いてみて下さい。

続いて挿入したデータを全件取得する処理です。
先ほどと同じBookmarkControllerにfetch用の処理を追加します。

BookmarkController
  //全権取得処理
  def index() = Action.async { implicit request: Request[AnyContent] =>
    val bookmarkFetch = slickDao.fetch().map(bookmarkRows => bookmarkRows.map({ bookmarkRow =>  BookmarkDto(bookmarkRow.id, bookmarkRow.user_id, bookmarkRow.urlToImage, bookmarkRow.title, bookmarkRow.url)}))
    logger.debug("bookmarkFetch" + bookmarkFetch)
    bookmarkFetch.map(bookmarks => Ok(bookmarks.asJson))
  }

//挿入処理(ry

ここではCirceを使ってJsonにしてデータを返しています。先ほど共有させていただいた記事のうち、case classを使うパターンと同じことをしています。今回はBookmarkDtoにcase classを作成しました。

/app/dto/BookmarkDto
package dto

import io.circe.{Encoder}
import io.circe.generic.semiauto._

final case class BookmarkDto(id: Option[Int], user_id: Long, urlToImage: String, title: String, url: String)

object BookmarkDto {
  implicit val encoder: Encoder[BookmarkDto] = deriveEncoder
}

続いて先ほどと同じようにDaoに全件取得処理を追加します。

/app/BookmarkSlickDao
//SELECTクエリ
  def fetch(): Future[Seq[BookmarkDao.BookmarkRow]] = {
    val action = Bookmark.result.map(rows => {
      rows.map(row => {
        BookmarkDao.BookmarkRow(
          row.bookmarkId,
          row.userId,
          row.imageUrl.getOrElse(""),
          row.articleTitle,
          row.articleUrl)
      })
    })

    db.run(action).recover {
      case NonFatal(e) =>
        logger.error("DB操作でエラー発生", e)
        throw e
    }
  }

//Insertクエリ(ry

以上でブックマークの全件取得ができるようになりました。

最後に削除機能を作ります。
流れはinsert,fetchと同じです。

/app/controllers/endpoints/BookmarkController

~~

    def delete(url: String) = Action { implicit request =>
    slickDao.delete(url)
    Ok("削除完了")
  }
}

今回は記事のURLでwhereしています。
mapのfilterを使うことでwhereと同じことができます。

続いてDAOです。

/app/controllers/endpoints/BookmarkSlickDao

~~

  //DELETEクエリ
  def delete(url: String): Future[Int] = {
    val action = Bookmark.filter(_.articleUrl === url.bind).delete

    //クエリ実行
    db.run(action).recover {
      case NonFatal(e) =>
        logger.error("DB操作で例外が発生しました", e)
        throw e
    }
  }
}

これで記事のURLをパラメータに渡してAPIを叩くことでブックマーク一覧から該当の記事の削除ができるようになりました。

以上です。

その他の参考にしたサイト

github

フロント側

API側

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0