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.yamlfile 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.yamlingested by Experimenter should give a complete picture of all the features available in the application. - The components (and their
fml.yamlfiles) 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.
- Android
- iOS
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 by a Build Phase.
% nimbus-fml generate --language swift --channel release component.fml.yaml component/Generated
This will generated exactly one Swift 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.yamlfile.
If not present:
- the file may be included in another
.fml.yamlfile.
about associates a particular .fml.yaml file what Kotlin/Swift class and package/module this file will generate:
- Kotlin
- Swift
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.
about:
ios:
class: AccountsNimbus
module: Accounts
The module property is the name of the module the generate class will be in.
The class is the name of the class used to access the features.
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
aboutblock, - must not contain a
channelslist, or must match thechannelslist 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
aboutblock. - may have an
includeblock.
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
aboutblocks of the imported files. - The
apiproperty connects the Nimbus SDK to the generated code. Setting this lets theAppNimbusget configuration from the server. The setter now sets theapiall imported (and generated above) classes— in this caseComponentNimbus. - The companion object
initblock callswithConfigurationas soon asAppNimbusis used.withConfigurationis a new method onFeatureHolderwhich provides an alternativecreateclosure. It has to be public and is named as to appear aftervalue()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.