Skip to content

Instantly share code, notes, and snippets.

@seoh
Created March 4, 2019 13:32
Show Gist options
  • Save seoh/87f0276a8fce98094bb400244911e376 to your computer and use it in GitHub Desktop.
Save seoh/87f0276a8fce98094bb400244911e376 to your computer and use it in GitHub Desktop.
EitherT example
Display the source blob
Display the rendered blob
Raw
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"EitherT를 사용하면 편하게 해결할 수 있는 문제가 올라와서 Scala의 기본 문법과 implicit conversion 정도만 아는 사람을 대상으로 쉽게?? 예제를 만들어볼까 했습니다. 간단한 API 서버를 만든다고 가정하고 회원가입 시 생길 수 있는 다양한 에러를 fail-fast로 만들어봅시다. 우선 필요한 패키지들을 import합니다."
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"\u001b[32mimport \u001b[39m\u001b[36m$ivy.$ \n",
"\u001b[39m\n",
"\u001b[32mimport \u001b[39m\u001b[36mcats._\n",
"\u001b[39m\n",
"\u001b[32mimport \u001b[39m\u001b[36mscala.concurrent.Future\n",
"\u001b[39m\n",
"\u001b[32mimport \u001b[39m\u001b[36mscala.concurrent.ExecutionContext.Implicits.global\u001b[39m"
]
},
"execution_count": 1,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import $ivy.`org.typelevel::cats-core:1.6.0`\n",
"import cats._\n",
"import scala.concurrent.Future\n",
"import scala.concurrent.ExecutionContext.Implicits.global"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"그리고 데이터 타입들을 정의해봅시다. 사실 여기서 email은 [Refined](https://github.com/fthomas/refined)를 써볼까하다가 필요한 지식이 너무 많아질까봐 생략했습니다."
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"defined \u001b[32mtype\u001b[39m \u001b[36mUserId\u001b[39m\n",
"defined \u001b[32mclass\u001b[39m \u001b[36mUser\u001b[39m"
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"//\n",
"// Data\n",
"//\n",
"type UserId = Long\n",
"case class User(id: UserId, name: String, email: String)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"이제 생길 수 있는 에러들을 타입으로 정의해봅시다. 회원가입, 정보조회 등을 생각해봤을 때 바로 떠오르는 에러는 \"사용자를 찾을 수 없습니다\", \"이름이 이미 존재합니다\", \"이메일 형식이 틀렸습니다\" 정도가 있겠네요."
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"defined \u001b[32mtrait\u001b[39m \u001b[36mAPIError\u001b[39m\n",
"defined \u001b[32mclass\u001b[39m \u001b[36mInvalidJSON\u001b[39m\n",
"defined \u001b[32mtrait\u001b[39m \u001b[36mUserAPIError\u001b[39m\n",
"defined \u001b[32mclass\u001b[39m \u001b[36mUserNotFound\u001b[39m\n",
"defined \u001b[32mclass\u001b[39m \u001b[36mNameAlreadyExist\u001b[39m\n",
"defined \u001b[32mclass\u001b[39m \u001b[36mInvalidEmailFormat\u001b[39m"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"//\n",
"// Error\n",
"//\n",
"trait APIError\n",
"case class InvalidJSON(message: String) extends APIError\n",
"\n",
"trait UserAPIError extends APIError\n",
"case class UserNotFound(id: UserId) extends UserAPIError\n",
"case class NameAlreadyExist(name: String) extends UserAPIError\n",
"case class InvalidEmailFormat(email: String) extends UserAPIError"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"이제 데이터베이스를 만들어봅시다. 물론 실제 DB를 띄우는게 가장 정확하지만 최소한의 지식과 세팅으로 하는게 목표라 mutable을 쓰는 죄를 저질러봅니다."
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"defined \u001b[32mobject\u001b[39m \u001b[36mDB\u001b[39m"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"object DB {\n",
" private val db = collection.mutable.Map[UserId, User](0L -> User(0, \"Admin\", \"admin@domain.com\"))\n",
" \n",
" def getUser(id: UserId): Future[Either[APIError, User]] =\n",
" Future(db.get(id).toRight[APIError](UserNotFound(id)))\n",
" \n",
" def register(user: User): Future[Either[APIError, UserId]] =\n",
" Future {\n",
" if(db.values.exists(_.name == user.name)) \n",
" Left(NameAlreadyExist(user.name))\n",
" else {\n",
" val nextId: UserId = db.keys.size\n",
" db += (nextId -> user.copy(id = nextId))\n",
" Right(nextId)\n",
" }\n",
" }\n",
"}"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"그런데 만들다보면 모든걸 DB에서 비동기로 처리하지 않고 필요한 유틸리티를 만들어서 제공하는 경우도 많습니다. 불필요하게 쓰레드를 만들기보다 바로 처리하는 작업들이 있죠. 예를 들어 정규식 처리가 그렇습니다. 이메일이 올바른지 아주 간단하게 확인해봅시다. 숫자와 문자만 존재하고 도메인은 dot이 중간에 하나만 존재한다고 가정합니다. 물론 실제로 이렇게 쓰면 직장이 사라집니다. 물론 전 이렇게 안짰는데도 직장이 사라졌지만요."
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"defined \u001b[32mobject\u001b[39m \u001b[36mUtility\u001b[39m"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"object Utility {\n",
" import scala.util.matching.Regex\n",
" private val emailRegExp: Regex = \"[a-z0-9]+@[a-z0-9]+\\\\.[a-z0-9]+\".r\n",
"\n",
" def isValidEmail(email: String): Either[APIError, String] = email match {\n",
" case emailRegExp() => Right(email)\n",
" case _ => Left(InvalidEmailFormat(email))\n",
" }\n",
"}"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"줄이고 줄여서 간단하게 만든다고 했는데 생각보다 길어졌습니다. 구현하기 전에 간단한 유틸리티 하나 더 구현해보겠습니다. scala에서 기능을 확장하기 위해 implicit class라는 implicit conversion을 주로 쓰는데 이미 아시겠지만 cats에서도 굉장히 많이 쓰이고 있으니 익숙해지는게 좋습니다."
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"\u001b[32mimport \u001b[39m\u001b[36mscala.concurrent.duration._\n",
"\u001b[39m\n",
"\u001b[32mimport \u001b[39m\u001b[36mscala.concurrent.Await\n",
"\n",
"\u001b[39m\n",
"defined \u001b[32mclass\u001b[39m \u001b[36mFutureOps\u001b[39m"
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import scala.concurrent.duration._\n",
"import scala.concurrent.Await\n",
"\n",
"implicit class FutureOps[A](future: Future[A]) {\n",
" def await: A = Await.result(future, 1 seconds)\n",
"}"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"이렇게 만들면 기존 `Future`에서 `await` 메소드가 추가된 것처럼 사용할 수 있습니다."
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"\u001b[36mres6\u001b[39m: \u001b[32mInt\u001b[39m = \u001b[32m0\u001b[39m"
]
},
"execution_count": 7,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"Future(0).await"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"REPL이나 worksheet에서 테스트를 할 때 비동기는 확인하기 어려우니 이런걸 사용하면 편해집니다."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"이제 서버를 구현해봅시다. 간단하게 `GET /api/user/:id`로 회원 정보를 조회한다고 하면 다음과 같이 만들 수 있습니다."
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"defined \u001b[32mtype\u001b[39m \u001b[36mResponse\u001b[39m\n",
"defined \u001b[32mobject\u001b[39m \u001b[36mServer\u001b[39m"
]
},
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"type Response = String\n",
"\n",
"object Server {\n",
" object User {\n",
" def get(id: UserId): Future[Response] =\n",
" DB.getUser(id) map {\n",
" case Left(UserNotFound(id)) => s\"\"\"{ \"error\": \"User($id) not exists\" }\"\"\"\n",
" case Left(error: APIError) => s\"\"\"{ \"error\": $error }\"\"\"\n",
" case Right(user) => s\"\"\"{ \"name\": \"${user.name}\", \"email\": \"${user.email}\" }\"\"\"\n",
" }\n",
" }\n",
"}"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"이제 간단하게 테스트를 해봅시다."
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{ \"name\": \"Admin\", \"email\": \"admin@domain.com\" }\n",
"{ \"error\": \"User(1) not exists\" }\n"
]
}
],
"source": [
"println(Server.User.get(0).await)\n",
"println(Server.User.get(1).await)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"위와 같이 존재하거나 존재하지 않을 때의 응답을 확인할 수 있습니다. 이제 판은 충분히 깔아놓은 것 같으니 제일 복잡한 회원가입을 구현해봅시다. 일단 서버에서 `POST /api/user/register`로 `{ \"name\": ..., \"email\": ... }` 형식으로 요청이 들어온다고 했을 때 보통 프레임워크 혹은 라이브러리를 통해 JSON을 class로 매핑하는 경우가 많은데 여기서는 `Map[String, String]`으로 들어온다고 가정해봅시다. 여기에서 우리가 필요한 정보(name, email)이 존재하거나 존재하지 않을 수도 있으니 먼저 확인할 수 있어야합니다."
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"defined \u001b[32mfunction\u001b[39m \u001b[36mdecode\u001b[39m"
]
},
"execution_count": 10,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"def decode(json: Map[String, String]): Option[(String, String)] =\n",
" for {\n",
" name <- json.get(\"name\")\n",
" email <- json.get(\"email\")\n",
" } yield (name, email)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"이제 다음과 같은걸 한 맥락에서 응답하려고 합니다.\n",
"\n",
"1. JSON을 파싱해서 정보를 얻는다: `Option[(String, String])`\n",
"2. 파싱한 정보 중 email이 형식에 맞는지 확인한다: `Either[APIError, String]`\n",
"3. 정보를 DB에 입력한다: `Future[Either[APIError, UserId]]`\n",
"\n",
"각각의 정보가 다른 형식의 값을 갖지만 위에서 실패한 경우에 그 다음을 실행할 필요가 없고 에러를 제외한 데이터만 다음 맥락에서 사용하는 하나의 흐름을 만들 때 가장 간단하게는 if/else나 패턴매칭을 중첩해서 사용할 수도 있습니다만 호환되는 상위 타입으로 모으면 한번에 처리할 수 있습니다. 예를 들어 Either를 Option으로 변환하면 Left 정보가 손실되지만 Option을 Either로 변환할 때 에러에 대한 추가 정보를 입력할 수 있습니다. 동기를 비동기(Future)로 변환할 때는 쓰레드를 생성하는 비용이 추가되지만 비동기를 동기로 바꾸면 비동기에 해당하는 작업을 하는동안 한 쓰레드를 못쓰게되니 비용면에서 비동기로 모으는게 좋아보입니다. 그래서 `Future[Either[APIError, ?]]`의 형식으로 모으려고 합니다. `?`는 각각의 작업에 대한 데이터의 타입입니다."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"1. `Option[(String, String)]`의 경우엔 다음과 같이 변환할 수 있습니다."
]
},
{
"cell_type": "code",
"execution_count": 11,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"\u001b[36mo\u001b[39m: \u001b[32mOption\u001b[39m[(\u001b[32mString\u001b[39m, \u001b[32mString\u001b[39m)] = \u001b[33mSome\u001b[39m((\u001b[32m\"name\"\u001b[39m, \u001b[32m\"email\"\u001b[39m))\n",
"\u001b[36mresult\u001b[39m: \u001b[32mFuture\u001b[39m[\u001b[32mEither\u001b[39m[\u001b[32mAPIError\u001b[39m, (\u001b[32mString\u001b[39m, \u001b[32mString\u001b[39m)]] = \u001b[32m\u001b[33mSuccess\u001b[39m(\u001b[33mRight\u001b[39m((\u001b[32m\"name\"\u001b[39m, \u001b[32m\"email\"\u001b[39m)))\u001b[39m"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"val o: Option[(String, String)] = Option((\"name\", \"email\"))\n",
"val result: Future[Either[APIError, (String, String)]] = Future(o.toRight(InvalidJSON(\"required: name, email\")))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"2. `Either[APIError, String]`의 경우엔 `Future(???)`로 바로 변환 가능하고\n",
"3. 이미 `Future[Either[APIError, UserId]]`인 경우엔 변환할 필요가 없습니다.\n",
"\n",
"\n",
"이제 형식이 같으니 한번에 쓸 수 있어보이지만 Future, Either로 필요한 정보가 두번 감싸져있으니 바로 사용하기 어렵습니다. 이걸 해결하기 위한 wrapper를 만들어볼텐데, 바로 삭제할 예정이니 주의깊게보지않으셔도 됩니다."
]
},
{
"cell_type": "code",
"execution_count": 12,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"defined \u001b[32mclass\u001b[39m \u001b[36mFutureEither\u001b[39m\n",
"defined \u001b[32mobject\u001b[39m \u001b[36mFutureEither\u001b[39m"
]
},
"execution_count": 12,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"class FutureEither[T](val value: Future[Either[APIError, T]]) {\n",
" def flatMap[R](fn: T => FutureEither[R]): FutureEither[R] =\n",
" FutureEither(value flatMap {\n",
" case Left(e) => Future(Left(e))\n",
" case Right(t) => fn(t).value\n",
" })\n",
"\n",
"// def withFilter(fn: T => Boolean): FutureEither[T] =\n",
"// FutureEither(value.filter(e => e.map(fn).getOrElse(false)))\n",
" \n",
" def map[R](fn: T => R): FutureEither[R] =\n",
" FutureEither(value.map(_.map(fn)))\n",
"}\n",
"\n",
"object FutureEither {\n",
" def apply[T](fe: Future[Either[APIError, T]]): FutureEither[T] = new FutureEither(fe)\n",
" def apply[T](e: Either[APIError, T]): FutureEither[T] = apply(Future(e))\n",
"}"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"이제 회원가입을 구현해봅시다."
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Right(1)\n"
]
},
{
"data": {
"text/plain": [
"\u001b[36mjson\u001b[39m: \u001b[32mMap\u001b[39m[\u001b[32mString\u001b[39m, \u001b[32mString\u001b[39m] = \u001b[33mMap\u001b[39m(\n",
" \u001b[32m\"name\"\u001b[39m -> \u001b[32m\"user name\"\u001b[39m,\n",
" \u001b[32m\"email\"\u001b[39m -> \u001b[32m\"user@email.com\"\u001b[39m\n",
")\n",
"\u001b[36mresult\u001b[39m: \u001b[32mFutureEither\u001b[39m[\u001b[32mUserId\u001b[39m] = ammonite.$sess.cmd11$Helper$FutureEither@4f9fa079"
]
},
"execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"val json = Map(\n",
" \"name\" -> \"user name\",\n",
" \"email\" -> \"user@email.com\"\n",
")\n",
"\n",
"\n",
"val result: FutureEither[UserId] = for {\n",
" pair <- FutureEither(decode(json).toRight(InvalidJSON(\"required: name, email\")))\n",
" _ <- FutureEither(Utility.isValidEmail(pair._2))\n",
" id <- FutureEither(DB.register(User(0L, pair._1, pair._2)))\n",
"} yield id\n",
"\n",
"println(result.value.await) // Right(1L)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"실제 데이터까지 두 단계에 거쳐 들어가야하는 것을 한 맥락에서 다루기 위해 위에서 `FutureEither`를 구현했습니다. 하지만 지금 구조에서는 `Future`와 `Either`만 지원할 뿐더러 순서가 바뀐 경우에는 지원되지 않습니다. 이런 경우에 일반적으로 사용할 수 있는 인터페이스를 통해 일관적된 흐름을 다룰 수 있으면 좋지 않을까요? 이럴 때 사용하면 좋은 라이브러리가 있습니다. 일단 코드를 봅시다."
]
},
{
"cell_type": "code",
"execution_count": 14,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Right(2)\n"
]
},
{
"data": {
"text/plain": [
"\u001b[36mjson2\u001b[39m: \u001b[32mMap\u001b[39m[\u001b[32mString\u001b[39m, \u001b[32mString\u001b[39m] = \u001b[33mMap\u001b[39m(\n",
" \u001b[32m\"name\"\u001b[39m -> \u001b[32m\"user name2\"\u001b[39m,\n",
" \u001b[32m\"email\"\u001b[39m -> \u001b[32m\"user@email.com\"\u001b[39m\n",
")\n",
"\u001b[32mimport \u001b[39m\u001b[36mcats.data.EitherT\n",
"\u001b[39m\n",
"\u001b[32mimport \u001b[39m\u001b[36mcats.implicits._\n",
"\n",
"\u001b[39m\n",
"\u001b[36mresult2\u001b[39m: \u001b[32mEitherT\u001b[39m[\u001b[32mFuture\u001b[39m, \u001b[32mAPIError\u001b[39m, \u001b[32mUserId\u001b[39m] = \u001b[33mEitherT\u001b[39m(\n",
" \u001b[32m\u001b[33mSuccess\u001b[39m(\u001b[33mRight\u001b[39m(\u001b[32m2L\u001b[39m))\u001b[39m\n",
")"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"val json2 = Map(\n",
" \"name\" -> \"user name2\",\n",
" \"email\" -> \"user@email.com\"\n",
")\n",
"\n",
"import cats.data.EitherT\n",
"import cats.implicits._\n",
"\n",
"val result2: EitherT[Future, APIError, UserId] = for {\n",
" pair <- EitherT.fromOption[Future](decode(json2), InvalidJSON(\"required: name, email\"))\n",
" _ <- EitherT.fromEither[Future](Utility.isValidEmail(pair._2))\n",
" id <- EitherT(DB.register(User(0L, pair._1, pair._2)))\n",
"} yield id\n",
"\n",
"println(result2.value.await) // Right(2L)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"거의 비슷한 코드로 장황한 준비작업없이 사용할 수 있고 이전에 만들었던 것보다 더 일반적으로 사용할 수 있습니다. 모나드 트랜스포머니 하는 이름들은 있지만 굳이 사용하지 않은 경우는 일단 이름만 듣고 너무 긴장해서 어디에 쓸지 모르는 경우가 많아서가 있고 둘째는 제가 잘 몰라서입니다. 저번 회사에서 처음으로 써봤고 validator를 만들 때 `OptionT[Future, T]`를 쓰거나 지금처럼 에러를 fail-first로 만들면서 한 맥락으로 다룰 때 `EtherT[Future, Error, T]`를 쓰는 정도였습니다.\n",
"\n",
"책을 보면 그때는 잠깐 이해가 될 것 같은 느낌만 드는 느낌이었는데 실제로 코딩할 때 어떻게 써야할지 막막하다가 일단 한정적인 환경에서라도 써보자하고 쓰다보니 쓰는 방식만 계속 쓰게 되었지만 그걸로도 충분하지 않을까요? 예전에 왜 막막했나 싶었나 생각해보니, 1. 데이터가 여러 타입이 있고, 2. data class나 type class가 또 여러 타입이 있고, 3. MTL도 이해하지 못한 여러 타입들이 있어서 `n * n * n`으로 생각하다보니 너무 복잡하게 생각했던 것 같습니다. `Future` + `Either`, `Future` + `Option`, 정도 정해진 타입만 쓰다보면 `n + 2` 정도로 생각하는게 익숙해져서 그럭저럭 짜기는 했습니다. 물론 정확한 개념을 이해했다고 보기도 어렵고 http4s를 쓰다보니 `Kleisli`도 어째저째 쓰고는 있지만 이것도 계층이 또 생기면 또 헤매고 있긴 합니다만 계속 쓰다보면 어차피 쓰는 패턴만 나오고 그럼 그 패턴에는 익숙해질 것 같습니다. 일단 시도해보세요."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### 더 알아보기\n",
"\n",
"- [Scala with Cats](https://underscore.io/books/scala-with-cats/)\n",
"- [Scala Exercises: Cats](https://www.scala-exercises.org/cats)\n",
"- [위의 코드 실행해보기](https://scastie.scala-lang.org/seoh/8QlIz3oQQ8aVSjQEyM2GqA)\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Scala",
"language": "scala",
"name": "scala"
},
"language_info": {
"codemirror_mode": "text/x-scala",
"file_extension": ".scala",
"mimetype": "text/x-scala",
"name": "scala",
"nbconvert_exporter": "script",
"version": "2.12.8"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment