什么是http4s洋访?
Http4s 是一個小型的 Scala 框架,用于處理 HTTP 請求谴餐。它可以用于構建服務端姻政,接收請求,或作為客戶端發(fā)送請求岂嗓。Http4s 的主要特點包括:
- http4s 是一個基于純函數(shù)式編程原則構建的庫汁展。使得代碼更容易理解、測試和維護厌殉。
- http4s 支持異步和非阻塞的 HTTP 請求處理食绿,這對于高并發(fā)應用程序和 I/O 密集型任務非常重要。
- http4s 是一個輕量級的庫公罕,不包含過多的依賴關系炫欺,因此它可以靈活地集成到各種項目中。
- http4s 適用于構建各種類型的應用程序熏兄,包括 Web 服務品洛、RESTful API、微服務架構等摩桶。無論是構建小型項目還是大規(guī)模應用桥状,http4s 都能夠提供高性能和可維護性。
- 以及更多優(yōu)點硝清,更多介紹可以參考 https://http4s.org/
接下來辅斟,我們將演示如何使用 Http4s 構建一個簡單的 Web 服務。
創(chuàng)建一個基本的示例
目標
假設有如下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
的時候可以返回此店鋪下所有的產(chǎn)品信息
- 通過訪問
- 查詢商家的詳細信息
- 通過訪問
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
定義數(shù)據(jù)模型
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)
產(chǎn)品有名字(name)和價格(price)的屬性酵幕,商店有id扰藕,名字(name),星級(star)芳撒,產(chǎn)品列表(products)以及所有者(seller)的屬性邓深,賣家有first name和last name的屬性以及賣家信息額外包含了性別(sex)和年齡(age)的屬性。
模擬數(shù)據(jù)庫和查詢方法
// 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
接下來創(chuàng)建 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路由的定義萌壳。
- 接下來創(chuàng)建一個
HttpRoutes.of[F]
塊,這是HTTP路由的主要定義部分日月。 - 下面的模式匹配就是開始處理HTTP請求讶凉,這里是2個GET請求。
- Root意思是使用路由的根路徑
-
:?
+&
分別對應了URL里面的?和&的連接符山孔,這個是dsl框架提供的語法糖,簡化路由的定義和處理荷憋,增加可讀性 -
SellerParamDecoderMatcher
和StarParamDecoderMatcher
是用來提取和解析URL中的參數(shù)台颠。當取到對應參數(shù)的值后,matcher就進行后續(xù)相關的處理 - 第一個URL解析出來后就是這樣:
/shops?seller=[seller]&star=[star]
- 而另一種就通過
/
來分割參數(shù)勒庄,此時可以指定參數(shù)的類型 - 所以第二個URL是這個樣子:
/shops/[shopId]/products
- 最后返回對應的響應對象串前,200或者其他
下一步需要簡單實現(xiàn)一下matcher
object SellerParamDecoderMatcher extends QueryParamDecoderMatcher[String]("seller")
object StarParamDecoderMatcher extends QueryParamDecoderMatcher[Int]("star")
- 只是簡單的返回從參數(shù)中提取出來的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
轉(zhuǎn)成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參數(shù)會怎么樣呢箕昭?
> 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,這是因為上面對于參數(shù)的定義是不能不傳的解阅。所以使用了QueryParamDecoderMatcher來提取參數(shù)的值落竹。那么該如何讓參數(shù)變成可以空類型呢。
這里就需要提一下
常用的Matcher
- QueryParamDecoderMatcher
- OptionalQueryParamDecoderMatcher
- ValidatingQueryParamDecoderMatcher
- OptionalValidatingQueryParamDecoderMatcher
簡單修改matcher使用OptionalQueryParamDecoderMatcher瓮钥,讓star參數(shù)可以不傳
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"}]%
接下來增加參數(shù)驗證筋量,這時就需要使用另外2個marcher了。這里的例子是使用OptionalValidatingQueryParamDecoderMatcher
假如需求上要求star的范圍必須是1 - 5碉熄。
首先需要把
object StarParamDecoderMatcher extends OptionalQueryParamDecoderMatcher[Int]("star")
修改成
object StarParamDecoderMatcher extends OptionalValidatingQueryParamDecoderMatcher[Int]("star")
此時并不會報錯桨武,因為http4s給Int類型提供了一個默認的隱式參數(shù),但是我們的需要實現(xiàn)對于star的范圍限定锈津。所以增加一個隱式參數(shù)
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]呀酸,在它的具體實現(xiàn)里,調(diào)用了QueryParamDecoder的apply方法琼梆,這個apply方法需要一個隱式參數(shù)
(implicit ev: QueryParamDecoder[T])
性誉,這也是為什么我們需要增加一個隱式參數(shù) - 這個隱式參數(shù)里需要實現(xiàn)decode方法,用于解碼參數(shù)茎杂,且它的返回值是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的參數(shù),同樣是Int類型且范圍必須是2000 ~ 2023之間曲管。如果按照之前的寫法却邓,首先要創(chuàng)建一個year的matcher
object YearParamDecoderMatcher extends OptionalValidatingQueryParamDecoderMatcher[Int]("year")
然后增加一個隱式參數(shù)
implicit val yearQueryParam: QueryParamDecoder[Int] = ???
那么問題來了,已經(jīng)有一個Int類型的隱式參數(shù)starQueryParam院水,該如何區(qū)分他們呢腊徙?
第一種方式是在對應的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)))
第二種方式是把參數(shù)包裝成對象昧穿。
case class Year(value: Int)
object YearParamDecoderMatcher extends OptionalValidatingQueryParamDecoderMatcher[Year]("year")
定義一個Year
的case class。YearParamDecoderMatcher
也改成接受Year類型的參數(shù)橙喘,但是實際上還是解碼Year類型并賦值給value
就像上面提到的因為http4s提供了基本數(shù)據(jù)類型的隱式參數(shù)时鸵,但是Year是我們新添加的類型,此時代碼就會報錯,提示需要提供一個隱式參數(shù)給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
}
}
運行一下得到和上面一樣的結果
多路由的實現(xiàn)
接下來實現(xiàn)一下seller相關的API饰潜。首先創(chuàng)建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的實例合并在一起彭雾,以便他們共同工作碟刺。這樣可以把不同的路由模塊分開定義,但是統(tǒng)一組合充單一的路由薯酝。便于構建復雜的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方法需要有一個隱式參數(shù)EntityDecoder。 而這里引用org.http4s.circe.CirceEntityDecoder.circeEntityDecoder
酿矢,需要一個Concurrent類型榨乎。
注意,在舊版的http4s里使用的是Sync
瘫筐,但是1.x的版本中發(fā)生了變化蜜暑,是需要使用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
去處理轉(zhuǎn)換失敗的情況策肝。把POST的部分改成
case req @ POST -> Root / "sellers" =>
req.attemptAs[Seller].value.flatMap {
case Right(data) =>
Ok("Add success")
case Left(failure) =>
BadRequest("Add failed")
}
此時發(fā)送一個沒有對應上的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"%
當然如果有特定的邏輯也可以自己寫一個隱式參數(shù),例如發(fā)送的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"}%