引言

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. 利用flask框架实现对用户的注册登录

    ------------------------------------(分割线)----------------------------------------------------------- ...

  2. vue基础题

    一.对于MVVM的理解? MVVM 是 Model-View-ViewModel 的缩写. Model代表数据模型,也可以在Model中定义数据修改和操作的业务逻辑. View 代表UI 组件,它负责 ...

  3. 最简 Spring AOP 源码分析!

    前言 最近在研究 Spring 源码,Spring 最核心的功能就是 IOC 容器和 AOP.本文定位是以最简的方式,分析 Spring AOP 源码. 基本概念 上面的思维导图能够概括了 Sprin ...

  4. 【题解】「UVA681」Convex Hull Finding

    更改了一下程序的错误. Translation 找出凸包,然后逆时针输出每个点,测试数据中没有相邻的边是共线的.多测. Solution 首先推销一下作者的笔记 由此进入>>> ( ...

  5. VMware虚拟机下Centos8 设置静态IP地址

    缘起 我们在平时学习Redis.Nginx等分布式微服务的组件的时候,无法避免的需要用到Linux操作系统,而Linux操作系统的主机来源差不多就三种情况: 真实物理机 阿里云等云服务器 利用虚拟机 ...

  6. Oracle 迁移数据库到 mysql

    一. oracle导出.sql文件(Navicat Premiu  11.0.8  无法实现oracle到mysql的数据传输亲测有效) exp username/pass@数据局地址ip:1521/ ...

  7. Redis达到最大占用内存后的淘汰策略

    1. 查询Redis最大占用内存 # 查询最大占用内存 config get maxmemory # 为0时在64操作系统中不限制内存,在32位操作系统中最大为3GB 2. Redis设置最大占用内存 ...

  8. Swing01-概述

    1.Swing概述 Swing百分之百由Java本身实现,是一套轻量级组件(完全由Java实现的组件叫做轻量级套件,依赖于本地平台的套件称之为重量级套件).Swing不再依赖于平台的GUI,因此真正做 ...

  9. mysqldump 导出命令

    在命令行下mysql的数据导出有个很好用命令mysqldump,它的参数有一大把,可以这样查看: mysqldump 最常用的: mysqldump -uroot -pmysql databasefo ...

  10. 部署docker镜像仓库及高可用

      下载地址: https://github.com/goharbor/harbor/releases   安装harbor服务器: 安装harbor root@harbor-vm1:/usr/loc ...