บล็อก

💁🏻‍♀️ ทำไม Gato GraphQL ถึงต้องใช้ Monorepo และการปรับให้เหมาะสม

Leonardo Losoviz
โดย Leonardo Losoviz ·

เมื่อไม่กี่วันก่อน ผมได้เผยแพร่บทความ Hosting all your PHP packages together in a monorepo ซึ่งอธิบายว่าทำไมเราถึงอาจต้องการใช้ monorepo เพื่อจัดการ PHP codebase และวิธีทำผ่าน Monorepo Builder

ที่นี่ผมอยากจะเสริมบทความนั้น โดยอธิบายอย่างละเอียดมากขึ้นว่าทำไม codebase ของ GatoGraphQL/GatoGraphQL (ซึ่งโฮสต์ Gato GraphQL, GraphQL engine ที่เป็นพื้นฐาน และ component-model architecture ที่สร้างขึ้นบนนั้น) จึงต้องโฮสต์บน monorepo และการปรับให้เหมาะสมที่ผมได้ทำไป

ทำไม Gato GraphQL ถึงต้องใช้ monorepo

เพื่อรองรับความเป็น CMS-agnostic codebase สำหรับ Gato GraphQL และโปรเจกต์ที่เกี่ยวข้องถูกแบ่งออกเป็น packages จำนวนมาก ซึ่งจัดการผ่าน Composer รวมแล้วมีมากกว่า 100 packages! (ปัจจุบันมีมากกว่า 200 packages แล้ว)

จำนวน packages ที่มากไม่ได้เพิ่มความซับซ้อนในการนำมารวมกันผ่าน Composer: เพียงแค่รัน composer install ก็ทำงานได้ทั้งหมด อย่างไรก็ตาม มันกลายเป็นปัญหาสำหรับการพัฒนาเมื่อแต่ละ package อาศัยอยู่ใน repository ของตัวเอง เนื่องจากเรื่อง versioning

แต่ละ package ต้องมีการ versioning และทุก version ของ package จะขึ้นอยู่กับ version บางอย่างของ package อื่น เมื่อมี packages มากมายขนาดนี้ การกำหนดค่าว่า versions ทั้งหมดขึ้นอยู่กับกันอย่างไรเมื่อสร้าง PR จะกลายเป็นฝันร้าย คล้ายกับจานสปาเกตตี้ที่คุณเห็นปลายเส้นหนึ่ง แต่ไม่รู้ว่ามันจบที่ไหน

กำลังค้นหาปลายอีกข้าง

ความจริงคือ การเชื่อมโยง versions ทั้งหมดของ branches หลายอันจาก repositories ที่เกี่ยวข้องทั้งหมดนั้นยากมากจนผมข้ามกระบวนการนี้ไปเลย โดย push code ตรงไปยัง branch master ของแต่ละ repo แล้ว depend กับ version dev-master ในแต่ละอัน

มันไม่เหมาะสม การเปลี่ยนไปใช้ monorepo model โดยโฮสต์ code ทั้งหมดใน GatoGraphQL/GatoGraphQL ได้แก้ปัญหานั้นได้อย่างมีประสิทธิภาพ

ผลข้างเคียงที่ยินดีต้อนรับ: ลดอุปสรรคสำหรับ contributors

ดังที่ผมพูดถึงในบทความ ในยุคที่โปรเจกต์ใช้ repository แยกต่างหากสำหรับแต่ละ package มี contributor รายหนึ่งที่ออกจากโปรเจกต์ก่อนจะเริ่มต้น เนื่องจากไม่สามารถตั้งค่า working environment ได้

ก่อนที่จะเปลี่ยนไปใช้ monorepo การตั้งค่า development environment นั้นยากมาก เนื่องจากผมเป็นผู้เขียน ผมพอจะ clone repos ทั้งหมดและนำมารวมกันใน VSCode workspace เดียวได้ ซึ่งทำให้พอใช้ได้สำหรับผม

ผมพยายามทำให้ผู้ที่อาจเป็น contributors ตั้งค่า environment เดิมได้ง่ายขึ้นผ่าน bash script นี้ แต่จริงๆ แล้ว มันไม่มีทางได้ผลตั้งแต่ต้น และไม่มีใครเริ่มมีส่วนร่วมกับโปรเจกต์ได้เลย

ด้วย monorepo ผมสามารถนอนหลับได้อย่างสบายในตอนกลางคืน โดยรู้ว่าจะไม่ปฏิเสธ contributors ด้วยระบบราชการที่ไม่สมเหตุสมผล หากพวกเขาต้องการมีส่วนร่วม

การปรับ monorepo ให้เหมาะสม

ดังที่กล่าวไว้ในบทความ ข้อได้เปรียบของการใช้ Monorepo Builder library เมื่อเทียบกับทางเลือกอื่น คือมันถูกสร้างด้วย PHP และเราสามารถขยายมันได้

ตัวอย่างเช่น เมื่อ push ไปยัง master และแยก monorepo matrix ใน GitHub Action มักจะสร้าง runner instance หนึ่งอันต่อ package เพื่อซิงโครไนซ์ code กับ repository ของมันเอง (สำหรับการแจกจ่ายผ่าน Packagist)

เนื่องจาก GatoGraphQL/GatoGraphQL มีมากกว่า 200 packages นั่นหมายความว่ามีการเปิด runner instances มากกว่า 200 อัน

กำลังประมวลผลมากกว่า 200 packages

ปัญหาที่นี่คือ GitHub จำกัดจำนวน jobs ที่รันพร้อมกันไว้ที่ 20 jobs เนื่องจาก actions ทั้งหมดถูกวางในคิว ผมต้องรอให้มันเสร็จก่อนจึงจะดำเนินการ actions อื่นต่อได้

นอกจากนี้ บางครั้ง GitHub จะไม่จัดสรร runner ทันที และทำให้คุณรอจนกว่าจะถึงเวลาหลังจากนั้น:

กำลังรอให้ runners พร้อมใช้งาน

สิ่งทั้งหมดนี้แปลเป็นเวลารอ เมื่อมีมากกว่า 200 packages การ merge PR เพียงอันเดียวอาจใช้เวลาถึง 1 ชั่วโมง! นี่คือปัญหาที่ต้องแก้ไข

การขยาย monorepo ด้วย custom commands สามารถแก้ปัญหาได้

การขยาย Monorepo Builder

โดยปกติ เมื่อรัน command ต่อไปนี้ เราจะได้รับรายการ packages ทั้งหมดใน repo:

vendor/bin/monorepo-builder packages-json

กำลังดึงรายการ packages ทั้งหมดใน repo

แต่แล้วผมก็คิดว่า: ไม่จำเป็นต้องซิงโครไนซ์ packages ทั้งหมด แค่ packages ที่มี code ที่ถูกแก้ไขใน PR เท่านั้น

ถ้าเราสามารถหารายการไฟล์ที่ถูกแก้ไขได้ เราก็สามารถคำนวณว่า packages ที่แก้ไขที่บรรจุไฟล์เหล่านั้นคืออะไร กล่าวอีกนัยหนึ่ง: รัน git diff และส่งผลลัพธ์ไปยัง command packages-json ผ่าน input filter แบบนี้:

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

ตอนนี้ command packages-json ที่มาพร้อมกับ Monorepo Builder ไม่รับ input filter เราจึงต้องขยายมันด้วย custom commands ของเราเอง

Monorepo Builder ใช้ Symfony's DependencyInjection ดังนั้นจึงสามารถขยายได้โดยการ inject services ใหม่เข้าไปใน container จริงๆ แล้ว ไฟล์ configuration monorepo-builder.php เป็น service configurator อยู่แล้ว

ผมจึงขยาย Monorepo Builder ด้วย command ใหม่ชื่อ package-entries-json ซึ่งรองรับ input 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;
  }
}

มัน inject เข้า service container แบบนี้:

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

ตอนนี้ command ใหม่ชื่อ package-entries-json จะพร้อมใช้งานใน GitHub Action workflow

การดึงรายการไฟล์ที่ถูกแก้ไขใน GitHub Action

ต่อไปมาดูวิธีอัปเดต workflow

ผมใช้ action technote-space/get-diff-action อย่างสะดวก ซึ่ง provide git diff ของไฟล์ทั้งหมดที่ถูกแก้ไขใน PR:

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

จากผลลัพธ์เหล่านี้ (เก็บไว้ใน ${{ env.GIT_DIFF }}) ผมจึงสร้างการเรียก custom command package-entries-json และตั้งค่าเป็น output:

- 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))"

packages ที่ได้จึงถูกนำไปใช้สร้าง matrix:

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

มันทำงานได้ดีมาก! ในกรณีนี้ มีเพียง 2 packages ที่ถูกแก้ไข ดังนั้นจึงมีแค่ 2 instances ที่เปิดขึ้นใน matrix:

กำลังดึงรายการ packages ที่ถูกแก้ไข

ตอนนี้การ merge PR อาจใช้เวลาเพียงไม่กี่นาที (จาก 1 ชั่วโมง) ผมจึงกลับมาเป็น developer ที่มีความสุขอีกครั้ง

การปรับให้เหมาะสมและความท้าทายเพิ่มเติม

มีอีกกรณีหนึ่งที่ผมสามารถลดเวลาจาก GitHub Action: เมื่อรัน PHPUnit tests

ปัจจุบัน เมื่อมี code ใหม่ถูก upload ขึ้นไป ชุดทดสอบทั้งหมดสำหรับทุก packages จะถูกรัน แต่อีกครั้ง สิ่งนี้สามารถปรับให้เหมาะสมได้

สมมติว่า monorepo มี 3 packages: A, B และ C โดยที่ B ขึ้นอยู่กับ A และ C ขึ้นอยู่กับ B

ถ้าเราแก้ไข code จาก package เดียว tests ที่ต้องรันจะแตกต่างกัน:

  • แก้ไข code จาก A: ต้องทดสอบ A, B และ C
  • แก้ไข code จาก B: ต้องทดสอบ B และ C
  • แก้ไข code จาก C: ต้องทดสอบ C

การปรับให้เหมาะสมจะขึ้นอยู่กับการดึงรายการ packages ที่ถูกแก้ไข (เช่นเดียวกับการปรับให้เหมาะสมก่อนหน้า) และรัน tests สำหรับ packages เหล่านั้นและสำหรับ packages ทั้งหมดที่ขึ้นอยู่กับมัน

อย่างไรก็ตาม ปัจจุบันผมยังไม่มีข้อมูลว่า packages แต่ละอันใน monorepo ขึ้นอยู่กับกันอย่างไร

แม้ว่า composer.json ที่ root จะมี local packages ทั้งหมด แต่ผมไม่สามารถดึง dependencies ของ packages เหล่านั้นผ่าน Composer โดยการรัน composer info ${ package_name } ได้ เพราะมันถูกกำหนดในส่วน replace แทนที่จะเป็น require

ทางเลือกอื่น ผมอาจเข้าไปในแต่ละ subfolder ของ package รัน composer install แล้วทำ composer info แต่การรัน composer install มากกว่า 200 ครั้งนั้นเป็นเรื่องที่บ้าอย่างสมบูรณ์

ดังนั้น ผมยังไม่ได้ปรับสถานการณ์นี้ให้เหมาะสม ผมได้สร้าง issue ไว้แล้ว และหวังว่าจะหาทางออกได้ในที่สุด

บทสรุป

ผมต้องบอกว่าดีใจมากที่ค้นพบ Monorepo Builder ผมคิดว่าคงไม่สามารถจัดการ codebase สำหรับ Gato GraphQL ได้หากไม่มีมัน

ผมไม่ได้บอกว่าทุกโปรเจกต์ควรใช้มัน แต่เมื่อคุณมีมากกว่า 200 packages เช่นในกรณีของผม หรืออาจแม้แต่มากกว่า 20 packages มันก็ทำให้ชีวิตง่ายขึ้นอย่างแน่นอน

การจัดการ monorepo ใช้เวลาและความพยายามเล็กน้อยในการตั้งค่าและดูแลรักษา แต่ผมประหยัดเวลาและความพยายามนั้นได้หลายเท่าทุกวัน เพียงจากการพัฒนาที่ดำเนินอยู่เป็นประจำ


สมัครรับจดหมายข่าวของเรา

ติดตามการอัปเดตทั้งหมดของ Gato GraphQL