Chez Deepki, nous mettons à disposition une application qui nécessite, pour certaines fonctionnalités, des réglages fins pour chaque client. Malgré ce paramétrage adapté, nous arrivons à répondre efficacement aux besoins de chacun.

Par exemple, mettre à disposition un budget nécessite de traverser toutes ces étapes :

  1. glaner des données à partir de différentes sources brutes
  2. enrichir et intégrer les données via notre ETL
  3. normaliser les collections dans un data warehouse
  4. mettre à jour quotidiennement
  5. exposer les données via une API
  6. charger les données
  7. sérialiser les données
  8. présenter de manière intelligible

Nous allons voir comment nous sommes capables de peaufiner la configuration de chaque application sans sacrifier notre agilité.

La configuration

Bien que les fonctionnalités de notre application sont standards, toutes sont souvent paramétrables et composables. C’est pourquoi nous avons une configuration ad-hoc pour chaque application qui permet de régler avec précision chaque fonctionnalité.

En moyenne (sur 150 projets représentatifs), une configuration c’est :

  • 462Ko de JSON
  • 11750 paths
  • 14 features
  • 19 sources

Voici une fraction d’une configuration :

{
  "sources": [
    {
      "id": "buildings",
      "type": "mongodb",
      "collection": "buildings",
      "pk": "building_id"
    },
    {
      "id": "electricity",
      "type": "influxdb",
      "measurement": "electricity",
      "pk": "dp_ref",
      "freq": "60S"
    }
  ],
  "features": [
    {
      "id": "budget",
      "verbose_name": "Budget",
      "endpoint": {
        "rbac": ["group-1"],
        "component": "breadcrumb-page",
        "verbose_name": "Synthesis",
        "methods": [
          {
            "id": "budget.get_budget_data",
            "key": "budget_data"
          }
        ]
      },
      "tasks": {
        "fluids": [
          {
            "id": "electricity",
            "segment_column": "segment"
          }
        ]
      }
    }
  ]
}

Configurer les applications est la responsabilité des CSM (Customer Success Manager). En moyenne cette équipe déploie 140 nouvelles versions par jour (tous clients confondus). Ils les modifient avec confiance en se basant sur une documentation exhaustive et des tests automatisés de configuration.

Ces “tests de config” comme on les appelle ici sont une des composantes de notre Q/A et sont joués autant par les CSM que par l’ingénierie :

  • Lors de chaque déploiement, la nouvelle configuration est systématiquement testée sur l’environnement visé avant qu’elle ne soit mise en service. On assure ainsi qu’elle ne provoquera pas d’interruption de service.
  • Lors du développement, l’ingénieur teste ses modifications sur toutes les configurations déployées en production. Si ce développement impacte ces configurations, l’ingénieur doit assurer que sa proposition est rétro compatible.

Du point de vue de la configuration, il y a trois types de tests : la forme, la cohésion des paramètres et l’existant sur les différents environnements.

Tester la forme

Les premiers tests apportés s’appuient sur des json-schema afin de valider globalement la forme de la configuration.

Ces tests sont intéressants car ils sont très simples à mettre en œuvre, et fonctionnent sur tous types d’applications.

Par exemple, nous vérifions que chaque source de type mongodb a bien une collection et une définition de clé primaire ( pk ) :

{
  "type": "object",
  "properties": {
    "type": { "const": "mongodb" },
    "collection": { "type": "string", "pattern": "^\\w{5,}$" },
    "pk": { "type": "string", "pattern": "^\\w{5,}$" }
  },
  "required": ["type", "collection", "pk"]
}

L’utilisation de json-ref nous permet aussi de factoriser des fragments de schémas. Cette factorisation est un indicateur de bonne normalisation. En effet, l’un des risques est d’exprimer la même notion de manière différente sur différentes features.

Tester la cohésion

Nous testons aussi la cohésion dans le paramétrage. Par exemple, la fonctionnalité budget.electricity ne peut fonctionner sans une source electricity :

def validate_source_exists(source_name):
    source = jmespath.search("sources[?id=='%s']" % source_name, config)
    assert source, f"Missing source {source_name}"

for fluid in jmespath.search("features[?id=='budget'].tasks.fluids[*].id", config):
    validate_source_exists(fluid)

L’utilisation de jmespath se prête bien à ce genre de tests.

Tester la plateforme

Certaines parties de configuration s’appuient sur du code présent en production. C’est un aspect très important du côté adaptable de notre plateforme.

Pour cela nous pouvons être amenés à faire de l’introspection de code. Par exemple, nous contrôlons que les composants existent bien dans la version du code en production :

def validate_method(id: str, **arguments: Mapping[str, Any]):
    signature: Signature = get_method_signature(id)
    assert validate_arguments(signature, arguments)

def validate_arguments(signature: Signature, arguments: Mapping[str, Any]):
    for parameter in signature.parameters:
        if parameter.default == parameter.empty:
            if parameter.name not in arguments:
                raise ValueError(f"{parameter.name} is required")
        if parameter.name in arguments:
            if parameter.annotation != parameter.empty:
                typeguard.check_type(parameter.name, arguments[name], parameter.annotation)

for method_config in jmespath.search("features[*].endpoint.methods", config):
    validate_method(**method_config)

Exploitation de la configuration par le code

Cette configuration est très dense et rarement exploitée directement. Elle est le plus souvent encapsulée dans des objets de configuration.

Ces objets exposent des sous-configurations pour chaque fonctionnalité :

class ProjectConfiguration:
    def __init__(self, config: Mapping):
        self.config = config

    def search(self, path: str):
        return extract(path, self.config)

    def budget(self) -> BudgetConfiguration:
        methods = self.search("features[?id = 'budget'].endpoint.methods")
        verbose_name = self.search("features[?id = 'budget'].verbose_name", "Budget")
        return BudgetConfiguration(methods, verbose_name)

class BudgetConfiguration:
    def __init__(self, methods: List, verbose_name: str):
        self.methods = methods
        self.verbose_name = verbose_name

L’encapsulation nous permet de garantir plusieurs qualités de notre code :

  • Un code évolutif
  • Des facilités de développement

Un code de production en avance de phase

Le code et la configuration ont chacun leurs canaux de déploiement indépendants l’un de l’autre.

Le code étant toujours en mouvement, la configuration doit parfois être adaptée. Cependant nous procédons rarement à des mises à jour synchronisées des deux parties.

L’ancienne et la nouvelle versions d’une même fonctionnalité cohabitent. Une fois en production, on migre les configurations une à une. On supprimera l’ancienne version quand elle ne sera plus utilisée.

Parfois il est impossible de migrer la configuration, et maintenir l’ancienne version non plus. Une autre stratégie consiste à émuler une configuration dite “nouveau format” à partir d’une configuration dite “ancien format” dans nos objets de configuration.

Cela est possible grâce au path jmespath. En effet, le path peut être vu comme une uri, on peut donc redéfinir la ressource qu’il retourne :

def extract(path, config):
    payload = jmespath.search(path, config)
    if path == "features[?id = 'budget'].endpoint.methods" and payload is None:
        # new configuration is missing, old configuration needs to be adapted
        return amend_old_format_budjet_endpoint_methods(config)
    return payload  # default behavior

def amend_old_format_budjet_endpoint_methods(config):
    ... # many LOC to adapt old configuration to new
    return amended_payload

extract("features[?id = 'budget'].endpoint.methods", config)

En exprimant le code ainsi, on encapsule toute la configuration brute dans une simple fonction, et on peut adapter la configuration à la nouvelle version du code : c’est le design pattern adapter.

Développement des features

Le TDD est une manière de concevoir du code qui puisse être testé unitairement le plus simplement possible. Nous allons voir dans les exemples suivants comment nous concevons du code et les configurations pour l’exploiter.

Par exemple nous souhaitons implémenter la sérialisation des données. Dans notre cycle de conception nous commencerions par écrire le test :

# feature_test.py

def test_serialize(config):
    data = {"MY": "DATA"}
    assert serialize(data, config) == {
      "verbose_name": "MY-NAME",
      "data": {"MY": "DATA"}
    }

L’implémentation naturelle serait donc :

# feature.py

Data = dict

class HasVerboseName(Protocol):
    @property
    def verbose_name(str): ...

def serialize(data: Data, config: HasVerboseName):
    return {
      "verbose_name": gettext(config.verbose_name),
      "data": data
    }

Nous n’avons plus qu’à compléter notre test avec une configuration idéale :

# feature_test.py

@pytest.fixture
def config():
    return BudgetConfiguration(verbose_name="MY-NAME")

puis implémenter l’objet config :

# feature.py

@dataclass
class BudgetConfiguration:
    verbose_name: str

Après plusieurs cycles de red-green-refactor, nous avons pu étoffer notre code. Nous avons commencé à implémenter d’autres phases de la chaîne, en définissant notre objet de configuration.

L’injection de dépendances est une alliée naturelle au TDD. Ce même objet peut être injecté dans nos différents composants :

# feature.py
Data = dict

@dataclass
class BudgetConfiguration:
    verbose_name: str = "N/A"
    methods: List = field(default_factory=list)

def load_data(config: BudgetConfiguration) -> Data:
    return {method["key"]: call_method(method) for method in config.methods}

def serialize(data: Data, config: BudgetConfiguration):
    return {
      "verbose_name": gettext(config.verbose_name),
      "data": data
    }

@app.route("/budget", methods=["GET"])
def budget_view():
    budget_config: BudgetConfiguration = load_budget_config()
    data = load_data(budget_config)
    payload = serialize(data, budget_config)
    return jsonify(data), 200

Chaque composant a été testé individuellement, en recevant une version adaptée de la configuration :

# feature_tests.py

def stub_method(*args, **kwargs):
    return "STUBBED-DATA", args, kwargs

def test_load_data():
    method_id = stub_method.__name__
    config = BudgetConfiguration(methods=[{"id": method_id, "key": "MY-KEY"}])
    assert load_data(config) == {"MY-KEY": ("STUBBED-DATA", (), {"id": method_id, "key": "MY-KEY")}

def test_serialize():
    config = BudgetConfiguration(verbose_name="MY-NAME")
    data = "MY-DATA"
    assert serialize(data, config) == {
      "verbose_name": "MY-NAME",
      "data": "MY-DATA"
    }

def test_endpoint():
    with mock("feature.load_budget_config"):
        ...

L’injection de dépendances est une technique simple et puissante. Elle permet de créer des applications faiblement couplées en différant le chargement de nos dépendances applicatives au moment du runtime.

Ainsi notre logiciel est flexible et ouvert au changement. Si nous avions dû modifier une configuration globale, c’était au risque d’affecter des variables globales dont peuvent dépendre d’autres parties de l’application, et provoquer des effets de bord difficiles à anticiper et à maîtriser.

Pour finir

L’ensemble des pratiques et techniques déployées ici nous ont permis d’atteindre les objectifs que nous nous étions fixés dès le départ : embrasser la complexité et la diversité des paramétrages de nos clients sans sacrifier notre réactivité et la qualité de notre travail. Pour ne pas rendre cet article trop long, nous n’avons pas détaillé toutes les notions abordées (TDD, injection de dépendances, DDD, design patterns…), mais de même que Rome ne s’est pas faite en un jour, toutes ces pratiques sont le fruit d’une longue maturation de nos équipes, et ce n’est probablement pas terminé ! Nous en parlerons ici au fur et à mesure de nos expérimentations car nous continuons à nous améliorer pour réaliser le meilleur produit possible pour nos clients.