弯光公司18年3月的akka生态全景介绍 - 知乎 (zhihu.com)
如何学习scala,akka,play framework? - 知乎 (zhihu.com)
参考文档与案例代码
在之前,笔者曾简单介绍过 Akka 的基本用法,见 Scala+Akka 实现主从节点的心跳检测 - 掘金 (juejin.cn) 和 Scala 之 Akka 框架实战 - 掘金 (juejin.cn) 。整个专栏的学习笔记参考 Akka in action。
注意
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 版本为主 ):
下方的 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):
- I/O 阻塞限制了并行性,因此非阻塞 I/O 是首选。
- 同步交互限制了并行性,因此要使用异步交互。
- 轮询占用资源,因此需要使用事件驱动是首选。
- 如果一个节点会拖慢其它节点,那么就需要隔离来避免丢失其它工作。
- 系统需要弹性。如果需求变少,那么也应该使用较少的资源;如果需求变多,那么也应该使用较多的资源。
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。
改变
状态机是保证系统在特定状态执行特定功能的有力工具。Actor 每次仅接收一条消息,而这种机制适合实现状态机。一个 Actor 可以通过交换它的行为来改变它处理消息的方式。假如某个处理会话的 Session Actor 在接受到 CloseConservation 之后变为 关闭状态。这样,后续任何发送给 Session Actor 的消息都会被忽略。
三个维度的解耦
Actor 在以下的三个维度上进行了解耦:
- 空间 —— Actor 不保证,也不会期望另一个 Actor 的物理地址在哪里。
- 时间 —— Actor 不保证,也不会预期它的工作何时完成。
- 接口 —— 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 进行处理。
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
标识。管理员可以创建活动名并发行指定数量的门票,或者直接取消某场活动;用户可一次性购买同一个活动下连号的多张门票,查询活动等基本操作。
项目结构
接口文档如下:
描述 | 方法 | URL | Body | StateCode | 实例 |
---|---|---|---|---|---|
创建活动 | 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} |
整个项目有以下核心类:
为了使代码的逻辑看起来更清晰,下面首先给出代码的大体框架,所有待实现的具体功能暂时用 ???
方法进行标记。
单例对象 Main
继承了 App
,它可以被 sbt
工具自动识别为程序入口,主程序将作视为一个 HTTP server 启动。
- 读取配置,设置监听端口。( 已给出 )
- 混入
RequestTimeout
特质设定请求超时 ( 已给出 ) - 创建 Akka 系统和分发器,并设置为上下文环境 ( 已给出 )
- 创建 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" 的活动。
- 它是业务的实际处理者,包括注销业务,这意味着 Actor 将销毁自身。
- 每个 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 = ???
}
BoxOffice
被 BoxOfficeApi
代理,而 BoxOfficeApi
,RestRoutes
,RestApi
保持着层次关系。
RestRoutes
提供了RestApi
的 HTTP 请求处理功能,形式上为RestApi
混入了RestRoutes
特质。RestApi
本身仅负责提供 ActorSystem 的上下文。另外,RestRoutes 返回一个 Route 类型,Akka HTTP 组件需要引入它来对外提供 HTTP 服务。BoxOfficeApi
提供了RestRoutes
与BoxOffice
的交互,形式上为RestRoutes
混入了BoxOfficeApi
特质。BoxOfficeApi
负责 TicketSeller Actor 的创建和信息交互。- 接受 / 返回 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 的以下基本用法:
- 需要回传消息时,不需要显示寻找发送者的 ActorRef,只需要调用 sender() 即可。
- 每个 Actor 通过定义 receive 偏函数来表达对不同消息的处理。
!
表示消息发完即弃。更多的,见:Actor 模型中的通信模式 | tisonkun (lmlphp.com)- 通过
self
指向 TicketSeller ActorRef 自身,不使用this
关键字。 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 并 转发 任务 ( 任务即消息 )。有以下需要注意的地方:
ask
表示异步请求内容 ( 但可以选择同步等待 ),等同于?
。它发完即弃的!
相比是存在区别的。该请求的返回值是一个Future[T]
,因为消息发送方不会确定何时能得到结果。forward
表示消息的转发。比如,RestApi 向 BoxOffice 发送消息,而 BoxOffice 将消息转发给 TicketSeller,那么 TicketSeller 的sender()
将指向 RestApi。- 通过
context.child(name)
检索当前 Actor 所管理的子 Actor(Ref)s。 context.child(event)
返回一个Option[ActorRef]
,因为上下文不保证ActorRef
是一定存在的。以context.child(event).fold(create())(_ => sender() ! EventExists)
调用为例,表示:若返回的Option[ActorRef]
为None
,则调用create()
方法 ( 这是个传名调用,会延迟执行 ),否则,需要传入并执行后面的方法,类似 非空即调用 的逻辑。- 消息管道
pipe ... to
( 或者是pipeTo
方法 ) 能够在当前 ActorRef 获得Future[T]
结果之后再将其转发给另一个 ActorRef。 sequence
是函数式编程中表达 "翻转" 的常用的泛化方法。见:Scala:函数式编程下的异常处理 - 掘金 (juejin.cn) 中对Option
和Either
的理解。
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 请求视作是消息流。其参数 api
是 RestApi
实例返回的路由机制 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