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 😉
/** * 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.
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