使用 Http4s 构建 Web 服务(一)- Server

什么是http4s?

Http4s 是一个小型的 Scala 框架,用于处理 HTTP 请求。它可以用于构建服务端,接收请求,或作为客户端发送请求。Http4s 的主要特点包括:

  • http4s 是一个基于纯函数式编程原则构建的库。使得代码更容易理解、测试和维护。
  • http4s 支持异步和非阻塞的 HTTP 请求处理,这对于高并发应用程序和 I/O 密集型任务非常重要。
  • http4s 是一个轻量级的库,不包含过多的依赖关系,因此它可以灵活地集成到各种项目中。
  • http4s 适用于构建各种类型的应用程序,包括 Web 服务、RESTful API、微服务架构等。无论是构建小型项目还是大规模应用,http4s 都能够提供高性能和可维护性。
  • 以及更多优点,更多介绍可以参考 https://http4s.org/

接下来,我们将演示如何使用 Http4s 构建一个简单的 Web 服务。

创建一个基本的示例

目标

假设有如下Model:商家(Seller),店铺(Shop),商品(Product)

想实如下功能

  • 通过商家的名字和星级来搜索店铺
    • 通过访问 GET http://127.0.0.1:8080/shops?seller=[name]&star=[star] 的时候可以返回所匹配到的店铺信息
  • 通过店铺来查找下面所有的商品
    • 通过访问 GET http://127.0.0.1:8080/shops/[UUID]/products 的时候可以返回此店铺下所有的产品信息
  • 查询商家的详细信息
    • 通过访问 GET http://127.0.0.1:8080/sellers?name=[name] 的时候可以返回所匹配到的卖家信息

首先在build.sbt中添加需要用到的library

val Http4sVersion = "1.0.0-M40"
val CirceVersion = "0.14.5"

lazy val root = (project in file("."))
  .settings(
    organization := "com.example",
    name := "firstforhttp4",
    version := "0.1.0-SNAPSHOT",
    scalaVersion := "3.3.0",
    libraryDependencies ++= Seq(
      "org.http4s"      %% "http4s-ember-server" % Http4sVersion, //used for receive http request
      "org.http4s"      %% "http4s-ember-client" % Http4sVersion, //used for send http request
      "org.http4s"      %% "http4s-circe"        % Http4sVersion, //uesd for encode or decode request|response model
      "org.http4s"      %% "http4s-dsl"          % Http4sVersion, //used for define http route
      "io.circe"        %% "circe-generic"       % CirceVersion,
    )
  )

需要注意的是,http4s的1.0.0-M40的版本是不支持blaze的,所以这里使用的是http4s-ember-server

定义数据模型

case class Product(name: String, price: Int)
case class Shop(id: String, name: String, star: Int, products: List[Product], seller: String)
case class Seller(firstName: String, lastName: String) 
case class SellerDetail(firstName: String, lastName: String, sex: String, age: Int) 

产品有名字(name)和价格(price)的属性,商店有id,名字(name),星级(star),产品列表(products)以及所有者(seller)的属性,卖家有first name和last name的属性以及卖家信息额外包含了性别(sex)和年龄(age)的属性。

模拟数据库和查询方法

// prepare DB
var shopInfo: Shop = Shop(
  "ed7e9740-09ee-4748-857c-c692e32bdfee",
  "我的小店",
  5,
  List(Product("锅", 10), Product("碗", 20), Product("瓢", 30), Product("盆", 40)),
  "Tom"
)
val shops: Map[String, Shop] = Map(shopInfo.id -> shopInfo)

var sellerInfo: Seller = Seller("Tom", "Ming")
var sellers: Map[String, Seller] = Map(sellerInfo.firstName -> sellerInfo)

private def findShopById(id: UUID) = shops.get(id.toString)

private def findShopBySeller(seller: String): List[Shop] =
  shops.values.filter(_.seller == seller).toList

private def findShopBySeller(seller: String): List[Shop] =
  shops.values.filter(_.seller == seller).toList

接下来创建 HTTP 路由,通过request来返回想要的response。

这里需要使用HttpRoutes的对象。先看一下代码

def shopRoutes[F[_]: Monad]: HttpRoutes[F] = {
  val dsl = Http4sDsl[F]
  import dsl._

  HttpRoutes.of[F] {
    case GET -> Root / "shops" :? SellerParamDecoderMatcher(seller) +& StarParamDecoderMatcher(star) =>
      val shopsBySeller = findShopBySeller(seller)
      val shopsByStar = shopsBySeller.filter(_.star == star)
      Ok(shopsByStar.asJson)
    case GET -> Root / "shops" / UUIDVar(shopId) / "products" =>
      findShopById(shopId).map(_.products) match {
        case Some(products) => Ok(products.asJson)
        case _              => NotFound()
      }
  }
}
  • 首先导入dsl的库,用来简化HTTP路由的定义。
  • 接下来创建一个HttpRoutes.of[F]块,这是HTTP路由的主要定义部分。
  • 下面的模式匹配就是开始处理HTTP请求,这里是2个GET请求。
    • Root意思是使用路由的根路径
    • :? +&分别对应了URL里面的?和&的连接符,这个是dsl框架提供的语法糖,简化路由的定义和处理,增加可读性
    • SellerParamDecoderMatcherStarParamDecoderMatcher是用来提取和解析URL中的参数。当取到对应参数的值后,matcher就进行后续相关的处理
    • 第一个URL解析出来后就是这样:/shops?seller=[seller]&star=[star]
    • 而另一种就通过/来分割参数,此时可以指定参数的类型
    • 所以第二个URL是这个样子:/shops/[shopId]/products
    • 最后返回对应的响应对象,200或者其他

下一步需要简单实现一下matcher

object SellerParamDecoderMatcher extends QueryParamDecoderMatcher[String]("seller")
object StarParamDecoderMatcher extends QueryParamDecoderMatcher[Int]("star")
  • 只是简单的返回从参数中提取出来的seller和star的值

接下来就要准备一个web服务器,应用上刚刚写好的HTTP路由的定义。

implicit val loggerFactory: LoggerFactory[IO] = Slf4jFactory.create[IO]

override def run(args: List[String]): IO[ExitCode] = {
  EmberServerBuilder
    .default[IO]
    .withHost(ipv4"0.0.0.0")
    .withPort(port"8085")
    .withHttpApp(shopRoutes[IO].orNotFound)
    .build
    .use(_ => IO.never)
    .as(ExitCode.Success)
}
  • 构建了一个全局的loggerFactory,是因为EmberServerBuilder.default[IO]会期望在构建过程中获得一个隐式的日志工厂变量,用来保证在服务器构建过程中,在需要的时候可以正确的进行日志记录
  • run方法是一个典型的Cats Effect应用程序的入口点,因为http4s通常会和cats effect集成,所以这里extends了cats effect的IOApp
  • withHost(ipv4"0.0.0.0")代表服务器的IP地址
  • withPort(port"8085")定义了服务器的端口
  • withHttpApp(shopRoutes[IO].orNotFound)把刚刚写好的路由传入这里,用orNotFound转成HTTP应用程序
  • 然后用build来构建这个服务器
  • .use(_ => IO.never)表示启动HTTP服务器并使其运行。而使用_ => IO.never是标识它是一个永远不会完成的IO效果,因此服务器会一直运行。
  • 最后.as(ExitCode.Success)将程序的退出代码设置为成功,表示程序成功运行。

最后运行一下,看看结果

> curl -v "localhost:8085/shops?seller=Tom&star=5"

*   Trying 127.0.0.1:8085...
* Connected to localhost (127.0.0.1) port 8085 (#0)
> GET /shops?seller=Tom&star=5 HTTP/1.1
> Host: localhost:8085
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Wed, 13 Sep 2023 03:42:34 GMT
< Connection: keep-alive
< Content-Type: application/json
< Content-Length: 210
<
* Connection #0 to host localhost left intact
[{"id":"ed7e9740-09ee-4748-857c-c692e32bdfee","name":"我的小店","star":5,"products":[{"name":"锅","price":10},{"name":"碗","price":20},{"name":"瓢","price":30},{"name":"盆","price":40}],"seller":"Tom"}]%

如果我们不传递star参数会怎么样呢?

> curl -v "localhost:8085/shops?seller=Tom"

*   Trying 127.0.0.1:8085...
* Connected to localhost (127.0.0.1) port 8085 (#0)
> GET /shops?seller=Tom HTTP/1.1
> Host: localhost:8085
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Date: Mon, 25 Sep 2023 03:10:13 GMT
< Connection: keep-alive
< Content-Type: text/plain; charset=UTF-8
< Content-Length: 9
<
* Connection #0 to host localhost left intact
Not found%

可以看到此时得到的结果是Not found,这是因为上面对于参数的定义是不能不传的。所以使用了QueryParamDecoderMatcher来提取参数的值。那么该如何让参数变成可以空类型呢。

这里就需要提一下

常用的Matcher

  • QueryParamDecoderMatcher
  • OptionalQueryParamDecoderMatcher
  • ValidatingQueryParamDecoderMatcher
  • OptionalValidatingQueryParamDecoderMatcher

简单修改matcher使用OptionalQueryParamDecoderMatcher,让star参数可以不传

  object StarParamDecoderMatcher extends QueryParamDecoderMatcher[Int]("star")

  case GET -> Root / "shops" :? SellerParamDecoderMatcher(seller) +& StarParamDecoderMatcher(star) =>
    val shopsBySeller = findShopBySeller(seller)
    val shopsByStar = shopsBySeller.filter(_.star == star)
    Ok(shopsByStar.asJson)

改成如下代码

  object StarParamDecoderMatcher extends OptionalQueryParamDecoderMatcher[Int]("star")

  case GET -> Root / "shops" :? SellerParamDecoderMatcher(seller) +& StarParamDecoderMatcher(star) =>
    val shopsBySeller = findShopBySeller(seller)
    val shopsByStar = star match
      case Some(starVal) => shopsBySeller.filter(_.star == starVal)
      case None => shopsBySeller
    Ok(shopsByStar.asJson)

此时运行一下

curl -v "localhost:8085/shops?seller=Tom"

*   Trying 127.0.0.1:8085...
* Connected to localhost (127.0.0.1) port 8085 (#0)
> GET /shops?seller=Tom HTTP/1.1
> Host: localhost:8085
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Mon, 25 Sep 2023 07:08:01 GMT
< Connection: keep-alive
< Content-Type: application/json
< Content-Length: 210
<
* Connection #0 to host localhost left intact
[{"id":"ed7e9740-09ee-4748-857c-c692e32bdfee","name":"我的小店","star":5,"products":[{"name":"锅","price":10},{"name":"碗","price":20},{"name":"瓢","price":30},{"name":"盆","price":40}],"seller":"Tom"}]%

接下来增加参数验证,这时就需要使用另外2个marcher了。这里的例子是使用OptionalValidatingQueryParamDecoderMatcher

假如需求上要求star的范围必须是1 - 5。

首先需要把

  object StarParamDecoderMatcher extends OptionalQueryParamDecoderMatcher[Int]("star")

修改成

  object StarParamDecoderMatcher extends OptionalValidatingQueryParamDecoderMatcher[Int]("star")

此时并不会报错,因为http4s给Int类型提供了一个默认的隐式参数,但是我们的需要实现对于star的范围限定。所以增加一个隐式参数

  implicit val starQueryParam: QueryParamDecoder[Int] = (star: QueryParameterValue) => {
    val starInt = star.value.toInt
    if (starInt >= 1 && starInt <= 5) {
      starInt.validNel
    } else {
      ParseFailure("Failed star value", s"Value must be between 1 and 5 (inclusive), but was $star.value").invalidNel
    }
  }
  • OptionalValidatingQueryParamDecoderMatcher是需要一个[T: QueryParamDecoder],在它的具体实现里,调用了QueryParamDecoder的apply方法,这个apply方法需要一个隐式参数(implicit ev: QueryParamDecoder[T]),这也是为什么我们需要增加一个隐式参数
  • 这个隐式参数里需要实现decode方法,用于解码参数,且它的返回值是ValidatedNel[ParseFailure, Int],如果解码成功,并且验证成功,就返回validNel,否则返回invalidNel

此时再去运行一下

curl -v "localhost:8085/shops?seller=Tom&star=6"

*   Trying 127.0.0.1:8085...
* Connected to localhost (127.0.0.1) port 8085 (#0)
> GET /shops?seller=Tom&star=6 HTTP/1.1
> Host: localhost:8085
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 400 Bad Request
< Date: Sat, 07 Oct 2023 05:25:20 GMT
< Connection: keep-alive
< Content-Type: text/plain; charset=UTF-8
< Content-Length: 11
<
* Connection #0 to host localhost left intact
bad request%

可以看到此时返回了bad request。验证生效

接下里考虑这样一个场景,如果url变成了/shops?seller=Tom&star=6&year=2023,增加了一个year的参数,同样是Int类型且范围必须是2000 ~ 2023之间。如果按照之前的写法,首先要创建一个year的matcher

  object YearParamDecoderMatcher extends OptionalValidatingQueryParamDecoderMatcher[Int]("year")

然后增加一个隐式参数

  implicit val yearQueryParam: QueryParamDecoder[Int] = ???

那么问题来了,已经有一个Int类型的隐式参数starQueryParam,该如何区分他们呢?

第一种方式是在对应的matcher那里指定使用哪一个。代码如下:

  implicit val starQueryParam: QueryParamDecoder[Int] = (star: QueryParameterValue) => {
    val starInt = star.value.toInt
    if (starInt >= 1 && starInt <= 5) {
      starInt.validNel
    } else {
      ParseFailure("Failed star value", s"Value must be between 1 and 5 (inclusive), but was $star.value").invalidNel
    }
  }

  implicit val yearQueryParam: QueryParamDecoder[Int] = (year: QueryParameterValue) => {
    val yearInt = year.value.toInt
    if (yearInt >= 2000 && yearInt <= 2023) {
      yearInt.validNel
    } else {
      ParseFailure(
        "Failed year value",
        s"Value must be between 2000 and 2023 (inclusive), but was $year.value"
      ).invalidNel
    }
  }

  object StarParamDecoderMatcher extends OptionalValidatingQueryParamDecoderMatcher[Int]("star")(using starQueryParam)
  object YearParamDecoderMatcher extends OptionalValidatingQueryParamDecoderMatcher[Int]("year")(using yearQueryParam)

运行一下

curl -v "localhost:8085/shops?seller=Tom&star=6&year=1999"

*   Trying 127.0.0.1:8085...
* Connected to localhost (127.0.0.1) port 8085 (#0)
> GET /shops?seller=Tom&star=6&year=1999 HTTP/1.1
> Host: localhost:8085
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 400 Bad Request
< Date: Sat, 07 Oct 2023 05:44:09 GMT
< Connection: keep-alive
< Content-Type: text/plain; charset=UTF-8
< Content-Length: 11
<
* Connection #0 to host localhost left intact
bad request%

得到了bad request,然后在console里面可以看到错误输出

Some(Invalid(NonEmptyList(org.http4s.ParseFailure: Failed star value: Value must be between 1 and 5 (inclusive), but was QueryParameterValue(6).value)))
Some(Invalid(NonEmptyList(org.http4s.ParseFailure: Failed year value: Value must be between 2000 and 2023 (inclusive), but was QueryParameterValue(1999).value)))

第二种方式是把参数包装成对象。

  case class Year(value: Int)
  
  object YearParamDecoderMatcher extends OptionalValidatingQueryParamDecoderMatcher[Year]("year")

定义一个Year的case class。YearParamDecoderMatcher也改成接受Year类型的参数,但是实际上还是解码Year类型并赋值给value
就像上面提到的因为http4s提供了基本数据类型的隐式参数,但是Year是我们新添加的类型,此时代码就会报错,提示需要提供一个隐式参数给OptionalValidatingQueryParamDecoderMatcher[Year]
增加下面的代码

  implicit val yearQueryParam: QueryParamDecoder[Year] = (year: QueryParameterValue) => {
    val yearInt = year.value.toInt
    if (yearInt >= 2000 && yearInt <= 2023) {
      Year(yearInt).validNel
    } else {
      ParseFailure(
        "Failed year value",
        s"Value must be between 2000 and 2023 (inclusive), but was $year.value"
      ).invalidNel
    }
  }

运行一下得到和上面一样的结果

多路由的实现

接下来实现一下seller相关的API。首先创建seller的Route

def sellerRoutes[F[_]: Monad]: HttpRoutes[F] = {
  val dsl = Http4sDsl[F]
  import dsl._

  HttpRoutes.of[F] { case GET -> Root / "sellers" :? SellerParamDecoderMatcher(seller) =>
    findSellerByFirstName(seller) match {
      case Some(sellerInfo) => Ok(sellerInfo.asJson)
      case _ => NotFound()
    }
  }
}

以及对应的matcher

  object SellerParamDecoderMatcher extends QueryParamDecoderMatcher[String]("first_name")

然后修改run方法里的server的构建。

  override def run(args: List[String]): IO[ExitCode] = {
    def allRoutes[F[_] : Monad]: HttpRoutes[F] =
      shopRoutes[F] <+> sellerRoutes[F]
    
    EmberServerBuilder
      .default[IO]
      .withHost(ipv4"0.0.0.0")
      .withPort(port"8085")
      .withHttpApp(allRoutes[IO].orNotFound)
      .build
      .use(_ => IO.never)
      .as(ExitCode.Success)
  }
  • 先定义一个方法,组合了shopRoutes和sellerRoutes
  • <+>的作用是将两个Monad的实例合并在一起,以便他们共同工作。这样可以把不同的路由模块分开定义,但是统一组合充单一的路由。便于构建复杂的HTTP服务

此时尝试一下seller的API,运行结果如下:

curl -v "localhost:8085/sellers?first_name=Tom"

*   Trying 127.0.0.1:8085...
* Connected to localhost (127.0.0.1) port 8085 (#0)
> GET /sellers?first_name=Tom HTTP/1.1
> Host: localhost:8085
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Sun, 08 Oct 2023 12:49:12 GMT
< Connection: keep-alive
< Content-Type: application/json
< Content-Length: 37
<
* Connection #0 to host localhost left intact
{"firstName":"Tom","lastName":"Ming"}%

如果他们的前缀不同,可以写成如下的代码:

  def apis[F[_]: Concurrent] = Router(
    "/api" -> shopRoutes[IO],
    "/api/management" -> sellerRoutes[IO]
  ).orNotFound

这样刚刚的get seller的URL就变成了localhost:8085/api/management/sellers?first_name=Tom

下面增加个POST的方法吧。看下与Get方法的不同地方。

  case req @ POST -> Root / "sellers" => req.as[Seller].flatMap(Ok(_))

增加一个POST方法如上,然后就会报错No given instance of type cats.MonadThrow[F] was found for parameter F of method as in class InvariantOps

此时要使用Concurrent而不是Monad,这是因为as方法需要有一个隐式参数EntityDecoder。 而这里引用org.http4s.circe.CirceEntityDecoder.circeEntityDecoder,需要一个Concurrent类型。

注意,在旧版的http4s里使用的是Sync,但是1.x的版本中发生了变化,是需要使用Concurrent

修改后的代码变成了

  def sellerRoutes[F[_]: Concurrent]: HttpRoutes[F] = {
    val dsl = Http4sDsl[F]
    import dsl._

    HttpRoutes.of[F] {
      case GET -> Root / "sellers" :? SellerParamDecoderMatcher(firstName) =>
        findSellerByFirstName(firstName) match {
          case Some(sellerInfo) => Ok(sellerInfo.asJson)
          case _                => NotFound()
        }
      case req @ POST -> Root / "sellers" => req.as[Seller].flatMap(Ok(_)) // 
    }
  }

尝试运行一下,得到如下结果

curl -H "Content-Type: application/json" -d '{"firstName": "Jacky", "lastName": "Gang" }' -v "localhost:8085/sellers"

*   Trying 127.0.0.1:8085...
* Connected to localhost (127.0.0.1) port 8085 (#0)
> POST /sellers HTTP/1.1
> Host: localhost:8085
> User-Agent: curl/8.1.2
> Accept: */*
> Content-Type: application/json
> Content-Length: 43
>
< HTTP/1.1 200 OK
< Date: Mon, 09 Oct 2023 06:18:06 GMT
< Connection: keep-alive
< Content-Type: application/json
< Content-Length: 39
<
* Connection #0 to host localhost left intact
{"firstName":"Jacky","lastName":"Gang"}%

as可以使用attemptAs去处理转换失败的情况。把POST的部分改成

  case req @ POST -> Root / "sellers" =>
    req.attemptAs[Seller].value.flatMap {
      case Right(data) =>
        Ok("Add success")
      case Left(failure) =>
        BadRequest("Add failed")
    }

此时发送一个没有对应上的field,返回结果如下:

curl -H "Content-Type: application/json" -d '{"test": "Jacky", "name": "Gang" }' -v "localhost:8085/sellers"

*   Trying 127.0.0.1:8085...
* Connected to localhost (127.0.0.1) port 8085 (#0)
> POST /sellers HTTP/1.1
> Host: localhost:8085
> User-Agent: curl/8.1.2
> Accept: */*
> Content-Type: application/json
> Content-Length: 40
>
< HTTP/1.1 400 Bad Request
< Date: Thu, 12 Oct 2023 08:51:16 GMT
< Connection: keep-alive
< Content-Type: application/json
< Content-Length: 12
<
* Connection #0 to host localhost left intact
"Add failed"%

当然如果有特定的逻辑也可以自己写一个隐式参数,例如发送的Body不再是{"firstName": "Jacky", "lastName": "Gang" },而是{"first_name": "Jacky", "last_name": "Gang" }。那么需要写一个匹配的逻辑。

  object SellerInstances {
    implicit val sellerDecoder: Decoder[Seller] =
      Decoder.instance(c => {
        for {
          firstName <- c.get[String]("first_name")
          lastName <- c.get[String]("last_name")
        } yield Seller(firstName, lastName)
      })
    implicit def SellerEntityDecoder[F[_]: Concurrent]: EntityDecoder[F, Seller] =
      jsonOf[F, Seller]
  }

此时再去运行一下带新的Body的请求,结果如下:

curl -H "Content-Type: application/json" -d '{"first_name": "Jacky", "last_name": "Gang" }' -v "localhost:8085/sellers"

*   Trying 127.0.0.1:8085...
* Connected to localhost (127.0.0.1) port 8085 (#0)
> POST /sellers HTTP/1.1
> Host: localhost:8085
> User-Agent: curl/8.1.2
> Accept: */*
> Content-Type: application/json
> Content-Length: 45
>
< HTTP/1.1 200 OK
< Date: Thu, 12 Oct 2023 05:46:14 GMT
< Connection: keep-alive
< Content-Type: application/json
< Content-Length: 39
<
* Connection #0 to host localhost left intact
{"firstName":"Jacky","lastName":"Gang"}%

你可能感兴趣的:(使用 Http4s 构建 Web 服务(一)- Server)