Loupe

Utiliser TypeScript pour écrire des procédures stockées Cosmos DB avec async/await

Avertissement : je ne suis absolument pas un expert de TypeScript. En fait, je ne connais même pas grand chose à JavaScript, npm, gulp, etc. Il est donc tout à fait possible que je dise quelque chose de complètement idiot dans cet article, ou que je sois passé à côté d'une solution beaucoup plus simple. N'hésitez pas à me le dire en commentaire !

Azure Cosmos DB (anciennement connu sous le nom "Azure Document DB") est une base de données NoSQL, multi-modèle, globalement distribuée hébergée dans Azure. Si vous êtes habitué aux bases de données relationnelles, c'est un monde très différent. Certaines choses sont géniales, par exemple la modélisation de données est beaucoup plus facile que dans un SGBDR, et les performances sont excellentes. D'autres sont un peu déconcertantes, comme le manque de support d'ACID. Du point de vue du client, il n'y a pas de transactions : on ne peut pas modifier plusieurs documents de façon atomique. Bien sûr, il existe une solution de contournement : on peut écrire des procédures stockées et des triggers, qui s'exécutent dans le contexte d'une transaction. Donc si vous avez vraiment besoin de faire des modifications multiples de façon atomique, la solution est d'écrire une procédure stockée qui le fait.

La mauvaise nouvelle

Malheureusement, dans Cosmos DB, les procédures stockées sont écrites… en JavaScript 😢 (je sais, il y a plein de gens qui adorent JavaScript, mais je n'en fais pas partie). Toutes les APIs qui effectuent des opérations sur la base de données sont asynchrones (ce qui est une bonne chose), mais elles sont basées sur des callbacks et non sur des promises, donc même si ECMAScript 2017 est supporté, on ne peut pas utiliser async/await avec ces APIs. Cela suffit à transformer toute tâche non triviale (par exemple du code avec des ifs ou des boucles) en cauchemar, en tout cas pour un développeur C# comme moi… Typiquement, je peux passer une journée entière à écrire une procédure stockée qui aurait été faite en moins d'une heure avec async/await.

Wrapper renvoyant des promises

Bien sûr, il est possible de rendre l'écriture de procédures stockées un peu moins laborieuse, sinon je n'aurais pas écrit ce billet ! Andrew Liu, un Product Manager de Cosmos DB, a eu la gentillesse de me montrer comment écrire un wrapper autour des APIs serveur de Cosmos DB pour pouvoir utiliser async/await. Grosso modo, il s'agit simplement de quelques fonctions à ajouter à vos procédures stockées :

function setFoo() {
    async function main() {
        let { feed, options } = await queryDocuments("SELECT * from c");
        for (let doc of feed) {
            doc.foo = "bar";
            await replaceDocument(doc);
        }
    }

    main().catch(err => getContext().abort(err));
}

function queryDocuments(sqlQuery, options) {
    return new Promise((resolve, reject) => {
        let isAccepted = __.queryDocuments(__.getSelfLink(), sqlQuery, options, (err, feed, opts) => {
            if (err) reject(err);
            else resolve({ feed, options: opts });
        });
        if (!isAccepted) reject(new Error(429, "queryDocuments was not accepted."));
    });
}

function replaceDocument(doc, options) {
    return new Promise((resolve, reject) => {
        let isAccepted = __.replaceDocument(doc._self, doc, (err, result, opts) => {
            if (err) reject(err);
            else resolve({ result, options: opts });
        });
        if (!isAccepted) reject(new Error(429, "replaceDocument was not accepted."));
    });
}

// et ainsi de suite pour les autres APIs…

Remarquez que le point d'entrée de la procédure stockée (setFoo dans cet exemple) ne peut pas être asynchrone (si la fonction renvoie une promise, Cosmos DB n'attendra pas qu'elle se termine), il faut donc écrire une autre fonction asynchrone (main), l'appeler depuis le point d'entrée, et intercepter l'éventuelle erreur. Notez l'utilisation de getContext().abort(err), qui interrompt et annule la transaction en cours, et évite que l'exception ne soit ignorée.

Je vais vous épargner le code équivalent avec les callbacks, parce que franchement, ça me fait mal à la tête rien que d'y penser. Mais faites-moi confiance là-dessus : ce n'est pas joli-joli, et beaucoup plus compliqué à comprendre.

Utilisation de TypeScript

Le code ci-dessus est assez simple, si on ignore les fonctions wrappers. Cependant, il y a encore au moins deux problèmes :

  • C'est toujours du JavaScript, qui est faiblement typé, il est donc facile de faire des erreurs qui ne seront pas détectées avant l'exécution.
  • Les procédures stockées et triggers CosmosDB doivent être constituées d'un seul fichier indépendant ; les import ou require ne sont pas permis. Cela signifie qu'on ne peut pas partager le code des wrappers entre plusieurs procédures stockées, il faut l'inclure avec chacune. C'est embêtant car la duplication du code complique sa maintenance…

Voyons comment écrire notre procédure stockée en TypeScript et réduire le code répétitif et ennuyeux.

Commençons par installer TypeScript. Créez un fichier package.json avec la commande npm init (elle vous demandera de renseigner certains champs, que vous pouvez laisser vides), et exécutez la commande npm install typescript. On aura aussi besoin des définitions TypeScript des APIs serveur de Cosmos DB. Pour cela, installez le package @types/documentdb-server, qui contient ces définitions : npm install @types/documentdb-server.

Il nous faut aussi un fichier tsconfig.json :

{
    "exclude": [
        "node_modules"
    ],
    "compilerOptions": {
        "target": "es2017",
        "strict": true,
    }
}

Maintenant, créons quelques helpers à utiliser dans nos procédures stockées. Je les ai tous mis dans un dossier CosmosServerScriptHelpers. La partie la plus importante est la classe AsyncCosmosContext, qui est un wrapper fortement typé et basé sur des promises pour l'object __. Elle implémente l'interface suivante :

export interface IAsyncCosmosContext {

    readonly request: IRequest;
    readonly response: IResponse;

    // Basic query and CRUD methods
    queryDocuments(sqlQuery: any, options?: IFeedOptions): Promise<IFeedResult>;
    readDocument(link: string, options?: IReadOptions): Promise<any>;
    createDocument(doc: any, options?: ICreateOptions): Promise<any>;
    replaceDocument(doc: any, options?: IReplaceOptions): Promise<any>;
    deleteDocument(doc: any, options?: IDeleteOptions): Promise<any>;

    // Helper methods
    readDocumentById(id: string, options?: IReadOptions): Promise<any>;
    readDocumentByIdIfExists(id: string, options?: IReadOptions): Promise<any>;
    deleteDocumentById(id: string, options?: IDeleteOptions): Promise<any>
    queryFirstDocument(sqlQuery: any, options?: IFeedOptions): Promise<any>;
    createOrReplaceDocument(doc: any, options?: ICreateOrReplaceOptions): Promise<any>;
}

Je ne montre pas le code complet dans cet article parce que ce serait un peu long, mais vous pouvez voir l'implémentation et les types auxiliaires dans le repo GitHub ici : https://github.com/thomaslevesque/TypeScriptCosmosDBStoredProceduresArticle.

OK, comment utiliser tout ça ? Reprenons notre exemple précédent et voyons comment le réécrire en TypeScript en utilisant nos wrappers :

import {IAsyncCosmosContext} from "CosmosServerScriptHelpers/IAsyncCosmosContext";
import {AsyncCosmosContext} from "CosmosServerScriptHelpers/AsyncCosmosContext";

function setFoo() {
    async function main(context: IAsyncCosmosContext) {
        let { feed, options } = await context.queryDocuments("SELECT * from c");
        for (let doc of feed) {
            doc.foo = "bar";
            await replaceDocument(doc);
        }
    }

    main(new AsyncCosmosContext()).catch(err => getContext().abort(err));
}

Ça ressemble beaucoup à la version précédente, avec juste deux différences :

  • On n'a plus les wrappers dans le même fichier, on les importe via la classe AsyncCosmosContext.
  • On passe une instance de AsyncCosmosContext à la fonction main.

Ca commence à avoir une bonne tête, mais il y a encore quelque chose qui m'embête : devoir explicitement créer le contexte et faire le .catch(...). Créons donc un autre helper pour encapsuler cela :

import {IAsyncCosmosContext} from "./IAsyncCosmosContext";
import {AsyncCosmosContext} from "./AsyncCosmosContext";

export class AsyncHelper {
    /**
     * Executes the specified async function and returns its result as the response body of the stored procedure.
     * @param func The async function to execute, which returns an object.
     */
    public static executeAndReturn(func: (context: IAsyncCosmosContext) => Promise<any>) {
        this.executeCore(func, true);
    }

    /**
     * Executes the specified async function, but doesn't write anything to the response body of the stored procedure.
     * @param func The async function to execute, which returns nothing.
     */
    public static execute(func: (context: IAsyncCosmosContext) => Promise<void>) {
        this.executeCore(func, false);
    }

    private static executeCore(func: (context: IAsyncCosmosContext) => Promise<any>, setBody: boolean) {
        func(new AsyncCosmosContext())
            .then(result => {
                if (setBody) {
                    __.response.setBody(result);
                }
            })
            .catch(err => {
                // @ts-ignore
                getContext().abort(err);
            });
    }
}

En utilisant cette classe, notre procédure stockée ressemble maintenant à ceci :

import {AsyncHelper} from "CosmosServerScriptHelpers/AsyncHelper";

function setFoo() 
{
    AsyncHelper.execute(async context => {
        let result = await context.queryDocuments("SELECT * from c");
        for (let doc of result.feed) {
            doc.foo = "bar";
            await context.replaceDocument(doc);
        }
    });
}

Cela réduit au minimum le code répétitif, il ne reste quasiment plus que le code vraiment utile. Je suis plutôt content du résultat, donc laissons ça de côté pour l'instant.

Générer la procédure stockée JS finale

OK, on arrive à la partie délicate… On a une poignée de fichiers TypeScript qui s'importent les uns les autres. Mais Cosmos DB veut un seul fichier JavaScript indépendant, avec le point d'entrée de la procédure stockée comme première fonction du fichier. Par défault, compiler les fichiers TypeScript en JavaScript va générer un fichier JS pour chaque fichier TS. L'option --outFile du compilateur met tout dans un seul fichier, mais ne fait pas vraiment l'affaire, car elle génère du code lié à l'utilisation des modules, qui ne fonctionnera pas dans Cosmos DB. Ce qu'il nous faut, pour chaque procédure stockée, c'est un fichier qui contient uniquement :

  • le code de la procédure stockée elle-même
  • le code des helpers, sans import ou require.

Puisqu'il ne semble pas possible d'obtenir le résultat souhaité en utilisant seulement le compilateur TypeScript, la solution que j'ai trouvée est d'utiliser un pipeline Gulp pour concaténer les fichiers de sortie et enlever les export et import superflus. Voici mon gulpfile.js :

const gulp = require("gulp");
const ts = require("gulp-typescript");
const path = require("path");
const flatmap = require("gulp-flatmap");
const replace = require('gulp-replace');
const concat = require('gulp-concat');

gulp.task("build-cosmos-server-scripts", function() {
    const sharedScripts = "CosmosServerScriptHelpers/*.ts";
    const tsServerSideScripts = "StoredProcedures/**/*.ts";

    return gulp.src(tsServerSideScripts)
        .pipe(flatmap((stream, file) =>
        {
            let outFile = path.join(path.dirname(file.relative), path.basename(file.relative, ".ts") + ".js");
            let tsProject = ts.createProject("tsconfig.json");
            return stream
                .pipe(gulp.src(sharedScripts))
                .pipe(tsProject())
                .pipe(replace(/^\s*import .+;\s*$/gm, ""))
                .pipe(replace(/^\s*export .+;\s*$/gm, ""))
                .pipe(replace(/^\s*export /gm, ""))
                .pipe(concat(outFile))
                .pipe(gulp.dest("StoredProcedures"));
        }));
});

gulp.task("default", gulp.series("build-cosmos-server-scripts"));

Notez que ce script requiert quelques packages npm supplémentaires : gulp, gulp-concat, gulp-replace, gulp-flatmap, et gulp-typescript.

Maintenant, il suffit d'exécuter gulp pour générer le fichier JS approprié pour chaque procédure stockée.

Pour être honnête, cette solution relève un peu du bricolage, mais c'est le mieux que j'ai pu faire. Si vous connaissez une meilleure approche, n'hésitez pas à la mentionner dans les commentaires !

Conclusion

L'expérience par défaut pour écrire des procédures stockées Cosmos DB n'est pas géniale, mais avec juste un peu d'effort, on peut la rendre bien meilleure. On peut maintenant avoir du typage fort grâce à TypeScript et les définitions de type, et on peut utiliser async/await pour rendre le code beaucoup plus simple. Remarquez que cette approche est également valable pour les triggers.

Avec un peu de chance, une future mise à jour de Cosmos DB introduira une vraie API à base de promises, et peut-être même le support de TypeScript. En attendant, n'hésitez pas à utiliser la solution présentée dans ce billet !

Le code complet pour cet article est disponible ici : https://github.com/thomaslevesque/TypeScriptCosmosDBStoredProceduresArticle.

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus