Hur man kan hantera Continuous Delivery med MongoDB

MongoDB är en schemalös, dokumentorienterad databas som har fått stor popularitet i den agila världen bland annat därför att man inte behöver underhålla något databasschema.

MongoDBs schemalöshet gör att många leds att tro att Continuous Delivery blir en promenad i parken, eftersom det ju inte behövs några datamigreringar när man driftsätter en ny version av koden!

Rent teoretiskt är detta sant, men är ett sluttande plan in i Land of Crappy Code™ !

För att slippa onödig komplexitet i form av varierande utseende på lagrade domänobjekt beroende på deras ålder, rekommenderar jag att man utför regelrätta datamigreringar även när man använder MongoDB!

Jag rekommenderar även att datamigreringen är en del av applikationen — till skillnad från skript som skall köras vid sidan av innan applikationsstart — helt enkelt för att eliminera risken för misstag.

Jag har i mitt sidoprojekt Varmfront.nu utvecklat en kompakt liten lösning som i MongoDB implementerar det som Flyway gör för SQL.

Mönstret bygger på Spring Data for MongoDB och Spring JavaConfig, och migreringarna är skrivna i Java. That’s right folks, no XML here 😀

Läs vidare, så får du se hur man kan göra!

Sammanfattning

Med bara en handfull klasser så löser detta mönster ett vanligt problem i samband med Continuous Delivery, nämligen att se till att persisterat data har ett väldefinierat format oavsett ålder på databasen.

Applikationen ansvarar själv för att migrera data till det rätta formatet, och använder databasen själv för att hålla reda på vilka migreringar som behöver köras.

Migreringarna är skrivna i Java, dvs de har full tillgång till ett riktigt programmeringsspråk och till applikationskoden.

Reklam för Lombok och Joda Time

Varmfront.nu använder Lombok, därav val, @Data och @SneakyThrows i kodexemplen nedan. Vi använder även Joda Time, dvs org.joda.time.DateTime i stället för java.util.Date.

Lombok och Joda Time gör Java mycket mer uthärdligt, rekommenderas starkt!

Spring Java Config

Läsaren förutsätts vara bekant med hur man konfigurerar Spring med Java (@Configuration, @Service, @Component, @Bean, @Autowired och så vidare), så de förklaras inte här. De förklaras mycket bättre i Spring 3.2 referensdokumentation, kap. 5.12.

Klasser

Följande klasser ingår i lösningen (paketnamn, importer, loggning och ointressanta kodrader utelämnade för maximal tydlighet):

  • class MongoConfig
  • class MongoMigrationManager
  • abstract class Migration
  • class Migration_*_* extends Migration
  • class MongoIndexConfig

MongoConfig

MongoConfig ansvarar för att skapa en MongoTemplate, som sedan kan autowiras till resten av Springbönorna.

Notera att innan MongoTemplate returneras till Spring anropas migrationManager.runMigrations().

Det betyder att när mongoTemplate injiceras till applikationsbönor är datat redan migrerat!

/**
 * Configures the MongoTemplate, runs all migrations through the MongoMigrationManager.
 */
@Configuration
public class MongoConfig {

    @Autowired
    private MongoMigrationManager migrationManager;

    @Bean
    public MongoURI mongoUri() {
        return new MongoURI("mongodb://...");
    }

    @Bean(destroyMethod = "close")
    @SneakyThrows
    public Mongo mongo() {
        val mongo = new Mongo(mongoUri());
        mongo.setWriteConcern(WriteConcern.ACKNOWLEDGED);
        return mongo;
    }

    @Bean
    public MongoTemplate mongoTemplate() {
        val uri = mongoUri();

        val mongoTemplate = new MongoTemplate(mongo(), uri.getDatabase(), new UserCredentials(uri.getUsername(), new String(uri.getPassword())));
        mongoTemplate.setWriteResultChecking(WriteResultChecking.EXCEPTION);

        // Run all new migrations before returning the MongoTemplate...
        migrationManager.runMigrations(mongoTemplate);

        return mongoTemplate;
     }
}

MongoMigrationManager

MongoMigrationManager ansvarar för att hålla reda på vilka migreringar som behöver köras, köra dem och lagra resultatet i databasen.

Notera den autowirade listan av Migrations. Detta är standard Spring-funktionalitet, som kommer extremt bra till pass här.

/**
 * Manages data migrations in the connected MongoDB instance.
 */
@Component
public class MongoMigrationManager {

    // Must use an explicit collection name, or else each migration will be stored in
    // a separate Mongo one-document collection. MongoTemplate by default stores an object
    // in a collection named by the class' simple name.
    public static final String COLLECTION_NAME = Migration.class.getSimpleName().toLowerCase();

    @Autowired
    private List<Migration> migrations;

    // Cannot @Autowire MongoTemplate, since that would cause a cyclic dependency

    public void runMigrations(MongoTemplate mongoTemplate) {
        if (migrations != null) {
            // Make sure migrations are executed in ID order...
            Collections.sort(migrations);

            for (val m : migrations) {
                if (shouldExecuteMigration(mongoTemplate, m)) {
                    val result = executeMigration(mongoTemplate, m) ? "executed" : "failed";
                    saveMigration(mongoTemplate, m, result);
                }
            }
        }
    }

    private boolean executeMigration(MongoTemplate mongoTemplate, Migration m) {
        // Prevent concurrent migration in clustered environments...
        saveMigration(mongoTemplate, m, "executing");

        try {
            m.execute(mongoTemplate);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public void saveMigration(MongoTemplate mongoTemplate, Migration m, String result) {
        m.setResult(result);
        m.setAt(DateTime.now());

        mongoTemplate.save(m, COLLECTION_NAME);
    }

    private boolean shouldExecuteMigration(MongoTemplate mongoTemplate, Migration m) {
        return mongoTemplate.findById(m.getId(), Object.class, COLLECTION_NAME) == null;
    }
}

Migration

Migration är en abstrakt basklass för alla migreringar.

Notera @Data-annoteringen! Denna gör att Lombok genererar constructor, getters, setters, equals(), hashCode() och toString()!

Dessa behövs eftersom konkreta Migration-objekt skall lagras i MongoDB.

/**
 * This is an abstract base class for database migrations.
 */
@Data
public abstract class Migration implements Comparable {
    private final String id;
    private String result;
    private DateTime at;

    private static final String ID_PATTERN_STRING = "Migration_([0-9]{5})_.+";
    private static final Pattern ID_PATTERN = Pattern.compile(ID_PATTERN_STRING);

    /**
     * Extract the migration's ID by using a regular expression on the own class name.
     */
    protected Migration() {
        val matcher = ID_PATTERN.matcher(this.getClass().getSimpleName());
        checkArgument(matcher.matches(), "Bad migration class name, must match " + ID_PATTERN_STRING);
        this.id = matcher.group(1);
        checkState(id.matches("[0-9]+"), "Bad migration class name, must match " + ID_PATTERN_STRING);
    }

    /**
     * Natural sorting order is on ID.
     */
    @Override
    public int compareTo(Migration that) {
        return this.getId().compareTo(that.getId());
    }

    @Override
    public String toString() {
        return id + "-" + getClass().getSimpleName();
    }

    abstract public void execute(MongoTemplate mongoTemplate);
}

Exempel på en konkret Migration_*_*

Som exempel har jag plockat fram en migrering som lägger till ett nytt fält på alla User-dokument. Anledningen är att jag implementerade en user story som lade till ett fält i Preferences-klassen som ingår i User.

Som ni ser är migreringen trivial, men ger en betydligt enklare resa för applikationskod som skall använda Preferences. När denna migrering har körts är man garanterad att det alltid finns en user.getPreferences().getFrequency() oavsett när User-objektet lagrades i databasen!

Alternativet till migrering är att varje gång man gör user.getPreferences().getFrequency() kolla att man inte får null. Detta är inget annat än accidental complexity, som enligt FFG-fördelningen förr eller senare kommer att bita dig 😉

 Notera även att klassen är annoterad med @Component. Detta gör att Spring upptäcker den och automatiskt lägger till den till MongoMigrationManagerns autowirade lista på migreringar.
/**
 * Assigns values to user.preferences.frequency
 *
 * @author olle
 */
@Component
public class Migration_00016_SetUserPreferencesFrequency extends Migration {

    protected Migration_00016_SetUserPreferencesFrequency() {
        super();
    }

    @Override
    public void execute(MongoTemplate mongoTemplate) {
        mongoTemplate.updateMulti(query(where("preferences.frequency").is(null)),
            new Update().set("preferences.frequency", ForecastFrequency.ALL), User.class);
    }
}

MongoIndexConfig

MongoIndexConfig är en vanlig Spring-böna med en @PostConstruct-metod som ser till att de rätta indexen finns innan start.

Detta skulle i princip kunna betraktas som en migrering, och borde således hanteras som en sådan, men MongoIndexConfig kom till innan jag skrev MongoMigrationManager, och är kvar enligt mottot If it ain’t broken, don’t fix it!

/**
 * Ensures that the correct indexes are defined in MongoDB.
 */
@Component
public class MongoIndexConfig {

    private final MongoTemplate mongoTemplate;

    @Autowired
    public MongoIndexConfig(MongoTemplate mongoTemplate) {
        this.mongoTemplate = mongoTemplate;
    }

    @PostConstruct
    public void ensureIndexes() {
        ensureIndexesOnUser();
        ensureIndexesOnMessage();
    }

    private void ensureIndexesOnUser() {
        val indexOps = mongoTemplate.indexOps(User.class);
        indexOps.ensureIndex(new Index("userDetails.authId", Order.ASCENDING));
        indexOps.ensureIndex(new Index("userDetails.authProvider", Order.ASCENDING));
        indexOps.ensureIndex(new Index("userDetails.name", Order.ASCENDING));
        indexOps.ensureIndex(new Index("subscription.lastMessageId", Order.ASCENDING));
        indexOps.ensureIndex(new Index("subscription.nextForecastAvailableAt", Order.ASCENDING));
        indexOps.ensureIndex(new Index("customerNumber", Order.ASCENDING).unique());
    }

    private void ensureIndexesOnMessage() {
        val indexOps = mongoTemplate.indexOps(Message.class);
        indexOps.ensureIndex(new Index("errorMessage", Order.ASCENDING));
        indexOps.ensureIndex(new Index("status", Order.ASCENDING));
    }

}

Exempel på modellobjekt

För att man skall förstå koden ovan visar jag här även ett par av modellklasserna. Notera hur rena och fina de är tack vare Lombok! (Klasserna är något rensade för maximal tydlighet)

User

/**
 * Data for a Varmfront user.
 *
 * @author olle
 */
@Data
@Document
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    @Id
    private String id;

    private UserDetails userDetails;
    private Preferences preferences;
    private int visits;
    private DateTime lastVisit;
    private String customerNumber;
    private Set roles;
}

Preferences

/**
 * User preferences.
 *
 * @author olle
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Preferences implements Serializable, Cloneable {

    private static final long serialVersionUID = 1L;

    /**
     * The user is in this time zone.
     */
    private String timeZoneName;

    /**
     * The user uses this locale.
     */
    private Locale locale;

    /**
     * The international GSM country code, including leading '+'.
     */
    private String gsmCountryCode;

    /**
     * The GSM number without country code. May contain a leading '0'.
     */
    private String gsmNumber;

    /**
     * Make the forecast fit this number of SMS messages
     */
    private int maxSms;

    /**
     * What location do we want forecasts for?
     */
    private Location location;

    /**
     * Should the subscription be paused?
     */
    private boolean pauseSubscription;

    /**
     * How granular should the forecast be?
     */
    private ForecastGranularity granularity;

    /**
     * How often shall we send forecasts?
     */
    private ForecastFrequency frequency;

    @Override
    public Preferences clone() {
        return new Preferences(this.timeZoneName, this.locale, this.gsmCountryCode, this.gsmNumber, this.maxSms,
            this.location.clone(), this.pauseSubscription, this.granularity, this.frequency);
    }

}

Testning

Jag brukar testa migreringar på en kopia av produktionsdatabasen.

Skulle man ha kodat fel – dvs migreringen kastar ett undantag – måste man innan man startar appen igen med den rättade migreringskoden göra

$ mongo mydatabase
> db.migration.remove({_id: "00017"})

om det nu var Migration_00017_Xxx man håller på och utvecklar. Annars kommer MongoMigrationManager att vägra köra den igen efter kodrättningen.

Alternativ gör man mongorestore på den senaste produktionsdumpen och börjar om.

Skulle man ha kört en migrering i produktionsmiljö, men upptäckt att resultatet inte blev bra skall man skriva en migrering till som rättar till migreringen. Ungefär som i ekonomisk bokföring 😉

Exempel på Mongo-data

Följade utskrift visar de Migration-dokument som just nu ligger i Varmfronts testdatabas.

Här kan man enkelt se vilka migreringar som har körts, när de kördes och om de gick bra eller inte.
För att se vad migreringarna gjorde letar man helt enkelt upp klassen i fråga. Det är ju applikationskod 😉

$ mongo varmfront
> db.migration.find()
{ "_id" : "00001", "_class" : "se.hit.varmfront.config.migrations.Migration_00001_RemoveMessages", "result" : "executed", "at" : ISODate("2013-05-07T21:19:36.961Z") }
{ "_id" : "00002", "_class" : "se.hit.varmfront.config.migrations.Migration_00002_RenameMigration_1", "result" : "executed", "at" : ISODate("2013-05-07T21:19:37.142Z") }
{ "_id" : "00003", "_class" : "se.hit.varmfront.config.migrations.Migration_00003_CreateToggleMap", "result" : "executed", "at" : ISODate("2013-05-07T21:19:37.264Z") }
{ "_id" : "00004", "_class" : "se.hit.varmfront.config.migrations.Migration_00004_RenameToggleMapToSettings", "result" : "executed", "at" : ISODate("2013-05-07T21:19:37.339Z") }
{ "_class" : "se.hit.varmfront.config.migrations.Migration_00005_AssignCustomerNumbers", "_id" : "00005", "at" : ISODate("2013-05-07T21:19:40.167Z"), "result" : "executed" }
{ "_id" : "00006", "_class" : "se.hit.varmfront.config.migrations.Migration_00006_CorrectHistoryForMigration00005", "result" : "executed", "at" : ISODate("2013-05-08T08:43:18.786Z") }
{ "_id" : "00007", "_class" : "se.hit.varmfront.config.migrations.Migration_00007_AssignAdminRole", "result" : "executed", "at" : ISODate("2013-05-08T11:50:13.666Z") }
{ "_id" : "00008", "_class" : "se.hit.varmfront.config.migrations.Migration_00008_SetMessagePayload", "result" : "executed", "at" : ISODate("2013-05-12T09:46:41.646Z") }
{ "_id" : "00009", "_class" : "se.hit.varmfront.config.migrations.Migration_00009_SetUserLocaleName", "result" : "executed", "at" : ISODate("2013-05-13T11:04:06.205Z") }
{ "_id" : "00010", "_class" : "se.hit.varmfront.config.migrations.Migration_00010_AssignSuperuserRole", "result" : "executed", "at" : ISODate("2013-05-16T15:07:57.142Z") }
{ "_id" : "00011", "_class" : "se.hit.varmfront.config.migrations.Migration_00011_RenameLogEntry", "result" : "executed", "at" : ISODate("2013-05-27T21:42:07.080Z") }
{ "_id" : "00012", "_class" : "se.hit.varmfront.config.migrations.Migration_00012_DefineNewUserNotificationReceiver", "result" : "executed", "at" : ISODate("2013-05-30T18:15:01.684Z") }
{ "_id" : "00013", "_class" : "se.hit.varmfront.config.migrations.Migration_00013_DropCollectionForecastData", "result" : "failed", "at" : ISODate("2013-07-02T13:52:00.689Z") }
{ "_id" : "00014", "_class" : "se.hit.varmfront.config.migrations.Migration_00014_DropUserForecastHoursUTC", "result" : "executed", "at" : ISODate("2013-07-03T09:32:06.752Z") }
{ "_id" : "00015", "_class" : "se.hit.varmfront.config.migrations.Migration_00015_SetUserPreferencesGranularity", "result" : "executed", "at" : ISODate("2013-07-13T09:11:13.979Z") }
{ "_id" : "00016", "_class" : "se.hit.varmfront.config.migrations.Migration_00016_SetUserPreferencesFrequency", "result" : "executed", "at" : ISODate("2013-07-14T19:01:11.597Z") }
{ "_id" : "00017", "_class" : "se.hit.varmfront.config.migrations.Migration_00017_SetUserPreferencesFrequencyAgain", "result" : "executed", "at" : ISODate("2013-07-15T09:53:20.650Z") }
{ "_id" : "00018", "_class" : "se.hit.varmfront.config.migrations.Migration_00018_UnsetUserFavourites", "result" : "executed", "at" : ISODate("2013-07-15T22:19:07.015Z") }
{ "_id" : "00019", "_class" : "se.hit.varmfront.config.migrations.Migration_00019_SetUserAuditTrailSize", "result" : "executed", "at" : ISODate("2013-07-15T23:16:37.140Z") }
>

Vidareutveckling

Varmfronts datamigreringslösning bygger på att alla apparna som kör mot en viss databas har samma version på sina persistenta klasser, och att man inte startar alla apparna samtidigt efter att man har lagt till nya migreringar.

Man kan använda databasen som semafor för att slippa starta apparna en och en, så att efterföljande appar väntar tills den första appen har migrerat klart.

Jag kommer nog att implementera detta den dag Varmfront har vuxit ur sin oklustrade miljö.

Däremot kommer nog Varmfront aldrig att stödja rullande uppgraderingar. Det leder bara till accidental complexity.

One response on “Hur man kan hantera Continuous Delivery med MongoDB

  1. Hej Olle!

    Vi är två studenter från högskolan i Borås som gör en kandidatuppsats om användandet av MongoDB, har du erfarenhet av användning och skulle kunna ställa upp på en intervju?

    Mvh / Fanny och Erik

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.