Kebetulan program Anda ditulis dalam bahasa scripting - misalnya, di Ruby - dan ada kebutuhan untuk menulis ulang di Golang.
Pertanyaan yang masuk akal: mengapa Anda perlu menulis ulang program yang sudah ditulis dan berfungsi dengan baik?

Pertama, katakanlah program dikaitkan dengan ekosistem tertentu - dalam kasus kami, ini adalah Docker dan Kubernetes. Seluruh infrastruktur proyek ini ditulis dalam Golang. Ini membuka akses ke perpustakaan yang menggunakan Docker, Kubernetes dan lainnya. Dari sudut pandang dukungan, pengembangan, dan penyempurnaan program Anda, lebih menguntungkan untuk menggunakan infrastruktur yang sama dengan yang digunakan produk utama. Dalam hal ini, semua fitur baru akan segera tersedia dan Anda tidak perlu mengimplementasikannya kembali dalam bahasa lain. Hanya kondisi ini dalam situasi khusus kami yang cukup untuk membuat keputusan tentang perlunya mengubah bahasa secara prinsip, dan tentang bahasa apa yang seharusnya. Namun, ada keuntungan lain ...
Kedua, kemudahan menginstal aplikasi di Golang. Anda tidak perlu menginstal Rvm, Ruby, satu set permata, dll ke dalam sistem. Anda perlu mengunduh satu file biner statis dan menggunakannya.
Ketiga, kecepatan program di Golang lebih tinggi. Ini bukan peningkatan sistemik yang signifikan dalam kecepatan, yang diperoleh dengan menggunakan arsitektur dan algoritma yang benar dalam bahasa apa pun. Tapi ini adalah peningkatan yang dirasakan saat Anda meluncurkan program Anda dari konsol. Misalnya, --help
di Ruby dapat bekerja dalam 0,8 detik, dan di Golang - 0,02 detik. Itu hanya terasa meningkatkan pengalaman pengguna menggunakan program.
NB : Seperti yang dapat ditebak oleh pembaca biasa di blog kami, artikel ini didasarkan pada pengalaman menulis ulang produk dapp kami, yang sekarang - belum secara resmi (!) - dikenal sebagai werf . Lihat akhir artikel untuk detail lebih lanjut tentang itu.
Bagus: Anda bisa mengambil dan menulis kode baru yang sepenuhnya terisolasi dari kode skrip lama. Tetapi segera muncul beberapa kesulitan dan keterbatasan sumber daya dan waktu yang dialokasikan untuk pembangunan:
- Versi program saat ini di Ruby secara konstan membutuhkan perbaikan dan koreksi:
- Bug muncul saat digunakan dan harus segera diperbaiki;
- Anda tidak dapat membekukan penambahan fitur baru selama enam bulan, karena Fitur-fitur ini sering dibutuhkan oleh klien / pengguna.
- Mempertahankan 2 basis kode sekaligus sulit dan mahal:
- Ada beberapa tim yang terdiri dari 2-3 orang, mengingat keberadaan proyek lain selain program Ruby ini.
- Pengenalan versi baru:
- Seharusnya tidak ada penurunan fungsi yang signifikan;
- Idealnya, ini harus mulus dan mulus.
Diperlukan proses porting berkelanjutan. Tapi bagaimana saya bisa melakukan ini jika versi Golang sedang dikembangkan sebagai program mandiri?
Kami menulis dalam dua bahasa sekaligus
Tetapi bagaimana jika Anda mentransfer komponen ke Golang dari bawah ke atas? Kami mulai dengan hal-hal tingkat rendah, kemudian naik abstraksi.
Bayangkan program Anda terdiri dari komponen-komponen berikut:
lib/ config.rb build/ image.rb git_repo/ base.rb local.rb remote.rb docker_registry.rb builder/ base.rb shell.rb ansible.rb stage/ base.rb from.rb before_install.rb git.rb install.rb before_setup.rb setup.rb deploy/ kubernetes/ client.rb manager/ base.rb job.rb deployment.rb pod.rb
Komponen port dengan fitur
Kasus sederhana. Kami mengambil komponen yang sudah ada yang cukup terisolasi dari yang lain - misalnya, config
( lib/config.rb
). Dalam komponen ini, hanya fungsi Config::parse
yang didefinisikan, yang mengambil path ke config, membacanya, dan menghasilkan struktur yang dihuni. Biner terpisah pada config
Golang dan config
paket terkait akan bertanggung jawab untuk implementasinya:
cmd/ config/ main.go pkg/ config/ config.go
Biner Golang menerima argumen dari file JSON dan menampilkan hasilnya ke file JSON.
config -args-from-file args.json -res-to-file res.json
config
bahwa config
dapat menampilkan pesan ke stdout / stderr (dalam program Ruby kami, output selalu menuju ke stdout / stderr, sehingga fitur ini tidak diparameterisasi).
Memanggil config
binary sama dengan memanggil beberapa fungsi dari komponen config
. Argumen melalui file args.json
menunjukkan nama fungsi dan parameternya. Pada output melalui file res.json
, res.json
mendapatkan hasil dari fungsinya. Jika fungsi harus mengembalikan objek dari beberapa kelas, maka data objek dari kelas ini dikembalikan dalam bentuk serial JSON.
Misalnya, untuk memanggil fungsi Config::parse
, tentukan args.json
berikut:
{ "command": "Parse", "configPath": "path-to-config.yaml" }
Kami res.json
hasilnya di res.json
:
{ "config": { "Images": [{"Name": "nginx"}, {"Name": "rails"}], "From": "ubuntu:16.04" }, }
Di bidang config
, kita mendapatkan status objek Config::Config
bersambung dalam JSON. Dari kondisi ini, pada pemanggil di Ruby, Anda perlu membuat objek Config::Config
.
Dalam kasus kesalahan yang disediakan , biner dapat mengembalikan JSON berikut:
{ "error": "no such file path-to-config.yaml" }
Bidang error
harus ditangani oleh penelepon.
Memanggil Golang dari Ruby
Di sisi Ruby, kami mengubah fungsi Config::parse(config_path)
menjadi wrapper yang memanggil config
kami, mendapatkan hasilnya, memproses semua kemungkinan kesalahan. Berikut ini contoh pseudocode Ruby dengan penyederhanaan:
module Config def parse(config_path) call_id = get_random_number args_file = "
Biner bisa rusak dengan bukan nol, kode tak terduga - ini adalah situasi yang luar biasa. Atau dengan kode yang disediakan - dalam hal ini kita melihat file res.json
untuk keberadaan bidang error
dan config
dan sebagai hasilnya kita mengembalikan objek Config::Config
dari bidang config
serial.
Dari sudut pandang pengguna, fungsi Config::Parse
tidak berubah.
Kelas komponen port
Sebagai contoh, ambil hierarki kelas lib/git_repo
. Ada 2 kelas: GitRepo::Local
dan GitRepo::Remote
. Masuk akal untuk menggabungkan implementasinya dalam biner git_repo
tunggal dan, dengan demikian, mengemas git_repo
di Golang.
cmd/ git_repo/ main.go pkg/ git_repo/ base.go local.go remote.go
Panggilan ke biner git_repo
sesuai dengan panggilan ke beberapa metode dari GitRepo::Local
atau GitRepo::Remote
objek GitRepo::Remote
. Objek memiliki status dan dapat berubah setelah pemanggilan metode. Oleh karena itu, dalam argumen, kami melewati keadaan saat ini berseri dalam JSON. Dan pada output, kita selalu mendapatkan status objek yang baru - juga di JSON.
Misalnya, untuk memanggil metode local_repo.commit_exists?(commit)
, kami menentukan args.json
berikut:
{ "localGitRepo": { "name": "my_local_git_repo", "path": "path/to/git" }, "method": "IsCommitExists", "commit": "e43b1336d37478282693419e2c3f2d03a482c578" }
Outputnya adalah res.json
:
{ "localGitRepo": { "name": "my_local_git_repo", "path": "path/to/git" }, "result": true, }
Di bidang localGitRepo
, keadaan objek baru diterima (yang mungkin tidak berubah). Kita harus meletakkan keadaan ini di objek- local_git_repo
Ruby saat ini local_git_repo
.
Memanggil Golang dari Ruby
Di sisi Ruby, kita mengubah setiap metode GitRepo::Base
, GitRepo::Local
, GitRepo::Remote
menjadi pembungkus yang memanggil git_repo
kami, dapatkan hasilnya, atur status objek objek baru GitRepo::Local
atau GitRepo::Remote
.
Kalau tidak, semuanya mirip dengan memanggil fungsi sederhana.
Bagaimana menghadapi polimorfisme dan kelas dasar
Cara termudah adalah tidak mendukung polimorfisme dari Golang. Yaitu pastikan bahwa panggilan ke biner git_repo
selalu secara eksplisit ditujukan ke implementasi tertentu (jika localGitRepo
ditentukan dalam argumen, maka panggilan tersebut berasal dari GitRepo::Local
objek kelas GitRepo::Local
; jika remoteGitRepo
ditentukan - lalu dari GitRepo::Remote
) dan dapatkan dengan menyalin sejumlah kecil boilerplate- kode dalam cmd. Lagipula, kode ini akan dibuang begitu pemindahan ke Golang selesai.
Cara mengubah keadaan objek lain
Ada situasi ketika suatu objek menerima objek lain sebagai parameter dan memanggil metode yang secara implisit mengubah keadaan objek kedua ini.
Dalam hal ini, Anda harus:
- Ketika sebuah biner dipanggil, sebagai tambahan dari keadaan serial dari objek yang dipanggil metode tersebut, mentransmisikan status serial dari semua objek parameter.
- Setelah panggilan, atur ulang keadaan objek tempat metode dipanggil, dan juga setel ulang keadaan semua objek yang diteruskan sebagai parameter.
Kalau tidak, semuanya serupa.
Apa itu
Kami mengambil komponen, port ke Golang, merilis versi baru.
Dalam kasus ketika komponen yang mendasarinya sudah porting dan komponen tingkat yang lebih tinggi yang menggunakannya ditransfer, komponen ini dapat “menerima” komponen yang mendasarinya . Dalam hal ini, binari tambahan terkait mungkin sudah dihapus sebagai tidak perlu.
Dan ini berlanjut sampai kita sampai pada lapisan paling atas, yang merekatkan semua abstraksi yang mendasarinya . Ini akan menyelesaikan tahap pertama porting. Lapisan atas adalah CLI. Dia masih bisa hidup di Ruby untuk sementara waktu sebelum beralih ke Golang sepenuhnya.
Bagaimana cara mendistribusikan monster ini?
Bagus: sekarang kami memiliki pendekatan untuk secara bertahap port semua komponen. Pertanyaan: bagaimana cara mendistribusikan program semacam itu dalam 2 bahasa?
Dalam kasus Ruby, program masih diinstal sebagai Gem. Segera setelah memanggil biner, ia dapat mengunduh dependensi ini ke URL tertentu (hardcoded) dan menyimpannya secara lokal di sistem (di suatu tempat di file layanan).
Ketika kami membuat rilis baru dari program kami dalam 2 bahasa, kami harus:
- Kumpulkan dan unggah semua dependensi biner ke hosting tertentu.
- Buat versi Ruby Gem baru.
Binari untuk setiap versi berikutnya dikumpulkan secara terpisah, bahkan jika beberapa komponen tidak berubah. Seseorang dapat membuat versi terpisah dari semua binari dependen. Maka tidak perlu mengumpulkan binari baru untuk setiap versi program yang baru. Tetapi dalam kasus kami, kami beralih dari kenyataan bahwa kami tidak punya waktu untuk melakukan sesuatu yang sangat rumit dan mengoptimalkan kode waktu, jadi untuk kesederhanaan kami mengumpulkan biner terpisah untuk setiap versi program hingga merugikan menghemat ruang dan waktu untuk mengunduh.
Kerugian dari pendekatan
Jelas, overhead yang terus-menerus memanggil program eksternal melalui system
/ exec
.
Sulit untuk melakukan cache data global apa pun di tingkat Golang - setelah semua, semua data di Golang (misalnya, variabel paket) dibuat ketika suatu metode dipanggil dan mati setelah selesai. Ini harus selalu diingat. Namun, caching masih dimungkinkan pada level instance kelas atau dengan secara eksplisit meneruskan parameter ke komponen eksternal.
Kita tidak boleh lupa untuk mentransfer keadaan objek ke Golang dan mengembalikannya dengan benar setelah panggilan.
Ketergantungan biner pada Golang memakan banyak ruang . Adalah satu hal ketika ada biner 30 MB tunggal - sebuah program di Golang. Hal lain, ketika Anda melakukan porting ~ 10 komponen, yang masing-masing memiliki berat 30 MB, kami mendapatkan 300 MB file untuk setiap versi . Karena itu, ruang di hosting biner dan di mesin host, tempat program Anda bekerja dan terus diperbarui, dengan cepat pergi. Namun, masalahnya tidak signifikan jika Anda menghapus versi lama secara berkala.
Perhatikan juga bahwa dengan setiap pembaruan program, akan diperlukan waktu untuk mengunduh dependensi biner.
Manfaat pendekatan
Terlepas dari semua kelemahan yang disebutkan, pendekatan ini memungkinkan Anda untuk mengatur proses porting berkelanjutan ke bahasa lain dan bertahan dengan satu tim pengembangan.
Keuntungan paling penting adalah kemampuan untuk mendapatkan umpan balik cepat pada kode baru, menguji dan menstabilkannya.
Dalam hal ini, Anda dapat, antara lain, menambahkan fitur baru ke program Anda, memperbaiki bug di versi saat ini.
Cara membuat kudeta terakhir di Golang
Saat ini semua komponen utama beralih ke Golang dan sudah diuji coba dalam produksi, tetap saja hanya menulis ulang antarmuka teratas program Anda (CLI) ke Golang dan membuang semua kode Ruby lama.
Pada tahap ini, tetap hanya untuk memecahkan masalah kompatibilitas CLI baru Anda dengan yang lama.
Hore, kawan! Revolusi telah menjadi kenyataan.
Bagaimana kami menulis ulang dapp di Golang
Dapp adalah utilitas yang dikembangkan oleh Flant untuk mengatur proses CI / CD. Itu ditulis dalam Ruby karena alasan historis:
- Pengalaman luas dalam mengembangkan program di Ruby.
- Chef Bekas (resep untuk itu ditulis dalam Ruby).
- Inersia, penolakan untuk menggunakan bahasa baru bagi kita untuk sesuatu yang serius.
Pendekatan yang dijelaskan dalam artikel itu diterapkan untuk menulis ulang dapp di Golang. Grafik di bawah ini menunjukkan kronologi perjuangan antara yang baik (Golang, biru) dan kejahatan (Ruby, merah):

Jumlah kode dalam proyek dapp / werf di Ruby vs. bahasa Golang selama rilis
Saat ini, Anda dapat mengunduh alpha versi 1.0 , yang tidak memiliki Ruby. Kami juga mengganti nama dapp menjadi werf, tapi itu cerita lain ... Tunggu rilis lengkap werf 1.0 segera!
Sebagai keuntungan tambahan dari migrasi ini dan ilustrasi integrasi dengan ekosistem Kubernet yang terkenal, kami mencatat bahwa menulis ulang dapp di Golang memberi kami kesempatan untuk membuat proyek lain - kubedog . Jadi kami dapat memisahkan kode untuk melacak sumber daya K8 menjadi proyek terpisah, yang dapat berguna tidak hanya di werf, tetapi juga di proyek lain. Ada solusi lain untuk tugas yang sama (lihat pengumuman terbaru kami untuk detail) , tetapi untuk "bersaing" dengan mereka (dalam hal popularitas) tanpa Go sebagai dasarnya tidak akan mungkin terjadi.
PS
Baca juga di blog kami: