Blog

💁🏻‍♀️ Por qué Gato GraphQL necesita un monorepo, y cómo está optimizado

Leonardo Losoviz
Por Leonardo Losoviz ·

Hace unos días publiqué el artículo Alojando todos tus paquetes PHP juntos en un monorepo, explicando por qué podemos querer usar un monorepo para gestionar nuestro código base PHP, y cómo hacerlo mediante el Monorepo Builder.

Aquí me gustaría complementar ese artículo, explicando un poco más en detalle por qué el código base de GatoGraphQL/GatoGraphQL (que aloja Gato GraphQL, su motor GraphQL subyacente, y la arquitectura de componentes-modelo sobre la que se basa) necesita estar alojado en un monorepo, y las optimizaciones que he hecho para ello.

Por qué Gato GraphQL necesita un monorepo

Para soportar la agnosticidad respecto al CMS, el código base de Gato GraphQL y proyectos asociados fue dividido en una multitud de paquetes, gestionados mediante Composer. ¡En total, se crearon más de 100 paquetes! (Actualmente, el número supera los 200.)

El gran número de paquetes no añade complejidad adicional para ensamblarlos todos mediante Composer: simplemente ejecutamos composer install, y todo funciona. Sin embargo, sí se vuelve problemático para el desarrollo cuando cada uno de los paquetes vive en su propio repositorio, debido al versionado.

Cada paquete debe ser versionado, y cada versión de un paquete dependerá de alguna versión de otro paquete. Con tantos paquetes, configurar cómo todas las versiones dependen entre sí al crear PRs se convertiría en una pesadilla, asemejándose a un plato de spaghetti code, donde puedes ver la punta de un fideo, pero no sabes dónde termina.

Buscando el otro extremo

La verdad es que se volvió tan difícil enlazar todas las versiones de las múltiples ramas de todos los repositorios involucrados, que terminé saltándome este proceso por completo, empujando el código directamente a la rama master en cada repo, y dependiendo después de la versión dev-master en todos.

No era apropiado. Cambiar al modelo de monorepo, alojando todo el código en GatoGraphQL/GatoGraphQL, ha resuelto efectivamente el problema.

Efecto secundario bienvenido: menor barrera para las contribuciones

Como mencioné en el artículo, en aquellos tiempos en que el proyecto usaba un repo por paquete, un colaborador abandonó el proyecto antes incluso de unirse, por su incapacidad de configurar el entorno de trabajo.

Antes de cambiar al monorepo, configurar el entorno de desarrollo era muy difícil. Como era el autor, podía arreglármelas para clonar todos los repos y añadirlos juntos bajo un único workspace de VSCode, así que, más o menos, funcionaba para mí.

Intenté facilitar a los colaboradores potenciales la configuración del mismo entorno, mediante este script bash. Pero en serio, eso nunca podía funcionar, era una batalla perdida desde el principio, y nadie podía empezar a contribuir al proyecto.

Con el monorepo, puedo dormir tranquilo por la noche, sabiendo que no estaré rechazando a colaboradores con una burocracia irrazonable, si alguna vez quieren involucrarse.

Optimizando el monorepo

Como mencioné en el artículo, la ventaja de usar la librería Monorepo Builder frente a las alternativas, es que está construida con PHP, y que podemos extenderla.

Por ejemplo, al hacer un push a master y dividir el monorepo, la matriz en la GitHub Action normalmente lanzará una instancia de runner por paquete, para sincronizar su código con su propio repositorio (para distribución vía Packagist).

Como GatoGraphQL/GatoGraphQL contiene más de 200 paquetes, eso significaba que se estaban lanzando más de 200 instancias de runners.

Procesando más de 200 paquetes

El problema aquí es que GitHub te da un límite de 20 jobs ejecutándose en paralelo. Como todas las acciones se colocan en una cola, necesitaba esperar a que terminaran, para continuar ejecutando otras acciones.

Además, de vez en cuando GitHub no provisiona un runner inmediatamente, y te hace esperar hasta más tarde:

Esperando a que los runners estén disponibles

Todo esto se traduce en tiempo de espera. Con más de 200 paquetes, ¡mergear un único PR podía tardar hasta 1 hora! Este es un problema que necesitaba ser resuelto.

Extender el monorepo con comandos personalizados puede resolver el problema.

Extendiendo el Monorepo builder

Normalmente, al ejecutar el siguiente comando, obtendremos la lista de todos los paquetes en el repo:

vendor/bin/monorepo-builder packages-json

Recuperando la lista de todos los paquetes en el repo

Pero entonces pensé: no hay necesidad de sincronizar todos los paquetes, sino solo aquellos que contienen código que fue modificado en el PR.

Si podemos averiguar la lista de archivos modificados, podemos calcular cuáles son los paquetes modificados que los contienen. En otras palabras: ejecutar git diff, y alimentar los resultados al comando packages-json, mediante una entrada filter, así:

vendor/bin/monorepo-builder packages-json --filter=modified_file_1 --filter=modified_file_2 --filter=...

Ahora, el comando packages-json que viene con el Monorepo Builder no acepta una entrada filter. Así que aquí es donde debemos extenderlo con nuestros comandos personalizados.

El Monorepo builder usa DependencyInjection de Symfony, por lo que puede ser extendido inyectando nuevos servicios en su contenedor. De hecho, el archivo de configuración monorepo-builder.php ya es un configurador de servicios.

Así que extendí el Monorepo builder con un nuevo comando llamado package-entries-json, que soporta la entrada filter:

final class PackageEntriesJsonCommand extends AbstractSymplifyCommand
{
  private PackageEntriesJsonProvider $packageEntriesJsonProvider;
 
  public function __construct(PackageEntriesJsonProvider $packageEntriesJsonProvider)
  {
    $this->packageEntriesJsonProvider = $packageEntriesJsonProvider;
 
    parent::__construct();
  }
 
  protected function configure(): void
  {
    $this->setDescription('Provides package entries in json format. Useful for GitHub Actions Workflow');
    $this->addOption(
      Option::FILTER,
      null,
      InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY,
      'Filter the packages to those from the list of files. Useful to split monorepo on modified packages only',
      []
    );
  }
 
  protected function execute(InputInterface $input, OutputInterface $output): int
  {
    /** @var string[] $fileFilter */
    $fileFilter = $input->getOption(Option::FILTER);
 
    $packageEntries = $this->packageEntriesJsonProvider->providePackageEntries($fileFilter);
 
    // must be without spaces, otherwise it breaks GitHub Actions json
    $json = Json::encode($packageEntries);
    $this->symfonyStyle->writeln($json);
 
    return ShellCode::SUCCESS;
  }
}

Se inyecta en el contenedor de servicios así:

return static function (ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $services->defaults()->autowire()->autoconfigure();
    $services->set(PackageEntriesJsonCommand::class);
}

Ahora, el nuevo comando llamado package-entries-json estará disponible para el workflow de GitHub Action.

Obteniendo la lista de archivos modificados en la GitHub Action

Veamos ahora cómo actualizar el workflow.

Convenientemente uso la action technote-space/get-diff-action, que proporciona el git diff de todos los archivos modificados en el PR:

# git diff to generate matrix with modified packages only
- uses: technote-space/get-diff-action@v4
  with:
    PATTERNS: layers/*/*/*/**

A partir de estos resultados (almacenados bajo ${{ env.GIT_DIFF }}) entonces genero la llamada al comando personalizado package-entries-json, y la establezco como salida:

- id: output_data
  name: Calculate matrix for packages
  run: |
    quote=\'
    clean_diff="$(echo "${{ env.GIT_DIFF }}" | sed -e s/$quote//g)"
    packages_in_diff="$(echo $clean_diff | grep -E -o 'layers/[A-Za-z0-9_\-]*/[A-Za-z0-9_\-]*/[A-Za-z0-9_\-]*/' | sort -u)"
    echo "[Packages in diff] $(echo $packages_in_diff | tr '\n' ' ')"
    filter_arg="--filter=$(echo $packages_in_diff | sed -e 's/ / --filter=/g')"
    echo "::set-output name=matrix::$(vendor/bin/monorepo-builder package-entries-json $(echo $filter_arg))"

Los paquetes resultantes se usan luego para crear la matriz:

outputs:
  matrix: ${{ steps.output_data.outputs.matrix }}

¡Funciona genial! En esta instancia, solo se modificaron dos paquetes, así que solo se lanzaron 2 instancias en la matriz:

Obteniendo la lista de paquetes modificados

Ahora, mergear el PR puede tardar solo unos minutos (en lugar de 1 hora), así que soy un desarrollador feliz de nuevo.

Más optimizaciones/desafíos

Hay otra instancia en la que puedo reducir tiempo de GitHub Action: al ejecutar los tests de PHPUnit.

Actualmente, siempre que se sube un nuevo trozo de código, se ejecuta toda la batería de tests para todos los paquetes. Pero de nuevo, esto puede optimizarse.

Digamos que el monorepo contiene 3 paquetes: A, B y C, donde B depende de A, y C depende de B.

Entonces, si modificamos código de un solo paquete, los tests que requieren ejecución variarán:

  • Modificar código de A: debe probar A, B y C
  • Modificar código de B: debe probar B y C
  • Modificar código de C: debe probar C

La optimización dependerá entonces de obtener la lista de paquetes modificados (como en la optimización anterior), y ejecutar tests para ellos y para todos los paquetes que dependen de ellos.

Sin embargo, actualmente no poseo la información de cómo cada paquete en el monorepo depende de los demás.

Aunque el composer.json raíz contiene todos los paquetes locales, no puedo obtener sus dependencias mediante Composer ejecutando composer info ${ package_name }, porque han sido definidos en la sección replace, en lugar de require.

Alternativamente, podría entrar en la subcarpeta de cada paquete, ejecutar composer install, y luego hacer composer info. Pero ejecutar composer install más de 200 veces sería una locura absoluta.

Por lo tanto, todavía no he optimizado este escenario. Hasta ahora he creado el issue, y espero encontrar finalmente una solución.

Cerrando

Debo decir que estoy extremadamente contento de haber descubierto el Monorepo Builder. No creo que pudiera gestionar el código base de Gato GraphQL de otra forma.

No estoy diciendo que todos los proyectos deberían usarlo. Pero cuando tienes más de 200 paquetes, como en mi caso, o posiblemente incluso más de 20, entonces simplifica absolutamente tu vida.

Gestionar el monorepo lleva algo de tiempo y esfuerzo para configurar y mantener, pero ese tiempo y esfuerzo lo ahorro múltiples veces cada día, solo con el desarrollo continuo.


Suscríbete a nuestra newsletter

Mantente al tanto de todas las novedades de Gato GraphQL.