引言

Lagom是出品Akka的Lightbend公司推出的一个微服务框架,目前最新版本为1.6.2。Lagom一词出自瑞典语,意为“适量”。

https://www.lagomframework.com/documentation/1.6.x/scala/Home.html

Lagom框架坚持,微服务是按服务边界Boundary将系统切分为若干个组成部分的结果,这意味着要使它们与限界上下文Bounded Context、业务功能和模块隔离等要求保持一致,才能达到可伸缩性和弹性要求,从而易于部署和管理。因此,在设计微服务时应考虑大小是否“Lagom”,而非是否足够“Micro”。

Lagom框架大量使用了Jonas Bonér所著Reactive Microservices Architecture: Design Principles For Distributed Systems一书的设计理念和思想,所以推荐在使用Lagom之前先阅读此书。

“船大好顶浪,船小好调头”——Jonas认为,将庞大的系统分割为若干独立的更小粒度的部分,同时将管理权限适当下放,可以使这些独立的部分更快地做出决断,以适应外部环境的不断变化。

️ Getting Start

Lagom开发环境要求:

  • JDK8
  • sbt 1.2.1以上版本(Lightbend推荐使用sbt,maven次之,故以下均使用sbt。)
  • 可用的互联网

Hello World

Lagom提供了Giter8模板,方便利用sbt构造一个Hello World项目结构,确保在开发前验证生成工具和项目已正确配置。Hello World包括Hello与Stream两个微服务,每个微服务包括API与实现两个子项目。Lagom会自动配置诸如持久化、服务定位等基础设施,并且支持发现并加载微服务的热更新。

sbt new lagom/lagom-scala.g8

hello                   → Project root
└ hello-api → hello api project
└ hello-impl → hello implementation project
└ hello-stream-api → hello-stream api project
└ hello-stream-impl → hello-stream implementation project
└ project → sbt configuration files
└ build.properties → Marker for sbt project
└ plugins.sbt → sbt plugins including the declaration for Lagom itself
└ build.sbt → Your project build file

使用sbt里的runAll一次性启动所有服务后(包括Cassandra、Kafka,若是手动则需要自己一个个启动包括基础服务在内的所有服务),在http://localhost:9000/api/hello/World处将得到显示了一行“Hello World”的页面。

  • 在Hello API里,HelloWorldService从Service派生,声明服务的API以及服务的回复消息。
  • 在API里,实现Service.descriptor方法,该方法返回一个Descriptor,用于定义服务的名称、REST端点路径、Kafka消息主题,以及REST路径与服务API方法的映射关系,等等。
  • 在Hello Impl里,HelloWorldServiceImpl从API里的HelloWorldService派生,定义服务的API。
  • 在服务API的定义里,将使用一个支持集群分片的Sharded、可持久化的Persistent、强类型Typed的Actor——HelloWorldBehavior采用Ask模式进行通信,按entityRef(id).ask[Message](replyTo => msg(replyTo)).map(reply => ...)的样式进行ask与map配对。其中,entityRef是该Actor在集群里的引用:clusterSharding.entityRefFor(HelloWorldState.typeKey, id)
  • HelloWorldBehavior实质就是一个EventSourcedBehavior,采用了State Pattern,定义了支撑服务的State、Command和Event以及相应的Handler。
  • 在Hello API内部,hello(id)实际是委托useGreeting(id)方法完成的,所以将World换作其他内容亦可。

服务与网络地址绑定

使用sbt将服务绑定到特定网络地址(默认是localhost):

lazy val biddingImpl = (project in file("biddingImpl"))
.enablePlugins(LagomScala)
.settings(lagomServiceAddress := "0.0.0.0")

端口号的分配机制

即便是将服务部署到不同的物理主机上,服务的端口号也将保持前后一致(总是使用特定的某个端口号),该端口号基于以下算法:

  • 先对项目Project的名称进行hash。
  • 将hash值投射到默认的端口范围[49152, 65535]内。
  • 如果没有其他项目申请同一个端口号,那么选定的该端口号将指定给该项目。如果发生冲突,那么将会按项目名称的字母顺序逐个递增地分配相邻的端口号。

通常情况下,无需关心上述细节,因为很少会发生这样的冲突。当然,也可以按下列方式手动指定端口号:

lazy val usersImpl = (project in file("usersImpl"))
.enablePlugins(LagomScala)
.settings(lagomServiceHttpPort := 11000)

如果嫌默认的端口范围[49152, 65535]不合适,可以指定端口范围,不过范围越窄,发生冲突的机率就越大:

lagomServicesPortRange in ThisBuild := PortRange(40000, 45000)

在开发模式下使用HTTPS

开发模式,是指在sbt或者maven支持下,启动和调试各种服务。在这种环境下,对代码做出修改后,Lagom将负责后续的编译和重新加载工作,相应的服务会自动重启。

在sbt里进行如下配置,将会使用一个自签名的证书为服务启用HTTPS并指定其端口,确保服务之间能通过HTTPS进行调用,但同时服务网关Service Gateway将仍旧只能使用HTTP:

lagomServiceEnableSsl in ThisBuild := true
lagomServiceHttpsPort := 20443

在客户端,则建议使用诸如Play-WS或者Akka-HTTP Client API这样的HTTPS Client框架。

服务定位子与网关

服务定位子

服务定位子Service Locator,是确保用于发现其他服务并与之联系的组件。Lagom内置的缺省Locator有以下特性:

  • 默认地址是localhost,可使用lagomServiceLocatorAddress in ThisBuild := "0.0.0.0"修改之。
  • 默认端口是9008,可使用lagomServiceLocatorPort in ThisBuild := 10000修改之。
  • 非Lagom的外部服务需要先注册:lagomUnmanagedServices in ThisBuild := Map("weather" -> "http://localhost:3333"),然后再使用。

网关

网关Gateway,相当于Service Locator的代理,用于防止对Locator的不当访问。Lagom内置的缺省Gateway有以下特性:

  • 默认地址是localhost,可使用lagomServiceGatewayAddress in ThisBuild := "0.0.0.0"修改之。
  • 默认端口是9000,可使用lagomServiceGatewayPort in ThisBuild := 9010修改之。
  • Lagom提供了一个基于Akka HTTP的网关(akka-http是默认的),一个旧式的基于Netty的网关,可使用lagomServiceGatewayImpl in ThisBuild := "netty"指定之。

启停与禁用

在开发环境下,使用runAll时默认会启动Locator与Gateway。需要手动启停时,分别执行lagomServiceLocatorStartlagomServiceLocatorStop任务即可。

如要需要禁用内置的Locater与Gateway,则使用lagomServiceLocatorEnabled in ThisBuild := false禁用之。之后便需要自己提供一个Locator的实现,并需要牢记每个服务的端口号以建立相互联系。

Cassandra服务

Cassandra是Apache提供的一个分布式、可扩展的NoSQL数据库。它以KeySpace为单位,其中包含若干个表Table或者列族Column Family,每个列族可以有不同的列(相当于RDBMS中的字段)并可自由添加列,每个行可以拥有不同的列,并支持索引。Cassandra支持的数据类型除常见的原生类型外,为List、Map和Set提供了直接支持,提供了TTL数据到期自动删除功能,并且可以自定义数据类型。

Lagom内置了一个Cassandra服务,作为Event Sourcing的事件持久化平台。

  • 默认端口是4000,是为避免与Cassandra默认端口9042冲突,可以用lagomCassandraPort in ThisBuild := 9042指定之。
  • 默认情况下生成的数据会在每次Cassandra服务运行期间被保存,可以用lagomCassandraCleanOnStart in ThisBuild := true使之在每次服务启动时清空数据库。
  • 默认使用文件dev-embedded-cassandra.yaml对Cassandra进行配置,可以使用lagomCassandraYamlFile in ThisBuild := Some((baseDirectory in ThisBuild).value / "project" / "cassandra.yaml")另行指定特定YAML配置文件。
  • Cassandra服务将运行在独立的JVM上,可以使用lagomCassandraJvmOptions in ThisBuild := Seq("-Xms256m", "-Xmx1024m", "-Dcassandra.jmx.local.port=4099")指定相应的JVM参数。
  • 默认的日志将输出至标准输出,默认级别为ERROR,暂时未提供配置措施。如果确需调整日志配置,则只有连接到一个本地的Cassandra实例再修改之。
  • 默认情况下Cassandra服务会最先启动,并预留20秒时间完成启动,可以使用lagomCassandraMaxBootWaitingTime in ThisBuild := 0.seconds修改之。
  • 使用lagomCassandraStartlagomCassandraStop手动启停。
  • 使用lagomCassandraEnabled in ThisBuild := false禁用之,再使用lagomUnmanagedServices in ThisBuild := Map("cas_native" -> "tcp://localhost:9042")注册本地Cassandra实例即可使用之。

Kafka服务

Kafka是Apache提供的一个分布式的流处理平台,简单讲可以理解为一个生产者-消费者结构的、集群条件下的消息队列Message Queue。每条消息流Stream以主题Topic作为唯一区别,每个Topic下可以有多个相同的分区Partition,分区里的消息都有一个Offset作为序号以确保按顺序被消费。Kafka依赖ZooKeeper提供的集群功能部署其节点。

Lagom内置了一个Kafka服务:

  • 默认端口为9092,可以用lagomKafkaPort in ThisBuild := 10000修改之
  • 依赖的ZooKeeper端口默认为2181,可以用lagomKafkaZookeeperPort in ThisBuild := 9999修改之
  • 默认使用文件kafka-server.properties配置Kafka运行参数,可以使用lagomKafkaPropertiesFile in ThisBuild := Some((baseDirectory in ThisBuild).value / "project" / "kafka-server.properties")另行指定配置文件。
  • Kafka服务将运行在独立的JVM上,可以使用lagomKafkaJvmOptions in ThisBuild := Seq("-Xms256m", "-Xmx1024m")指定相应的JVM参数。
  • 默认的日志将直接输出至文件,路径为<your-project-root>/target/lagom-dynamic-projects/lagom-internal-meta-project-kafka/target/log4j_output,而Kafka的提交日志将保存在<your-project-root>/target/lagom-dynamic-projects/lagom-internal-meta-project-kafka/target/logs
  • 使用lagomKafkaStartlagomKafkaStop手动启停。
  • 使用lagomKafkaEnabled in ThisBuild := false禁用之,再使用lagomKafkaAddress in ThisBuild := "localhost:10000"指定Kafka实例即可使用之。如果是本地实例,用lagomKafkaPort in ThisBuild := 10000指定端口即可。

️ 编写Lagom服务

服务描述子

每一个Lagom服务都由一个接口进行描述。该接口的内容不仅包括接口方法的声明和实现,同时还定义了接口的元数据如何被映射到底层的传输协议。

服务的每个接口方法都要求返回一个ServiceCall[Request, Response],其中Request或Response可以是Akka的NotUsed

trait ServiceCall[Request, Response] {
def invoke(request: Request): Future[Response]
}

每一个Lagom服务在覆写的descriptor中都将返回一个服务描述子Service Descriptor。以下便是声明了一个叫作hello的服务,该服务提供了sayHello的API:

trait HelloService extends Service {
def sayHello: ServiceCall[String, String] override def descriptor = {
import Service._
named("hello").withCalls(call(sayHello))
}
}

Call标识符

每个服务调用都必须有一个唯一的标识符,以保证调用能最终映射到正确的API方法上。这个标识符可以是静态的一个字符串名称,也可以在运行时动态生成。默认情况下,API方法的名称即该调用的标识符。

强命名的标识符

使用namedCall指定强命名的标识符,服务hello里的API方法sayHello的调用名为hello,在REST架构下的相应路径为/hello

named("hello").withCalls(namedCall("hello", sayHello))

基于路径的标识符

使用pathCall指定基于路径的标识符,类似于字符串中用$引导的内插值,此处用:引导内插变量。Lagom为此提供了一个隐式的PathParamSerializer,用于从路径中提取StringIntBoolean或者UUID类型的内插变量。比如以下便是提取了路径中类型为long的orderId和类型为String的itemId值,作为参数传递给API方法。在作参数映射时,默认将按从路径中从左至右提取的顺序进行映射。

这与ASP.NET MVC等一些HTTP框架采用的方法是类似的,所以要注意以构建RESTful应用的思路贯彻学习始终。

def getItem(orderId: Long, itemId: String): ServiceCall[NotUsed, Item]

override def descriptor = {
import Service._
named("orders").withCalls(pathCall("/order/:orderId/item/:itemId", getItem _))
}

提取查询串中的参数时,则使用的?起始、以&分隔的形式:

def getItems(orderId: Long, pageNo: Int, pageSize: Int): ServiceCall[NotUsed, Seq[Item]]

override def descriptor = {
import Service._
named("orders").withCalls(pathCall("/order/:orderId/items?pageNo&pageSize", getItems _))
}

在REST架构下,Lagom会努力正确实现上述映射,并且在有Request消息时使用POST方法,否则使用GET方法。

REST标识符

REST标识符用于完全REST形式的调用,和pathCall非常类似,区别只是REST标识符可指定HTTP调用方法:

def addItem(orderId: Long): ServiceCall[Item, NotUsed]
def getItem(orderId: Long, itemId: String): ServiceCall[NotUsed, Item]
def deleteItem(orderId: Long, itemId: String): ServiceCall[NotUsed, NotUsed] def descriptor = {
import Service._
import com.lightbend.lagom.scaladsl.api.transport.Method
named("orders").withCalls(
restCall(Method.POST, "/order/:orderId/item", addItem _),
restCall(Method.GET, "/order/:orderId/item/:itemId", getItem _),
restCall(Method.DELETE, "/order/:orderId/item/:itemId", deleteItem _)
)
}

消息

每个服务API都需要指定Request和Response的消息类型,可以用akka.NotUsed作为占位符,分为两种形式:

  • 严格消息Strict Message:这就是用Scala里的object或者case class表达的常见消息,它们将在内存的缓冲区中被序列化后进行传输。如果Request与Response均是这类严格消息,则调用将会是同步的,严格按请求-回复的顺序进行。
  • 流消息Streamed Message:流消息是一类特殊的消息,其类型为Akka-Stream里的Source。Lagom将使用WebSocket协议进行流的传输。如果Request与Response均是流消息时,任何一方关闭时,WebSocket将完全关闭。如果只有一方是流消息,则严格消息仍按序列化方式进行传输,而WebSocket将始终保持打开状态,直到另一个方向关闭为止。

以下分别是单向流和双向流消息的示例:

def tick(interval: Int): ServiceCall[String, Source[String, NotUsed]]

def descriptor = {
import Service._
named("clock").withCalls(pathCall("/tick/:interval", tick _))
} def sayHello: ServiceCall[Source[String, NotUsed], Source[String, NotUsed]] def descriptor = {
import Service._
named("hello").withCalls(call(this.sayHello))
}

消息的序列化

Lagom通过定义隐式的MessageSerializer,为call、namedCall、pathCall和restCall提供了消息的序列化支持。对String类型的消息和Play框架JSON格式的消息,Lagom提供了内置的序列化器。除此以外,可以自定义序列化器。(参考:消息序列化器

使用Play-JSON时,通常是用case class和companion object配合,case class定义消息的结构,companion object定义消息的格式:

case class User(
id: Long,
name: String,
email: Option[String]
) object User {
import play.api.libs.json._
implicit val format: Format[User] = Json.format[User]
}

对应的JSON结果为:

{
"id": 12345,
"name": "John Smith",
"email": "john.smith@example.org"
}

属性可以是Option,这样如果为None,则Play-JSON在序列化时不会解析它、反序列化时不会生成该属性。如果case class还内嵌了其他case class,则被嵌入的case class也需要定义它的format。

实现服务

服务的实现,即实现之前声明的服务描述子trait。对服务API中声明的每个方法,使用ServiceCall的工厂方法apply,传入一个Request => Future[Response],返回一个ServiceCall

( 注意:Lagom大量使用函数作为返回值,从而充分发挥了FP组合高阶函数的优势。)

class HelloServiceImpl extends HelloService {
override def sayHello = ServiceCall { name => Future.successful(s"Hello $name!") }
}

使用流消息

当消息不是普通的严格消息而是流消息时,需要使用Akka Stream来处理它。对应前面单向流的例子,它的实现如下:

override def tick(intervalMs: Int) = ServiceCall { tickMessage =>
Future.successful(
Source
.tick(
// 消息被发送前的延迟
intervalMs.milliseconds,
// 消息发送的间隔
intervalMs.milliseconds,
// 将被发送的消息
tickMessage
)
.mapMaterializedValue(_ => NotUsed)
)
}

处理消息的头部信息

如果某些时候需要处理消息的头部信息(通常是HTTP),那么Lagom提供了ServiceCall的派生类ServerServiceCall作为支持。ServerServiceCall将ServiceCall里的Header单独取出,提供了invokeWithHeaders方法,该方法第一个参数是RequestHeader,另一个参数才是Request本身,这样直接将invoke委托给invokeWithHeaders,从而方便在函数体中使用handleRequestHeaderhandleResponseHeader对头部信息进行处理(尽管ServiceCall本身也支持这2个处理函数)。

override def sayHello = ServerServiceCall { (requestHeader, name) =>
val user = requestHeader.principal
.map(_.getName)
.getOrElse("No one")
val response = s"$user wants to say hello to $name" val responseHeader = ResponseHeader.Ok.withHeader("Server", "Hello service") Future.successful((responseHeader, response))
}

ServerServiceCall工厂方法有一个版本可以同时处理Request与Response的头部信息,但也有不带处理头部信息的版本。后者虽然看起来与ServiceCall没什么区别,从而显得多此一举,但实际这是为满足组合服务调用的需求而存在的。

组合服务调用

组合服务调用,类似于把ServerServiceCall通过依赖注入传递给需要包裹在外层的日志、权限、过滤等切面服务,从而实现AOP切入到核心的服务API调用上。

在AOP切入的实现上,Lagom采取了组合高阶函数的方法。这就象是抹了一层又一层奶油的生日蛋糕,只有切开了才能看到最里层真正想要吃到的蛋糕。相比使用共享的线程变量等方法,不仅通过类型系统发挥了编译检查的优势而更加安全,并且还通过构造表达式树而提供了延迟计算的功能。

// AOP: Log
def logged[Request, Response](serviceCall: ServerServiceCall[Request, Response]) =
ServerServiceCall.compose { requestHeader =>
println(s"Received ${requestHeader.method} ${requestHeader.uri}")
serviceCall
} override def sayHello = logged(ServerServiceCall { name =>
Future.successful(s"Hello $name!")
}) // AOP: Authentication
trait UserStorage {
def lookupUser(username: String): Future[Option[User]]
} def authenticated[Request, Response](serviceCall: User => ServerServiceCall[Request, Response]) = {
// composeAsync允许异步地返回要调用的服务API
ServerServiceCall.composeAsync { requestHeader =>
// First lookup user
val userLookup = requestHeader.principal
.map(principal => userStorage.lookupUser(principal.getName))
.getOrElse(Future.successful(None)) // Then, if it exists, apply it to the service call
userLookup.map {
case Some(user) => serviceCall(user)
case None => throw Forbidden("User must be authenticated to access this service call")
}
}
} override def sayHello = authenticated { user =>
ServerServiceCall { name =>
// 注意:因为闭包,此处可访问经AOP切入带来的user
Future.successful(s"$user is saying hello to $name")
}
}

依赖注入

依赖注入,Dependency Injection,是把本应由服务承担的创建其自身依赖的责任移交给外部的框架,改由DI框架负责生产对象并维护彼此的关联,最终形成完整的对象图(Object Graph),从而达到公示服务所需依赖、消灭代码中硬编码的new操作、降低耦合度的目的。

Cake Pattern

在Scala语言里最常见的一个DI模式,是用trait的Self Type特性实现的蛋糕模式(Thin Cake Pattern)。

更复杂的示例,请参见 Real-World Scala: Dependency Injection

trait Stuffing {
val stuffing: String
} // 使用Self Type特性,限定继承了Cake的子类,必须同时也继承了Stuffing
// 此处Stuffing还可以with其他trait,从而实现多重注入
trait Cake { this: Stuffing =>
def flavour: String = this.stuffing
} object LemonCake extends Cake with Stuffing { ... }

Reader Monad

在FP的世界,实现DI的另一个选择是Reader Monad。

case class Reader[R, A](run: R => A) {
def map[B](f: A => B): Reader[R, B] =
Reader(r => f(run(r))) def flatMap[B](f: A => Reader[R, B]): Reader[R, B] =
Reader(r => f(run(r)).run(r))
} def balance(accountNo: String) = Reader((repo: AccountRepository) => repo.balance(accountNo))

DI框架MacWire基本用法

Dependency Injection in Scala using MacWire

MacWire使用指南中涉及的参考知识:轨道交通编组站

Lagom使用了MacWire作为默认的DI框架(当然也可以换Spring之类的其他DI框架)。

MacWire主要使用wire[T]进行DI,它会从标注了@Inject的构造子、非私有的主要构造子、Companion Object里的apply()方法里查找依赖关系。然后再根据依赖项的类型,按以下顺序查找匹配的参数项:

  • 先在当前块、闭合函数或匿名函数的参数中寻找唯一值;
  • 然后在闭合类型的参数、属性里寻找唯一值;
  • 最后在父类型里寻找唯一值。

使用MacWire相关事项:

  • 如果是隐式参数,MacWire将会跳过它,使它仍按Scala语法关于隐式参数的方法进行处理。

  • 只要类型匹配,此处的值可以是vallazy val或者用def定义的无参函数。

  • 需要根据条件使用不同的依赖项实现时,用类似lazy val component = if (condition) then wire[implementationA] else wire[implementationB]的方法,定义好component的值即可。

  • 需要在依赖项上切入拦截器Interceptor时,先在依赖项定义处声明一个拦截器def logInterceptor : Interceptor,再用lazy val component = logInterceptor(wire[Component])绑定拦截声明,最后在使用依赖项进行实现的地方用lazy val logInterceptor = { ... }给出具体实现即可。

    trait ShuntingModule {
    lazy val pointSwitcher: PointSwitcher =
    logEvents(wire[PointSwitcher])
    lazy val trainCarCoupler: TrainCarCoupler =
    logEvents(wire[TrainCarCoupler])
    lazy val trainShunter = wire[TrainShunter] def logEvents: Interceptor
    } object TrainStation extends App {
    val modules = new ShuntingModule
    with LoadingModule
    with StationModule { lazy val logEvents = ProxyingInterceptor { ctx =>
    println("Calling method: " + ctx.method.getName())
    ctx.proceed()
    }
    } modules.trainStation.prepareAndDispatchNextTrain()
    }
  • 默认情况下,使用lazy val component = wire[Component]声明在当前范围内唯一的依赖项(Singleton)。如果需要在每次调用时都创建新的依赖项(Dependent),则换作def即可。

  • 除Singleton和Dependent两种生命周期外,MacWire还支持其他形式的生命周期,方法类似使用拦截器。

    trait StationModule extends ShuntingModule with LoadingModule {
    lazy val trainDispatch: TrainDispatch =
    session(wire[TrainDispatch])
    lazy val trainStation: TrainStation =
    wire[TrainStation] def session: Scope
    } object TrainStation extends App {
    val modules = new ShuntingModule
    with LoadingModule
    with StationModule { lazy val session = new ThreadLocalScope
    } // implement a filter which attaches the session to the scope
    // use the filter in the server modules.trainStation.prepareAndDispatchNextTrain()
    }
  • 对需要参数进行构造的依赖项,使用lazy val component = (parameters) => wire[Component]这样的工厂方法进行声明,展开后将变成new Component(parameters)。对在trait里声明的依赖项,则改用def component(parameters: Parameter) = wire[Component]的方式定义工厂方法。

  • 在具体使用参数化的依赖项时,使用wireWith(real_parameter)代入实际参数。

  • 需要区别同一类依赖项的不同实例时,可以用trait作为标记tag。一种方法是用new Component(...) with tag细化其子类型,另一种是在声明依赖值的类型后面用@@ tag作标记。

    trait Regular
    trait Liquid class TrainStation(
    trainShunter: TrainShunter,
    regularTrainLoader: TrainLoader with Regular,
    liquidTrainLoader: TrainLoader with Liquid,
    trainDispatch: TrainDispatch) { ... } lazy val regularTrainLoader = new TrainLoader(...) with Regular
    lazy val liquidTrainLoader = new TrainLoader(...) with Liquid
    lazy val trainStation = wire[TrainStation] /////////////////////////////////////// class TrainStation(
    trainShunter: TrainShunter,
    regularTrainLoader: TrainLoader @@ Regular,
    liquidTrainLoader: TrainLoader @@ Liquid,
    trainDispatch: TrainDispatch) { ... } lazy val regularTrainLoader = wire[TrainLoader].taggedWith[Regular]
    lazy val liquidTrainLoader = wire[TrainLoader].taggedWith[Liquid]
    lazy val trainStation = wire[TrainStation]

组装完整的应用程序

在Lagom中使用MacWire进行DI是可行的,但前述的服务定位子基本上只能满足开发环境的需求,生产环境还是要换用Akka Discovery Service Locator之类更强大的定位子框架。

在完成组件的拼装后,还需要一个启动子启动应用程序。Lagom为此提供了Play框架ApplicationLoader的简化版本LagomApplicationLoader。其中的loadDevModeload是必须的,前者用with LagomDevModeComponents加载Lagom内嵌的服务定位子,用于开发环境;后者可以指定生产环境下的服务定位子,如果返回NoServiceLoader将意味着不提供服务定位。而describeService是可选的,它主要用于声明服务的API,为外部管理框架或者脚本语言提供服务的Meta信息,以方便进行动态配置。服务无数据Metadata,又称为ServiceInfo,主要包括服务的名称以及一个访问控制列表ACL。通常这些Metadata会由框架自动生成,前提是在使用withCalls声明描述子时,用withAutoAcl(true)激活即可。

import com.lightbend.lagom.scaladsl.server._
import com.lightbend.lagom.scaladsl.api.ServiceLocator
import com.lightbend.lagom.scaladsl.devmode.LagomDevModeComponents class HelloApplicationLoader extends LagomApplicationLoader {
override def loadDevMode(context: LagomApplicationContext) =
new HelloApplication(context) with LagomDevModeComponents override def load(context: LagomApplicationContext) =
new HelloApplication(context) {
override def serviceLocator = ServiceLocator.NoServiceLocator
} override def describeService = Some(readDescriptor[HelloService])
}

最后,只需在配置application.conf里用play.application.loader = com.example.HelloApplicationLoader指定启动子,即完成了应用程序的装配。

Lagom预置的组件类型

Lagom根据用途不同,通常将组件分为若干类型:

  • Service Components:定义服务时所需要的基本模板
  • Persistence and Cluster Components:与Akka Cluster、Akka Persistence协作,实现Cluster、ES和CQRS所需要的组件模板
  • Broker API Components:与Kafka协作,进行消息的生产和消费所需要的组件模板
  • Service Locator Components:实现服务定位子的组件模板
  • Third party Components:与诸如Web Service Client之类进行协作的组件模板

详细列表:https://www.lagomframework.com/documentation/1.6.x/scala/ScalaComponents.html

消费服务

基本步骤

定义并实现服务后,该服务即可被其他服务或其他类型的客户消费使用。Lagom将根据服务描述子的内容,通过使用ServiceClientimplement宏进行绑定,自动生成一个调用该服务的框架,然后就可以象调用本地对象的方法一样invoke服务提供的API了。

// 先绑定HelloService
abstract class MyApplication(context: LagomApplicationContext)
extends LagomApplication(context)
with AhcWSComponents {
lazy val helloService = serviceClient.implement[HelloService]
} // 然后在另一个服务MyService里消费HelloService
class MyServiceImpl(helloService: HelloService)(implicit ec: ExecutionContext) extends MyService {
override def sayHelloLagom = ServiceCall { _ =>
val result: Future[String] = helloService.sayHello.invoke("Lagom") result.map { response => s"Hello service said: $response" }
}
}

消费流服务

当服务使用了流消息时,Lagom将在消费端使用带有最大帧长度参数的WebSocket进行通信。该参数定义了可以发送的消息的最大尺寸,可以在application.conf中配置。

#This configures the websocket clients used by this service.
#This is a global configuration and it is currently not possible to provide different configurations if multiple websocket services are consumed.
lagom.client.websocket {
#This parameter limits the allowed maximum size for the messages flowing through the WebSocket. A similar limit exists on the server side, see:
#https://www.playframework.com/documentation/2.6.x/ScalaWebSockets#Configuring-WebSocket-Frame-Length
frame.maxLength = 65536
}

断路器

断路器Circuit Breaker,如同电路保险丝,可以在服务崩溃时迅速熔断,从而避免产生更大面积的连锁反应。

断路器有以下3种状态:

  • 闭合状态 Closed:通常情况下,断路器处于闭合状态,保证服务调用正常通过。

    • 由于触发异常或者调用超过设定的call-timeout,导致失败计数值增长,在超过设定的max-failures,断路器跳到断开状态。
    • 由于半开状态下试探成功,失败计数值被重置归零。
  • 断开状态 Open:这种情况下,断路器处于断开状态,服务将始终不可用。
    • 所有的服务调用,都会得到一个CircuitBreakerOpenException异常而快速失败。
    • 在超过设定的reset-timeout后,断路器跳入半开状态。
  • 半开状态 Half-Open:这种情况下,断路器将尝试恢复到闭合状态。
    • 除第一个调用将被允许通过,以试探服务是否恢复可用外,后续调用将继续被快速失败所拒绝。
    • 如果试探成功,断路器将通过重置跳到闭合状态,恢复服务的可用性;如果试探失败,断路器将回到断开状态,然后等待下一个reset-timeout周期后再重新进行试探。

Lagom为所有消费端对服务的调用都默认启用了断路器。尽管断路器在消费端配置并使用,但用于绑定到服务的标识符则要由服务提供者定义和确定相应粒度。默认情况下,一个断路器实例将覆盖对一个服务所有API的调用。但通过设置断路器标识符,可以为每个API方法设置唯一的断路器标识符,以便为每个API方法使用单独的断路器实例。或者通过在某几个API方法上设置使用相同的标识符,实现对API调用的断路保护分组。

下例中,sayHi将使用默认的断路器,而hiAgain将使用断路器hello2

def descriptor: Descriptor = {
import Service._ named("hello").withCalls(
namedCall("hi", this.sayHi),
namedCall("hiAgain", this.hiAgain).withCircuitBreaker(CircuitBreaker.identifiedBy("hello2"))
)
}

对应的application.conf中配置如下:

lagom.circuit-breaker {
# will be used by sayHi method
hello.max-failures = 5 # will be used by hiAgain method
hello2 {
max-failures = 7
reset-timeout = 30s
} # Change the default call-timeout will be used for both sayHi and hiAgain methods
default.call-timeout = 5s
}

默认情况下,Lagom的客户端会将所有4字头和5字头的HTTP响应都映射为相应的异常,而断路器会将所有的异常都视作失败,从而触发熔断。通过设置白名单,可以忽略某些类型的异常。断路器完整的断路器配置选项如下:

# Circuit breakers for calls to other services are configured
# in this section. A child configuration section with the same
# name as the circuit breaker identifier will be used, with fallback
# to the `lagom.circuit-breaker.default` section.
lagom.circuit-breaker {
# Default configuration that is used if a configuration section
# with the circuit breaker identifier is not defined.
default {
# Possibility to disable a given circuit breaker.
enabled = on # Number of failures before opening the circuit.
max-failures = 10 # Duration of time after which to consider a call a failure.
call-timeout = 10s # Duration of time in open state after which to attempt to close
# the circuit, by first entering the half-open state.
reset-timeout = 15s # A whitelist of fqcn of Exceptions that the CircuitBreaker
# should not consider failures. By default all exceptions are
# considered failures.
exception-whitelist = []
}
}

测试服务

Lightbend推荐使用ScalaTest和Spec2作为Lagom的测试框架。类似Akka ActorTestKit,可以使用Lagom提供的ServiceTest工具包对服务进行测试。

测试单个服务

为每个测试创建一个单独的服务实例,其关键步骤包括:

  • 使用Scala的异步测试支持AsyncWordSpec。
  • 使用ServiceTest.withServer(config)(lagomApplication)(testBlock)的结构进行测试。
  • 为上一步中的lagomApplication混入一个LocalServiceLocator,以启用默认的定位子服务。
  • 在testBlock中使用一个客户端进行服务调用。
import com.lightbend.lagom.scaladsl.server.LocalServiceLocator
import com.lightbend.lagom.scaladsl.testkit.ServiceTest
import org.scalatest.AsyncWordSpec
import org.scalatest.Matchers // 1. 启用AsyncWordSpec
class HelloServiceSpec extends AsyncWordSpec with Matchers {
"The HelloService" should {
// 2. 使用ServiceTest.withServer进行测试
"say hello" in ServiceTest.withServer(ServiceTest.defaultSetup) { ctx =>
// 3. 启用默认的服务定位子
new HelloApplication(ctx) with LocalServiceLocator
} { server =>
// 4. 创建一个客户端进行服务调用
val client = server.serviceClient.implement[HelloService] client.sayHello.invoke("Alice").map { response =>
response should ===("Hello Alice!")
}
}
}
}

若要由多个测试共享一个服务实例,则需要使用ServiceTest.startServer()代替withServer(),然后在beforeAll与afterAll中启动和停止服务。

import com.lightbend.lagom.scaladsl.server.LocalServiceLocator
import com.lightbend.lagom.scaladsl.testkit.ServiceTest
import org.scalatest.AsyncWordSpec
import org.scalatest.Matchers
import org.scalatest.BeforeAndAfterAll class HelloServiceSpec extends AsyncWordSpec with Matchers with BeforeAndAfterAll {
lazy val server = ServiceTest.startServer(ServiceTest.defaultSetup) { ctx =>
new HelloApplication(ctx) with LocalServiceLocator
}
lazy val client = server.serviceClient.implement[HelloService] "The HelloService" should {
"say hello" in {
client.sayHello.invoke("Alice").map { response =>
response should ===("Hello Alice!")
}
}
} protected override def beforeAll() = server protected override def afterAll() = server.stop()
}

若要启用Cluster、PubSub或者Persistence支持,则需要在withServer第1个参数中启用。若需要调用其他服务,可以在第2个参数中构造要调用服务的Stub或者Mock。注意事项包括:

  • 持久化功能只能在Cassandra与JDBC之间二者择其一。
  • 使用withCassandra或者withJdbc启用Persistence后。会自动启用Cluster
  • 无论自动或手动启用Cluster,都会启动PubSub。而PubSub不能手动启用。
lazy val server = ServiceTest.startServer(ServiceTest.defaultSetup.withCluster) { ctx =>
new HelloApplication(ctx) with LocalServiceLocator {
override lazy val greetingService = new GreetingService {
override def greeting = ServiceCall { _ =>
Future.successful("Hello")
}
}
}
}

在测试时使用TLS

Lagom没有为HTTPS提供客户端框架,因此只有借用Play-WS、Akka HTTP或者Akka gRPC等框架创建使用SSL连接的客户端。而在服务端,可以使用withSsl激活SSL支持,随后框架将会为测试端自动打开一个随机的端口并提供一个javax.net.ssl.SSLContext类型的上下文环境。接下来,客户端就可以使用testServer提供的httpsPortsslContext连接到服务端并发送Request。Lagom测试工具提供的证书仅限于CN=localhost,所以该上下文SSLContext也只会信任本地的testServer,这就要求在发送请求时也设置好相应的权限,否则服务器将会拒绝该请求。目前,Lagom还无法为测试服务器设置不同的SSL证书。

"complete a WS call over HTTPS" in {
val setup = defaultSetup.withSsl()
ServiceTest.withServer(setup)(new TestTlsApplication(_)) { server =>
implicit val actorSystem = server.application.actorSystem
implicit val ctx = server.application.executionContext
// To explicitly use HTTPS on a test you must create a client of your own
// and make sure it uses the provided SSLContext
val wsClient = buildCustomWS(server.clientSslContext.get)
// use `localhost` as authority
val url = s"https://localhost:${server.playServer.httpsPort.get}/api/sample"
val response = wsClient.url(url).get().map { _.body[String] }
whenReady(response, timeout) { r => r should be("sample response") }
}
}

测试流消息

在测试支持流消息的服务时,需要搭配Akka Streams TestKit进行测试。

"The EchoService" should {
"echo" in {
// Use a source that never terminates (concat Source.maybe)
// so we don't close the upstream, which would close the downstream
val input = Source(List("msg1", "msg2", "msg3")).concat(Source.maybe)
client.echo.invoke(input).map { output =>
val probe = output.runWith(TestSink.probe(server.actorSystem))
probe.request(10)
probe.expectNext("msg1")
probe.expectNext("msg2")
probe.expectNext("msg3")
probe.cancel
succeed
}
}
}

测试持久化实体

在服务测试中,可以通过额外编写PersistEntityTestDriver,使用持久化实体Persistent Entity进行与数据库无关的功能测试。

消息序列化器

Play、Akka和Lagom均出自Lightbend,因此Lagom使用Play JSON作为消息的序列化框架,算是开箱即用了。

选配序列化器

Lagom的序列化器,通常是与服务描述子放在一起的一个MessageSerializer类型的隐式变量。也可以在withCalls()声明服务API时,在call、namedCall、pathCall、restCall或者topic方法里显式地指定分别用于Request和Response的序列化器。

Lagom通过借用Play的JSON序列化器,对case class进行JSON格式的序列化。该JSON序列化器有一个主要方法是jsValueFormatMessageSerializer,可以使用它在case classscompanion object里指定其他格式的JSON模板。同时,Lagom的MessageSerializer也为NotUsedDoneString等类型提供了缺省的非JSON格式的序列化支持,比如MessageSerializer.StringMessageSerializer

trait HelloService extends Service {
def sayHello: ServiceCall[String, String] override def descriptor = {
import Service._ named("hello").withCalls(
call(sayHello)(
MessageSerializer.StringMessageSerializer,
MessageSerializer.StringMessageSerializer
)
)
}
}

在companion object里定义不同的JSON格式,随后在服务描述子中显式地选择使用:

import play.api.libs.json._
import play.api.libs.functional.syntax._ case class MyMessage(id: String) object MyMessage {
implicit val format: Format[MyMessage] = Json.format
val alternateFormat: Format[MyMessage] = {
// 将id映射为JSON串里的identifier
(__ \ "identifier")
.format[String]
.inmap(MyMessage.apply, _.id)
}
} trait MyService extends Service {
def getMessage: ServiceCall[NotUsed, MyMessage]
def getMessageAlternate: ServiceCall[NotUsed, MyMessage] override def descriptor = {
import Service._ named("my-service").withCalls(
call(getMessage),
call(getMessageAlternate)(
// Request的序列化器
implicitly[MessageSerializer[NotUsed, ByteString]],
// Response的序列化器
MessageSerializer.jsValueFormatMessageSerializer(
implicitly[MessageSerializer[JsValue, ByteString]],
// 指定JSON格式
MyMessage.alternateFormat
)
)
)
}
}

自定义序列化器

Lagom的trait MessageSerializer也可以用来实现自定义的序列化器,派生的StrictMessageSerializer和StreamedMessageSerializer分别用于严格消息与流消息。其中,严格消息的序列化类型为二进制串ByteString,而流消息的则是Source[ByteString, _]

在实现自定义的序列化器之前,有几个关键性概念:

  • 消息协议 Message Protocols:包括内容类型,字符集和版本等3个可选属性,这3个属性将被投射到HTTP头部信息中的Content-TypeAccept,以及MIME Type Scheme的版本号,或者根据服务的配置方式直接从URL中提取。
  • 内容协商 Content Negotiation:Lagom镜像了HTTP的内容协商能力,保证服务器一方能根据客户端发出请求所使用的消息协议,采用对应协议进行通信。
  • 协商序列化器 Negotiation Serializer:使用内容协商后,MessageSerializer将不再直接承担序列化与反序列化职责,改由NegotiatedSerializerNegotiatedDeserializer负责。

内容协商在多数情况下并不是必要的,所以并不是一定需要实现的。

第一步:定义不同协议对应的序列化与反序列化器
/* ------------ String Protocol ------------ */
import akka.util.ByteString
import com.lightbend.lagom.scaladsl.api.deser.MessageSerializer.NegotiatedSerializer
import com.lightbend.lagom.scaladsl.api.transport.DeserializationException
import com.lightbend.lagom.scaladsl.api.transport.MessageProtocol
import com.lightbend.lagom.scaladsl.api.transport.NotAcceptable
import com.lightbend.lagom.scaladsl.api.transport.UnsupportedMediaType // 注意:`charset`由构造子传入,传递给MessageProtocol用于定义协议使用的字符集。
class PlainTextSerializer(val charset: String) extends NegotiatedSerializer[String, ByteString] {
override val protocol = MessageProtocol(Some("text/plain"), Some(charset)) def serialize(s: String) = ByteString.fromString(s, charset)
} import com.lightbend.lagom.scaladsl.api.deser.MessageSerializer.NegotiatedDeserializer class PlainTextDeserializer(val charset: String) extends NegotiatedDeserializer[String, ByteString] {
def deserialize(bytes: ByteString) =
bytes.decodeString(charset)
} /* ------------ JSON Protocol ------------ */
import play.api.libs.json.Json
import play.api.libs.json.JsString class JsonTextSerializer extends NegotiatedSerializer[String, ByteString] {
override val protocol = MessageProtocol(Some("application/json")) def serialize(s: String) =
ByteString.fromString(Json.stringify(JsString(s)))
} import scala.util.control.NonFatal class JsonTextDeserializer extends NegotiatedDeserializer[String, ByteString] {
def deserialize(bytes: ByteString) = {
try {
Json.parse(bytes.iterator.asInputStream).as[String]
} catch {
case NonFatal(e) => throw DeserializationException(e)
}
}
}
第二步:集成不同协议的序列化器
import com.lightbend.lagom.scaladsl.api.deser.StrictMessageSerializer
import scala.collection.immutable class TextMessageSerializer extends StrictMessageSerializer[String] {
// 支持的协议
override def acceptResponseProtocols = List(
MessageProtocol(Some("text/plain")),
MessageProtocol(Some("application/json"))
) /* ------ Serializer -----*/
// Client发出Request时还不需要协商协议,故使用最简单的文本协议
def serializerForRequest = new PlainTextSerializer("utf-8") def serializerForResponse(accepted: immutable.Seq[MessageProtocol]) = accepted match {
case Nil => new PlainTextSerializer("utf-8")
case protocols =>
protocols
.collectFirst {
case MessageProtocol(Some("text/plain" | "text/*" | "*/*" | "*"), charset, _) =>
new PlainTextSerializer(charset.getOrElse("utf-8"))
case MessageProtocol(Some("application/json"), _, _) =>
new JsonTextSerializer
}
.getOrElse {
throw NotAcceptable(accepted, MessageProtocol(Some("text/plain")))
}
} /* ------ Deserializer for Client and Service -----*/
def deserializer(protocol: MessageProtocol) = protocol.contentType match {
case Some("text/plain") | None =>
new PlainTextDeserializer(protocol.charset.getOrElse("utf-8"))
case Some("application/json") =>
new JsonTextDeserializer
case _ =>
// 抛出异常在生产环境并不可取,因为对于诸如WebSocket之类的应用,浏览器不允许设置ContentType
// 此时应返回一个缺省的反序列化器更为妥当。
throw UnsupportedMediaType(protocol, MessageProtocol(Some("text/plain")))
}
}

另一个用Protocol buffers实现的例子:

import akka.util.ByteString
import com.lightbend.lagom.scaladsl.api.deser.MessageSerializer.NegotiatedDeserializer
import com.lightbend.lagom.scaladsl.api.deser.MessageSerializer.NegotiatedSerializer
import com.lightbend.lagom.scaladsl.api.deser.StrictMessageSerializer
import com.lightbend.lagom.scaladsl.api.transport.MessageProtocol import scala.collection.immutable class ProtobufSerializer extends StrictMessageSerializer[Order] {
private final val serializer = {
new NegotiatedSerializer[Order, ByteString]() {
override def protocol: MessageProtocol =
MessageProtocol(Some("application/octet-stream")) def serialize(order: Order) = {
val builder = ByteString.createBuilder
order.writeTo(builder.asOutputStream)
builder.result
}
}
} private final val deserializer = {
new NegotiatedDeserializer[Order, ByteString] {
override def deserialize(bytes: ByteString) =
Order.parseFrom(bytes.iterator.asInputStream)
}
} override def serializerForRequest = serializer
override def deserializer(protocol: MessageProtocol) = deserializer
override def serializerForResponse(acceptedMessageProtocols: immutable.Seq[MessageProtocol]) = serializer
}

头部过滤器

在服务描述子中可以加入头部过滤器Header Filter,用于实现协商协议、身份验证或访问授权的沟通。过滤器会根据预设的条件,对服务与客户端双方通信的消息进行转换或修改。

下例是一个典型的过滤器实现。如果没有特意进行绑定,那么所有的服务默认都将会使用它,并使用ServicePrincipal来标识带有服务名称的客户端。在客户端,当Client发出Request时,过滤器将会在头部附加User-Agent,Lagom默认会自动将服务名称作为ServicePrinciple。而在服务端,则会读取Request中的User-Agent,并将其值设置为Request的Principle。

切记:头部过滤器仅用于通信双方的协商,以确定双方采取何种方式进行后续的通信,而不应用来执行实际的验证逻辑。验证逻辑属于业务逻辑的组成部分,应当放在服务API及其组合当中。

object UserAgentHeaderFilter extends HeaderFilter {
override def transformClientRequest(request: RequestHeader): RequestHeader = {
request.principal match {
case Some(principal: ServicePrincipal) =>
request.withHeader(HeaderNames.USER_AGENT, principal.serviceName)
case _ => request
}
} override def transformServerRequest(request: RequestHeader): RequestHeader = {
request.getHeader(HeaderNames.USER_AGENT) match {
case Some(userAgent) =>
request.withPrincipal(ServicePrincipal.forServiceNamed(userAgent))
case _ =>
request
}
} override def transformServerResponse(response: ResponseHeader,request: RequestHeader): ResponseHeader = response override def transformClientResponse(response: ResponseHeader, request: RequestHeader): ResponseHeader = response
}

头部过滤器的组合

类似服务使用compose进行组合,头部过滤器也可以使用HeaderFilter.composite方法进行组合。

注意:过滤器在发送消息与接收消息时适用的顺序是刚好相反的。对于Request,越后加入的过滤器越新鲜就越早被运用。

class VerboseFilter(name: String) extends HeaderFilter {
private val log = LoggerFactory.getLogger(getClass) def transformClientRequest(request: RequestHeader) = {
log.debug(name + " - transforming Client Request")
request
} def transformServerRequest(request: RequestHeader) = {
log.debug(name + " - transforming Server Request")
request
} def transformServerResponse(response: ResponseHeader, request: RequestHeader) = {
log.debug(name + " - transforming Server Response")
response
} def transformClientResponse(response: ResponseHeader, request: RequestHeader) = {
log.debug(name + " - transforming Client Response")
response
}
} /* ----- 按下列顺序组合过滤器 ----- */ def descriptor = {
import Service._
named("hello")
.withCalls(
call(sayHello)
)
.withHeaderFilter(
HeaderFilter.composite(
new VerboseFilter("Foo"),
new VerboseFilter("Bar")
)
)
}

在服务端,控制台得到的输出将是如下的顺序:

[debug] Bar - transforming Server Request
[debug] Foo - transforming Server Request
[debug] Foo - transforming Server Response
[debug] Bar - transforming Server Response

错误的处理

Lagom在设计错误处理机制时,遵循了以下一些原则:

  • 在生产环境下,一个Lagom服务将永远不会将错误的细节暴露给另一个服务,除非知道这样做是肯定安全的。否则,未经审查和过滤的错误信息将有可能因为暴露了服务的内部细节,而被人利用。
  • 在开发环境下,则要尽可能地将异常的细节暴露出来,方便发现和定位Bug。
  • 如果可能,通常在客户端会复刻服务端触发的异常,以直接反馈服务发生失败的原因。
  • 如果可能,通常会将异常映射到HTTP的4字头或5字头的协议响应代码,或者是WebSocket的错误关闭代码。
  • 在断路器中实现HTTP响应代码映射,通常被视为最佳实践。

异常的序列化

Lagom为异常提供了trait ExceptionSerializer,用于将异常信息序列化为JSON等序列化格式,或者是特定的错误编码或响应代码。ExceptionSerializer会将异常转换为RawExceptionMessage,其中包括对应HTTP响应代码或WebSocket关闭代码的状态码、消息主体,以及一个关于协议的描述子(在HTTP,此处对应响应头部信息中的Content Type)。

默认的ExceptionSerializer使用Play JSON将异常信息序列化为JSON格式。除非是在开发模式下,否则它只会返回TransportException派生异常类的详细信息,最常见的派生类包括NotFoundPolicyViolation。Lagom通常也允许Client抛出这一类的异常,也允许自己创建或实现一个TransportException的派生类实例,不过前提是Client得认识这个异常并且知道如何进行反序列化。

额外的路由

从Lagom 1.5.0版本开始,允许使用Play Router对Lagom的服务进行扩展。该功能在需要将Lagom服务与既存的Play Router进行集成时显得更为实用。

在Lagom做DI时,可以注入额外的Router:

override lazy val lagomServer = serverFor[HelloService](wire[HelloServiceImpl])
.additionalRouter(wire[SomePlayRouter])

一个关于文件上传的范例

此例基于ScalaSirdRouter实现,它将为服务添加一个/api/files的路径,用于接收支持断点续传数据的POST请求。

import play.api.mvc.DefaultActionBuilder
import play.api.mvc.PlayBodyParsers
import play.api.mvc.Results
import play.api.routing.Router
import play.api.routing.sird._ class FileUploadRouter(action: DefaultActionBuilder, parser: PlayBodyParsers) {
val router = Router.from {
case POST(p"/api/files") =>
action(parser.multipartFormData) { request =>
val filePaths = request.body.files.map(_.ref.getAbsolutePath)
Results.Ok(filePaths.mkString("Uploaded[", ", ", "]"))
}
}
} override lazy val lagomServer =
serverFor[HelloService](wire[HelloServiceImpl])
.additionalRouter(wire[FileUploadRouter].router)

在控制台使用命令curl -X POST -F "data=@somefile.txt" -v http://localhost:65499/api/files,即可发出上传请求。

与服务网关有关的注意事项

由于额外的路由并没有在服务描述子中定义,因此当服务使用了服务网关时,这些外部的路由将不会自动被服务网关暴露,因此需要显式地将它的路径加入访问控制列表ACL(Access Control List)里,然后通过服务网关访问它们。

trait HelloService extends Service {
def hello(id: String): ServiceCall[NotUsed, String] final override def descriptor = {
import Service._
named("hello")
.withCalls(
pathCall("/api/hello/:id", hello _).withAutoAcl(true)
)
.withAcls(
// extra ACL to expose additional router endpoint on ServiceGateway
ServiceAcl(pathRegex = Some("/api/files"))
)
}
}

然后在控制台使用命令curl -X POST -F "data=@somefile.txt" -v http://localhost:9000/api/files访问它们。

与Lagom客户端有关的注意事项

由于额外的路由不是服务API的一部分,所以无法从Lagom生成的客户端进行直接的访问,而只能改用Play-WS之类的客户端去访问其暴露的HTTP端点。

编写可持久化和集群化的服务

这部分将使用Akka Typed按照DDD的方法实现一个CQRS架构的Lagom服务。

在这个ShoppingCart的示例里( 完整代码),使用了Dock作为容器,包装了Zookeeper、Kafka和PostGres服务,所以在演示前需要用docker-compose up -d进行初始化。同时,因为Lagom将使用Read-Side ProcessorTopic Producer对AggregateEventTag标记的Event进行消费,所以需要用AkkaTaggerAdapter.fromLagom把AggregateEventTag转换为Akka能理解的Tag类型。而在读端,Lagom提供的ReadSideProcessor,在Cassandra和Relational数据库插件支持下,可以为实现CQRS的读端提供完整的支持。

使用JDBC驱动数据库存储Journal时,分片标记数不能超过10。这是该插件已知的一个Bug,如果超过了10,将会导致某些事件被多次传递。

1. 采用类似State Pattern的方式,把State与Handler设计在一起

/* ----- State & Handlers ----- */
final case class ShoppingCart(
items: Map[String, Int],
// checkedOutTime defines if cart was checked-out or not:
// case None, cart is open
// case Some, cart is checked-out
checkedOutTime: Option[Instant] = None){
/* ----- Command Handlers ----- */
def applyCommand(cmd: ShoppingCartCommand): ReplyEffect[ShoppingCartEvent, ShoppingCart] =
if (isOpen) {
cmd match {
case AddItem(itemId, quantity, replyTo) => onAddItem(itemId, quantity, replyTo)
case Checkout(replyTo) => onCheckout(replyTo)
case Get(replyTo) => onGet(replyTo)
}
} else {
cmd match {
case AddItem(_, _, replyTo) => Effect.reply(replyTo)(Rejected("Cannot add an item to a checked-out cart"))
case Checkout(replyTo) => Effect.reply(replyTo)(Rejected("Cannot checkout a checked-out cart"))
case Get(replyTo) => onGet(replyTo)
}
} private def onAddItem(itemId: String, quantity: Int, replyTo: ActorRef[Confirmation]): ReplyEffect[ShoppingCartEvent, ShoppingCart] = {
if (items.contains(itemId))
Effect.reply(replyTo)(Rejected(s"Item '$itemId' was already added to this shopping cart"))
else if (quantity <= 0)
Effect.reply(replyTo)(Rejected("Quantity must be greater than zero"))
else
Effect
.persist(ItemAdded(itemId, quantity))
.thenReply(replyTo)(updatedCart => Accepted(toSummary(updatedCart)))
} private def onCheckout(replyTo: ActorRef[Confirmation]): ReplyEffect[ShoppingCartEvent, ShoppingCart] = {
if (items.isEmpty)
Effect.reply(replyTo)(Rejected("Cannot checkout an empty shopping cart"))
else
Effect
.persist(CartCheckedOut(Instant.now()))
.thenReply(replyTo)(updatedCart => Accepted(toSummary(updatedCart)))
} private def onGet(replyTo: ActorRef[Summary]): ReplyEffect[ShoppingCartEvent, ShoppingCart] = {
Effect.reply(replyTo)(toSummary(shoppingCart = this))
} private def toSummary(shoppingCart: ShoppingCart): Summary = {
Summary(shoppingCart.items, shoppingCart.checkedOut)
} /* ----- Event Handlers ----- */
def applyEvent(evt: ShoppingCartEvent): ShoppingCart =
evt match {
case ItemAdded(itemId, quantity) => onItemAdded(itemId, quantity)
case CartCheckedOut(checkedOutTime) => onCartCheckedOut(checkedOutTime)
} private def onItemAdded(itemId: String, quantity: Int): ShoppingCart =
copy(items = items + (itemId -> quantity)) private def onCartCheckedOut(checkedOutTime: Instant): ShoppingCart = {
copy(checkedOutTime = Option(checkedOutTime))
}
}

2. 将Protocol与Factory放在Companion Object里

Lagom借用Akka Cluster Sharding实现了服务的集群部署,确保在任意时刻,有且只有一个聚合的实例在集群内活动,相同类型的多个聚合实例则均匀分布在各个节点之上。为此,需要给ShoppingCart设定一个EntityKey,并向其工厂方法传入EntityContext。

在Command-Reply-Event的设计上,要注意区别Reply是回复调用者的副作用,它可以是确认Command是否成功执行,或者是返回聚合的内部状态(要注意区别查询命令与Read-Side)。而Event才是因Command而导致聚合发生变化的正作用,是要持久化的。相应的,Effect.Reply()用于聚合未发生变化的场合,而Effect.persist().thenReply()则用于聚合发生变化之后。

/* ----- Factory & Protocols ----- */
object ShoppingCart {
val empty = ShoppingCart(items = Map.empty)
val typeKey: EntityTypeKey[ShoppingCartCommand] = EntityTypeKey[ShoppingCartCommand]("ShoppingCart") /* ----- Commands ----- */
trait CommandSerializable sealed trait ShoppingCartCommand extends CommandSerializable final case class AddItem(itemId: String, quantity: Int, replyTo: ActorRef[Confirmation])
extends ShoppingCartCommand final case class Checkout(replyTo: ActorRef[Confirmation]) extends ShoppingCartCommand final case class Get(replyTo: ActorRef[Summary]) extends ShoppingCartCommand /* ----- Replies (will not be persisted) ----- */
sealed trait Confirmation final case class Accepted(summary: Summary) extends Confirmation final case class Rejected(reason: String) extends Confirmation final case class Summary(items: Map[String, Int], checkedOut: Boolean) /* ----- Events (will be persisted) ----- */
sealed trait ShoppingCartEvent extends AggregateEvent[ShoppingCartEvent] {
override def aggregateTag: AggregateEventTagger[ShoppingCartEvent] = ShoppingCartEvent.Tag
} final case class ItemAdded(itemId: String, quantity: Int) extends ShoppingCartEvent final case class CartCheckedOut(eventTime: Instant) extends ShoppingCartEvent /* ----- Tag for read-side consuming ----- */
object ShoppingCartEvent {
// will produce tags with shard numbers from 0 to 9
val Tag: AggregateEventShards[ShoppingCartEvent] =
AggregateEventTag.sharded[ShoppingCartEvent](numShards = 10)
} def apply(entityContext: EntityContext[ShoppingCartCommand]): Behavior[ShoppingCartCommand] = {
EventSourcedBehavior
.withEnforcedReplies[ShoppingCartCommand, ShoppingCartEvent, ShoppingCart](
persistenceId = PersistenceId(entityContext.entityTypeKey.name, entityContext.entityId),
emptyState = ShoppingCart.empty,
commandHandler = (cart, cmd) => cart.applyCommand(cmd),
eventHandler = (cart, evt) => cart.applyEvent(evt)
)
// convert tag of Lagom to tag of Akka
.withTagger(AkkaTaggerAdapter.fromLagom(entityContext, ShoppingCartEvent.Tag))
// snapshot every 100 events and keep at most 2 snapshots on db
.withRetention(RetentionCriteria.snapshotEvery(numberOfEvents = 100, keepNSnapshots = 2))
}
}

3. 实现Lagom用于启动服务的ApplicationLoader,集成形成完整应用

class ShoppingCartLoader extends LagomApplicationLoader {
override def load(context: LagomApplicationContext): LagomApplication =
new ShoppingCartApplication(context) with AkkaDiscoveryComponents override def loadDevMode(context: LagomApplicationContext): LagomApplication =
new ShoppingCartApplication(context) with LagomDevModeComponents override def describeService = Some(readDescriptor[ShoppingCartService])
} trait ShoppingCartComponents
extends LagomServerComponents
with SlickPersistenceComponents
with HikariCPComponents
with AhcWSComponents {
implicit def executionContext: ExecutionContext override lazy val lagomServer: LagomServer =
serverFor[ShoppingCartService](wire[ShoppingCartServiceImpl])
override lazy val jsonSerializerRegistry: JsonSerializerRegistry =
ShoppingCartSerializerRegistry // Initialize the sharding for the ShoppingCart aggregate.
// See https://doc.akka.io/docs/akka/2.6/typed/cluster-sharding.html
clusterSharding.init(
Entity(ShoppingCart.typeKey) { entityContext =>
ShoppingCart(entityContext)
}
)
} abstract class ShoppingCartApplication(context: LagomApplicationContext)
extends LagomApplication(context)
with ShoppingCartComponents
with LagomKafkaComponents {}

4. 在服务的实现中在服务的实现中使用Ask模式访问Actor

服务API的相关操作实际将由幕后的Actor负责实现,因此在服务的实现里需要访问Actor的实例,为此需要通过ClusterSharding.entityRefFor获取其EntityRef。

class ShoppingCartServiceImpl(
clusterSharding: ClusterSharding,
persistentEntityRegistry: PersistentEntityRegistry
)(implicit ec: ExecutionContext) extends ShoppingCartService {
def entityRef(id: String): EntityRef[ShoppingCartCommand] =
clusterSharding.entityRefFor(ShoppingCart.typeKey, id)
}

在获得Reference后,便可使用Ask模式与Actor进行交互。Ask返回的是Future[Response],因此示例将Future[Summary]投射为ShoppingCartView,方便Read-Side使用。

implicit val timeout = Timeout(5.seconds)

override def get(id: String): ServiceCall[NotUsed, ShoppingCartView] = ServiceCall { _ =>
entityRef(id)
.ask(reply => Get(reply))
.map(cartSummary => asShoppingCartView(id, cartSummary))
} final case class ShoppingCartItem(itemId: String, quantity: Int)
final case class ShoppingCartView(id: String, items: Seq[ShoppingCartItem], checkedOut: Boolean) private def asShoppingCartView(id: String, cartSummary: Summary): ShoppingCartView = {
ShoppingCartView(
id,
cartSummary.items.map((ShoppingCartItem.apply _).tupled).toSeq,
cartSummary.checkedOut
)
}

5. 其他一些需要考虑的细节

  • 分片数与节点数的协调:分片数少于节点数,某些节点将无所事事;分片数多于节点数,节点的工作量需要细心平衡。
  • 实体钝化:让所有的聚合实例都始终活在内存里并不是高效的办法。对较长时间内没有活动的聚合,可以使用实体钝化功能将其暂时从分片节点上移除,需要它时再重新唤醒并加载即可。
  • 数据的序列化:选择JSON、二进制串等格式进行序列化时,需要注意的是消息中可能包含ActorRef这样的字段,所以这部分内容要参考Akka指南。

迁移到Akka Persistence Typed

这部分内容是对比上一节直接使用Akka Persistence Typed建立领域模型的方式,改从传统的Lagom Persistence迁移到Akka Persistence Typed的角度进行了详细的分步讲解。所以,如果是全新开始设计的Lagom的服务,建议直接使用Akka Persistence Typed进行实现,只有此前用Lagom Persistence实现的服务才需要考虑迁移。

由于内容主要涉及Akka Typed,可参考我的博客内容:

选择合适的数据库平台

Lagom与下列数据库平台兼容:

  • Cassandra
  • PostgreSQL
  • MySQL
  • Oracle
  • H2
  • Microsoft SQL Server
  • Couchbase

参考链接:

Cassandra

Cassandra需要至少3个KeySpace:

  • Journal:存储Event:cassandra-journal.keyspace = my_service_journal
  • Snapshot:存储快照:cassandra-snapshot-store.keyspace = my_service_snapshot
  • Offset:存储Read-Side侧最近处理的Event:lagom.persistence.read-side.cassandra.keyspace = my_service_read_side

这只是Lagom官方文档的一小部分内容,算是对如何使用该框架实现服务的初窥,有兴趣的请移步官方网站寻找更多的内容。

Lagom 官方文档之随手记的更多相关文章

  1. Akka Typed 官方文档之随手记

    ️ 引言 近两年,一直在折腾用FP与OO共存的编程语言Scala,采取以函数式编程为主的方式,结合TDD和BDD的手段,采用Domain Driven Design的方法学,去构造DDDD应用(Dom ...

  2. 【AutoMapper官方文档】DTO与Domin Model相互转换(上)

    写在前面 AutoMapper目录: [AutoMapper官方文档]DTO与Domin Model相互转换(上) [AutoMapper官方文档]DTO与Domin Model相互转换(中) [Au ...

  3. 2DToolkit官方文档中文版打地鼠教程(三):Sprite Collections 精灵集合

    这是2DToolkit官方文档中 Whack a Mole 打地鼠教程的译文,为了减少文中过多重复操作的翻译,以及一些无必要的句子,这里我假设你有Unity的基础知识(例如了解如何新建Sprite等) ...

  4. 2DToolkit官方文档中文版打地鼠教程(二):设置摄像机

    这是2DToolkit官方文档中 Whack a Mole 打地鼠教程的译文,为了减少文中过多重复操作的翻译,以及一些无必要的句子,这里我假设你有Unity的基础知识(例如了解如何新建Sprite等) ...

  5. 2DToolkit官方文档中文版打地鼠教程(一):初始设置

    这是2DToolkit官方文档中 Whack a Mole 打地鼠教程的译文,为了减少文中过多重复操作的翻译,以及一些无必要的句子,这里我假设你有Unity的基础知识(例如了解如何新建Sprite等) ...

  6. 【AutoMapper官方文档】DTO与Domin Model相互转换(中)

    写在前面 AutoMapper目录: [AutoMapper官方文档]DTO与Domin Model相互转换(上) [AutoMapper官方文档]DTO与Domin Model相互转换(中) [Au ...

  7. 【AutoMapper官方文档】DTO与Domin Model相互转换(下)

    写在前面 AutoMapper目录: [AutoMapper官方文档]DTO与Domin Model相互转换(上) [AutoMapper官方文档]DTO与Domin Model相互转换(中) [Au ...

  8. Ionic2系列——Ionic 2 Guide 官方文档中文版

    最近一直没更新博客,业余时间都在翻译Ionic2的文档.之前本来是想写一个入门,后来觉得干脆把官方文档翻译一下算了,因为官方文档就是最好的入门教程.后来越翻译越觉得这个事情确实比较费精力,不知道什么时 ...

  9. Kotlin开发语言文档(官方文档)-- 目录

    开始阅读Kotlin官方文档.先上文档目录.有些内容还未阅读,有些目录标目翻译还需琢磨琢磨.后续再将具体内容的链接逐步加上. 文档链接:https://kotlinlang.org/docs/kotl ...

随机推荐

  1. AcWing 380. 舞动的夜晚

    大型补档计划 题目链接 这题是求必须边,而不是不可行边,因为不可行边 = 必须边 + 死掉了的边(貌似lyd第三版书上还是说的不可行边)先跑最大流. 在跑完以后的残余网络上,对于一条当前匹配的边 \( ...

  2. Python+Selenium基本语法

    对Selenium自动化已有了解,最近开始做h5端的自动化,所以总结了下Python+Selenium自动化基本语法 一.启动浏览器 1.普通方式启动 #coding=utf-8 import tim ...

  3. KafkaMirrorMaker 的不足以及一些改进

    背景 某系统使用 Kafka 存储实时的行情数据,为了保证数据的实时性,需要在多地机房维护多个 Kafka 集群,并将行情数据同步到这些集群上. 一个常用的方案就是官方提供的 KafkaMirrorM ...

  4. C#数据结构-线索化二叉树

    为什么线索化二叉树? 对于二叉树的遍历,我们知道每个节点的前驱与后继,但是这是建立在遍历的基础上,否则我们只知道后续的左右子树.现在我们充分利用二叉树左右子树的空节点,分别指向当前节点的前驱.后继,便 ...

  5. [日常摸鱼]bzoj1257余数之和

    题意:输入$k,n$,求$\sum_{i=1}^n k \mod i$ $k \mod i=k-i*\lfloor \frac{k}{i} \rfloor $,$n$个$k$直接求和,后面那个东西像比 ...

  6. [日常摸鱼]JSOI2008最大数

    校运会的时候随手抽的题- 一句话题意 维护一个序列,初始为空,要求滋兹: 1.查询这个序列末尾$x$个数的最大值 2.设上一次查询的答案为$t$(如果还没查询$t=0$),在末尾插入一个数$(x+t) ...

  7. 在IDEA中使用JDBC获取数据库连接时的报错及解决办法

    在IDEA中使用JDBC获取数据库连接时,有时会报错Sat Dec 19 19:32:18 CST 2020 WARN: Establishing SSL connection without ser ...

  8. Java_day_01

    一.方法的定义 方法的定义在Java中可以使用多种方式,如果在定义的方法名前面加上 public static 关键字,即可直接在主方法(main)中调用 public class Method{ p ...

  9. VSCode---REST Client接口测试辅助工具

    我们一般都会用 PostMan 来完成接口测试的工作,因为用起来十分简单快捷,但是一直以来我也在寻找更好的方案,一个不用切换窗口多开一个 app 的方案 -- 终于在使用 VSCode 一段时版本间, ...

  10. kvm环境部署及常用指令

    Linux下通过kvm创建虚拟机,通过vnc连接,做好配置后,通过ssh登录,并开启iptables Kvm虚拟化搭建教程参考链接:https://jingyan.baidu.com/article/ ...