Akka 配置,日志与部署

459 阅读6分钟

在本章之前,我们的主要目的是学习如何创建并实用 Actor 系统。为了创建一个可以实际运行的系统,还有几件事情要做。首先,详细介绍 Akka 的配置;然后介绍日志,包括自己的日志框架。同时,继 Assembly 和 multi-jvm 插件之后,我们最后还会引入一个新的插件用于快速部署项目到本地环境。

Typesafe 库的配置

有关于 Typesafe 库的详细信息可以参见:GitHub - lightbend/config: configuration library for JVM languages using HOCON files

和其它的 Akka 库一样,Typesafe 库耗费了大量的精力以减少需要的依赖。除了 Akka 项目之外,它还可以被单独引入到任何需要读取配置的项目当中。

"com.typesafe" % "config" % "1.3.3"

调用 ConfigFactory 的加载方法就可以获取项目resources/ 下的配置文件,就像这样:

// 默认读取 application 
val conf = ConfigFactory.load()

下面介绍更多的细节。调用不带任何参数的 load() 方法时,配置库按照以下顺序查找配置:

  1. application.properties:以经典的 Java 配置的格式保存配置属性。
  2. application.json:以 JSON 数据格式保存配置属性。
  3. application.conf:以 HOCON 格式配置属性。

HOCON 格式看起来和 JSON 有点像,不过它的优点是能够梳理出明确的路径层次结构。比如:

http.port=8000
http.server=netty

# 上述两项配置可以整合为一个
http {
  port=8000
  server=netty
}

对于简单的应用,通常情况下定义一份配置文件就足够了。获取配置之后,不同类型的值有不同的获取方法,使用 . 做属性路径的分隔符,如下所示。

// application.{conf,json,properties}
val conf =ConfigFactory.load()
val port = conf.getInt("http.port")
val server = conf.getString("http.server")
println(s"port : ${port}, server : ${server}")

我们注意到所有的配置均来自于路径 http。可以通过获取配置子树的方式复用配置路径,比如:

// application.{conf,json,properties}
val conf =ConfigFactory.load()
// 重用配置路径
val httpConf = conf.getConfig("http")
val port = httpConf.getInt("port")
val server = httpConf.getString("server")
println(s"port : ${port}, server : ${server}")

如果一个属性在多处被复用,还可以通过 ${} 进行替代。比如:

hostname="localhost"
connect="jdbc:mysql://"${hostname}"/mydata"

注意,${hostname}前后的字符串均使用了引号。

当配置文件存在重复的配置项时,ConfigFactory 将以最后一行为准。比如在下面的配置项中,读取到的 http.port 信息将是 8000 而非 7888。之前声明的配置项会被覆盖

http.port=7888
http.port=8000

使用默认配置

通常情况下,开发者的测试环境和部署环境需要不同的配置。最偷懒的解决方案是,复制一份配置,然后基于这个配置上做一些少许改动。这样做的问题是:这两份文件中的大部分内容都是一样的。当多份配置文件的公共属性发生变化时,我们要手动去所有的配置文件中都作一次更改。

假设有一个默认的配置文件能够统一管理所有基本的配置,而我们期望在不同场合下将少部分需要改动的属性单独覆盖掉就好了。Typesafe 库提供了这样的默认机制:在项目的 resources/ 目录下配置一份 reference.conf 文件,ConfigFactory 会将其内部的所有属性整合到配置的后备结构中。

这样,子系统的配置只需要单独列出自己需要覆盖的属性即可,其它则以 reference.conf 的配置为准。如图所示:

typesafe_conf.png

在调用无参数的 load() 方法时,ConfigFactory 默认读取 application.{conf, json, properties} 文件。如果要读取其它名称的文件,比如:myapp.conf,则可以在调用 load() 方法时传入主文件名 ( 不包含拓展名 ):

val conf =ConfigFactory.load("myapp")

引入配置

除了前文介绍的利用 reference.conf 来 "继承" 配置之外,Akka 还允许通过引入其它配置文件来进行 "组合"。见:

# baseConfig.conf
MyApp {
  version = 10
  description = "my application"
}

假定 baseConfig.conf 是所有子系统的基本配置,那么在子系统的配置文件中可以使用 include 简单地将这份公共配置引入进来。如:

# subConfig.conf
include "baseConfig"
MyApp {
  description = "sub application"
}

由于我们先引入了 baseConfig.conf,因此 description 的值将是当前配置文件声明的 "sub application"

val conf = ConfigFactory.load("subConfig").getConfig("MyApp")
val ver =conf.getInt("version")
val description = conf.getString("description")
println(s"ver : ${ver}, description = ${description}")

此外,还有一种配置提升 ( lifting a configuration ) 的办法。首先将默认配置和子系统的追加配置仿照下文的结构组织起来,将它们写在同一份配置文件下。如:

# combined.conf
baseApp {
  version=10
  description="my app"
}

SubApp {
  baseApp {
    description="sub app"
  }
}

对于子系统,我们希望默认情况下首先从 SubApp.baseApp 块中读取配置,同时将配置文件中顶级声明的baseApp 块设置为后备配置。说得再直白一些,就是将 SubApp 配置子树拿出来,放到配置链的更高级别,配置提升因此得名。参考:

val conf = ConfigFactory.load("combined")
val subConf = conf.getConfig("SubApp") // 提取子系统配置路径
			  .withFallback(conf)     // 将源文件的低层次路径配置设置为后备选项
			  .getConfig("baseApp")   // 获取 baseApp 路径

val ver = subConf.getInt("version")
val description =subConf.getString("description")

// ver : 10, description = sub app
println(s"ver : ${ver}, description = ${description}")

至于 Akka 是如何使用这个配置库的?如果创建 ActorSystem 时没有提供配置文件,那么 Actor 系统使用内部的默认值创建配置。如:

val system = ActorSystem("mySystem")

在更一般的情况下,我们在创建 Actor 系统时要主动传入配置。比如:

val conf = ConfigFactory.load("myAppl")
val system = ActorSystem("mySystem",conf)

日志系统

Akka 框架的日志模块和 Actor 系统是分离的,因此开发者可以自行引入外部的库实现日志记录。

首先,如果一个 Actor 需要记录日志消息,则它需要先创建一个 LoggingAdapter 日志适配器的实例。比如:

class MyActor extends Actor {
  val log: LoggingAdapter = Logging(context.system,this)
  log.info("the actor has been created.")
  override def receive: Receive = echo
  def echo : Receive = {case x => sender() ! x}
}

其中 Logging 构造函数的第一个参数传入 ActorSystem 的上下文,而第二个参数 this 表示日志的消息源是该 Actor 实例自身。随后,Actor 通过调用适配器实例 log 的各种方法 声明 记录日志消息。

不过,在一般情况下只需要混入 ActorLogging 特质就够用了,它将自带一个名为 log 的日志适配器实例:

class MyActor extends Actor with ActorLogging {
  log.info("the actor has been created.")
  override def receive: Receive = echo
  def echo : Receive = {case x => sender() ! x}
}

适配器支持在创建消息时使用 {} 做占位符:

log.info("two params {},{}","one","two")

另一方面,我们知道记录日志需要 IO 操作,而这通常来说是很慢的。因此,考虑到性能问题,MyActor 不会亲自执行日志操作,而是将其转发给系统内的 EventHandler ( 它也是 Actor ) 专门处理,类似于订阅 —— 发布机制。EventHandler 接收外部所有其它 Actor 发来的日志记录请求,并自行决定如何记录,比如引入第三方框架来完成。这样,系统内任何一个混入 ActorLogging 特质的 Actor 都可以声明记录日志,但是只会有一个 Actor 专门负责日志写入。

在默认情况下,ActorSystem 选择标准输出流 STDOUT ( 即控制台 ) 记录日志。Akka 还提供了另外一种 SLF4J 去实现。首先添加它的依赖包:

"com.typesafe.akka" %% "akka-slf4j" % akkaVersion

同时向配置文件中添加以下配置:

akka {
  loglevel = DEBUG
  loggers = ["akka.event.slf4j.Slf4jLogger"]
}

Slf4jLogger 类本身就是一个继承于 Actor 的,预设好的 EventHandler。我们也可以给出自己的实现:

class MyEventHandler extends Actor {
  override def receive: Receive = {
    case InitializeLogger(_) => sender() ! LoggerInitialized
    case Error(ex, logSource, logClass, message) =>
      print("ERROR" + message)
    case Warning(logSource, logClass, message) =>
      print("WARNING" + message)
    case Info(logSource, logClass, message) =>
      print("INFO" + message)
    case Debug(logSource, logClass, message) =>
      print("DEBUG" + message)
  }
}

日志输出由低到高一共有五种级别:OFFERRORWARNINGINFODEBUG。举个例子,当日志级别设置为 WARNING 时,更高的 INFODEBUG 级别的日志消息就会被忽略掉。

在 ActorSystem 系统刚刚启动时,它会优先创建 EventHandler 用于日志记录,并等待它初始化完毕后回传 LoggerInitialized。如果没有及时收到这条消息,那么 ActorSystem 会停止启动。logClass 记录了产生日志的 Actor 类型,而 logSource 记录了 Actor 在系统内的路径。

同时,在配置文件中替换为自己的 EventHandler 类型:

akka {
  loggers=["priv.gotickets.MyEventHandler"]
  loglevel = DEBUG
}

在开发应用程序时,可能还需要低层次的 调试日志。Akka 可以在内部事件发生时对特定的消息进行记录,并提供了一个简单的配置层来控制向日志中输出什么内容,见 akka.actor.debug 路径下的配置。

akka {
  # 控制日志输出的级别
  loglevel = DEBUG
  loggers=["priv.gotickets.MyEventHandler"]
  
  # 当使用 STDOUT 时,日志输出的级别。options : OFF,ERROR,WARNING,INFO,DEBUG
  stdout-loglevel = DEBUG
  
  # 当 ActorSystem 启动时,将读取的配置信息以 INFO 级别输出到日志中。options : on, off
  log-config-on-start = off
  
  actor.debug {
    # 允许记录用户定义的消息。
    receive=on
    # 允许记录 ActorSystem 内部系统的消息,比如 PoisonPill, Kill 等消息
    autoreceive=on
    # 允许记录生命周期变化的信息。
    lifecycle=on
  }
}

当相关的选项被开启时,Actor 会将对应的消息以 DEBUG 级别额外记录到日志。注意,此时如果 loglevel 的级别低于 DEBUG,则这些消息会被忽略掉。除此之外,被调试的 Actor 还需要创建日志适配器 ( 简单混入 ActorLogging 接口就可以了 ),然后使用 LoggingReceive 类型的偏函数接收消息:

class MyActor extends Actor with ActorLogging {
  log.info("the actor has been created.")
  override def receive: Receive = echo
  def echo: Receive = LoggingReceive {
    // 除了回传消息之外,它还会自动向 log 写入接收到的消息。
    case x => sender() ! x
  }
}

部署

插件官网,见:Introduction — sbt-native-packager 1.9.0 documentation (scala-sbt.org)

想要创建一个独立的应用程序,我们可以使用 sbt-native-packager 插件创建一个本地发布。

// 适用于 Scala 2.12 版本
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.7.6")

同时,在 build.sbt 中开启此插件:

enablePlugins(JavaAppPackaging)

随后进入到 sbt shell 内部,输入 stage,插件便会将项目发布到项目的 target/universal/stage 目录下。这包含了两个目录:

  1. bin —— 包含了项目所有主程序入口的启动脚本,一个用于 Windows 系统,一个用于 UNIX 系统。
  2. lib —— 包含了应用依赖的所有 jar 包文件。

由于我们在 IDE 中指定了 *.conf 配置文件存放在 src/main/resources 文件夹下,这些配置在编译时会随着二进制类文件一同被打包进 jar 包目录内。可以将 stage 目录整体移动到磁盘的其它地方,然后通过启动脚本直接启动。