Manajemen Ketergantungan

Dalam melihat halaman sebelumnya, satu tema akan diulang berulang kali: mengelola kode Anda sendiri cukup mudah, tetapi mengelola dependensinya jauh lebih sulit. Ada berbagai jenis dependensi: terkadang ada dependensi pada tugas (seperti "push dokumentasi sebelum saya menandai rilis sebagai selesai"), dan terkadang ada dependensi pada artefak (seperti "Saya perlu memiliki library computer vision versi terbaru untuk membuat kode saya"). Terkadang, Anda memiliki dependensi internal pada bagian lain codebase Anda, dan terkadang Anda memiliki dependensi eksternal pada kode atau data milik tim lain (salah satu dari organisasi Anda). Namun, dalam kasus apa pun, gagasan “Saya memerlukan itu sebelum dapat memiliki ini” adalah sesuatu yang berulang kali terjadi dalam desain sistem build, dan mengelola dependensi mungkin merupakan tugas yang paling mendasar dari sistem build.

Menangani Modul dan Dependensi

Project yang menggunakan sistem build berbasis artefak seperti Bazel dibagi menjadi sekumpulan modul, dengan modul yang menyatakan dependensi satu sama lain melalui file BUILD. Pengaturan yang tepat dari modul dan dependensi ini dapat memberikan dampak besar terhadap performa sistem build dan jumlah pekerjaan yang diperlukan untuk mengelolanya.

Menggunakan Modul Halus dan Aturan 1:1:1

Pertanyaan pertama yang muncul saat menyusun build berbasis artefak adalah menentukan seberapa banyak fungsi yang harus disertakan dalam setiap modul. Di Bazel, modul direpresentasikan oleh target yang menentukan unit yang dapat dibangun seperti java_library atau go_binary. Pada satu titik ekstrem, seluruh project dapat dimasukkan dalam satu modul dengan menempatkan satu file BUILD di root dan mengarahkan semua file sumber project tersebut secara rekursif. Di sisi lain, hampir setiap file sumber dapat dibuat menjadi modul tersendiri, yang secara efektif mengharuskan setiap file untuk mencantumkan setiap file lain yang menjadi dependensinya dalam file BUILD.

Sebagian besar project berada di antara ekstrem ini, dan pilihannya melibatkan kompromi antara performa dan kemudahan pemeliharaan. Menggunakan satu modul untuk seluruh project dapat berarti bahwa Anda tidak perlu menyentuh file BUILD kecuali saat menambahkan dependensi eksternal. Namun, ini berarti sistem build harus selalu mem-build seluruh project sekaligus. Artinya, build tidak dapat paralelkan atau mendistribusikan bagian-bagian build, juga tidak akan dapat meng-cache bagian yang sudah dibuat. Kebalikannya: sistem build memiliki fleksibilitas maksimum dalam langkah-langkah pembuatan cache dan penjadwalan build, tetapi para engineer perlu mengerahkan lebih banyak upaya untuk mempertahankan daftar dependensi setiap kali mereka mengubah referensi file mana.

Meskipun tingkat perincian yang tepat bervariasi menurut bahasa (dan sering kali dalam bahasa), Google cenderung mendukung modul yang jauh lebih kecil daripada yang biasanya ditulis dalam sistem build berbasis tugas. Biner produksi standar di Google sering kali bergantung pada puluhan ribu target, dan bahkan tim berukuran sedang dapat memiliki ratusan target dalam codebase-nya. Untuk bahasa seperti Java yang memiliki konsep paket bawaan yang kuat, setiap direktori biasanya berisi satu paket, target, dan file BUILD (Pants, sistem build lain berdasarkan Bazel, menyebutnya aturan 1:1:1). Bahasa dengan konvensi pengemasan yang lebih lebih lemah sering kali menentukan beberapa target per file BUILD.

Manfaat target build yang lebih kecil benar-benar mulai terlihat dalam skala besar karena menyebabkan build terdistribusi yang lebih cepat dan kebutuhan untuk membangun ulang target yang lebih jarang. Keuntungannya akan semakin menarik setelah pengujian masuk ke proses selanjutnya karena target yang lebih sempit membuat sistem build dapat menjadi jauh lebih cerdas dalam menjalankan hanya subset pengujian terbatas yang dapat terpengaruh oleh perubahan tertentu. Karena Google meyakini manfaat sistemik dari penggunaan target yang lebih kecil, kami telah membuat beberapa langkah dalam mengurangi kerugian dengan berinvestasi dalam alat untuk mengelola file BUILD secara otomatis guna menghindari beban developer.

Beberapa alat ini, seperti buildifier dan buildozer, tersedia bersama Bazel di direktori buildtools.

Meminimalkan Visibilitas Modul

Bazel dan sistem build lainnya memungkinkan setiap target untuk menentukan visibilitas — properti yang menentukan target lain mana yang mungkin bergantung padanya. Target pribadi hanya dapat direferensikan dalam file BUILD-nya sendiri. Target dapat memberikan visibilitas yang lebih luas ke target dari daftar file BUILD yang ditentukan secara eksplisit, atau, dalam hal visibilitas publik, ke setiap target di ruang kerja.

Seperti kebanyakan bahasa pemrograman, sebaiknya minimalkan visibilitas sebanyak mungkin. Umumnya, tim di Google akan membuat target menjadi publik hanya jika target tersebut mewakili library yang digunakan secara luas yang tersedia untuk tim mana pun di Google. Tim yang mengharuskan orang lain untuk berkoordinasi dengan mereka sebelum menggunakan kode mereka akan mempertahankan daftar target pelanggan yang diizinkan sebagai visibilitas target mereka. Setiap target implementasi internal tim akan dibatasi hanya untuk direktori yang dimiliki oleh tim, dan sebagian besar file BUILD hanya akan memiliki satu target yang tidak pribadi.

Mengelola dependensi

Modul harus dapat saling merujuk. Kelemahan dari membagi codebase menjadi modul terperinci adalah Anda perlu mengelola dependensi di antara modul-modul tersebut (meskipun alat dapat membantu mengotomatiskannya). Mengekspresikan dependensi ini biasanya berakhir menjadi sebagian besar konten dalam file BUILD.

Dependensi internal

Dalam sebuah project besar yang dipecah menjadi modul terperinci, sebagian besar dependensi kemungkinan besar bersifat internal; yaitu, pada target lain yang ditentukan dan dibangun dalam repositori sumber yang sama. Dependensi internal berbeda dengan dependensi eksternal, karena dibuat dari sumber, bukan didownload sebagai artefak bawaan saat menjalankan build. Ini juga berarti bahwa tidak ada konsep “versi” untuk dependensi internal—target dan semua dependensi internalnya selalu di-build pada commit/revisi yang sama dalam repositori. Salah satu masalah yang harus ditangani dengan cermat sehubungan dengan dependensi internal adalah cara menangani dependensi transitif (Gambar 1). Misalkan target A bergantung pada target B, yang bergantung pada target library umum C. Haruskah target A dapat menggunakan class yang ditentukan dalam target C?

Dependensi transitif

Gambar 1. Dependensi transitif

Sejauh yang berkaitan dengan alat dasar, tidak ada masalah dengan ini; baik B dan C akan ditautkan ke target A saat dibuat, sehingga setiap simbol yang ditentukan dalam C diketahui A. Bazel mengizinkan hal ini selama bertahun-tahun, tetapi seiring dengan berkembangnya Google, kami mulai mengalami masalah. Misalkan B difaktorkan ulang sedemikian rupa sehingga tidak perlu lagi bergantung pada C. Jika dependensi B pada C kemudian dihapus, A dan target lain yang menggunakan C melalui dependensi pada B akan rusak. Tepatnya, dependensi target menjadi bagian dari kontrak publiknya dan tidak dapat diubah dengan aman. Ini berarti dependensi yang terakumulasi seiring waktu dan build di Google mulai melambat.

Google akhirnya memecahkan masalah ini dengan memperkenalkan "mode dependensi transitif yang ketat" di Bazel. Dalam mode ini, Bazel mendeteksi apakah target mencoba mereferensikan simbol tanpa bergantung padanya secara langsung dan, jika demikian, gagal dengan error serta perintah shell yang dapat digunakan untuk menyisipkan dependensi secara otomatis. Meluncurkan perubahan ini ke seluruh codebase Google dan memfaktorkan ulang setiap dari jutaan target build kami untuk mencantumkan dependensi mereka secara eksplisit merupakan upaya yang dilakukan selama beberapa tahun, tetapi hal ini sangat sia-sia. Build kami sekarang jauh lebih cepat mengingat target memiliki lebih sedikit dependensi yang tidak diperlukan, dan engineer diberdayakan untuk menghapus dependensi yang tidak mereka perlukan tanpa khawatir merusak target yang bergantung padanya.

Seperti biasa, penerapan dependensi transitif yang ketat memerlukan kompromi. Hal ini membuat file build menjadi lebih panjang, karena library yang sering digunakan sekarang harus dicantumkan secara eksplisit di banyak tempat daripada ditarik secara tidak sengaja, dan engineer perlu menghabiskan lebih banyak upaya untuk menambahkan dependensi ke file BUILD. Sejak saat itu, kami telah mengembangkan alat yang mengurangi toil ini dengan mendeteksi banyak dependensi yang hilang secara otomatis dan menambahkannya ke file BUILD tanpa intervensi developer apa pun. Namun, meski tanpa alat semacam itu, kami mendapati komprominya sepadan dengan skala codebase: menambahkan dependensi secara eksplisit ke file BUILD hanya memerlukan biaya satu kali, tetapi menangani dependensi transitif implisit dapat menyebabkan masalah berkelanjutan selama target build masih ada. Bazel menerapkan dependensi transitif yang ketat pada kode Java secara default.

Dependensi eksternal

Jika tidak bersifat internal, dependensi harus eksternal. Dependensi eksternal adalah dependensi pada artefak yang dibuat dan disimpan di luar sistem build. Dependensi diimpor langsung dari repositori artefak (biasanya diakses melalui internet) dan digunakan sebagaimana adanya, bukan dibuat dari sumber. Salah satu perbedaan terbesar antara dependensi eksternal dan internal adalah dependensi eksternal memiliki beberapa versi, dan versi tersebut ada secara terpisah dari kode sumber project.

Pengelolaan dependensi otomatis versus manual

Sistem build dapat mengizinkan versi dependensi eksternal dikelola secara manual atau otomatis. Jika dikelola secara manual, buildfile secara eksplisit mencantumkan versi yang ingin didownload dari repositori artefak, sering kali menggunakan string versi semantik seperti 1.1.4. Jika dikelola secara otomatis, file sumber akan menentukan berbagai versi yang dapat diterima, dan sistem build akan selalu mendownload versi terbaru. Misalnya, Gradle mengizinkan versi dependensi dideklarasikan sebagai “1.+” untuk menentukan bahwa versi minor atau patch dependensi dapat diterima selama versi utamanya adalah 1.

Dependensi yang dikelola secara otomatis mungkin praktis untuk project kecil, tetapi dependensi tersebut biasanya merupakan penyebab bencana pada project berukuran besar atau yang sedang dikerjakan oleh lebih dari satu engineer. Masalah pada dependensi yang dikelola secara otomatis adalah Anda tidak memiliki kontrol atas kapan versi diupdate. Tidak ada cara untuk menjamin bahwa pihak eksternal tidak akan melakukan update yang dapat menyebabkan gangguan (bahkan ketika mereka mengklaim menggunakan pembuatan versi semantik). Jadi, build yang berfungsi pada suatu hari nanti mungkin akan rusak di hari berikutnya tanpa cara mudah untuk mendeteksi perubahan atau melakukan roll back ke status berfungsi. Meskipun build tidak rusak, mungkin ada perubahan perilaku atau performa yang halus yang tidak mungkin dilacak.

Sebaliknya, karena dependensi yang dikelola secara manual memerlukan perubahan pada kontrol sumber, dependensi tersebut dapat dengan mudah ditemukan dan di-roll back, serta Anda dapat memeriksa versi repositori yang lebih lama untuk di-build dengan dependensi yang lebih lama. Bazel mengharuskan versi semua dependensi ditentukan secara manual. Pada skala yang sedang, overhead pengelolaan versi manual sangat sebanding dengan stabilitas yang diberikannya.

Aturan Satu Versi

Versi library yang berbeda biasanya diwakili oleh artefak yang berbeda. Jadi, secara teori, tidak ada alasan bahwa versi berbeda dari dependensi eksternal yang sama tidak dapat dideklarasikan dalam sistem build dengan nama yang berbeda. Dengan begitu, setiap target dapat memilih versi dependensi yang ingin digunakan. Hal ini menyebabkan banyak masalah dalam praktiknya, sehingga Google menerapkan Aturan Satu Versi yang ketat untuk semua dependensi pihak ketiga dalam codebase kami.

Masalah terbesar dalam mengizinkan beberapa versi adalah masalah dependensi diamond. Misalkan target A bergantung pada target B dan pada v1 library eksternal. Jika target B nantinya difaktorkan ulang untuk menambahkan dependensi pada v2 dari library eksternal yang sama, target A akan rusak karena target A kini bergantung secara implisit pada dua versi library yang sama. Sebenarnya, menambahkan dependensi baru dari target ke library pihak ketiga dengan beberapa versi itu bukanlah hal yang aman, karena salah satu pengguna target tersebut mungkin sudah bergantung pada versi yang berbeda. Mengikuti Aturan Satu Versi akan membuat konflik ini tidak mungkin terjadi. Jika target menambahkan dependensi pada library pihak ketiga, semua dependensi yang ada akan berada pada versi yang sama, sehingga dapat hidup berdampingan dengan baik.

Dependensi eksternal transitif

Menangani dependensi transitif dependensi eksternal bisa sangat sulit. Banyak repositori artefak seperti Maven Central memungkinkan artefak untuk menentukan dependensi pada versi artefak lain tertentu dalam repositori. Alat build seperti Maven atau Gradle sering kali mendownload setiap dependensi transitif secara default secara rekursif. Artinya, menambahkan satu dependensi ke project Anda berpotensi menyebabkan puluhan artefak didownload secara total.

Cara ini sangat praktis: saat menambahkan dependensi pada library baru, akan sangat sulit jika Anda harus melacak setiap dependensi transitif library tersebut dan menambahkan semuanya secara manual. Namun, ada juga kelemahan besar: karena library yang berbeda dapat bergantung pada versi yang berbeda dari library pihak ketiga yang sama, strategi ini tentu melanggar Aturan Satu Versi dan menyebabkan masalah dependensi diamond. Jika target Anda bergantung pada dua library eksternal yang menggunakan versi berbeda dari dependensi yang sama, Anda tidak akan tahu versi mana yang akan Anda dapatkan. Ini juga berarti bahwa mengupdate dependensi eksternal dapat menyebabkan kegagalan yang tampaknya tidak terkait di seluruh codebase jika versi baru mulai menarik versi beberapa dependensinya yang bertentangan.

Karena alasan ini, Bazel tidak mendownload dependensi transitif secara otomatis. Dan, sayangnya, tidak ada solusi praktis—alternatif Bazel adalah mewajibkan file global yang mencantumkan setiap dependensi eksternal repositori dan versi eksplisit yang digunakan untuk dependensi tersebut di seluruh repositori. Untungnya, Bazel menyediakan alat yang dapat otomatis membuat file semacam itu yang berisi dependensi transitif dari satu set artefak Maven. Alat ini dapat dijalankan satu kali untuk membuat file WORKSPACE awal untuk sebuah project, dan file tersebut kemudian dapat diupdate secara manual untuk menyesuaikan versi setiap dependensi.

Namun sekali lagi, pilihan di sini adalah antara kenyamanan dan skalabilitas. Project kecil mungkin lebih suka untuk tidak perlu mengkhawatirkan pengelolaan dependensi transitif sendiri dan mungkin dapat menghindari penggunaan dependensi transitif otomatis. Strategi ini menjadi kurang menarik seiring berkembangnya organisasi dan codebase, serta konflik dan hasil yang tidak terduga menjadi makin sering. Pada skala yang lebih besar, biaya pengelolaan dependensi secara manual jauh lebih murah daripada biaya untuk menangani masalah yang disebabkan oleh pengelolaan dependensi otomatis.

Menyimpan hasil build ke dalam cache menggunakan dependensi eksternal

Dependensi eksternal paling sering disediakan oleh pihak ketiga yang merilis versi library stabil, mungkin tanpa memberikan kode sumber. Beberapa organisasi mungkin juga memilih untuk menyediakan beberapa kode mereka sendiri sebagai artefak, sehingga bagian kode lain dapat bergantung pada kode tersebut sebagai pihak ketiga, bukan dependensi internal. Secara teoritis, tindakan ini dapat mempercepat build jika artefak di-build lambat, tetapi cepat didownload.

Namun, hal ini juga menimbulkan banyak overhead dan kompleksitas: seseorang harus bertanggung jawab untuk membangun setiap artefak tersebut dan menguploadnya ke repositori artefak, dan klien harus memastikan bahwa mereka selalu mendapatkan update dengan versi terbaru. Proses debug juga menjadi jauh lebih sulit karena bagian sistem yang berbeda akan dibangun dari titik yang berbeda di repositori, dan tidak ada lagi tampilan yang konsisten dari hierarki sumber.

Cara yang lebih baik untuk mengatasi masalah artefak yang memerlukan waktu build lama adalah dengan menggunakan sistem build yang mendukung caching jarak jauh, seperti yang dijelaskan sebelumnya. Sistem build tersebut menyimpan artefak yang dihasilkan dari setiap build ke lokasi yang dibagikan kepada seluruh engineer, sehingga jika developer bergantung pada artefak yang baru saja dibuat oleh orang lain, sistem build akan otomatis mendownloadnya, bukan mem-build-nya. Hal ini memberikan semua manfaat performa dari bergantung pada artefak secara langsung sambil tetap memastikan bahwa build akan sekonsisten seolah-olah selalu dibangun dari sumber yang sama. Ini adalah strategi yang digunakan secara internal oleh Google, dan Bazel dapat dikonfigurasi untuk menggunakan cache jarak jauh.

Keamanan dan keandalan dependensi eksternal

Bergantung pada artefak dari sumber pihak ketiga, sangat berisiko. Ada risiko ketersediaan jika sumber pihak ketiga (seperti repositori artefak) mati, karena seluruh build Anda mungkin akan berhenti jika tidak dapat mendownload dependensi eksternal. Ada juga risiko keamanan: jika sistem pihak ketiga disusupi oleh penyerang, penyerang dapat mengganti artefak yang dirujuk dengan salah satu desainnya sendiri, yang memungkinkannya memasukkan kode arbitrer ke dalam build Anda. Kedua masalah tersebut dapat dimitigasi dengan mencerminkan artefak yang Anda andalkan ke server yang Anda kontrol dan memblokir sistem build agar tidak mengakses repositori artefak pihak ketiga seperti Maven Central. Konsekuensinya adalah bahwa mirror ini membutuhkan upaya dan resource untuk mempertahankannya, sehingga pilihan apakah akan menggunakannya sering kali bergantung pada skala project. Masalah keamanan juga dapat sepenuhnya dicegah dengan overhead yang sedikit dengan mengharuskan hash setiap artefak pihak ketiga ditentukan di repositori sumber, yang menyebabkan build gagal jika artefak dirusak. Alternatif lain yang sepenuhnya menghadang masalah ini adalah dengan mem-vendor dependensi project Anda. Saat menyediakan dependensinya, project akan memeriksanya ke dalam kontrol sumber bersama dengan kode sumber project, baik sebagai sumber maupun biner. Hal ini secara efektif berarti semua dependensi eksternal project dikonversi menjadi dependensi internal. Google menggunakan pendekatan ini secara internal, yang memeriksa setiap library pihak ketiga yang dirujuk di seluruh Google ke dalam direktori third_party pada root hierarki sumber Google. Namun, fitur ini hanya berfungsi di Google karena sistem kontrol sumber Google dibuat secara khusus untuk menangani monorepo yang sangat besar, sehingga vendoring mungkin bukan opsi untuk semua organisasi.