使用 Http4s 構建 Web 服務(一)- Server

什么是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框架提供的語法糖,簡化路由的定義和處理荷憋,增加可讀性
    • SellerParamDecoderMatcherStarParamDecoderMatcher是用來提取和解析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"}%
最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市悍手,隨后出現(xiàn)的幾起案子帘睦,更是在濱河造成了極大的恐慌,老刑警劉巖坦康,帶你破解...
    沈念sama閱讀 218,036評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件竣付,死亡現(xiàn)場離奇詭異,居然都是意外死亡滞欠,警方通過查閱死者的電腦和手機古胆,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,046評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人逸绎,你說我怎么就攤上這事惹恃。” “怎么了棺牧?”我有些...
    開封第一講書人閱讀 164,411評論 0 354
  • 文/不壞的土叔 我叫張陵巫糙,是天一觀的道長。 經(jīng)常有香客問我颊乘,道長参淹,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,622評論 1 293
  • 正文 為了忘掉前任乏悄,我火速辦了婚禮浙值,結果婚禮上,老公的妹妹穿的比我還像新娘纲爸。我一直安慰自己亥鸠,他們只是感情好,可當我...
    茶點故事閱讀 67,661評論 6 392
  • 文/花漫 我一把揭開白布识啦。 她就那樣靜靜地躺著负蚊,像睡著了一般。 火紅的嫁衣襯著肌膚如雪颓哮。 梳的紋絲不亂的頭發(fā)上家妆,一...
    開封第一講書人閱讀 51,521評論 1 304
  • 那天,我揣著相機與錄音冕茅,去河邊找鬼伤极。 笑死,一個胖子當著我的面吹牛姨伤,可吹牛的內(nèi)容都是我干的哨坪。 我是一名探鬼主播,決...
    沈念sama閱讀 40,288評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼乍楚,長吁一口氣:“原來是場噩夢啊……” “哼当编!你這毒婦竟也來了?” 一聲冷哼從身側響起徒溪,我...
    開封第一講書人閱讀 39,200評論 0 276
  • 序言:老撾萬榮一對情侶失蹤忿偷,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后臊泌,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體鲤桥,經(jīng)...
    沈念sama閱讀 45,644評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,837評論 3 336
  • 正文 我和宋清朗相戀三年渠概,在試婚紗的時候發(fā)現(xiàn)自己被綠了茶凳。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,953評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖慧妄,靈堂內(nèi)的尸體忽然破棺而出顷牌,到底是詐尸還是另有隱情,我是刑警寧澤塞淹,帶...
    沈念sama閱讀 35,673評論 5 346
  • 正文 年R本政府宣布窟蓝,位于F島的核電站,受9級特大地震影響饱普,放射性物質(zhì)發(fā)生泄漏运挫。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,281評論 3 329
  • 文/蒙蒙 一套耕、第九天 我趴在偏房一處隱蔽的房頂上張望谁帕。 院中可真熱鬧,春花似錦冯袍、人聲如沸匈挖。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,889評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽儡循。三九已至,卻和暖如春征冷,著一層夾襖步出監(jiān)牢的瞬間择膝,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,011評論 1 269
  • 我被黑心中介騙來泰國打工检激, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留肴捉,地道東北人。 一個月前我還...
    沈念sama閱讀 48,119評論 3 370
  • 正文 我出身青樓叔收,卻偏偏與公主長得像齿穗,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子饺律,可洞房花燭夜當晚...
    茶點故事閱讀 44,901評論 2 355

推薦閱讀更多精彩內(nèi)容