Project. NEW GAME! 경과 정리 (2018. 07. 30)

도입

Project. NEW GAME! 는 게임물관리위원회 의 게임 심의 결과 정보를 이용해 매일마다 새로운 게임 정보를 Cloud Messaging 로 알려주는 앱을 만드는 프로젝트로, 7월 28일 낙성대역의 어느 카페에서 직장 동료와 코딩을 하다가 떠오른 토이 프로젝트이다.

이 프로젝트에서 구현하는 기능은 다음과 같다.

  • 게임 심의 목록 리스트 / 검색 / 상세보기
  • 매일 웹사이트를 크롤링하여 새로운 정보가 있을 경우 Firebase Cloud Messaging 로 알림
  • 네이티브 안드로이드 앱
  • 백엔드 서버

이 프로젝트를 통해 추구하고자 하는 목적은 다음과 같다.

  • Backend stack: Ktor + Exposed + HikariCP + PostgreSQL 에 대한 탐구 및 정리
  • Frontend (Mobile) stack: Kotlin + MVVM + RxJava + Databinding 에 대한 탐구 및 정리
  • Java 코드를 전혀 쓰지 않는 100% Kotlin Backend + Frontend 프로젝트

그래서 오늘의 작업은?

  • HikariCP 를 이용해 로컬 PostgreSQL 10 연동
  • Ktor DSL를 이용해 API 설계 반영
  • Kotlin exposed 를 이용해 batchInsert 작업 실행
  • Kotlin coroutine를 이용해 결과를 기다린 후 응답

HikariCP를 이용해 로컬 PostgreSQL 10 연동

HikariCP는 JDBC (클라이언트가 어떻게 DB에 연결할 것인지 정의한 자바의 API) 컨넥션 풀을 관리하는 라이브러리이다. JDBC 나 ODBC 는 흔하게 많이 사용하는 Tomcat, Netty 등에서 데이터베이스를 연결하기 위해 사용하는 기능으로, ‘드라이버’ 라는 개념으로 이어진다.

기존에는 자주 C3P0나 bone 를 이용했던 모양이지만, 최근 이 HikariCP 를 이용하는 것이 추세로 보여 기왕 사용하는 것이면 차후에 발생할 컨넥션 풀 관리를 위해 사용하게 되었다.

사용되는 형태는 사용자가 일정 기능을 요청하면, 그 기능을 Exposed 를 이용하여 SQL Query 를 만든 다음, Exposed 로 실행하는 SQL Query 를 HikariCP 에서 가지고 있는 컨넥션 풀을 통해 PostgreSQL 로 전송하고, 쿼리의 결과를 HikariCP 로 통해 Exposed, 즉 웹 프레임워크 단에 전달해 최종적으로 유저에게 반환되는 형태이다.

HikariCP 를 이용하기 위해서는 HikariDataSource 란 객체의 인스턴스를 사용할 필요가 있는데, 이 HikariDataSource 의 인스턴스 초기화에는 HikariConfig 라는 클래스의 인스턴스가 필요하다. 이름이 뜻하는 그대로 HikariCP 에 사용되는 설정 인스턴스가 필요한데, 프로그래밍 적으로 이를 초기화하는 방법도 있고 파일을 통해 초기화하는 방법이 있다. 여기에서는 파일을 통해 초기화하는 것으로 한다.

Intellij CE 기준으로 src/main/resources 에 hikari.properties 파일을 만들고, 아래 설정을 적는다.

dataSourceClassName=org.postgresql.ds.PGSimpleDataSource
dataSource.user=
dataSource.password=
dataSource.databaseName=
dataSource.portNumber=5432
dataSource.serverName=localhost

여기서는 PostgreSQL 와 연동할 것이기 때문에 PGSimpleDataSource 라는 클래스 이름을 적어주었으나, 사용하는 DB에 따라 다르게 적어주면 된다. 관련 내용은 HikariCP 의 README 내 항목을 참고하면 된다.

적당히 user, password, databaseName 를 적고, 만든 파일을 사용해 HikariConfig 클래스와 HikariDataSource의 인스턴스를 만든다.

val config = HikariConfig("/hikari.properties")
val ds = HikariDataSource(config)

그리고 만들어진 HikariDataSource 의 인스턴스를 Exposed 의 클래스인 DataBase.connect 에 넘기면 연결이 수립되고, 인증에 실패하지 않는 이상 연결이 된다.

Database.connect(ds)

연결이 되면 2018-07-30 22:44:33.479 [HikariPool-1 housekeeper] DEBUG com.zaxxer.hikari.pool.HikariPool – HikariPool-1 – Pool stats (total=10, active=0, idle=10, waiting=0) 라는 로그가 나오게 되는데, 이는 현재 HikariPool 이 가지고 있는 컨넥션 풀의 상태를 주기적으로 보여주는 것이다. 현재는 아무런 작업을 하지 않으니 대기 상태의 컨넥션 풀 10개만 있다.

Ktor DSL를 이용해 API 설계 반영

Ktor 는 DSL 을 이용해 API 를 작성하는데, 주로 파이프라인에 기능을 ‘설치’한다는 의미를 가지고 있다. 이는 압축, CORS(Cross-Origin Resource sharing), 라우팅 등을 하나의 기능으로 보고 파이프라인에 설치하는 것 이라고도 말할 수 있다. 만들어진 파이프라인은 embeddedServer 라는 메서드를 통해 실행이 가능하며, 여기에서 사용할 Http client, 포트, 호스트 등을 정할 수 있다.

ktor 에서 사용이 가능한 http client 와 기타 설정 방법은 이 글에서 다루지 않으므로 Ktor 도큐먼트를 참고하는 편이 더 자세하다.

API는 라우팅 이란 기능에 정의하는데, 주로 route(“/”) 메서드를 활용하게 된다. 첫 번째 파라미터는 path 로, API 의 경로를 의미한다. /를 적어주면 http://0.0.0.0:port/ 를 나타내고, /api/users 를 적어주면 http://0.0.0.0:port/api/users 를 나타내게 된다.

route 밑에는 get, post, put, delete, update 등의 RESTful 를 구성하기 위해 필요한 메서드를 부를 수 있으며, 각각의 block 이라는 고차함수 파라미터에 행동을 정의함으로서 해당 path 로 된 API 가 불렸을 때 해당 고차함수가 실행되는 방식이다.

API 의 파라미터나 요청에 대한 결과를 보낼 때에는 call 라는 필드를 사용하며, 파라미터를 얻을 때에는 call.parameters 를, 요청에 대한 결과를 보낼 때 에는 call.respond 메서드를 사용한다. call.respond 에는 별도로 상태 코드를 정의하지 않는 이상 어떤 형태(Any) 라도 보낼 수 있는데, 파이프라인 내부에 설치된 ContentNegotiation 를 이용하여 respond 를 인코딩 하여 보낸다. 이 프로젝트에서는 서버 요청/결과를 json 형식으로 받을 것이기 때문에, Gson 을 이용할 것이다.

지난 글에서 구현한 UserController 를 이용해 유저에 대한 API 설계를 작성한 것이다.

fun Application.main() {
    install(Compression)
    install(CORS) {
        anyHost()
    }
    install(DefaultHeaders)
    install(CallLogging)
    install(ContentNegotiation) {
        gson {
            setDateFormat(DateFormat.LONG)
            setPrettyPrinting()
        }
    }

    initDB()

    install(Routing) {

        val logger = KotlinLogging.logger { }

        route("/api") {
            route("/users") {
                val userController = UserController

                get("/{id}") {
                    val id = call.parameters["id"]!!.toInt()
                    call.respond(userController.detail(id))
                }

                post("/") {
                    val user = call.receive()
                    logger.debug { user }
                    call.respond(userController.create(user))
                }

                put("/{id}") {
                    val id = call.parameters["id"]!!.toInt()
                    val user = call.receive()
                    call.respond(userController.update(id, user))
                }

                delete("/{id}") {
                    val id = call.parameters["id"]!!.toInt()
                    call.respond(userController.delete(id))
                }
            }
        }
    }
}

install(Routing) 은 공통 코드로, 여러 기능을 파이프라인에 설치하고 initDB 라는 메서드를 부르는데, 이 메서드는 위 챕터에서 말했던 HikariDataSource 의 인스턴스를 만들고 DataBase.connect 에 넘긴 다음, DB 에 필요한 스키마가 없을 경우 생성하는 기능이 담겨있다.

fun initDB() {
    val config = HikariConfig("/hikari.properties")
    val ds = HikariDataSource(config)
    Database.connect(ds)

    transaction {
        SchemaUtils.create(Games, Users)
    }
}

중간에 call.receive 라는 문구가 보이는데, 이는 받은 파라미터를 유형 매개변수로 선언한 클래스로 디코딩하여 얻는 메서드이다. 즉, 아래와 같이 request 를 보내면 된다.

이 request 를 보내면 UserController 를 통해 PostgreSQL 에 기록되고, 기록된 데이터가 response 로 내려오게 된다.

마지막으로 만들어진 파이프라인을 embeddedServer 메서드를 이용해 실행하면 된다.

fun main(args: Array<String>) {
    val server = embeddedServer(Netty, 8080, module = Application::main)

    server.start()
}

이 main 메서드를 Intellij 에서 실행시킬 경우 API 서버가 실행되며, 원하는 API 클라이언트로 정의한 API를 실행하면 관련된 로그가 터미널에 나오면서 작동할 것이다.

Kotlin exposed 를 이용해 batchInsert 작업 실행

28일에 만들었던 Controller 객체에서는 insert 를 하기 위해 Games.insert 란 메서드를 사용했으나, 만일 리스트를 insert 해야 할 경우 각각에 대해 insert를 하는 것은 컨넥션 관리에 매우 좋지 않다. 따라서 insert 대신 batchInsert 를 사용해 insert 를 한번에 호출하는 코드를 작성하려 한다.
다만, insert 와 크게 바뀐 부분이 거의 없다고 보면 된다.

fun insert(games: List<Game>): List<Game> {
     transaction {
         Games.batchInsert(games) { game ->
             this[Games.id] = game.id
             this[Games.name] = game.name
             this[Games.applicant] = game.applicant
             this[Games.regNo] = game.regNo
             this[Games.rate] = game.rate
             this[Games.regDate] = game.regDate
             this[Games.reason] = game.reason
             this[Games.platform] = game.platform
             this[Games.genre] = game.genre
         }
     }

     return games
 }

파라미터와 반환형에 Games 대신 List 를 사용하고, it[Games.id] 대신 this[Games.id] 를 사용한다. it 는 insert 될 Game의 객체를 의미하기 때문에 스키마에 접근하기 위해서는 it 가 아닌 BatchInsertStatement, 즉 this 를 사용한다. 또한, it.id 라고 표현할 수도 있으나 가독성을 위해 game 라고 별도로 정의한 것도 차이점이라고 할 수 있다.

Kotlin coroutine를 이용해 결과를 기다린 후 응답

위에서 사용한 Ktor DSL 를 이용해 작성하는 것은 크게 어렵지는 않았으나, 때로는 API 요청에 대한 결과를 줄 때에 시간이 오래 걸려서 blocking 한 다음에 나온 결과를 클라이언트에 돌려줄 필요가 있다.

이 때, 주로 Callback 를 사용하는 것이 일반적이나 지난 글에서도 언급했듯이 Ktor 및 Exposed 는 Kotlin corountine 를 기반으로 돌아가기 때문에 Callback 안에서는 해당 메서드가 호출되지 않는다.

따라서 작업에 대한 결과를 얻을 수 있는 코루틴의 async { } 를 이용하여 작업을 진행하고, 작업이 완료되기 까지 대기하고, 끝나면 작업에 대한 리스트를 클라이언트에 돌려주는 코드 작성이 필요하다.

여기서 조금 해멘 점이 많은데, 차근히 정리해보면 다음과 같다.

먼저 첫번째로, 작업을 생성하기 위해서는 launch { } 나 async { } 등의 메서드를 사용해야 되는데, launch 는 결과값을 기대하지 않는 작업이고, async 는 결과값을 기대하는 작업으로 그 역할이 다르다.

정확히는 launch 메서드는 Job 라는 클래스를 반환하며, async 는 Deferred 를 반환하는데, launch 의 설명에는 Launches new coroutine without blocking current thread and returns a reference to the coroutine as a [Job]. (스레드를 블로킹하지 않는 새로운 코루틴을 실행시키고, 코루틴의 참조를 Job 로서 반환한다.) 라고 되어있으며 async 의 설명에는 Creates new coroutine and returns its future result as an implementation of [Deferred]. (새로운 코루틴을 생성하고 미래에 다가올 결과를 Deferred 의 구현체로서 반환한다.) 라고 되어있다.

결론적으로는 Deferred 또한 Job 를 상속하기 때문에 두 개의 방식은 비슷하나 여기에서는 사이트 크롤링을 하고, DB 에 기록한 리스트 결과물을 받을 필요가 있으므로 asycnc 를 사용한다.

두 번째로, join() 메서드의 사용이나 이 join() 은 Job 클래스에 속해 있는 메서드 중 하나로 명시적으로 작업이 모두 끝날 때 까지 코루틴을 중단하는 역할을 한다. 참고로, 메인 코루틴에서 이 메서드를 사용할 경우에는 작업이 완료되기 까지 멈춰있게 된다.

세 번째로, async 의 결과물은 job.getCompleted() 로 가져올 수 있는데, 완료되지 않았을 경우에는 Exception 을 발생시킨다.

위 사항을 적절히 검토해서 낸 결과물은 다음과 같다.

val job = async {
    val parseResult = parser.start(index, count)

    val list = gameController.selectAll().map { it.id }
    val newGames = parseResult.filter { !list.contains(it.id) }

    gameController.insert(newGames)
}

job.join()
if (job.getCompleted().isNotEmpty()) {
    call.respond(HttpStatusCode.Created, job.getCompleted())
} else {
    call.respond(HttpStatusCode.InternalServerError, "Failed to Parse")
}

지금도 올바른 처리 방법인지는 파악이 안 가지만, 의도대로 모든 크롤링이 끝날 때 까지 클라이언트는 무기한 대기하고 있으며 끝나면 생성된 리스트가 결과물로 내려온다.

메모사항

이제 백엔드의 경우 푸시 전송 및 검색 기능만 대응하면 어느정도 완료가 될 것 같다.

Project. NEW GAME! 경과 정리 (2018. 07. 28)

도입

Project. NEW GAME! 는 게임물관리위원회 의 게임 심의 결과 정보를 이용해 매일마다 새로운 게임 정보를 Cloud Messaging 로 알려주는 앱을 만드는 프로젝트로, 7월 28일 낙성대역의 어느 카페에서 직장 동료와 코딩을 하다가 떠오른 토이 프로젝트이다.

이 프로젝트에서 구현하는 기능은 다음과 같다.

  • 게임 심의 목록 리스트 / 검색 / 상세보기
  • 매일 웹사이트를 크롤링하여 새로운 정보가 있을 경우 Firebase Cloud Messaging 로 알림
  • 네이티브 안드로이드 앱
  • 백엔드 서버

이 프로젝트를 통해 추구하고자 하는 목적은 다음과 같다.

  • Backend stack: Ktor + Exposed + HikariCP + PostgreSQL 에 대한 탐구 및 정리
  • Frontend (Mobile) stack: Kotlin + MVVM + RxJava + Databindign 에 대한 탐구 및 정리
  • Java 코드를 전혀 쓰지 않는 100% Kotlin Backend + Frontend 프로젝트

그래서 오늘의 작업은?

  • Jsoup를 이용해 게임물관리위원회 사이트 크롤링 코드 작성
  • 데이터베이스 테이블 설계
  • API 설계
  • 데이터베이스 테이블 설계를 바탕으로 DAO / Controller 객체 작성
  • Database Connection Pool 관리 모듈인 HikariCP 연동 준비 (하는중)
  • API 설계를 바탕으로 Ktor DSL 를 이용하여 Routing 코드 작성 (하는중)

이 중, (하는중) 이란 태그가 붙은 것은 금일에 100% 처리하지 못한 것으로, 100% 완료되었을 때 항목을 설명할 것이다.

게임물관리위원회 사이트 크롤링 코드 작성

대상 사이트는 2개인데, 하나는 게임물 심의 리스트인 https://www.grac.or.kr/Statistics/GameStatistics.aspx 와 각 항목에 대한 결정내용 팝업인 https://www.grac.or.kr/Statistics/Popup/Pop_ReasonInfo.aspx?74300c05da019ba330ba2344ddc871f6a6c813a6a053e8e5ec12581d53453bb0 이다. 주로 아래 작업을 반복하면 된다.

  1. 첫 번째 사이트에서 목록은 최신순으로 10개씩 나오니, 이 10개를 가져와 각각의 분류번호와 고유 ID 를 가져온다.
  2. 고유 ID 를 가지고 결정내용 팝업 뒤 주소에 붙여 전체 주소를 만든다음, 결정내용 팝업에서 필요한 정보를 모두 가져온다.

여기서 주의해야 되었던 점은 분류번호와 고유 ID는 서로 다르고, 고유 ID 의 경우 게임명에 걸리는 링크에서 regex로 추출해야 된다는 점이다.

나머지 작업의 경우 평이하게 진행되었기 때문에 특이점 외에는 작성할 필요가 없는 것 같다.

데이터베이스 테이블 설계

이 프로젝트의 백엔드의 경우 아래와 같은 구조를 가진다고 생각하면 된다.

PostgreSQL(DB) <-> HikariCP (Connection Pool) <-> Exposed (SQL Layer) <-> Ktor(Web Framework)

이 구조가 정석인지는 판단하기 어렵지만, 실제 백엔드 코드에서는 ORM을 이용해 데이터 모델을 가져오고 보낼 것이므로 데이터 클래스를 만들어 정의하는 것으로 설계를 했다.

필요한 데이터 클래스는 2종으로, 개별 사용자에 대한 푸시 키와 푸시 수신 여부를 가지고 추후에 로그인 기능이 붙었을 때를 위한 Member 클래스 와 게임물 심의 정보에 대한 정보를 가지고 있을 Game 클래스가 필요하다.

먼저 Member 클래스에 대한 데이터 클래스는 다음과 같다.

package com.github.windsekirun.newgame.data

data class User(var no: Int = 0,
                var deviceId: String = "",
                var registrationKey: String = "",
                var pushFlag: Int = 1)

그 다음 Game 에 대한 데이터 클래스이다.

package com.github.windsekirun.newgame.data

data class Game(var id: String = "",
            var name: String = "",
            var applicant: String = "",
            var regNo: String = "",
            var rate: String = "",
            var regDate: String = "",
            var reason: String = "",
            var platform: String = "",
            var genre: String = "")

API 설계

API 는 크게 두 가지 카테고리로 분리된다. 하나는 유저에 대한 /api/user 이고, 나머지 하나는 게임물에 대한 /api/game 이다. 모든 API 는 RESTful 하게 설계되었으며, 각각의 목적에 따라 get, post, put, delete 가 구현된다.

초기 설계에서 필요하다고 여겨진 유저 관련 API는 다음과 같다.

  • POST /api/user

    • 파라미터는 deviceId, deviceType, registrationKey

    • 결과: SUCCESS / FAILED

  • PUT /api/user

    • flag 업데이트 (true, false)

    • 결과: SUCCESS / FAILED

다음은 게임물 관련 API 이다.

  • GET /api/game

    • 파라미터: platform, name, rate, startDay, endDay, manufacturer

    • 결과: List<Game>

  • GET /api/game/id

    • 결과: Game

이 것 말고도 각각에 대한 delete 나 외부에서 크롤링 요청을 보낼 수 있게 하는 request 도 생각하였으나, 크롤링 코드나 DAO / Controller 가 어떻게 나올지는 모르기에 일단 이렇게 작성했다.

DAO / Controller 객체 작성

앞서 작성한 데이터 클래스는 Ktor <- Exposed 만 생각하였기에, 반대로 Ktor -> Exposed 의 역할도 필요한데, 그것이 바로 DAO 객체이다. DAO 객체 에서 primaryKey 나 autoincrement 등을 설정하기 때문에, 스키마의 역할을 한다고 보면 된다.

기본적으로 데이터 클래스와 선언법은 크게 다르지 않으나, data class 가 아닌 object 로 선언하고 Table 라는 객체를 상속하는 것, 그리고 각 변수에 text() 나 integer(), varchar() 의 메서드를 사용하여 대입해야 된다는 점이 다르다.

먼저 데이터 클래스 User에 대한 DAO 객체인 Users 객체이다.

package com.github.windsekirun.newgame.dao

import org.jetbrains.exposed.sql.Table

object Users : Table() {
    val no = integer("no").primaryKey().autoIncrement()
    val deviceId = text("deviceId")
    val registrationKey = text("registrationKey")
    val pushFlag = integer("pushFlag")
}

그 다음, 데이터 클래스 Game 에 대한 DAO 객체인 Games 객체이다.

package com.github.windsekirun.newgame.dao

import org.jetbrains.exposed.sql.Table

object Games : Table() {
    var id = text("id")
    var name = text("name")
    var applicant = text("applicant")
    var regNo = text("regNo")
    var rate = text("rate")
    var regDate = text("regDate")
    var reason = text("reason")
    var platform = text("platform")
    var genre = text("genre")
}

Controller 객체는 실제로 DB에 create 하고 update, delete 같은 행동을 하는 클래스를 말한다. 일반적으로 DB에 접근한다는 것은 asynchronous 한 작업이기 때문에, Exposed 와 Ktor 둘 다 코루틴에 기반하고 있다.

여기서는 기본적인 crud 작업인 list, detail, create, update, delete 만 구현하는 Controller 객체를 만드려 한다.

먼저 User 객체에 대한 UserController 객체이다.

package com.github.windsekirun.newgame.controller

import com.github.windsekirun.newgame.dao.Users
import com.github.windsekirun.newgame.data.User
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction

object UserController {

    fun list(): ArrayList<User> {
        val values: ArrayList<User> = arrayListOf()
        transaction {
            Users.selectAll().map {
                values.add(it.buildMemberObject())
            }
        }
        return values
    }

    fun detail(no: Int): User {
        return transaction {
            Users.select { Users.no eq no }
                    .map { it.buildMemberObject() }
                    .first()
        }
    }

    fun create(user: User): User {
        val memberNo = transaction {
            Users.insert {
                it[Users.deviceId] = user.deviceId
                it[Users.registrationKey] = user.registrationKey
                it[Users.pushFlag] = user.pushFlag
            }.generatedKey
        }

        return user.copy(no = memberNo!!.toInt())
    }

    fun update(no: Int, user: User): User {
        transaction {
            Users.update({ Users.no eq no }) {
                it[Users.deviceId] = user.deviceId
                it[Users.registrationKey] = user.registrationKey
                it[Users.pushFlag] = user.pushFlag
            }
        }
        return User(no, user.deviceId, user.registrationKey, user.pushFlag)
    }

    fun delete(no: Int) {
        transaction {
            Users.deleteWhere { Users.no eq no }
        }
    }

    private fun ResultRow.buildMemberObject() = User(no = this[Users.no],
            deviceId = this[Users.deviceId],
            registrationKey = this[Users.registrationKey],
            pushFlag = this[Users.pushFlag])

}

여기서 몇 가지 포인트를 볼 수 있는데, Users.no 와 실제 User 객체를 와리가리(?) 하는 것이 보이고, Users.no eq no 라는 좀 신기한 형태가 보인다.

첫 번째로, Users 객체는 User 객체의 DAO 객체로, 위에서도 언급했다싶이 User 테이블의 스키마 역할을 한다. 따라서 명시적으로 해당 스키마에 대한 실제 객체의 property 를 매칭할 필요가 있어, autoincrement 로 선언한 id를 제외한 나머지 파라미터들에 대해서 전부 매칭을 해주게 된다. 또한, delete 를 제외한 나머지들에 대해서 User 객체를 반환하고 있는데, 이는 User 객체를 바로 response 로 내려보내기 위해서다.

두 번째로 Users.no eq no 는 코틀린의 infix 문법을 이용한 것으로서, 기본적인 형태는 (Users.no).eq(no) 이다. eq 는 equal 로 줄임말로 즉 동위 관계를 체크하는 것이다. eq 이외에도 이런 연산자? 가 있는데, 그 목록은 다음과 같다.

  • eq -> =
  • neq => <>
  • less => <
  • lessEq => <=
  • greater => >
  • greaterEq => >=
  • like => LIKE
  • notlike => NOT LIKE
  • regexp => REGEXP
  • notRegexp => NOT REGEXP
  • plus => +
  • minus => –
  • times => *
  • div => /

그 다음 Game 객체에 대한 GameController이다.

package com.github.windsekirun.newgame.controller

import com.github.windsekirun.newgame.dao.Games
import com.github.windsekirun.newgame.data.Game
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction

object GameController {

    fun list(): ArrayList<Game> {
        val values: ArrayList<Game> = arrayListOf()
        transaction {
            Games.selectAll().map {
                values.add(it.buildGameObject())
            }
        }
        return values
    }

    fun detail(id: String): Game {
        return transaction {
            Games.select { Games.id eq id }
                    .map { it.buildGameObject() }
                    .first()
        }
    }

    fun create(game: Game): Game {
        transaction {
            Games.insert {
                it[Games.id] = game.id
                it[Games.name] = game.name
                it[Games.applicant] = game.applicant
                it[Games.regNo] = game.regNo
                it[Games.rate] = game.rate
                it[Games.regDate] = game.regDate
                it[Games.reason] = game.reason
                it[Games.platform] = game.platform
                it[Games.genre] = game.genre
            }
        }

        return game
    }

    fun create(games: List<Game>): List<Game> {
        return games.map { create(it) }
    }

    fun update(id: String, game: Game): Game {
        transaction {
            Games.update({ Games.id eq id }) {
                it[Games.id] = game.id
                it[Games.name] = game.name
                it[Games.applicant] = game.applicant
                it[Games.regNo] = game.regNo
                it[Games.rate] = game.rate
                it[Games.regDate] = game.regDate
                it[Games.reason] = game.reason
                it[Games.platform] = game.platform
                it[Games.genre] = game.genre
            }
        }
        return Game(id, game.name, game.applicant, game.regNo, game.rate,
                game.regDate, game.reason, game.platform, game.genre)
    }

    fun delete(id: String) {
        transaction {
            Games.deleteWhere { Games.id eq id }
        }
    }

    private fun ResultRow.buildGameObject() = Game(id = this[Games.id],
            name = this[Games.name],
            applicant = this[Games.applicant],
            regNo = this[Games.regNo],
            rate = this[Games.rate],
            regDate = this[Games.regDate],
            reason = this[Games.reason],
            platform = this[Games.platform],
            genre = this[Games.genre])

}

다만 아직까지 where 나 page 같은 대응은 없는데, 좀 더 공부하고 나서 구현할 생각이다.

DataBinding :: Organize about annotation types

도입

데이터바인딩을 도입하다보면 자주 접하는 어노테이션이 있다. BindingAdapter
, BindingConversion, InverseBindingAdapter, BindingMethods 이다. InverseBindingAdapter 는 양방향 데이터바인딩 글 (https://blog.uzuki.live/databinding-two-way-databinding-with-custom-view/) 에서 설명한 기억이 있고, 나머지도 한번씩은 언급했던 것 같지만 이 참에 정리를 해두려고 한다.

BindingAdapter

Set bold to TextView in DataBinding (https://blog.uzuki.live/set-bold-to-textview-in-databinding/) 글에서도 언급이 되었던 어노테이션인데, 특정 뷰에 xml 속성을 추가하는 기능을 한다.

@BindingAdapter("android:textStyle")
public static void setTypeface(TextView v, String style) {
    switch (style) {
        case "bold":
            v.setTypeface(null, Typeface.BOLD);
            break;
        default:
            v.setTypeface(null, Typeface.NORMAL);
            break;
    }
}

주로 첫 번째에 적용할 뷰, 두 번째 파라미터부터는 값을 넣는다. 첫 번째 파라미터에 들어가는 뷰는 해당 타입의 하위 클래스도 같이 해당된다. 예를 들면, TextView 를 상속하는 EditText 도 이 속성을 사용할 수 있다.

@BindingAdapter(value = {"minValue", "maxValue"}, requireAll = false)
public void bindMultiSliderMinValue(MultiSlider slider, int min, int max) {
    slider.setMin(min);
    slider.setMax(max);
}

값을 두 개 이상 넣을 때에는 value 를 string-array 로 만들고 각각 속성을 적어주면 된다. 추가로 나와있는 requireAll 의 경우에는 선택 파라미터(Optional Parameter) 로 기본값은 true 로 되어있다. 즉, requireAll 가 true 이면 minValue, maxValue 가 각각 있어야 된다는 의미이고, false 는 반대로 둘 중 하나만 있어도 된다는 뜻이다.

@BindingAdapter(value = "onEditorAction")
public void bindEditorAction(EditText editText, OnEditActionListener onEditActionListener) {
    editText.setOnEditorActionListener(onEditActionListener::onEditorAction);
}

public interface OnEditActionListener {
    boolean onEditorAction(TextView view, int actionId, KeyEvent event);
}

파라미터로 리스너를 추가할 수 있는데, interface 를 정의해놓고 파라미터에 넣으면 된다.

기본적으로 속성의 경우 app 네임스페이스가 붙어 app:minValue, app:maxValue 로 활용이 가능한데, 속성 자체를 android:maxValue 로 적어주면 android:maxValue 로 사용이 가능하다. 주로 안드로이드 기본 컴포넌트를 이용할 때 사용한다.

그리고, BindingAdapter 의 메서드는 static 로 선언하는 것이 기본적이다. static 로 선언하지 않을 수도 있지만 이 경우에는 BindingComponent 라는 클래스를 만들고 이 BindingComponentDataBindingUtils.setDefaultComponent 로 설정해주어야 한다.

public class BindingComponent implements DataBindingComponent {
    private final BindAdapter mAdapter;

    public BindingComponent(Context context) {
        this.mAdapter = new BindAdapter(context);
    }

    public static BindingComponent create(Context context) {
        return new BindingComponent(context);
    }

    @Override
    public BindAdapter getBindAdapter() {
        return mAdapter;
    }
}

이 조건이 문제가 되는 곳이 바로 Kotlin 으로 구성할 때 인데, 반드시 companion object 안이 아닌 해당 파일 자체를 object 로 선언하고 각 메서드에 @JvmStatic 어노테이션을 달아주어야 한다.
실제로 BindingAdpater 는 아래와 같이 적용된다.

com.github.windsekirun.demoapp.binding.BindAdapter.setTypeface(this.mboundView7, viewModelMDataSourceInt1JavaLangStringBoldJavaLangStringNormal);

BindingConversion

BindingAdapter 가 View 에 속성을 추가하는 것이라면, BindingConversion 는 이름에서 알 수 있다 싶이 변환하는 역할을 한다.
주의할 점은 적용될 뷰를 선택할 수 있는 BindingAdapter 와 달리 BindingConversion 는 전역적으로 적용된다. 예를 들어, boolean 을 View.VISIBLE 와 View.GONE 로 바꾸는 기능을 만들었다면 이는 View.VISIBLE 와 View.GONE 에만 사용할 수 있다. View.INVISIBLE 는 사용하지 못한다.

@BindingConversion
public static int convertBooleanToVisibility(boolean visible) {
    return visible ? View.VISIBLE : View.GONE;
}

이를 사용하면 기존에는 android:visibility="@{viewModel.mVisibleList ? View.VISIBLE : View.GONE}" 라고 적어야 될 것을, android:visibility="@{viewModel.mVisibleList}" 로 바꿀 수 있다. 다만, 위에도 말한듯이 INVISIBLE 는 기존처럼 적어야 한다.
실제로 BindingConversion 는 다음과 같이 적용된다.

this.mboundView0.setVisibility(com.github.windsekirun.demoapp.binding.BindConversion.convertBooleanToVisibility(adapterIsVisibleDatePositionData));

InverseBindingAdapter

Inverse(=역) BindingAdapter, 즉 BindingAdapter> 가 ViewModel -> XML 를 위해 xml 속성을 추가하는 것이라면, InverseBindingAdapter 는 XML 에서의 변경값을 ViewModel 에 역으로 적용하는 역할을 한다.
InverseBindingAdapter 어노테이션 자체에는 두 가지 파라미터가 들어가는데, 첫 번째 attribute는 속성을 적고, 두 번째 event 는 attribute 이름에 AttrChanged 를 붙인 것을 적는다.

@InverseBindingAdapter(attribute = "android:currentPage", event = "android:currentPageAttrChanged")
public static int getCurrentPage(ViewPager pager) {
    return pager.getCurrentItem();
}

자세한 설명은 DataBinding – Two-way Databinding with Custom View(https://blog.uzuki.live/databinding-two-way-databinding-with-custom-view/) 를 참고하는 것이 좋을 것 같다.

BindingMethods

기능은 BindingAdapter 와 같은데, 아래와 같은 BindingAdapter 를 대체할 수 있다.

@BindingAdapter("hintText")
public static void setInputHintText(InputView inputView, String hintText) {
    inputView.setHintText(hintText);
}
@BindingMethods({
        @BindingMethod(type = InputView.class, attribute = "hintText", method = "setHintText")
})

즉, 단순한 형태의 BindingAdapterBindingMethod 로 간단히 대체할 수 있는 것이다.
BindingMethods 어노테이션은 클래스에 붙이면 된다.
실제로는 다음과 같이 사용된다.

this.mboundView2.setHintText(viewModelHintText);

기존 다른 코드와는 다르게 해당 메서드를 단순히 실행시키는 것 처럼 생성된다.
BinidingMethods 와 같은 행동을 하는 InverseBindingMethods 가 있는데, 이는 단순한 형태의 InverseBindingAdapter 를 대체하는 것이다.
InverseBindingMethods 는 다음 링크를 참조하면 된다.
https://android.googlesource.com/platform/frameworks/data-binding/+/master/extensions/baseAdapters/src/main/java/android/databinding/adapters/CompoundButtonBindingAdapter.java