Akka 实战:构建第一个 Akka HTTP app

2,140 阅读17分钟

弯光公司18年3月的akka生态全景介绍 - 知乎 (zhihu.com)

如何学习scala,akka,play framework? - 知乎 (zhihu.com)

参考文档与案例代码

在之前,笔者曾简单介绍过 Akka 的基本用法,见 Scala+Akka 实现主从节点的心跳检测 - 掘金 (juejin.cn)Scala 之 Akka 框架实战 - 掘金 (juejin.cn) 。整个专栏的学习笔记参考 Akka in action。

akka in action.jpg

注意

Akka 是一个由 Lightbend 构建的开源项目,力图提供简单,单一的编程模型 —— 一个基于并发,分布式应用的 Actor 编程模型。Akka 的目标是部署在云端的应用,或者在多核设备上的应用开发变得更加容易。或者说,Akka 赋予了项目垂直拓展 ( 单机 ) 和水平拓展 ( 集群 ) 的能力。

Akka 有相当多的拓展模块,包括 Akka Cloud Platform,Akka Streams,Akka Actors ( Core ),Akka Http 等。

Akka 是一个 lib,而不是一个 framework。并不是所有的 Scala 开发者都有学习 Akka 的需求,见:为什么 Akka ( Actor 模型 ) 在中国不温不火? - 知乎 (zhihu.com)。如果专注于业务上的开发,那么直接使用基于 Akka 之上的 Play,Flink 这种成熟的方案更加合适。Akka 十分贴合 Instant Message 或者是 stateful 的领域,如游戏服务器 ( 而大部分 Web 应用是 stateless 的 )。

Akka 官方同时推送 Scala / Java 两种语言的教程 ( 目前 Akka 对 Scala 3 的支持是实验性的,JDK 仍以 8 ,11 这两个 LTE 版本为主 ):

Akka: build concurrent, distributed, and resilient message-driven applications for Java and Scala | Akka

下方的 github 连接提供了原书的全部代码。本文的代码对应 Akka in Action 的第二章节 chapter-up-and-running

git init
git clone https://github.com/RayRoestenburg/akka-in-action.git

进入到 chapter-up-and-running 目录,打开 sbt 交互模式,使用 assembly 命令进行编译;或者直接在终端使用以下命令:

sbt assembly

笔者的 sbt 版本是 0.13.7,jdk 版本是 8u241。在编译成功之后会提示类似的信息:

[info] SHA-1: b7a86b2544a0d3d5ec1f8096dc55e2ffe4e4fdab
[info] Packaging C:\Users\ljh\Desktop\akka\hello_world\akka-in-action\chapter-up-and-running\target\scala-2.11\goticks.jar ...
[info] Done packaging.
[success] Total time: 116 s, completed 2022-2-25 23:37:20

编译后的文件放置在 ~/chapter-up-and-running/target/scala-2.11/gotick-assembly-1.0.jar 目录下。

java -jar goticks.jar
# INFO  [Slf4jLogger]: Slf4jLogger started
# INFO  [go-ticks]: RestApi bound to /0:0:0:0:0:0:0:0:5000

除了 assembly 打包一个 .jar 之外,我们还可以使用其它的 sbt 命令。如:

sbt clean compile test

另外,IntelliJ 和 sbt 兼容性目前存在一点问题。比如使用 IntelliJ 导入外部的 sbt 项目时可能报错:

Extracting structure failed, reason: not ok build status: Error (BuildMessages(Vector(),Vector(),Vector(),Vector(),Error))

解决方案:IDEA cannot re-import sbt project - Stack Overflow

专栏涉及到的一切 sbt 使用方式见官方文档:sbt Reference Manual — sbt 1.4.x releases (scala-sbt.org)

sbt 的镜像仓库配置笔者的旧笔记:Play2 / sbt 操作指南 - 掘金 (juejin.cn)

sbt介绍与构建Scala项目 - 苍穹2018 - 博客园 (cnblogs.com)

Actor

Akka 系统基于 Actor。许多 Akka 中的组件都是提供如何使用 Actor,配置 Actor,用 Actor 连接网络,调度,以及构建集群。Actor 本身可看作是微缩版的消息队列 —— 可以非常容易地创建出几千或者几百万个 Actor。

Actor 之间通过 消息 来进行联络。消息是一个简单的不可变 ( immutable ) 数据结构。

Actor 之间的执行是异步的:当它们向另一个 Actor 发送消息之后,可以不等待它们的立刻回应。

其总体的设计宗旨见 反应式宣言 (reactivemanifesto.org)

  1. I/O 阻塞限制了并行性,因此非阻塞 I/O 是首选。
  2. 同步交互限制了并行性,因此要使用异步交互。
  3. 轮询占用资源,因此需要使用事件驱动是首选。
  4. 如果一个节点会拖慢其它节点,那么就需要隔离来避免丢失其它工作。
  5. 系统需要弹性。如果需求变少,那么也应该使用较少的资源;如果需求变多,那么也应该使用较多的资源。

Actor 模型的思想早在 1973 年就被 Carl Hewitt 等人提出,Erlang 语言及其 OTP 在 1986 年就已经支持 Actor 模型。Akka 的实现细节和 Erlang 有些许不同,但是受 Erlang 许多影响。

一个 Actor 是一个 "轻量级的进程",它只有四个基本操作:创建,发送,改变,监督。

发送

Actor 之间只通过消息进行信息交互。在对象中,一切被声明 public 的方法都可以被访问,但 Actor 不允许外界访问它内部的状态,包括 Actor 内的消息列表。消息的发送是异步的,这被称之 发完即弃 风格 ( fire and forget )。

消息是不可变的,这意味着消息一旦被创建之后就无法被更改。注:Actor 可以接受收发任何信息,这意味着 Scala 的静态类型检查机制是有限的。

在发送和接受消息的 Actor 之间,消息发送是有序的。一个 Actor 一次只会接受一条消息。如果涉及到修改信息,那么就需要重新发送一个消息副本。假如某一个用户多次修改一条消息,那么用户最终修改的消息才是有意义的。

创建与监督

一个 Actor 可以创建其它的 Actor,同时需要监管它们所创建的 Actor

father_son_actor.png

改变

状态机是保证系统在特定状态执行特定功能的有力工具。Actor 每次仅接收一条消息,而这种机制适合实现状态机。一个 Actor 可以通过交换它的行为来改变它处理消息的方式。假如某个处理会话的 Session Actor 在接受到 CloseConservation 之后变为 关闭状态。这样,后续任何发送给 Session Actor 的消息都会被忽略。

三个维度的解耦

Actor 在以下的三个维度上进行了解耦:

  1. 空间 —— Actor 不保证,也不会期望另一个 Actor 的物理地址在哪里。
  2. 时间 —— Actor 不保证,也不会预期它的工作何时完成。
  3. 接口 —— Actor 没有定义的接口,因为它们之间通过 消息 进行 RPC 交互。

Actor System

构建一个服务的所有 Actor 构成了一个 ActorSystem。创建 Akka 应用的第一步就是创建 ActorSystem。ActorSystem 创建了顶层的 Actor,并返回了该 Actor 的引用 ActorRef ,而非本身。ActorRef 是 Actor 传递消息的渠道 —— 尤其是当 Actor 在其它的机器时,这很有意义。

另一个用于查找系统内 Actor 的方式是 ActorPath。每个 Actor 都具备一个自己的名字 ( 这个名字在同一个系统内应该是唯一的 ),可以将 Actor 的层次结构和 URL 相比较。

消息被发往 Actor 的 ActorRef ,通过 Akka 系统的分发器 Dispatcher 传递信息。源源不断累积的信息会到对方 Actor 的 Mailbox 中 ( 相当于消息缓存队列,类似 Go 的 chan ),对方 Actor 依次从邮箱中取出消息并处理。因此,Actor 发送消息的本质可以简述为:Actor A 通过 ActorRef B 投递到 Mailbox B,随后等待分发器逐步将消息推送给 Actor B 进行处理。

how_dispatcher_work.png

Akka 的 Actor 比系统线程占用更少的空间:大约 270 万个 Actor 会占用 1 Gb 的空间,这意味着可以比直接使用线程更加自由地创建 Actor。

ActorSystem 是 Actor 的核心,而其它功能,如远程调用,持久化日志都以 Akka 拓展的形式提供。即针对具体的问题可以对 ActorSystem 进行不同的配置。

案例:用 Akka HTTP 构建微型服务

下面用 Actor 核心库和 Akka HTTP 实现一个简单的 HTTP server。这里有一篇 Akka HTTP 个人译文供参考:Akka HTTP 文档 (非官方汉化)- 导读 - 知乎 (zhihu.com)。项目背景:我们的 gotickets 服务器允许用户购买各种服务 ( 音乐会,游戏,whatever ) 的门票,每个门票仅有一个 id 标识。管理员可以创建活动名并发行指定数量的门票,或者直接取消某场活动;用户可一次性购买同一个活动下连号的多张门票,查询活动等基本操作。

项目结构

接口文档如下:

描述方法URLBodyStateCode实例
创建活动POST/events/{event_name}{"tickets":250}201 Created{"name":"RHCP",tickets:250}
获取活动GET/events/{event_name}_200 OK{"name":"RHCP",tickets:250}
获取所有活动GET/events_200 OK[{"name":"RHCP",tickets:249},{"name":"Radio",tickets:130}]
买票POST/events/{event_name}/tickets{"tickets":2}201 Created{"event":"RHCP","entries":[{"id":1},{"id":2}]}
取消活动DELETE/events/{event_name}_200 OK{"event":"RHCP","tickets":249}

整个项目有以下核心类:

akka-chapter2-structure.png

为了使代码的逻辑看起来更清晰,下面首先给出代码的大体框架,所有待实现的具体功能暂时用 ??? 方法进行标记。

单例对象 Main 继承了 App ,它可以被 sbt 工具自动识别为程序入口,主程序将作视为一个 HTTP server 启动。

  1. 读取配置,设置监听端口。( 已给出 )
  2. 混入 RequestTimeout 特质设定请求超时 ( 已给出 )
  3. 创建 Akka 系统和分发器,并设置为上下文环境 ( 已给出 )
  4. 创建 HTTP server ( TODO )
import akka.actor.ActorSystem
import akka.util.Timeout
import com.typesafe.config.{Config, ConfigFactory}
import scala.concurrent.ExecutionContextExecutor

object Main extends App with RequestTimeout {
  // 配置相关
  val config = ConfigFactory.load()
  val host = config.getString("http.host")
  val port = config.getInt("http.port")
    
  // Akka 核心: ActorSystem, 以及消息分发器
  implicit val sys: ActorSystem = ActorSystem()
  implicit val ec: ExecutionContextExecutor = sys.dispatcher

  // 启动 RestApi ,作为守护进程运行。
  val api = new RestApi(sys = sys, timeout = requestTimeout(config))
  // TODO 对外监听连接,使用 api, sys, ec
  ???
}

// 用于检测超时连接的特质。
trait RequestTimeout {  
  import scala.concurrent.duration._
  def requestTimeout(config: Config): Timeout = {
    val t = config.getString("akka.http.server.request-timeout")
    val d = Duration(t)
    FiniteDuration(d.length, d.unit)
  }
}

TicketSeller 接受 event 参数,由 BoxOffice Actor 接收到请求后通过调用 createTicketSeller() 方法 ( 后文给出了它的基本实现 ) 创建。比如当创建一个 context.actorOf(TicketSeller.props("RHCP"), RHCP) 时,表示外部 HTTP server 创建了一个名为 "RCHP" 的活动。

  1. 它是业务的实际处理者,包括注销业务,这意味着 Actor 将销毁自身。
  2. 每个 TickekSeller Actor 是自治的,它们各自维护自己的业务。
package com.gotickets

import akka.actor.{Actor, Props}

object TicketSeller {
  // 用于 ActorSystem 创建 TicketSeller
  def props(event : String) = Props(new TicketSeller(event))
}

class TicketSeller(event : String) extends Actor{
  // 处理由 BoxOfficer 发送的消息,在这里处理售票业务
  override def receive: Receive = ???
}

BoxOfficeBoxOfficeApi 代理,而 BoxOfficeApiRestRoutesRestApi 保持着层次关系。

  1. RestRoutes 提供了 RestApi 的 HTTP 请求处理功能,形式上为 RestApi 混入了 RestRoutes 特质。RestApi 本身仅负责提供 ActorSystem 的上下文。另外,RestRoutes 返回一个 Route 类型,Akka HTTP 组件需要引入它来对外提供 HTTP 服务。
  2. BoxOfficeApi 提供了 RestRoutesBoxOffice 的交互,形式上为 RestRoutes 混入了 BoxOfficeApi 特质。
  3. BoxOfficeApi 负责 TicketSeller Actor 的创建和信息交互。
  4. 接受 / 返回 HTTP 报文均涉及到 Scala 消息的序列化与反序列化,对外交互统一采取 JSON 数据格式。 数据转换工作使用 EventMarshalling 来完成。
package com.gotickets

import akka.actor.{Actor, ActorRef, ActorSystem, Props}
import akka.util.Timeout
import spray.json.DefaultJsonProtocol
import scala.concurrent.ExecutionContext

object BoxOffice {
  def props(implicit timeout: Timeout) = Props(new BoxOffice)
}

class BoxOffice(implicit timeout: Timeout) extends Actor {

  // 根据接收消息的不同向 TicketSeller 分配消息 (任务)
  override def receive: Receive = ???

  // 用于创建 TicketSeller Actor
  def createTicketSeller(name : String) =
    context.actorOf(TicketSeller.props(name),name)
}

// RestApi 与 BoxOffice 交互的中间渠道 BoxOfficeApi,
// 类似于 BoxOfficeHandler
trait BoxOfficeApi {

  // 默认由 ActorSystem 进行装配
  def createBoxOffice() :ActorRef
  lazy val boxOffice: ActorRef = createBoxOffice()

  implicit def executionContext : ExecutionContext
  implicit def requestTimeout : Timeout

  // TODO 通过 boxOffice 获取处理好的信息 , 创建 TicketSeller Actor
  ???
}


trait RestRoutes extends BoxOfficeApi with EventMarshalling {
  // 向 Main 返回某种形式的路由机制
  def routes = ???

  // TODO 建立 REST 方法, 类似 Spring 的 @RequestMapping("...")
  ???
}

// RestApi 仅需要提供上下文。
class RestApi(sys : ActorSystem,timeout: Timeout) extends RestRoutes with EventMarshalling {
  override def createBoxOffice(): ActorRef =  sys.actorOf(BoxOffice.props,"boxOffice")
  override implicit def executionContext: ExecutionContext = sys.dispatcher
  override implicit def requestTimeout: Timeout = timeout
}

// 注意,RestApi 有可能对外接收 Post 请求,因此需要提供 Json -> Entity 的隐式序列化。
trait EventMarshalling extends DefaultJsonProtocol {
  ???
}

另外,Actor 之间的 消息 以样例类 case class 或样例单例对象 case object 表达,这取决于消息本身是否需要携带参数。系统的所有消息统一存储在 MessageProtocols 单例对象中。

package com.gotickets

object MessageProtocols {

  // 用于通用的错误
  case class Error(msg : String)
  
  // 表达活动,活动列表的通用消息:name 表示活动名称,tickets 表达余票数量。
  case class Event(name : String,tickets : Int)
  case class Events(events : Vector[Event])

  // HTTP Request
  case object GetEvents // GET
  case class GetEvent(name : String) // POST
  case class GetTickets(event: String,tickets : Int) // POST
  case class CancelEvent(name : String) //DELETE
  // HTTP POST Request Body
  case class EventDescription(tickets : Int){require(tickets > 0)}
  case class TicketRequest(tickets : Int){require(tickets > 0)}
  
  // 由 RestApi 发起,BoxOffice 接收,表示创建一个活动 Event。
  case class CreateEvent(name : String,ticketCounts : Int)
  
  // BoxOffice 向 RestApi 返回新的 Event 是否被创建成功。成功则返回 EventCreated,重复则返回 EventExists.
  sealed trait EventResponse
  case class EventCreated(event: Event) extends EventResponse
  case object EventExists extends EventResponse

  // 封存 BoxOffice <=> TicketSeller 交互协议。
  // GetThisEvent 表示返回 TicketSeller Actor 自身管理的活动状态,以 Event 形式返回。
  // 接收到 CancelThis 的 TicketSeller Actor 将在返回活动信息 Event 之后销毁。
  case object TicketSeller {
    case class Ticket(id : Int)
    case class Add(tickets : Vector[Ticket])
    case class Buy(tickets : Int)
    case class Tickets(event : String,entries : Vector[Ticket] = Vector.empty[Ticket])
    case object GetThisEvent
    case object CancelThis
  }
}

本文给出 "自底向上" 的实现:先实现处理核心业务的 TickekSeller,然后实现 BoxOffice,然后实现 RestApi,最后完善 Main 的逻辑。

TicketSeller

TicketSeller 管理核心的业务代码,因此也是最容易理解的部分。每个 TicketSeller Actor 内部维护一个 tickets 列表,表示该活动的余票信息。回顾 Akka 的以下基本用法:

  1. 需要回传消息时,不需要显示寻找发送者的 ActorRef,只需要调用 sender() 即可。
  2. 每个 Actor 通过定义 receive 偏函数来表达对不同消息的处理。
  3. ! 表示消息发完即弃。更多的,见:Actor 模型中的通信模式 | tisonkun (lmlphp.com)
  4. 通过 self 指向 TicketSeller ActorRef 自身,不使用 this 关键字。
  5. self ! PoisonPill 表示销毁自身的 Actor 及其 Ref;只有当 ActorRef 接受到 CancelThis 消息时才执行。
class TicketSeller(event : String) extends Actor{
  import MessageProtocols.TicketSeller._
  import MessageProtocols.Event

  var tickets: Vector[Ticket] = Vector.empty[Ticket]

  // sender() 表示向消息发送者回传消息
  // PoisonPill; 意 "毒药",令 Actor 注销自身
  override def receive: Receive = {
    case Add(newTickets) => tickets = tickets ++ newTickets
    // 这里做了简化处理,当余票数不足时,返回空。
    case Buy(nums) =>
      val entries: Vector[Ticket] = tickets.take(nums)
      if(entries.size >= nums){
        sender() ! Tickets(event,entries)
        tickets = tickets.drop(nums)
      }else sender() ! Tickets(event)
    case GetThisEvent => sender() ! Some(Event(event,tickets.size))
    case CancelThis => sender() ! Some(Event(event,tickets.size));self ! PoisonPill
  }
}

BoxOffice

BoxOffice 本身不处理关于 "售票卖票" 的详细逻辑,它只负责创建,或寻找到对应的 TicketSeller ActorRef 并 转发 任务 ( 任务即消息 )。有以下需要注意的地方:

  1. ask 表示异步请求内容 ( 但可以选择同步等待 ),等同于 ?。它发完即弃的 ! 相比是存在区别的。该请求的返回值是一个 Future[T] ,因为消息发送方不会确定何时能得到结果。
  2. forward 表示消息的转发。比如,RestApi 向 BoxOffice 发送消息,而 BoxOffice 将消息转发给 TicketSeller,那么 TicketSeller 的 sender() 将指向 RestApi。
  3. 通过 context.child(name) 检索当前 Actor 所管理的子 Actor(Ref)s。
  4. context.child(event) 返回一个 Option[ActorRef] ,因为上下文不保证 ActorRef 是一定存在的。以 context.child(event).fold(create())(_ => sender() ! EventExists) 调用为例,表示:若返回的 Option[ActorRef]None,则调用 create() 方法 ( 这是个传名调用,会延迟执行 ),否则,需要传入并执行后面的方法,类似 非空即调用 的逻辑。
  5. 消息管道 pipe ... to ( 或者是 pipeTo 方法 ) 能够在当前 ActorRef 获得 Future[T] 结果之后再将其转发给另一个 ActorRef。
  6. sequence 是函数式编程中表达 "翻转" 的常用的泛化方法。见:Scala:函数式编程下的异常处理 - 掘金 (juejin.cn) 中对 OptionEither 的理解。
class BoxOffice(implicit timeout: Timeout) extends Actor {

  import MessageProtocols.TicketSeller._
  import com.gotickets.MessageProtocols.{Event, GetEvents}
  import context._

  // TODO 根据接收消息的不同向 TicketSeller 分配消息 (任务)
  override def receive: Receive = {
    case CreateEvent(name, tickets) => 
      def create(): Unit = {
        val thisNewEvent: ActorRef = createTicketSeller(name)
        // 默认从 id : 1 开始创建 Tickets.
        // map 中的 Ticket 是构造函数,等价 id => Ticket(id)。
        val newTickets: Vector[Ticket] = (1 to tickets).map {
          Ticket
        }.toVector
        thisNewEvent ! Add(newTickets)
        sender() ! EventCreated(Event(name, tickets))
      }
      // context.child 表示当前 Actor 寻找的子 Actor。
      // 只有在找不到的情况下才会创建.
      // fold 左侧是一个传名调用。右侧接受一个 Actor => B 函数
      context.child(name).fold(create())(_ => sender() ! EventExists)

    case GetTickets(event, tickets) =>
      def notFound(): Unit = sender() ! Tickets(event)
      // forward 有转发的意思.这导致 TicketSeller 的 sender() 是 RestApi 而非 BoxOffice.
      def buy(child: ActorRef): Unit = child.forward(Buy(tickets))
      context.child(event).fold(notFound())(buy)

    case GetEvent(event) =>
      def notFound(): Unit = sender() ! None
      def getEvent(child: ActorRef): Unit = child.forward(GetThisEvent)
      context.child(event).fold(notFound())(getEvent)

    case GetEvents =>
      // 相当于创建了一个广播机制.相当于不断轮询自己是否有此 Event.
      def getEvents: Iterable[Future[Option[Event]]] = context.children.map {
        child => self.ask(GetEvent(child.path.name)).mapTo[Option[Event]]
      }

      // sequence 是一个在 FP 中常见的 "翻转" 操作,用于将 A[B] 翻转为 B[A].
      // 比如在 Option 中,sequence 方法可以将 Option[List[_]] 翻转为 List[Option[_]}.
      def convertToEvents(f: Future[Iterable[Option[Event]]]): Future[Events] = {
        // import akka.actor.TypedActor.dispatcher
        // Future { Iterable[Option[Event]] => Iterable[Event] => Events(Vector[Event]) }
        f.map(optionSeqs => optionSeqs.flatten).map(l => Events(l.toVector))
      }

      // 将一个 Future[T] 转发给另一个 ActorRef。
      pipe(convertToEvents(Future.sequence(getEvents))) to sender()

    case CancelEvent(event) =>
      def notFound(): Unit = sender() ! None
      def cancelEvent(child: ActorRef): Unit = child.forward(CancelThis)
      context.child(event).fold(notFound())(cancelEvent)
  }

  // 用于创建 TicketSeller Actor
  def createTicketSeller(name: String): ActorRef =
    context.actorOf(TicketSeller.props(name), name)
}

BoxOfficeApi 相当于 BoxOffice 的代理,持有一个对应的 BoxOffice ActorRef 的引用,相当于封装了对 BoxOffice 的内部交互。其定义的抽象方法 createBoxOffice 方法由 RestRoutes 负责实现。

trait BoxOfficeApi {

  import MessageProtocols._

  // 默认由 ActorSystem 进行装配
  def createBoxOffice(): ActorRef

  lazy val boxOffice: ActorRef = createBoxOffice()

  implicit def executionContext: ExecutionContext

  implicit def requestTimeout: Timeout

  // TODO 通过 boxOffice 获取处理好的信息 , 创建 TicketSeller Actor
  // 向 boxOffice(Ref) 异步发送 GetEvents,接受 Events 消息。
  def getEvents: Future[Events] = boxOffice.ask(GetEvents).mapTo[Events]

  def createEvent(event: String, ticketCounts: Int): Future[EventResponse] = boxOffice.ask(CreateEvent(event, ticketCounts)).mapTo[EventResponse]

  def getEvent(event: String): Future[Option[Event]] = boxOffice.ask(GetEvent(event)).mapTo[Option[Event]]

  def cancelEvent(event: String): Future[Option[Event]] = boxOffice.ask(CancelEvent(event)).mapTo[Option[Event]]
    
  def getTickets(event: String, tickets: Int): Future[TicketSeller.Tickets] = boxOffice.ask(GetTickets(event, tickets)).mapTo[TicketSeller.Tickets]

}

RestApi

RestRoutes 对外提供 路由机制 routes,引导 Akka HTTP 如何处理接收到的请求,这一段的逻辑类似 Java SpringBoot 项目中的 Controller ( 但可明显感受到 Akka HTTP dsl 遵循着与 SpringBoot 迥然不同的消息处理机制 ),对内则通过 BoxOfficeApi 代理获取处理结果。

onSuccess(getEvents) ( 其它代码处同理 ) 的语义更接近于:getEvents onSuccess {events => complete(OK,events)}。这里因此需要导入 akka.http.scaladsl.marshallers.sparyjson.SprayJsonSupport._(OK,events) 自动封装为 HTTP Response body。

trait RestRoutes extends BoxOfficeApi with EventMarshalling {

  import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._

  // 建立一个路由机制
  def routes: Route = eventsRoute~eventRoute~ticketsRoute

  // TODO 建立 REST 方法
  def eventsRoute: Route = pathPrefix("events") {
    // pathEndOrSingleSlash: 接受 events 或者 events/ 这样的路径
    pathEndOrSingleSlash {
      get {
        // GET /events
        // getEvents 来源于 BoxOfficeApi 特质,下同。
        onSuccess(getEvents) {
          // 这里使用了一个隐式转换,将 (OK,events) 封装成了 JSON。
          // 需要导入  akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
          events => complete(OK, events)
        }
      }
    }
  }

  def eventRoute: Route = pathPrefix("events" / Segment) {

    event => {
      // event 相当于 POST 的参数
      pathEndOrSingleSlash {
        post {
          // POST /events/{event}
          // Body {"tickets":250}
          entity(as[EventDescription]) {
            ed =>
              onSuccess(createEvent(event, ed.tickets)) {
                case EventCreated(event) => complete(Created, event)
                case EventExists =>
                  val err: MessageProtocols.Error = MessageProtocols.Error(s"$event exists already.")
                  complete(BadRequest, err)
              }
          }
        } ~ get {
          // GET /events/{event}
          onSuccess(getEvent(event)) { maybeEvent =>
            // maybeEvent 是一个 Option[Event] 类型,可以直接用 fold 表达:
            maybeEvent.fold(complete(NotFound))(event => complete(OK,event))
          }
        } ~ delete {
          onSuccess(cancelEvent(event)) {
            // 也可以传递一个对 Option[Event] 的模式匹配,语义是相同的。
            case Some(event) => complete(OK,event)
            case None => complete(NotFound)
          }
        }
      }
    }
  }

  def ticketsRoute : Route = pathPrefix("events" / Segment / "tickets") {
    event => {
      pathEndOrSingleSlash {
        post {
          entity(as[TicketRequest]){
            ticketRequest => {
              onSuccess(getTickets(event,ticketRequest.tickets)) {
                case tickets if tickets.entries.isEmpty => complete(NotFound)
                case tickets => complete(Created,tickets)
              }
            }
          }
        }
      }
    }
  }
}

RestApi 混入 RestRoutes,和消息序列 / 反序列化特质 EventMarshalling,它事实上描述了项目所有的消息路由与处理方案。除此之外,它提供由 Main 创建的 ActorSystem 和消息分发器作为上下文:

class RestApi(sys: ActorSystem, timeout: Timeout) extends RestRoutes {
  override def createBoxOffice(): ActorRef = sys.actorOf(BoxOffice.props, "boxOffice")

  override implicit def executionContext: ExecutionContext = sys.dispatcher

  override implicit def requestTimeout: Timeout = timeout
}

Main

Http().bindAndHandler(api,host,port) 让主程序作为一个 HTTP server 启动。ActorMaterializer 是 akka.stream 中必须创建的一个隐式上下文,笔者推测原因是 Akka HTTP 将即将到来的 HTTP 请求视作是消息流。其参数 apiRestApi 实例返回的路由机制 routes。在更新的版本中,ActorMaterializer 不需要再被显示地定义了:Migration Guide to and within Akka HTTP 10.2.x • Akka HTTP

object Main extends App with RequestTimeout {
  val config = ConfigFactory.load()
  val host = config.getString("http.host")
  val port = config.getInt("http.port")

  implicit val sys: ActorSystem = ActorSystem()
  implicit val ec: ExecutionContextExecutor = sys.dispatcher
  val api = new RestApi(sys = sys, timeout = requestTimeout(config)).routes

  // TODO 对外监听连接
  implicit val materializer =  ActorMaterializer()
  val bindingFuture = Http().bindAndHandle(api,host,port) // Start HTTP server

  val log = Logging(sys.eventStream,"go-tickets")
  bindingFuture.map {
    serverBinding =>
      log.info(s"Rest Api bound to ${serverBinding.localAddress}")
  }.onFailure {
    case ex : Exception =>
      log.error(ex,"Failed to bind to {}:{}",host,port)
      sys.terminate()
  }
}

最后一步,在 project 文件夹下建立 application.conf 文件,做如下基本配置:

akka {
  http {
    server {
      request-timeout = 8s
    }
  }
}

http {
  host = "0.0.0.0"
  port = 4396
}

*.conf 配置的写法,层次结构类似于 *.yml

运行与打包

进入到 sbt 交互终端,使用 run 命令启动程序。

[info] [INFO] [02/28/2022 13:28:39.037] [default-akka.actor.default-dispatcher-5] [go-tickets] Rest Api bound to /0:0:0:0:0:0:0:0:4396

可以使用 Postman 工具向本机的端口中发送消息并测试。在这个实例中,所有的数据都是在内存的,因此每次重新启动项目时,数据都会丢失。

如果要将源代码打包为 jar,可以使用 sbt 的 assembly 集成插件。在 /project/plugins.sbt 中添加此插件:

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.9")

在根目录的 build.sbt 中配置 assembly 的相关选项 ( in 语法在新版本的 sbt 工具中被标记过时了,但是目前仍然能够正常使用 ):

mainClass in assembly := Some("com.gotickets.Main")
assemblyJarName in assembly := "go-ticks.jar"

在 sbt 中通过 reload 命令刷新配置,随后执行 assembly,在项目根目录的 target/scala-x.x/ 下可找到编译好的 jar 包。

java -jar ~\go-ticks.jar