Let’s talk about reading YAML configuration files in Dart. Nearly any dart:io application is going to have a configuration file for values that are different across instances and environments. While deploying a dart:io application is really easy, managing configuration files in an ever-changing system is prone to error.

We’ve all done one of these things: forget to add a value to a configuration file, typo’ed a key in a configuration file or typo’ed a key when accessing configuration values in your code. Stringly-typed data has bitten the best of us.

The safe_config package is here to help. safe_config simply introduces a class, ConfigurationItem, to help mitigate these issues.

Let’s say our application talks to a different database depending on its environment. The information to connect to that database – like host, port, etc. – should be in a config file on each instance running your application. The config file is probably named ‘config.yaml’ and looks something like this:

databaseHost: stablekernel.com
databasePort: 5432

Now, we could simply pull these values in from the configuration file and pass them around in a Map<String, dynamic>, but that’s exactly the type of structure that causes errors. Instead, we create a ConfigurationItem subclass specific to the application:

class ApplicationConfiguration extends ConfigurationItem {
    ApplicationConfiguration(String fileName) : super.fromFile(fileName);
    
    String databaseHost;
    int databasePort;
}

When your application starts up, you create an instance of ApplicationConfiguration and have it read the config file:


var appConfig = new ApplicationConfiguration("config.yaml");

var dbConnection = await db.connect(appConfig.host, appConfig.port);

No more stringly-typed data! That’s just the beginning, though. ConfigurationItem will throw exceptions when you screw up so that you can catch your mistakes right when you launch.

First, keys in the YAML file must match the names of properties in your ConfigurationItem subclass. If a property is not present as a key – using a case-sensitive compare – in the loaded YAML file, you’ll get an exception. This mechanism ensures that we don’t miss or typo any important configuration values.

The values for each key in the YAML file will be assigned to the properties of the ConfigurationItem for the matching key. This is how we avoid accessing values with a string key, which is prone to error.

There is more, however. One of the challenges of configuration files is that as a project grows, the configuration file gets a bit unwieldy. Therefore, you can nest configuration items. There are two built-in configuration item subclasses available in the safe_config package for common configurations: DatabaseConnectionConfiguration and APIConfiguration. Let’s nest a DatabaseConnectionConfiguration.


class ApplicationConfiguration extends ConfigurationItem {
    ApplicationConfiguration(String fileName) : super.fromFile(fileName);

    DatabaseConnectionConfiguration database;
    int port;
}

The config file should now take the following format:


port: 8000
database:
  host: stablekernel.com
  port: 5432
  username: bob
  password: foo
  databaseName: db1 

And this is how we access those values.

var appConfig = new ApplicationConfiguration("config.yaml");
var dbConnection = await db.connect(appConfig.database.host, appConfig.database.port, ...);
var application = new Application();

await application.run(port: appConfig.port, numberOfIsolates: 3);

Configuration values can be infinitely nested, and you can create your own subclasses of ConfigurationItem to use as nested configuration types.

By default, all declared properties in a ConfigurationItem subclass are required to exist in the YAML file it loads. However, it sometimes makes sense to mark some items as optional.


class ApplicationConfiguration extends ConfigurationItem {

    ApplicationConfiguration(String fileName) : super.fromFile(fileName);

    DatabaseConnectionConfiguration database;

    @optionalConfiguration

    int port = 8000;

}

If port is omitted from the top-level configuration file, it won’t throw an exception, but will still have a value.

If you have truly dynamic data, you may also declare Arrays and Maps in ConfigurationItem subclasses. These values may be primitives or ConfigurationItem subclasses. Here is an example with a Map:


class ApplicationConfiguration extends ConfigurationItem {
    ApplicationConfiguration(String fileName) : super.fromFile(fileName);

    Map<String, DatabaseConnectionConfiguration> databases;

    @optionalConfiguration
    int port = 8000;
}

And the corresponding YAML:

port: 8000
databases:
  db1:
    host: stablekernel.com
    port: 5432
    username: bob
    password: foo
    databaseName: db1   
  db2:
    host: somewhereoutthere.com
    port: 5432
    username: bob
    password: foobar
    databaseName: db2   

To access these values:

var config = ...;

var db1Host = config.databases["db1"].host;

(Yeah, sometimes you do need stringly-typed data.)

We typically store a config.yaml.src file in our repositories for our dart:io applications. This has all the keys that a configuration file for that application should have. When we spin up a new instance, we simply copy the configuration source file on the target machine and edit values as necessary. The source stays checked in, and the actual configuration file for the instance is in .gitignore.

There is a side benefit that is really important, though. We do integration testing for all of our web servers. It is important that all of our team is able to run these tests locally during development, and for that to work, our configuration values for a testable version of our applications must match the values expected in our tests. Therefore, we store the ‘test’ values in the config.yaml.src file.

Thus, the configuration source file not only contains the expected format of a per-instance configuration file, but is also the configuration file for our integration test suite.

Joe Conway

Founder at Stable Kernel

Leave a Reply

Your email address will not be published. Required fields are marked *