什么是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框架提供的语法糖,简化路由的定义和处理,增加可读性 -
SellerParamDecoderMatcher
和StarParamDecoderMatcher
是用来提取和解析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"}%