Lorsque vous développez de nouvelles fonctionnalités à l'aide d'une base de données, le cycle de développement comprend généralement (mais sans s'y limiter) les étapes suivantes:
Écriture de migrations SQL → écriture de code → test → version → surveillance.
Dans cet article, je souhaite partager quelques conseils pratiques sur la façon dont vous pouvez réduire la durée de ce cycle à chaque étape, sans réduire la qualité, mais même l'augmenter.
Puisque nous travaillons avec PostgreSQL dans l'entreprise et écrivons le code serveur en Java, les exemples seront basés sur cette pile, bien que la plupart des idées ne dépendent pas de la base de données et du langage de programmation utilisés.
Migration SQL
La première étape du développement après la conception consiste à écrire la migration SQL. Le principal conseil - ne modifiez pas manuellement le schéma de données, mais faites-le toujours via des scripts et stockez-les au même endroit.
Dans notre entreprise, les développeurs écrivent eux-mêmes les migrations SQL, donc toutes les migrations sont stockées dans un référentiel avec le code principal. Dans certaines entreprises, les administrateurs de bases de données sont impliqués dans la modification du schéma, auquel cas le registre de migration se trouve quelque part avec eux. D'une manière ou d'une autre, cette approche apporte les avantages suivants:
- Vous pouvez toujours facilement créer une nouvelle base à partir de zéro ou mettre à niveau une base existante vers la version actuelle. Cela vous permet de déployer rapidement de nouveaux environnements de test et des environnements de développement locaux.
- Toutes les bases ont la mĂŞme disposition - aucune surprise en service.
- Il existe un historique de toutes les modifications (gestion des versions).
Il existe de nombreux outils prĂŞts Ă l'emploi pour l' automatisation de ce processus, Ă la fois commercial et libre: voie de migration , liquibase , sqitch , etc. Dans cet article , je ne vais pas comparer et choisir le meilleur outil - c'est un grand sujet distinct, et vous trouverez de nombreux articles sur ce ...
Nous utilisons flyway, voici donc quelques informations Ă ce sujet:
- Il existe 2 types de migrations: base sql- et java
- Les migrations SQL sont immuables (immuables). Après la première exécution, la migration SQL ne peut pas être modifiée. Flyway calcule une somme de contrôle pour le contenu du fichier de migration et le vérifie à chaque exécution. Des manipulations manuelles supplémentaires sont nécessaires pour rendre les migrations Java immuables .
- flyway_schema_history ( schema_version). , , , .
Selon nos accords internes, toutes les modifications de schéma de données sont effectuées uniquement via des migrations SQL. Leur immuabilité garantit que nous pouvons toujours obtenir un schéma réel qui est complètement identique à tous les environnements.
Les migrations Java ne sont utilisées que pour DML , lorsqu'il est impossible d'écrire en SQL pur. Pour nous, un exemple typique d'une telle situation est les migrations pour transférer des données vers Postgres à partir d'une autre base de données (nous passons de Redis à Postgres, mais c'est une histoire complètement différente). Un autre exemple est la mise à jour des données d'une grande table, qui est effectuée dans plusieurs transactions pour minimiser le temps de verrouillage de la table. Cela vaut la peine de dire qu'à partir de la 11e version de Postgres, cela peut être fait en utilisant des procédures SQL sur plpgsql.
Lorsque le code Java est obsolète, la migration peut être supprimée afin de ne pas produire d'héritage (la classe de migration Java elle-même reste, mais à l'intérieur elle est vide). Dans notre pays, cela peut se produire au plus tôt un mois après la migration vers la production - nous pensons que ce délai est suffisant pour que tous les environnements de test et de développement local soient mis à jour. Il convient de noter que puisque les migrations Java ne sont utilisées que pour DML, leur suppression n'affecte en rien la création de nouvelles bases de données à partir de zéro.
Une nuance importante pour ceux qui utilisent pg_bouncer
Flyway applique un verrou pendant la migration pour empêcher l'exécution simultanée de plusieurs migrations. Simplifié, cela fonctionne comme ceci:
- la capture de verrouillage se produit
- effectuer des migrations dans des transactions distinctes
- déblocage.
Pour Postgres, il utilise des verrous consultatifs en mode session, ce qui signifie que pour qu'il fonctionne correctement, il est nécessaire que le serveur d'applications s'exécute sur la même connexion pendant la capture et la libération des verrous. Si vous utilisez pg_bouncer en mode transactionnel (ce qui est le plus courant) ou en mode de demande unique, alors pour chaque transaction, il peut renvoyer une nouvelle connexion et flyway ne pourra pas libérer un verrou établi.
Pour résoudre ce problème, nous utilisons un petit pool de connexions séparé sur pg_bouncer en mode session, qui est uniquement destiné aux migrations. Du côté de l'application, il existe également un pool séparé qui contient 1 connexion et il est fermé par timeout après la migration, afin de ne pas gaspiller de ressources.
Codage
La migration a été créée, maintenant nous écrivons le code.
Il existe 3 approches pour travailler avec la base de données du côté de l'application:
- Utilisation d'ORM (si nous parlons de Java, la mise en veille prolongée est de facto la norme)
- Utilisation de plain sql + jdbcTemplate etc.
- Utilisation des bibliothèques DSL.
L'utilisation d'ORM vous permet de réduire les exigences de connaissance de SQL - beaucoup est généré automatiquement:
- le schéma de données peut être créé à partir de la description xml ou de l'entité Java disponible dans le code
- les relations d'objet sont définies à l'aide d'une description déclarative - ORM créera des jointures pour vous
- lors de l'utilisation de Spring Data JPA, des requêtes encore plus délicates peuvent également être générées automatiquement en fonction de la signature de la méthode du référentiel .
Un autre "bonus" est la présence de la mise en cache des données prête à l'emploi (pour la mise en veille prolongée, ce sont 3 niveaux de caches).
Mais il est important de noter que l'ORM, comme tout autre outil puissant, nécessite certaines qualifications lors de son utilisation. Sans une configuration appropriée, le code fonctionnera très probablement, mais loin d'être optimal.
Le contraire est d'écrire le SQL à la main. Cela vous permet d'avoir un contrôle complet sur vos demandes - exactement ce que vous avez écrit est exécuté, sans surprise. Mais, évidemment, cela augmente la quantité de travail manuel et augmente les exigences en matière de qualifications des développeurs.
Bibliothèques DSL
A peu près au milieu de ces approches, il y en a une autre, qui consiste à utiliser des bibliothèques DSL ( jOOQ , Querydsl , etc.). Ils sont généralement beaucoup plus légers que les ORM, mais plus pratiques qu'une manipulation de base de données entièrement manuelle. L'utilisation de DSL est moins courante, donc cet article examinera rapidement cette approche.
Nous parlerons de l'une des bibliothèques - jOOQ . Que propose-t-elle:
- inspection de base de données et génération automatique de classes
- API fluide pour rédiger des requêtes.
jOOQ n'est pas un ORM - il n'y a pas de génération automatique de requêtes ou de mise en cache, mais en même temps, certains des problèmes d'une approche complètement manuelle sont résolus:
- les classes pour les tables, vues, fonctions, etc. les objets de base de données sont générés automatiquement
- les requêtes sont écrites en Java, cela garantit un type sûr - une requête syntaxiquement incorrecte ou une requête avec un paramètre du type incorrect ne sera pas compilée - votre IDE vous demandera immédiatement une erreur, et vous n'aurez pas à passer du temps à lancer l'application pour vérifier l'exactitude de la requête. Cela accélère le processus de développement et réduit la probabilité d'erreurs.
Dans le code, les requĂŞtes ressemblent Ă ceci :
BookRecord book = dslContext.selectFrom(BOOK)
.where(BOOK.LANGUAGE.eq("DE"))
.orderBy(BOOK.TITLE)
.fetchAny();
Vous pouvez utiliser SQL brut si vous le souhaitez:
Result<Record> records = dslContext.fetch("SELECT * FROM BOOK WHERE LANGUAGE = ? ORDER BY TITLE LIMIT 1", "DE");
Évidemment, dans ce cas, l'exactitude de la requête et l'analyse des résultats sont entièrement sur vos épaules.
jOOQ Record et POJO
Le BookRecord dans l'exemple ci-dessus est un wrapper sur une ligne dans la table de livre et implémente le modèle d' enregistrement actif . Puisque cette classe fait partie de la couche d'accès aux données (en plus de son implémentation spécifique), vous ne voudrez peut-être pas la transférer vers d'autres couches de l'application, mais utilisez une sorte d'objet pojo de votre choix. Pour faciliter la conversion des enregistrements <–> pojo jooq propose plusieurs mécanismes: automatique et manuel . La documentation des liens ci-dessus contient une variété d'exemples de lecture, mais aucun exemple pour l'insertion de nouvelles données et la mise à jour. Comblons cette lacune:
private static final RecordUnmapper<Book, BookRecord> unmapper =
book -> new BookRecord(book.getTitle(), ...); // -
public void create(Book book) {
context.insertInto(BOOK)
.set(unmapper.unmap(book))
.execute();
}
Comme vous pouvez le voir, tout est assez simple.
Cette approche vous permet de masquer les détails d'implémentation dans la classe de couche d'accès aux données et d'éviter les «fuites» dans d'autres couches de l'application.
De plus, jooq peut générer des classes DAO avec un ensemble de méthodes de base pour simplifier le travail avec les données de table et réduire la quantité de code manuel (ceci est très similaire à Spring Data JPA):
public interface DAO<R extends TableRecord<R>, P, T> {
void insert(P object) throws DataAccessException;
void update(P object) throws DataAccessException;
void delete(P... objects) throws DataAccessException;
void deleteById(T... ids) throws DataAccessException;
boolean exists(P object) throws DataAccessException;
...
}
Dans l'entreprise, nous n'utilisons pas la génération automatique de classes DAO - nous générons uniquement des wrappers sur des objets de base de données et écrivons nous-mêmes des requêtes. Les wrappers sont générés chaque fois qu'un module maven séparé est reconstruit, dans lequel les migrations sont stockées. Un peu plus tard, il y aura des détails sur la façon dont cela est mis en œuvre.
Essai
L'écriture de tests est une partie importante du processus de développement - de bons tests garantissent la qualité de votre code et vous font gagner du temps tout en le maintenant. Dans le même temps, il est juste de dire que l'inverse est également vrai: de mauvais tests peuvent créer l'illusion d'un code de qualité, masquer les erreurs et ralentir le processus de développement. Ainsi, il ne suffit pas de décider que vous allez écrire des tests, vous devez le faire correctement . Dans le même temps, le concept de l' exactitude des tests est très vague et chacun a son petit morceau.
Il en va de même pour la question de la classification des tests. Cet article suggère d'utiliser l'option de fractionnement suivante:
- tests unitaires (tests unitaires)
- test d'intégration
- tests de bout en bout (de bout en bout).
Les tests unitaires consistent à vérifier la fonctionnalité des modules individuels indépendamment les uns des autres. La taille du module est encore une fois indéfinie, pour certains c'est une méthode séparée, pour certains c'est une classe. L'isolement signifie que tous les autres modules sont des simulacres ou des stubs (en russe, ce sont des imitations ou des stubs, mais ils ne sonnent pas très bien). Suivez ce lien pour lire l'article de Martin Fowler sur la différence entre les deux. Les tests unitaires sont petits, rapides, mais ne peuvent garantir que l'exactitude de la logique d'une unité individuelle.
Tests d'intégrationcontrairement aux tests unitaires, ils vérifient l'interaction de plusieurs modules entre eux. Travailler avec une base de données est un bon exemple lorsque les tests d'intégration ont du sens, car il est très difficile de «verrouiller» une base de données de haute qualité, en tenant compte de toutes ses nuances. Les tests d'intégration dans la plupart des cas sont un bon compromis entre la vitesse d'exécution et l'assurance qualité lors du test d'une base de données par rapport à d'autres types de tests. Par conséquent, dans cet article, nous parlerons plus en détail de ce type de test.
Les tests de bout en bout sont les plus étendus. Pour le réaliser, il est nécessaire d'élever tout l'environnement. Il garantit le plus haut niveau de confiance dans la qualité du produit, mais c'est le plus lent et le plus cher.
Test d'intégration
Lorsqu'il s'agit de tests d'intégration de code qui fonctionne avec une base de données, la plupart des développeurs se posent des questions: comment démarrer la base de données, comment initialiser son état avec les données initiales et comment le faire le plus rapidement possible?
Il y a quelque temps, h2 était une pratique assez courante dans les tests d'intégration . Il s'agit d'une base de données en mémoire écrite en Java qui possède des modes de compatibilité avec les bases de données les plus courantes. L'absence de nécessité d'installer une base de données et la polyvalence de h2 en ont fait un remplacement très pratique pour les bases de données réelles, surtout si l'application ne dépend pas d'une base de données spécifique et n'utilise que ce qui est inclus dans le standard SQL (ce qui n'est pas toujours le cas).
Mais les problèmes commencent au moment où vous utilisez une fonctionnalité de base de données délicate (ou une complètement nouvelle à partir d'une nouvelle version), dont le support n'est pas implémenté dans h2. En général, puisqu'il s'agit d'une «simulation» d'un SGBD spécifique, il peut toujours y avoir des différences de comportement.
Une autre option consiste à utiliser des postgres intégrés . Il s'agit de vrais Postgres, livrés sous forme d'archive et ne nécessitant pas d'installation. Il vous permet de travailler comme une version Postgres standard.
Il existe plusieurs implémentations, les plus populaires de Yandex et openTable... Dans l'entreprise, nous avons utilisé la version de Yandex. Parmi les inconvénients - il est assez lent au démarrage (chaque fois que l'archive est décompressée et que la base de données est lancée - cela prend 2-5 secondes, selon la puissance de l'ordinateur), il y a aussi un problème avec le retard par rapport à la version officielle. Nous avons également été confrontés au problème qu'après une tentative d'arrêt du code, une erreur s'est produite et le processus Postgres est resté suspendu dans le système d'exploitation - vous deviez le supprimer manuellement.
testconteneurs
La troisième option utilise le docker. Pour Java, il existe une bibliothèque testcontainers qui fournit une api pour travailler avec des conteneurs docker à partir du code. Ainsi, toute dépendance dans votre application qui a une image docker peut être remplacée dans les tests à l'aide de testcontainers. En outre, pour de nombreuses technologies populaires, il existe des classes prêtes à l'emploi distinctes qui fournissent une API plus pratique, en fonction de l'image utilisée:
- bases de données (Postgres, Oracle, Cassandra, MongoDB, etc.),
- nginx
- kafka, etc.
À propos, lorsque le projet tescontainers est devenu très populaire, les développeurs de yandex ont officiellement annoncé qu'ils arrêtaient le développement du projet postgres embarqué et ont conseillé de passer à testcontainers.
Quels sont les avantages:
- les conteneurs de test sont rapides (démarrer Postgres vide prend moins d'une seconde)
- La communauté postgres publie des images Docker officielles pour chaque nouvelle version
- testcontainers a un processus spécial qui tue les conteneurs suspendus après l'arrêt de jvm, sauf si vous l'avez fait par programme
- avec testcontainers, vous pouvez utiliser une approche uniforme pour tester les dépendances externes de votre application, ce qui facilite évidemment les choses.
Exemple de test avec Postgres:
@Test
public void testSimple() throws SQLException {
try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>()) {
postgres.start();
ResultSet resultSet = performQuery(postgres, "SELECT 1");
int resultSetInt = resultSet.getInt(1);
assertEquals("A basic SELECT query succeeds", 1, resultSetInt);
}
}
S'il n'y a pas de classe distincte pour l'image dans les testcontainers, la création du conteneur ressemble à ceci :
public static GenericContainer redis = new GenericContainer("redis:3.0.2")
.withExposedPorts(6379);
Si vous utilisez JUnit4, JUnit5 ou Spock, alors testcontainers a un supplément. prise en charge de ces frameworks, ce qui facilite l'écriture des tests.
Accélérer les tests avec les conteneurs de test
Même si le passage des postgres intégrés aux conteneurs de test a rendu nos tests plus rapides en exécutant Postgres plus rapidement, avec le temps, les tests ont recommencé à ralentir. Cela est dû au nombre accru de migrations SQL effectuées par flyway au démarrage. Lorsque le nombre de migrations dépassait la centaine, le temps d'exécution était d'environ 7 à 8 secondes, ce qui ralentissait considérablement les tests. Cela a fonctionné quelque chose comme ça:
- avant la prochaine classe de test, un conteneur "propre" avec Postgres a été lancé
- migrations effectuées sur la voie de migration
- les tests de cette classe ont été exécutés
- le conteneur a été arrêté et retiré
- répéter à partir de l'élément 1 pour la classe de test suivante.
Évidemment, avec le temps, la deuxième étape a pris de plus en plus de temps.
En essayant de résoudre ce problème, nous nous sommes rendu compte qu'il suffisait d'effectuer des migrations une seule fois avant tous les tests, enregistrer l'état du conteneur puis utiliser ce conteneur dans tous les tests. Donc, l'algorithme a changé:
- un container "propre" avec Postgres est lancé avant tous les tests
- flyway effectue des migrations
- l'Ă©tat du conteneur persiste
- avant la prochaine classe de test, un conteneur préalablement préparé est lancé
- les tests de cette classe sont exécutés
- le conteneur s'arrête et est retiré
- répétez à partir de l'étape 4 pour la classe de test suivante.
Désormais, le temps d'exécution d'un test individuel ne dépend pas du nombre de migrations, et avec le nombre actuel de migrations (200+), le nouveau schéma économise plusieurs minutes à chaque exécution de tous les tests.
Voici quelques détails techniques sur la façon de l'implémenter.
Docker dispose d'un mécanisme intégré pour créer une nouvelle image à partir d'un conteneur en cours d'exécution à l'aide de la commande commit . Il vous permet de personnaliser les images, par exemple, en modifiant les paramètres.
Une nuance importante est que la commande n'enregistre pas les données des partitions montées. Mais si vous prenez l'image docker officielle de Postgres, le répertoire PGDATA, dans lequel les données sont stockées, est situé dans une section séparée (de sorte qu'après le redémarrage du conteneur, les données ne sont pas perdues), par conséquent, lorsque le commit est exécuté, l'état de la base de données elle-même n'est pas enregistré.
La solution est simple - n'utilisez pas la section pour PGDATA, mais gardez les données en mémoire, ce qui est tout à fait normal pour les tests. Il y a 2 façons d'y parvenir: utilisez votre fichier docker (quelque chose comme ceci) sans créer de section, ou remplacer la variable PGDATA lors du démarrage du conteneur officiel (la section restera, mais ne sera pas utilisée). La deuxième façon semble beaucoup plus simple:
PostgreSQLContainer<?> container = ...
container.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");
container.start();
Avant de valider, il est recommandé de checkpoint postgres pour vider les modifications des tampons partagés vers "disk" (qui correspond à la variable PGDATA surchargée):
container.execInContainer("psql", "-c", "checkpoint");
Le commit lui-mĂŞme ressemble Ă ceci:
CommitCmd cmd = container.getDockerClient().commitCmd(container.getContainerId())
.withMessage("Container for integration tests. ...")
.withRepository(imageName)
.withTag(tag);
String imageId = cmd.exec();
Il est à noter que cette approche utilisant des images préparées peut être appliquée à de nombreuses autres images, ce qui permettra également de gagner du temps lors de l'exécution des tests d'intégration.
Quelques mots supplémentaires sur l'optimisation du temps de construction
Comme mentionné précédemment, lors de l'assemblage d'un module maven séparé avec des migrations, entre autres choses, des wrappers java sont générés sur les objets de la base de données. Pour cela, un plugin maven auto-écrit est utilisé, qui est lancé avant de compiler le code principal et effectue 3 actions:
- Exécute un conteneur Docker "propre" avec postgres
- Lance Flyway, qui effectue des migrations SQL pour toutes les bases de données, vérifiant ainsi leur validité
- Exécute Jooq, qui inspecte le schéma de la base de données et génère des classes Java pour les tables, vues, fonctions et autres objets de schéma.
Comme vous pouvez facilement le constater, les 2 premières étapes sont identiques à celles effectuées lors de l'exécution des tests. Pour gagner du temps lors du démarrage du conteneur et de l'exécution des migrations avant les tests, nous avons déplacé l'enregistrement de l'état du conteneur vers un plugin. Ainsi, maintenant, immédiatement après la reconstruction du module, des images prêtes à l'emploi pour les tests d'intégration de toutes les bases de données utilisées dans le code apparaissent dans le référentiel local des images docker.
Exemple de code plus détaillé
( «start»):
save-state stop .
:
@ThreadSafe
public class PostgresContainerAdapter implements PostgresExecutable {
private static final String ORIGINAL_IMAGE = "postgres:11.6-alpine";
@GuardedBy("this")
@Nullable
private PostgreSQLContainer<?> container; // not null if it is running
@Override
public synchronized String start(int port, String db, String user, String password)
{
Preconditions.checkState(container == null, "postgres is already running");
PostgreSQLContainer<?> newContainer = new PostgreSQLContainer<>(ORIGINAL_IMAGE)
.withDatabaseName(db)
.withUsername(user)
.withPassword(password);
newContainer.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");
// workaround for using fixed port instead of random one chosen by docker
List<String> portBindings = new ArrayList<>(newContainer.getPortBindings());
portBindings.add(String.format("%d:%d", port, POSTGRESQL_PORT));
newContainer.setPortBindings(portBindings);
newContainer.start();
container = newContainer;
return container.getJdbcUrl();
}
@Override
public synchronized void saveState(String name) {
try {
Preconditions.checkState(container != null, "postgres isn't started yet");
// flush all changes
doCheckpoint(container);
commitContainer(container, name);
} catch (Exception e) {
stop();
throw new RuntimeException("Saving postgres container state failed", e);
}
}
@Override
public synchronized void stop() {
Preconditions.checkState(container != null, "postgres isn't started yet");
container.stop();
container = null;
}
private static void doCheckpoint(PostgreSQLContainer<?> container) {
try {
container.execInContainer("psql", "-c", "checkpoint");
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
}
private static void commitContainer(PostgreSQLContainer<?> container, String image)
{
String tag = "latest";
container.getDockerClient().commitCmd(container.getContainerId())
.withMessage("Container for integration tests. It uses non default location for PGDATA which is not mounted to a volume")
.withRepository(image)
.withTag(tag)
.exec();
}
// ...
}
( «start»):
@Mojo(name = "start")
public class PostgresPluginStartMojo extends AbstractMojo {
private static final Logger logger = LoggerFactory.getLogger(PostgresPluginStartMojo.class);
@Nullable
static PostgresExecutable postgres;
@Parameter(defaultValue = "5432")
private int port;
@Parameter(defaultValue = "dbName")
private String db;
@Parameter(defaultValue = "userName")
private String user;
@Parameter(defaultValue = "password")
private String password;
@Override
public void execute() throws MojoExecutionException {
if (postgres != null) {
logger.warn("Postgres already started");
return;
}
logger.info("Starting Postgres");
if (!isDockerInstalled()) {
throw new IllegalStateException("Docker is not installed");
}
String url = start();
testConnection(url, user, password);
logger.info("Postgres started at " + url);
}
private String start() {
postgres = new PostgresContainerAdapter();
return postgres.start(port, db, user, password);
}
private static void testConnection(String url, String user, String password) throws MojoExecutionException {
try (Connection conn = DriverManager.getConnection(url, user, password)) {
conn.createStatement().execute("SELECT 1");
} catch (SQLException e) {
throw new MojoExecutionException("Exception occurred while testing sql connection", e);
}
}
private static boolean isDockerInstalled() {
if (CommandLine.executableExists("docker")) {
return true;
}
if (CommandLine.executableExists("docker.exe")) {
return true;
}
if (CommandLine.executableExists("docker-machine")) {
return true;
}
if (CommandLine.executableExists("docker-machine.exe")) {
return true;
}
return false;
}
}
save-state stop .
:
<build>
<plugins>
<plugin>
<groupId>com.miro.maven</groupId>
<artifactId>PostgresPlugin</artifactId>
<executions>
<!-- running a postgres container -->
<execution>
<id>start-postgres</id>
<phase>generate-sources</phase>
<goals>
<goal>start</goal>
</goals>
<configuration>
<db>${db}</db>
<user>${dbUser}</user>
<password>${dbPassword}</password>
<port>${dbPort}</port>
</configuration>
</execution>
<!-- applying migrations and generation java-classes -->
<execution>
<id>flyway-and-jooq</id>
<phase>generate-sources</phase>
<goals>
<goal>execute-mojo</goal>
</goals>
<configuration>
<plugins>
<!-- applying migrations -->
<plugin>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-maven-plugin</artifactId>
<version>${flyway.version}</version>
<executions>
<execution>
<id>migration</id>
<goals>
<goal>migrate</goal>
</goals>
<configuration>
<url>${dbUrl}</url>
<user>${dbUser}</user>
<password>${dbPassword}</password>
<locations>
<location>filesystem:src/main/resources/migrations</location>
</locations>
</configuration>
</execution>
</executions>
</plugin>
<!-- generation java-classes -->
<plugin>
<groupId>org.jooq</groupId>
<artifactId>jooq-codegen-maven</artifactId>
<version>${jooq.version}</version>
<executions>
<execution>
<id>jooq-generate-sources</id>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<jdbc>
<url>${dbUrl}</url>
<user>${dbUser}</user>
<password>${dbPassword}</password>
</jdbc>
<generator>
<database>
<name>org.jooq.meta.postgres.PostgresDatabase</name>
<includes>.*</includes>
<excludes>
#exclude flyway tables
schema_version | flyway_schema_history
# other excludes
</excludes>
<includePrimaryKeys>true</includePrimaryKeys>
<includeUniqueKeys>true</includeUniqueKeys>
<includeForeignKeys>true</includeForeignKeys>
<includeExcludeColumns>true</includeExcludeColumns>
</database>
<generate>
<interfaces>false</interfaces>
<deprecated>false</deprecated>
<jpaAnnotations>false</jpaAnnotations>
<validationAnnotations>false</validationAnnotations>
</generate>
<target>
<packageName>com.miro.persistence</packageName>
<directory>src/main/java</directory>
</target>
</generator>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</configuration>
</execution>
<!-- creation an image for integration tests -->
<execution>
<id>save-state-postgres</id>
<phase>generate-sources</phase>
<goals>
<goal>save-state</goal>
</goals>
<configuration>
<name>postgres-it</name>
</configuration>
</execution>
<!-- stopping the container -->
<execution>
<id>stop-postgres</id>
<phase>generate-sources</phase>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Libération
Le code est écrit et testé - il est temps de le publier. En général, la complexité d'une version dépend des facteurs suivants:
- sur le nombre de bases de données (une ou plusieurs)
- sur la taille de la base de données
- sur le nombre de serveurs d'applications (un ou plusieurs)
- version transparente ou non (si le temps d'arrêt de l'application est autorisé).
Les éléments 1 et 3 imposent une exigence de compatibilité descendante sur le code, car dans la plupart des cas, il est impossible de mettre à jour simultanément toutes les bases de données et tous les serveurs d'applications - il y aura toujours un moment où les bases de données auront des schémas différents et les serveurs auront différentes versions du code.
La taille de la base de données affecte le temps de migration - plus la base de données est volumineuse, plus vous devrez probablement effectuer une longue migration.
La transparence est en partie un facteur résultant - si la libération est effectuée avec arrêt (temps d'arrêt), les 3 premiers points ne sont pas si importants et affectent uniquement le temps pendant lequel l'application est indisponible.
Si nous parlons de notre service, alors ce sont:
- environ 30 clusters de bases de données
- taille d'une base 200 - 400 Go
- ( 100),
- .
Nous utilisons des versions Canary : une nouvelle version de l'application est d'abord affichée sur un petit nombre de serveurs (nous l'appelons une pré-version), et après un certain temps, si aucune erreur n'est trouvée dans la pré-version, elle est diffusée sur d'autres serveurs. Ainsi, les serveurs de production peuvent fonctionner sur différentes versions.
Lors du lancement, chaque serveur d'application vérifie la version de la base de données avec les versions des scripts qui se trouvent dans le code source (en termes de flyway, cela s'appelle la validation ). S'ils sont différents, le serveur ne démarrera pas. Cela garantit la compatibilité du code et de la base de données . Une situation ne peut pas se produire lorsque, par exemple, le code fonctionne avec une table qui n'a pas encore été créée, car la migration s'effectue dans une version différente du serveur.
Mais cela ne résout bien sûr pas le problème lorsque, par exemple, dans la nouvelle version de l'application, il y a une migration qui supprime une colonne de la table qui peut être utilisée dans l'ancienne version du serveur. Désormais, nous vérifions de telles situations uniquement au stade de la révision (c'est obligatoire), mais à l'amiable il est nécessaire d'introduire des compléments. étape avec un tel contrôle dans le cycle CI / CD.
Parfois, les migrations peuvent prendre du temps (par exemple, lors de la mise à jour de données à partir d'une grande table) et afin de ne pas ralentir les versions en même temps, nous utilisons la technique des migrations combinées... La combinaison consiste à exécuter manuellement la migration sur un serveur en cours d'exécution (via le panneau d'administration, sans flyway et, par conséquent, sans enregistrement dans l'historique de migration), puis à une sortie "régulière" de la même migration dans la prochaine version du serveur. Ces migrations sont soumises aux exigences suivantes:
- Tout d'abord, il doit être écrit de manière à ne pas bloquer l'application lors d'une exécution longue (l'essentiel ici n'est pas d'acquérir des verrous à long terme au niveau DB). Pour ce faire, nous avons des directives internes pour les développeurs sur la façon d'écrire des migrations. A l'avenir, je pourrais aussi les partager sur Habré.
- Deuxièmement, la migration lors d'un lancement "normal" doit déterminer qu'elle a déjà été effectuée en mode manuel et ne rien faire dans ce cas - il suffit de valider un nouvel enregistrement dans l'historique. Pour les migrations SQL, une telle vérification est effectuée en exécutant une requête SQL pour les modifications. Une autre approche pour les migrations Java consiste à utiliser des indicateurs booléens stockés, qui sont définis après une exécution manuelle.
Cette approche résout 2 problèmes:
- la libération est rapide (bien qu'avec des actions manuelles)
- ( ) - .
Une fois publié, le cycle de développement ne se termine pas. Pour comprendre si la nouvelle fonctionnalité fonctionne (et comment elle fonctionne), il est nécessaire de «renfermer» des métriques. Ils peuvent être divisés en 2 groupes: entreprise et système.
Le premier groupe dépend fortement du domaine: pour un serveur de messagerie, il est utile de connaître le nombre de lettres envoyées, pour une ressource d'actualité - le nombre d'utilisateurs uniques par jour, etc.
Les paramètres du second groupe sont à peu près les mêmes pour tout le monde - ils déterminent l'état technique du serveur: cpu, mémoire, réseau, base de données, etc.
Que faut -il exactement à surveiller et comment le faire - c'est un sujet d'un grand nombre d'articles séparés et il ne sera pas abordé ici. Je voudrais rappeler uniquement les choses les plus élémentaires (même celles du capitaine):
définir les métriques à l'avance
Il est nécessaire de définir une liste de métriques de base. Et cela doit être fait à l'avance , avant la publication, et non après le premier incident, lorsque vous ne comprenez pas ce qui se passe avec le système.
configurer des alertes automatiques
Cela accélérera votre temps de réaction et vous fera gagner du temps sur la surveillance manuelle. Idéalement, vous devez connaître les problèmes avant que les utilisateurs ne les ressentent et ne vous écrivent.
collecter les métriques de tous les nœuds
Les métriques, comme les journaux, ne sont jamais trop nombreuses. La présence de données de chaque nœud de votre système (serveur d'application, base de données, extracteur de connexion, équilibreur, etc.) vous permet d'avoir une image complète de son état, et, si nécessaire, vous pouvez localiser rapidement le problème.
Un exemple simple: le chargement des données d'une page Web a commencé à ralentir. Il peut y avoir plusieurs raisons:
- le serveur Web est surchargé et met beaucoup de temps à répondre aux demandes
- La requête SQL prend plus de temps à s'exécuter
- une file d'attente s'est accumulée sur le pool de connexions et le serveur d'applications ne peut pas recevoir de connexion pendant une longue période
- problèmes de réseau
- autre chose
Sans métriques, trouver la cause d'un problème ne sera pas facile.
Au lieu de l'achèvement
Je voudrais dire une phrase très banale sur le fait qu’il n’existe pas de solution miracle et que le choix de l’une ou de l’autre approche dépend des exigences d’une tâche spécifique, et ce qui fonctionne bien pour d’autres peut ne pas être applicable pour vous. Mais plus vous connaissez d'approches différentes, plus vous pourrez faire ce choix de manière approfondie et qualitative. J'espère que de cet article vous avez appris quelque chose de nouveau pour vous-même qui vous aidera à l'avenir. Je serais heureux de commenter les approches que vous utilisez pour améliorer le processus de travail avec la base de données.