distage-framework

Roles

Many software suites have more than one entrypoint. For example Scalac provides a REPL and a Compiler. A typical distributed application may provide both server and client code, as well as a host of utility applications for maintenance.

In distage such entrypoints are called “roles”. Role-based applications may contain roles of different types:

To use roles, add distage-framework library:

sbt
libraryDependencies += "io.7mind.izumi" %% "distage-framework" % "1.2.5"

To declare roles use RoleModuleDef#makeRole:

import distage.plugins.PluginDef
import izumi.distage.roles.model.definition.RoleModuleDef
import izumi.distage.roles.model.RoleDescriptor
import izumi.distage.roles.model.RoleTask
import izumi.fundamentals.platform.cli.model.raw.RawEntrypointParams
import logstage.LogIO
import zio.UIO

object AppPlugin extends PluginDef {
  include(roleModule)

  def roleModule = new RoleModuleDef {
    makeRole[ExampleRoleTask]
  }
}

class ExampleRoleTask(log: LogIO[UIO]) extends RoleTask[UIO] {
  override def start(roleParameters: RawEntrypointParams, freeArgs: Vector[String]): UIO[Unit] = {
    log.info(s"Running ${ExampleRoleTask.id}!")
  }
}

object ExampleRoleTask extends RoleDescriptor {
  override def id = "exampletask"
}

Create a Role Launcher, RoleAppMain, with the example role:

import distage.plugins.PluginConfig
import izumi.distage.roles.RoleAppMain
import zio.IO

object ExampleLauncher extends RoleAppMain.LauncherBIO[IO] {
  override def pluginConfig = {
    PluginConfig.const(
      // add the plugin with ExampleRoleTask
      AppPlugin
    )
  }
}

You can now specify roles to launch on the command-line:

./launcher :exampletask
ExampleLauncher.main(Array(":exampletask"))
I 2020-12-14T10:00:16.172           (RoleAppPlanner.scala:70)  …oleAppPlanner.Impl.makePlan [ 944:run-main-1          ] phase=late Planning finished. main ops=7, integration ops=0, shared ops=0, runtime ops=17
I 2020-12-14T10:00:16.253        (RoleAppEntrypoint.scala:99)  …AppEntrypoint.Impl.runTasks [ 944:run-main-1          ] phase=late Going to run: tasks=1
I 2020-12-14T10:00:16.259       (RoleAppEntrypoint.scala:104)  …R.I.runTasks.101.loggedTask [ 944:run-main-1          ] phase=late Task is about to start: task=repl.MdocSession$App0$ExampleRoleTask@76b7592c
I 2020-12-14T10:00:16.263           (distage-framework.md:43)  ….App0.ExampleRoleTask.start [ 944:run-main-1          ] Running id=exampletask!
I 2020-12-14T10:00:16.267       (RoleAppEntrypoint.scala:106)  ….R.I.r.1.loggedTask.104.105 [ 944:run-main-1          ] phase=late Task finished: task=repl.MdocSession$App0$ExampleRoleTask@76b7592c

Only the components required by the specified roles will be created, everything else will be pruned. (see: Dependency Pruning)

Further reading:

Inspecting components in REPL

Use .replLocator method of a RoleAppMain to obtain an object graph of the application inspectable in the REPL:

import izumi.functional.bio.UnsafeRun2

val runner = UnsafeRun2.createZIO()
// runner: UnsafeRun2.ZIORunner[Any] = izumi.functional.bio.UnsafeRun2$ZIORunner@14c61f09

val objects = runner.unsafeRun(ExampleLauncher.replLocator(":exampletask"))
// objects: izumi.distage.model.Locator = izumi.distage.LocatorDefaultImpl@24d8503e

// inspecting a component inside the application
val logger = objects.get[LogIO[UIO]]
// logger: LogIO[UIO] = logstage.LogIO$$anon$1@7787074

Bundled roles

BundledRolesModule contains two bundled roles:

  • Help - prints help message when launched ./launcher :help
  • ConfigWriter - writes reference config into files, split by roles (includes only parts of the config used by the application)

Use include to add it to your application:

import distage.plugins.PluginDef
import izumi.distage.roles.bundled.BundledRolesModule
import zio.Task

object RolesPlugin extends PluginDef {
  include(BundledRolesModule[Task](version = "1.0"))
}

Compile-time checks

To use compile-time checks, add distage-framework library:

sbt
libraryDependencies += "io.7mind.izumi" %% "distage-framework" % "1.2.5"

The easiest way to add full compile-time safety to your application is to add an object inheriting PlanCheck.Main in __ test scope__ of the same module where you define your Role Launcher

import izumi.distage.framework.PlanCheck
import com.example.myapp.MainLauncher

object WiringCheck extends PlanCheck.Main(MainLauncher)

This object will emit compile-time errors for any issues or omissions in your ModuleDefs. It will recompile itself as necessary to provide feedback during development.

By default, all possible roles and activations are checked efficiently.

You may exclude specific roles or activations from the check by passing a PlanCheckConfig case class to PlanCheck API. Most options in PlanCheckConfig can also be set using system properties, see DebugProperties.

Checking default config

By default PlanCheck will check parsing of config bindings (from distage-extension-config) using configs loaded with the same settings as the role launcher.

This allows you to ensure correctness of default configs during development without writing tests.

If you need to disable this check, set checkConfig option of PlanCheckConfig to false.

Using with distage-teskit

Use SpecWiring to spawn a test-suite that also triggers compile-time checks.

SpecWiring will check the passed application when compiled, then perform the check again at runtime when ran as a test.

import izumi.distage.framework.PlanCheckConfig
import izumi.distage.testkit.scalatest.SpecWiring
import com.example.myapp.MainLauncher

object WiringCheck extends SpecWiring(
  app = MainLauncher,
  cfg = PlanCheckConfig(checkConfig = false),
  checkAgainAtRuntime = false, // disable the runtime re-check
)

Checking distage-core apps

distage-framework’s Role-based applications are checkable out of the box, but applications assembled directly via distage-core’s distage.Injector APIs must implement the CheckableApp trait to provide all the data necessary for the checks. You may use CoreCheckableAppSimple implementation for applications definable by a single collection of modules.

Low-Level APIs

PlanCheckMaterializer, ForcedRecompilationToken and PlanCheck.runtime provide the low-level APIs behind distage compile-time checks, they can be used to wrap the capability in other APIs or implement similar functionality.

PlanVerifier hosts the multi-graph traversal doing the actual checking and can be invoked at runtime or in macro. It can also be invoked using Injector#assert and Injector#verify methods.

Scala 2.12

If you’re using Scala 2.12 and get compilation errors such as

[error]  type mismatch;
[error]  found   : String("mode:test")
[error]  required: izumi.fundamentals.platform.language.literals.LiteralString{type T = String("mode:test")} with izumi.fundamentals.platform.language.literals.LiteralString

Then you’ll have to refactor your instance of PlanCheck.Main (or similar) to make sure that PlanCheckConfig is defined in a separate val. You may do this by moving it from a constructor parameter to an early initializer.

Example:

object WiringTest extends PlanCheck.Main(
  MyApp,
  PlanCheckConfig(...)
)
// [error]

Fix:

object WiringTest extends {
  val config = PlanCheckConfig(... )
} with PlanCheck.Main(MyApp, config)

Note that this issue does not exist on Scala 2.13+, it is caused by a bug in Scala 2.12’s treatment of implicits in class parameter position.

Typesafe Config

distage-extension-config library allows parsing case classes and sealed traits from typesafe-config configuration files.

To use it, add the distage-extension-config library:

sbt
libraryDependencies += "io.7mind.izumi" %% "distage-extension-config" % "1.2.5"

Use helper functions in ConfigModuleDef, makeConfig and make[_].fromConfig to parse the bound AppConfig instance into case classes, sealed traits or literals:

import distage.{Id, Injector}
import distage.config.{AppConfig, ConfigModuleDef}
import com.typesafe.config.ConfigFactory

final case class Conf(
  name: String,
  age: Int,
)

final case class OtherConf(
  other: Boolean
)

final class ConfigPrinter(
  conf: Conf,
  otherConf: OtherConf @Id("other"),
) {
  def print() = {
    println(s"name: ${conf.name}, age: ${conf.age}, other: ${otherConf.other}")
  }
}

val module = new ConfigModuleDef {
  make[ConfigPrinter]

  // declare paths to parse
  makeConfig[Conf]("conf")
  makeConfig[OtherConf]("conf").named("other")

  // add config instance
  make[AppConfig].from(AppConfig.provided(
    ConfigFactory.parseString(
      """conf {
        |  name = "John"
        |  age = 33
        |  other = true
        |}""".stripMargin
    )
  ))
}
// module: AnyRef with ConfigModuleDef = 
// make[{type.izumi.distage.config.model.AppConfig}].from(call(ƒ:<function0>(): izumi.distage.config.model.AppConfig)) ((distage-framework.md:194))
// make[{type.repl.MdocSession::MdocApp4::Conf}].from(call(ƒ:izumi.distage.config.codec.AbstractDIConfigReader$$Lambda$25510/0x0000000804ce14b0@5acc0daf(izumi.distage.config.model.AppConfig): repl.MdocSession::MdocApp4::Conf)).tagged(Set(ConfTag(conf))) ((distage-framework.md:190))
// make[{type.repl.MdocSession::MdocApp4::ConfigPrinter}].from(call(π:Class(repl.MdocSession::MdocApp4::Conf, repl.MdocSession::MdocApp4::OtherConf): repl.MdocSession::MdocApp4::ConfigPrinter)) ((distage-framework.md:187))
// make[{type.repl.MdocSession::MdocApp4::OtherConf@other}].from(call(ƒ:izumi.distage.config.codec.AbstractDIConfigReader$$Lambda$25510/0x0000000804ce14b0@2bac1f90(izumi.distage.config.model.AppConfig): repl.MdocSession::MdocApp4::OtherConf)).tagged(Set(ConfTag(conf))) ((distage-framework.md:191))

Injector().produceRun(module) {
  configPrinter: ConfigPrinter =>
    configPrinter.print()
}
// name: John, age: 33, other: true
// res5: izumi.fundamentals.platform.functional.package.Identity[Unit] = ()

Automatic derivation of config codecs is based on pureconfig-magnolia.

When a given type already has custom pureconfig.ConfigReader instances in implicit scope, they will be used, otherwise they will be derived automatically.

You don’t have to explicitly make[AppConfig] in distage-testkit’s tests and in distage-framework’s Roles, unless you want to override default behavior.

By default, tests and roles will try to read the configurations from resources with the following names, in order:

  • ${roleName}.conf
  • ${roleName}-reference.conf
  • ${roleName}-reference-dev.conf
  • application.conf
  • application-reference.conf
  • application-reference-dev.conf
  • common.conf
  • common-reference.conf
  • common-reference-dev.conf

Where distage-testkit uses `TestConfig#configBaseName` instead of roleName.

Explicit config files passed to the role launcher -c file.conf the command-line flag have a higher priority than resource configs.

Role-specific configs on the command-line, passed via -c file.conf option after a :roleName argument, in turn have a higher priority than the explicit config files passed before the first :role argument.

Example:

  ./launcher -c global.conf :role1 -c role1.conf :role2 -c role2.conf

Here configs will be loaded in the following order, with higher priority earlier and earlier configs overriding the values in later configs:

  • explicits: role1.conf, role2.conf, global.conf,
  • resources: role1[-reference,-dev].conf, role2[-reference,-dev].conf, ,application[-reference,-dev].conf, common[-reference,-dev].conf

Plugins

distage-extension-plugins module adds classpath discovery for modules that inherit a marker trait PluginBase.

Plugins reduce friction in adding new components, a programmer needs only to define new plugins and does not also have to stop to add a new plugin to a central wiring point. Plugins also enable extreme late-binding: they allow a program to extend itself at launch time with new Plugin classes on the classpath.

Plugins are compatible with compile-time checks as long as they’re defined in a separate module.

To use plugins, add the distage-extension-plugins library:

sbt
libraryDependencies += "io.7mind.izumi" %% "distage-extension-plugins" % "1.2.5"

To declare a plugin, create a top-level object extending the PluginDef trait instead of ModuleDef:

package com.example.petstore

import distage.Injector
import distage.plugins.{PluginConfig, PluginDef, PluginLoader}

object PetStorePlugin extends PluginDef {
  make[PetRepository]
  make[PetStoreService]
  make[PetStoreController]
}

Use PluginLoader to find all the PluginDef objects in a specific package or its subpackages:

val pluginConfig = PluginConfig.cached(
  // packages to scan
  packagesEnabled = Seq("com.example.petstore")
)
// pluginConfig: PluginConfig = PluginConfig(List(com.example.petstore),List(),true,false,List(),List())

val appModules = PluginLoader().load(pluginConfig)
// appModules: izumi.distage.plugins.load.LoadedPlugins = LoadedPlugins(List(
// make[{type.com.example.petstore.PetStoreController}].from(call(π:Class(): com.example.petstore.PetStoreController)) ((PetStorePlugin.scala:8))
// make[{type.com.example.petstore.PetStoreService}].from(call(π:Class(): com.example.petstore.PetStoreService)) ((PetStorePlugin.scala:7))
// make[{type.com.example.petstore.PetRepository}].from(call(π:Class(): com.example.petstore.PetRepository)) ((PetStorePlugin.scala:6))),List(),List())

You may then pass plugins to Injector as ordinary modules:

// combine all plugins into one

val appModule = appModules.result.merge
// appModule: izumi.distage.model.definition.ModuleBase = 
// make[{type.com.example.petstore.PetRepository}].from(call(π:Class(): com.example.petstore.PetRepository)) ((PetStorePlugin.scala:6))
// make[{type.com.example.petstore.PetStoreService}].from(call(π:Class(): com.example.petstore.PetStoreService)) ((PetStorePlugin.scala:7))
// make[{type.com.example.petstore.PetStoreController}].from(call(π:Class(): com.example.petstore.PetStoreController)) ((PetStorePlugin.scala:8))

// launch

Injector()
  .produceGet[PetStoreController](appModule)
  .use(_.run())
// PetStoreController: running!
// res9: izumi.fundamentals.platform.functional.package.Identity[Unit] = ()

Compile-time scanning

Plugin scan can be performed at compile-time, this is mainly useful for deployment on platforms with reduced runtime reflection capabilities compared to the JVM, such as Graal Native Image, Scala.js and Scala Native. Use PluginConfig.compileTime to perform a compile-time scan.

Be warned though, for compile-time scanning to find plugins, they must be placed in a separate module from the one in which scan is performed. When placed in the same module, scanning will fail.

Example:

package com.example.petstore.another.module

val pluginConfig = PluginConfig.compileTime("com.example.petstore")
// pluginConfig: PluginConfig = PluginConfig(
//   packagesEnabled = List(),
//   packagesDisabled = List(),
//   cachePackages = false,
//   debug = false,
//   merges = List(
//     
// make[{type.com.example.petstore.PetStoreController}].from(call(π:Class(): com.example.petstore.PetStoreController)) ((PetStorePlugin.scala:8))
// make[{type.com.example.petstore.PetStoreService}].from(call(π:Class(): com.example.petstore.PetStoreService)) ((PetStorePlugin.scala:7))
// make[{type.com.example.petstore.PetRepository}].from(call(π:Class(): com.example.petstore.PetRepository)) ((PetStorePlugin.scala:6))
//   ),
//   overrides = List()
// )

val loadedPlugins = PluginLoader().load(pluginConfig)
// loadedPlugins: izumi.distage.plugins.load.LoadedPlugins = LoadedPlugins(
//   loaded = List(),
//   merges = List(
//     
// make[{type.com.example.petstore.PetStoreController}].from(call(π:Class(): com.example.petstore.PetStoreController)) ((PetStorePlugin.scala:8))
// make[{type.com.example.petstore.PetStoreService}].from(call(π:Class(): com.example.petstore.PetStoreService)) ((PetStorePlugin.scala:7))
// make[{type.com.example.petstore.PetRepository}].from(call(π:Class(): com.example.petstore.PetRepository)) ((PetStorePlugin.scala:6))
//   ),
//   overrides = List()
// )