Skip to main content

Componentizing the Nimbus Feature Manifest

In the first iteration of the nimbus-fml, the tooling was only able to work on one file at a time.

It accepted a nimbus.fml.yaml file, and outputed a Kotlin or Swift file for inclusion in the product, or an .experimenter.yaml file for ingestion into Experimenter.

The pattern we have seen is that the application specifies code that:

  • grows each time a new feature is added. This makes it hard to navigate.
  • is only able to generate code app code, which is not usable in any of the app's components.

This second restriction is most problematic: the app's components (e.g. Fenix is made up of Android Components, Application Services, GeckoView and Gecko) are unable to instrument their code for experimentation, even though they by themselves might make up the majority of the codebase of the app.

This document discusses three new blocks in the FML specification, which are to implement including and importing FML files which easily solve these problems.

It allows for experimentation in the re-usable and library codebases that makes up most of our applications.

It enables cross-platform re-use of data definitions and schemas which will in turn, make it easier to reason about experimental features by experiment owners.

Goals

  • The nimbus.fml.yaml file should be composable such the monolithic file can be broken up in to smaller pieces, potentially to live closer to the code it's configuring.
  • The .experimenter.yaml ingested by Experimenter should give a complete picture of all the features available in the application.
  • The components (and their fml.yaml files) can live in a different repository to the application.
  • The components' feature code generation happens at component compile time
  • Generated feature code should be visible and configurable from the application
    • e.g. Fenix and Focus might use the same feature from Android Components, but require different configuration.
  • versioning and branches:
    • experiments are launched at a population of users using different versions of the app.
    • each specific version of the app is potentially built with a specific version of their components
    • for this proposal, easily varying the versions of components is not explored.
  • local development
    • for some feature development, working in multiple repos at the same time is necessary.
    • for this proposal, easily varying the paths or URLs of components is not explored, although this will likely involve a config file.

Non-goals

  • namespacing
    • currently names of feature, object, and enum classes are unique; collisions are disallowed
    • for this proposal, no attempt is made to allow classes of the same name to be used.
  • connectors to different languages
    • some features may be written in a different language or context to the one that the application is written in: e.g. C++, Rust or JS, or in an iOS app-extension.
    • for this proposal, this is only lightly discussed.

Concepts

This proposal changes the way the nimbus-fml file is invoked, and adds three new structures to the Feature Manifest Language spec.

Invoking the nimbus-fml command

The following nimbus-fml commands can be run in fml files in either applications or components.

Each component or app has its own build.gradle.

The following command is assembled and run by the Nimbus Gradle Plugin.

% nimbus-fml generate --language kotlin --channel release component.fml.yaml components/build/generated/debug/nimbus

This will generated exactly one Kotlin file.

The following command is assembled and run at application build time only.

% nimbus-fml generate-experimenter application.fml.yaml .experimenter.yaml

The .experimenter.yaml file is checked into source control, and ingested by Experimenter.

The about block

The about block is an optional block in any given .fml.yaml file.

If present:

  • the file may be used to generate a Swift or Kotlin file.
  • the file may be imported by another .fml.yaml file.

If not present:

  • the file may be included in another .fml.yaml file.

about associates a particular .fml.yaml file what Kotlin/Swift class and package/module this file will generate:

about:
android:
class: .nimbus.MyNimbus
package: mozilla.components.search

The package property must correspond to the app's namespace, where the R and BuildConfig files are generated.

The class property is the qualified name of the class that will be generated. If the class has . as a prefix, the fully qualified name is created by appending the package to the class. The filename is taken to be the final segment of the fully qualified name.

For Android developers, this should be the familiar way android:name is specified in the AndroidManifest.xml.

As with existing .fml.yaml files, any file with an about block, must contain a channels list. e.g.

channels:
- release
- beta
- developer
- testing

The include list

This is a list of files which will be merged with this one. The files may be relative to this one, absolute or URLs.

include:
- nimbus/search.yaml
- @mozilla/nimbus-shared/fml/messaging.yaml

Each element in the list is a path or URL to another file to be included into this one.

Included files:

  • must not contain an about block,
  • must not contain a channels list, or must match the channels list of the including file.

Including a file means that contents of the features, objects and enums blocks will be appended from the included file to the including file. Any collisions will cause an error, i.e. if two files declare a type of the same name, this should cause an error.

Included files may include other files. These files may be remote or on a local filesystem.

Including the same file twice should be a no-op.

Included files may import files. These files may be remote or on a local filesystem.

The import list

import:
- path: ../Accounts/nimbus.fml.yaml
channel: production
- path: @mozilla-mobile/ios-components/components/feature/search/nimbus.fml.yaml
channel: release

The list contains blocks with the following mandatory properties:

  • path: this string value is a relative path or URL to the imported file.
  • channel: this string value is the name of a channel. The channel must be in the channel list of the included file.

An imported file:

  • must have an about block.
  • may have an include block.

Optionally, a features block is provided, which is a Map<FeatureId, List<DefaultBlock>>. This provides a way of configuring components.

A list of DefaultBlocks is the same way feature defaulting, and channel specific defaulting works when specifying a feature.

import:
- path: ../Accounts/nimbus.fml.yaml
channel: production
features:
accounts:
- value:
button-color: blue

The value used by the imported code will come from the importing code.

Implementation notes

Illustrative sketch

Consider two projects that are already linked: the app, and the components. One of the components is a messaging feature which we'd like to be able to use in the app.

The app needs to initialize the messaging component with specific configuration, not available to the component when it was compiled.

Two FML files

In ../components/messaging.fml.yaml

about:
class: .nimbus.ComponentNimbus
package: org.example.components
channels:
- testing
- staging
- production

features:
messaging:
# definition elided, for clarity
# We include one variable for illustration
variable:
triggers:
type: Map<String, String>
defaults:
- channel: production
value:
triggers:
ALWAYS: "true"
NEVER: "false"

The top level file, app.fml.yaml.

about:
class: .nimbus.AppNimbus
package: org.example.app
channels:
- developer
- nightly
- beta
- release
import:
- path: ../components/messaging.fml.yaml
channel: production
features:
messaging:
- channel: release
value:
I_AM_DEFAULT_BROWSER: is_default_browser == true
I_AM_NOT_DEFAULT_BROWSER: is_default_browser != true

The corresponding Kotlin files

Running nimbus-fml on messaging.fml.yaml in the components directory, with the channel as production generates:

package org.example.components.nimbus

import org.example.components.R

class ComponentNimbus {
var api: NimbusFeaturesInterface?


}

class Messaging
private constructor() {
constructor(
_variables: Variables = NullVariables.instance,
triggers: Map<String, String> = mapOf(
"ALWAYS" to "true",
"NEVER" to "false"
)
)
}

When running the nimbus-fml command with the release channel, the component file is imported.

The generated file looks like:

package org.example.app.nimbus

import org.example.component.ComponentNimbus
import org.example.app.R

class AppNimbus {
var api: NimbusFeaturesInterface? = null
set(value) {
ComponentNimbus.api = value
}

companion object {
init() {
ComponentNimbus.features.messaging.withConfiguration { _variables ->
Messaging(
_variables,
triggers = mapOf(
"ALWAYS" to "true",
"NEVER" to "false",
"I_AM_DEFAULT_BROWSER" to "is_default_browser == true",
"I_AM_NOT_DEFAULT_BROWSER" to "is_default_browser != true"
)
)
}
}
}
}

Several places to highlight in this code:

  • The class names and package names are gained from the about blocks of the imported files.
  • The api property connects the Nimbus SDK to the generated code. Setting this lets the AppNimbus get configuration from the server. The setter now sets the api all imported (and generated above) classes— in this case ComponentNimbus.
  • The companion object init block calls withConfiguration as soon as AppNimbus is used. withConfiguration is a new method on FeatureHolder which provides an alternative create closure. It has to be public and is named as to appear after value() in the list auto-completed identifiers offered by IDEs. The new configuration comes from merging the component default with the app default.

So when ComponentNimbus.features.messaging.value() is called, even from within the component itself, it returns configuration from the Nimbus SDK, and defaults from the app.