2

Http4s x Finagle Tutorial

 3 years ago
source link: https://blog.oyanglul.us/scala/http4s-tutorial
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Get Started with Http4s follow the structure of Getting Started with Rails

This guide covers getting up and running a production ready http4s example.

After reader this guide, you will know:

  • How to install Http4s, create a new Http4s application, and connect your application to a database.
  • The general layout of a Http4s application.
  • The basic principles of FP design.
  • How to quickly generate the starting pieces of a Http4s application.
  • How to package and deploy a Http4s application
  • How to monitor a Http4s application

Prerequisites

Verify your environment

Start server

$ nix-shell
> sbt ~reStart

Let us assume all future prefix of > represent for command in nix-shell, and $ for bash.

You should able to see a empty list [] since there is nothing in database yet.

$ curl localhost:8080/joke

To run test simply

> sbt test

Now you have a proven working environment for the service to test and run, let us see how we build it.

Creating a new Joke Application

$ sbt new jcouyang/http4s.g8

You can either answer all those question that it prompt or press Enter all the way to the end.

file structure

File/Folder Purpose .github folder of github workflow etc. .scalafmt.conf Specification of how to format Scala source code build.sbt Specify build tasks and Scala library dependencies db Database migrations docker-compose.yml Definition of how to boot local services like zipkin, postgres project sbt plugins shell.nix Nix shell configuration src Scala source target Compiled target

source structure

$ tree src
src
├── main
│   ├── resources
│   │   ├── com
│   │   │   └── twitter
│   │   │       └── toggles
│   │   │           └── configs
│   │   │               └── com.your.domain.http4sexample.json
│   │   └── logback.xml
│   └── scala
│       └── com
│           └── your
│               └── domain
│                   └── http4sexample
│                       ├── Config.scala
│                       ├── Main.scala
│                       ├── NatureTransfomation.scala
│                       ├── package.scala
│                       ├── resource
│                       │   ├── database.scala
│                       │   ├── http.scala
│                       │   ├── logger.scala
│                       │   ├── package.scala
│                       │   ├── toggle.scala
│                       │   └── trace.scala
│                       └── route
│                           ├── config.scala
│                           ├── joke.scala
│                           └── package.scala
└── test
    └── scala
        └── com
            └── your
                └── domain
                    └── http4sexample
                        ├── SpecHelper.scala
                        └── route
                            └── JokeSpec.scala

File/Folder Purpose com.your.domain.http4sexample.json feature toggles logback.xml log config Config.scala Application Config as code Main.scala The entry point of the program NatureTransfomation.scala A helper for kind to kind transformation package.scala index of common types and function across whole application resource/database.scala Database resource, transactor, helper methods etc resource/http.scala Http Client resource resource/package.scala index of all resources resource/toggle.scala Resource of feature toggles resource/trace.scala Resource of zipkin tracing route/config.scala API route of /config endpoint route/joke.scala API route of /joke endpoint route/package.scala Index of all APIs SpecHelper.scala Common helper methods for test like database connection route/JokeSpec.scala Test Specification of route /joke

Data migration

Before we start to build the joke service, what we need is a database table, to store the detail of jokes.

You might ask, where is our local DB?

The Postgres DB is defined in docker-compose.yml for local development

db:
  image: postgres:10
  environment:
    - POSTGRES_DB=joke
    - POSTGRES_HOST_AUTH_METHOD=trust
  ports:
    - 5432:5432

Where POSTGRES_DB=joke will help creating the database and name it joke.

You don't need to run DB migration manually most of the time, since nix-shell hook will run it for you.

Every time you enter nix-shell, you will see the migration log:

nix-shell
Creating network "http4s-example_default" with the default driver
Creating http4s-example_zipkin_1 ... done
Creating http4s-example_db_1     ... done
[info] welcome to sbt 1.3.13 (Azul Systems, Inc. Java 1.8.0_202)
[info] loading settings for project http4s-example-build from plugins.sbt,metals.sbt ...
[info] loading project definition from /Users/jichao.ouyang/Develop/http4s-example/project
[info] loading settings for project root from build.sbt ...
[info] set current project to http4s-example (in build file:/Users/jichao.ouyang/Develop/http4s-example/)
[info] running Main migrate
Sep 14, 2020 12:14:15 PM org.flywaydb.core.internal.license.VersionPrinter printVersionOnly
INFO: Flyway Community Edition 6.5.5 by Redgate
Sep 14, 2020 12:14:15 PM org.flywaydb.core.internal.database.DatabaseFactory createDatabase
INFO: Database: jdbc:postgresql://localhost:5432/joke (PostgreSQL 10.14)
Sep 14, 2020 12:14:15 PM org.flywaydb.core.internal.command.DbValidate validate
INFO: Successfully validated 1 migration (execution time 00:00.015s)
Sep 14, 2020 12:14:15 PM org.flywaydb.core.internal.schemahistory.JdbcTableSchemaHistory create
INFO: Creating Schema History table "public"."flyway_schema_history" ...
Sep 14, 2020 12:14:15 PM org.flywaydb.core.internal.command.DbMigrate migrateGroup
INFO: Current version of schema "public": << Empty Schema >>
Sep 14, 2020 12:14:15 PM org.flywaydb.core.internal.command.DbMigrate doMigrateGroup
INFO: Migrating schema "public" to version 1.0 - CreateJokeTable

To migrate when schema changed:

> sbt "db/run migration"

Migration file located in db/src/main/scala/db/migration

$ tree db/src
db/src
└── main
    └── scala
        ├── DoobieMigration.scala
        ├── Main.scala
        └── db
            └── migration
                └── V1_0__CreateJokeTable.scala

A migration file is actually a Scala doobie source code.

class V1_0__CreateJokeTable extends DoobieMigration {
  override def migrate =
    sql"""create table joke (
                id serial not null
                        constraint joke_pk
                        primary key,
                text text not null,
                created timestamptz default now() not null
          )""".update.run
}

The prefix V1_0__ in class name means version 1.0, detail of naming convention please refer to Flyway

Now we have database scheme set, next we need a API to save data into the new table.

Save a joke POST /joke

To be to able to save data, a database library such as Doobie or Quill is required.

The following example uses Quill:

 1: val CRUD = AppRoute {                               // <- (route)
 2:     case req @ POST -> Root / "joke" =>
 3:       for {
 4:         has <- Kleisli.ask[IO, HasDatabase]         // <- (kleisli)
 5:         joke <- Kleisli.liftF(req.as[Repr.Create])  // <- (reqbody)
 6:         id <- has.transact(run(quote {              // <- (quill)
 7:           query[Dao.Joke]
 8:             .insert(_.text -> lift(joke.text))
 9:             .returningGenerated(_.id)
10:         }))
11:         _ <- log.infoF(s"created joke with id $id")
12:         resp <- Created(json"""{"id": $id}""")
13:       } yield resp
14: }
  1. AppRoute is simply a wrapper of Http4s' HttpRoutes.of[IO] but dependencies injectable.
  2. Kleisli.ask is something like @Inject in Java world except everything is lazy, when you ask[IO, HasDatabase], it will <- a instance has of HasDatabase type Kleisli is also known as ReaderT https://blog.oyanglul.us/scala/into-the-readert-verse
  3. We also need to read the body from the req using Http4s DSL req.as[Repr.Create] will parse the body and return a IO[Repr.Create]. We need to liftF because the for comprehension is type Kleisli[IO, HasXYZ, Response[IO]].
  4. has has type HasDatabase, which means it has database transact method, when run convert Quill's quote into ConnectionIO[A], transact can execute it in one transaction.

It is pretty cool that Quill will translate the DSL directly into SQL at compile time:

image.png

If you're not fan of Macro it is very easy to switch back to doobie DSL:

 1: val CRUD = AppRoute {
 2:     case req @ POST -> Root / "joke" =>
 3:       for {
 4:         has <- Kleisli.ask[IO, HasDatabase]
 5:         joke <- Kleisli.liftF(req.as[Repr.Create])
 6:         id <- has.transact(
 7:           sql"insert into joke (text) values ${joke.text}".update.withUniqueGeneratedKeys("id")) // <- (doobie)
 8:         _ <- log.infoF(s"created joke with id $id")
 9:         resp <- Created(json"""{"id": $id}""")
10:       } yield resp
11: }

Stream some jokes GET /joke

Similarly you will probably figure out how to implement a GET /joke endpoint already.

But we has some killer feature in Http4s, we can stream the list of jokes direct from DB to response body. Which means you don't actually need to read all jokes into memory, and then return it back at one go, the data of jokes can actually flow through your Http4s server without accumulating in the memory.

 1: case GET -> Root / "joke" =>
 2:   Kleisli
 3:     .ask[IO, HasDatabase]
 4:     .flatMap(
 5:       db =>
 6:         Ok(
 7:           db.transact(stream(quote {   // <- (stream)
 8:             query[Dao.Joke]
 9:           }))
10:             .map(Repr.View.from)
11:         )
12:     )

stream is provide by doobie, which returns Stream[ConnectionIO, A], when transact it we will get a Stream[IO, A], luckly Http4s response accept a Stream[IO, A] as long as we have a EntityEncoder[IO, A].

Feature Toggle GET /joke/:id

It is too straightforward to implement a GET /joke/:id:

case GET -> Root / "joke" / IntVar(id) =>
  for {
    has <- Kleisli.ask[IO, HasDatabase]
    joke <- log.infoF(s"getting joke $id") *> Kleisli.liftF(
      IO.shift(IO.contextShift(ExecutionContext.global))
    ) *> has.transact(run(quote {
      query[Dao.Joke].filter(_.id == lift(id)).take(1)
    }))
    resp <- joke match {
      case a :: Nil => Ok(a)
      case _        => NotFound(id)
    }
  } yield resp

Let's add some feature to it, for instance, if there is no joke in database, how about randomly generate some dad joke? And we like 50% of users can see random joke instead of hitting NotFound

To prepare a feature toggle in Finagle, you have to put a file in directory src/main/resources/com/twitter/toggles/configs/com.your.domain.http4sexample.json. where com.your.domain.http4sexample is your application package.

And then put in the toggle:

{
  "toggles": [
    {
      "id": "com.your.domain.http4sexample.useDadJoke",
      "description": "random generate dad joke",
      "fraction": 0.5
    }
  ]
}

It is good practice to have id naming with proper namespace too.

0.5 fraction means there will be 50% chance for the toggle to be on status.

How can we use this toggle in source code? https://github.com/jcouyang/http4s-example/blob/master/src/main/scala/com/your/domain/http4sexample/resource/toggle.scala#L9

Inject HasToggle effect

- has <- Kleisli.ask[IO, HasDatabase]
+ has <- Kleisli.ask[IO, HasDatabase with HasToggle]

Switch on the toggle

1: dadJoke =                             // <- (declare)
2:   if (has.toggleOn("com.your.domain.http4sexample.useDadJoke"))
3:     log.infoF(s"cannot find joke $id") *> dadJokeApp.flatMap(NotFound(_))
4:   else
5:     NotFound(id)
6: resp <- joke match {
7:   case a :: Nil => Ok(a)
8:   case _        => dadJoke            // <- (usage)
9: }

dadJokeApp is a HTTP effect which call another API, we will go through later.

Here is another advantage of FP over Imperative Programming, dadJoke is lazy and referential transparent, which means I can place it anywhere, and whenever I reference it will always be the same thing. While in Imperative Programming this won't be always true, i.e. when you declare a val printlog = println("log") it will execute immediately where it declared. But later on when you refer to printlog, it is not the same thing it was defined. Since the log is already print, it won't print again.

So, simply declare a dadJoke won't execute dadJokeApp to actually send out the request. We can safely put it for later usage in pattern matching

Random dad joke GET /random-joke

To get a random dad joke remotely, you will need a Http client that talk connected to the remote host.

Finagle Client is actually a RPC client, which means a client will bind to particular service.

Assuming we have already define a jokeClient in HasClient, a dad joke endpoint will be as simple as:

val dadJokeApp =
  Kleisli.ask[IO, HasClient].flatMapF(_.jokeClient.expect[DadJoke]("/"))

The client can be make from resource/package.scala and then inject into AppResource

js <- http.mk(cfg.jokeService)

where cfg.jokeService is uri"https://icanhazdadjoke.com"

Tracing Metrics and Logging

Finagle already provide sophisticated tracing and metrics, zipkin tracing is by default enable, but it is sample rate is 0.1%, to verify it work, we could start the server with parameter

> sbt '~reStart -zipkin.initialSampleRate=1'

Sample rate 1 means 100% of trace will report to zipkin.

curl localhost:8080/random-joke

Logging

You can see the server console will print something like:

root [7cb6f08c27a8b33c finagle/netty4-2-2] INFO  c.y.d.h.r.joke - generating random joke
root [7cb6f08c27a8b33c finagle/netty4-2-2] INFO  c.y.d.h.r.joke - getting dad joke...

Logs belong to the same request will print the exactly same TRACE ID

Logger format can be adjusted in src/main/resources/logback.xml

<encoder>
  <pattern>[%X{trace.id} %thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n</pattern>
</encoder>

Zipkin Tracing

if you grab 7cb6f08c27a8b33c and search as trace id in localhost:9411

image.png

It will show the trace of the request, from the trace you can simply tell that our server took 3.321s to response, where 2.955s was spend in requesting icanhazdadjoke.com.

Prometheus Metrics

If you have Prometheus setup, scrap localhost:9990/metrics to get server and client metrics.

Why Resource of resource

The resource maker's type is slightly tricky because it is Resource[IO, Resource[IO, AppResource]]:

def mk(implicit ctx: ContextShift[IO]): Resource[IO, Resource[IO, AppResource]] =
  for {
    cfg <- Resource.liftF(Config.all.load[IO])
    js <- http.mk(cfg.jokeService)
    db <- database.transactor
  } yield Resource.make(IO {
    new AppResource {
      val config = cfg
      val jokeClient = js
      val database = db
    }
  }) { res =>
    res.logEval
  }

Why should we have nested Resource here?

These are actually two different kinds of resource, the first level is whole server scope, all requests through this server share the same resource.

  • config
  • database
  • HTTP client

In another word, these resources are acquired when server start, closed when server close. And there are few resources not share across server, they are acquired when request arrived, closed when response sent:

  • trace
  • toggle
  • logger

Once we implemented all CRUD endpoints for /joke, testing these endpoints actually are very easy via ScalaCheck property based testing:

 1: property("CRUD") {
 2:   implicit val appRes = new TestAppResource              // <- (testResource)
 3:   forAll { (requestBody: joke.Repr.Create, updateBody: joke.Repr.Create) =>
 4:     when(appRes.toggleMap.apply(useDadJokeToggleName))   // <- (toggleOff)
 5:       .thenReturn(Toggle.off(useDadJokeToggleName))
 6:     createAndDelete(requestBody)                         // <- (createDelete)
 7:       .use { id =>
 8:         assertEquals(query(id).flatMap(_.as[joke.Repr.View]).unsafeRunSync().text, requestBody.text)
 9:         update(id, updateBody)                           // <- (update)
10:           .map(_ => assertEquals(query(id).flatMap(_.as[joke.Repr.View]).unsafeRunSync().text, updateBody.text))
11:       }
12:       .unsafeRunSync()                                   // <- (execute)
13:   }
14: }

To test all CRUD we just need scalacheck to randomly generate arbitrary create request body and update request body.

  1. New a fake resource TestAppResource, defined in SpecHelper.scala
  2. Don't forget to toggle off our fancy dad joke toggle
  3. Make create and delete a resource so our test data will always clean after assertion
def createAndDelete(req: joke.Repr.Create)(implicit router: HttpApp[IO]) =
    Resource.make[IO, String](create(req))(delete)
  1. Assert there will be a joke created
  2. Update the joke and then query again to verify the data is updated
  3. Don't hesitate to unsafeRunSync the Resource, it is OK to fail fast at runtime in test.

Package and deploy

To package the server into a runable binary, simply:

> sbt bootstrap

To run:

> ./http4s-example

Package it to docker to ship to heroku or k8s

> docker build . -t http4s-example

The same way we can package and deploy migration scripts as well

> sbt db/bootstrap
> ./http4s-example-db-migration migrate

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK