Travailler avec des bases de données à travers les yeux d'un développeur



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:





À 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:



  1. avant la prochaine classe de test, un conteneur "propre" avec Postgres a été lancé
  2. migrations effectuées sur la voie de migration
  3. les tests de cette classe ont été exécutés
  4. le conteneur a été arrêté et retiré
  5. 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é:



  1. un container "propre" avec Postgres est lancé avant tous les tests
  2. flyway effectue des migrations
  3. l'Ă©tat du conteneur persiste
  4. avant la prochaine classe de test, un conteneur préalablement préparé est lancé
  5. les tests de cette classe sont exécutés
  6. le conteneur s'arrête et est retiré
  7. 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:



  1. Exécute un conteneur Docker "propre" avec postgres
  2. Lance Flyway, qui effectue des migrations SQL pour toutes les bases de données, vérifiant ainsi leur validité
  3. 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é
@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.



All Articles