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.
- 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.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:
- 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
about
block, - must not contain a
channels
list, or must match thechannels
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 DefaultBlock
s 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 theAppNimbus
get configuration from the server. The setter now sets theapi
all imported (and generated above) classes— in this caseComponentNimbus
. - The companion object
init
block callswithConfiguration
as soon asAppNimbus
is used.withConfiguration
is a new method onFeatureHolder
which provides an alternativecreate
closure. 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.