Memilih Alternatif Tech Stack - Pasang surut

Diterbitkan: 2022-03-11

Jika aplikasi web cukup besar dan cukup tua, mungkin ada saatnya Anda perlu memecahnya menjadi bagian-bagian yang lebih kecil dan terisolasi dan mengekstrak layanan darinya, beberapa di antaranya akan lebih independen daripada yang lain. Beberapa alasan yang dapat mendorong keputusan seperti itu meliputi: mengurangi waktu untuk menjalankan pengujian, dapat menerapkan berbagai bagian aplikasi secara independen, atau memberlakukan batasan antar subsistem. Ekstraksi layanan membutuhkan insinyur perangkat lunak untuk membuat banyak keputusan penting, dan salah satunya adalah tumpukan teknologi apa yang akan digunakan untuk layanan baru.

Dalam posting ini, kami berbagi cerita tentang mengekstraksi layanan baru dari aplikasi monolitik – Platform Toptal . Kami menjelaskan tumpukan teknis mana yang kami pilih dan alasannya, dan menguraikan beberapa masalah yang kami temui selama implementasi layanan.

Layanan Toptal's Chronicles adalah aplikasi yang menangani semua tindakan pengguna yang dilakukan di Platform Toptal. Tindakan pada dasarnya adalah entri log. Ketika pengguna melakukan sesuatu (misalnya menerbitkan posting blog, menyetujui pekerjaan, dll), entri log baru dibuat.

Meskipun diekstraksi dari Platform kami, pada dasarnya tidak bergantung padanya dan dapat digunakan dengan aplikasi lain. Inilah sebabnya kami memublikasikan akun terperinci tentang proses tersebut dan mendiskusikan sejumlah tantangan yang harus diatasi oleh tim teknik kami saat melakukan transisi ke tumpukan baru.

Ada sejumlah alasan di balik keputusan kami untuk mengekstrak layanan dan meningkatkan tumpukan:

  • Kami ingin layanan lain dapat mencatat peristiwa yang dapat ditampilkan dan digunakan di tempat lain.
  • Ukuran tabel database yang menyimpan catatan sejarah tumbuh dengan cepat dan non-linear, menimbulkan biaya operasi yang tinggi.
  • Kami menilai pelaksanaan yang ada terbebani utang teknis.

Tabel tindakan - tabel database

Pada pandangan pertama, itu tampak seperti inisiatif langsung. Namun, berurusan dengan tumpukan teknologi alternatif cenderung menciptakan kelemahan yang tidak terduga, dan itulah yang ingin dibahas oleh artikel hari ini.

Ikhtisar Arsitektur

Aplikasi Chronicles terdiri dari tiga bagian yang bisa lebih atau kurang independen dan dijalankan dalam wadah Docker terpisah.

  • Konsumen Kafka adalah konsumen Kafka berbasis Karafka yang sangat tipis dari pesan pembuatan entri. Itu mengantrekan semua pesan yang diterima ke Sidekiq.
  • Pekerja Sidekiq adalah pekerja yang memproses pesan Kafka dan membuat entri dalam tabel database.
  • Titik akhir GraphQL:
    • Titik akhir publik mengekspos API pencarian entri, yang digunakan untuk berbagai fungsi Platform (misalnya, untuk merender tooltip komentar pada tombol penyaringan, atau menampilkan riwayat perubahan pekerjaan).
    • Endpoint internal menyediakan kemampuan untuk membuat aturan tag dan template dari migrasi data.

Chronicles digunakan untuk menghubungkan ke dua database yang berbeda:

  • Basis datanya sendiri (tempat kami menyimpan aturan dan templat tag)
  • Basis data Platform (tempat kami menyimpan tindakan yang dilakukan pengguna beserta tag dan penandaannya)

Dalam proses mengekstrak aplikasi, kami memigrasikan data dari database Platform dan mematikan koneksi Platform.

Rencana Awal

Awalnya, kami memutuskan untuk menggunakan Hanami dan semua ekosistem yang disediakannya secara default (model hanami yang didukung oleh ROM.rb, dry-rb, hanami-newrelic, dll). Mengikuti cara "standar" dalam melakukan sesuatu menjanjikan kami gesekan yang rendah, kecepatan implementasi yang hebat, dan "kemampuan google" yang sangat baik dari setiap masalah yang mungkin kami hadapi. Selain itu, ekosistem hanami sudah matang dan populer, dan perpustakaannya dipelihara dengan hati-hati oleh anggota komunitas Ruby yang disegani.

Selain itu, sebagian besar sistem telah diimplementasikan di sisi Platform (misalnya, titik akhir GraphQL Entry Search dan operasi CreateEntry), jadi kami berencana untuk menyalin banyak kode dari Platform ke Chronicles apa adanya, tanpa membuat perubahan apa pun. Ini juga salah satu alasan utama kami tidak menggunakan Elixir, karena Elixir tidak mengizinkannya.

Kami memutuskan untuk tidak mengerjakan Rails karena rasanya terlalu berlebihan untuk proyek kecil seperti itu, terutama hal-hal seperti ActiveSupport, yang tidak akan memberikan banyak manfaat nyata untuk kebutuhan kami.

Ketika Rencana Berjalan ke Selatan

Meskipun kami melakukan yang terbaik untuk tetap pada rencana itu, rencana itu segera tergelincir karena sejumlah alasan. Salah satunya adalah kurangnya pengalaman kami dengan tumpukan yang dipilih, diikuti oleh masalah asli dengan tumpukan itu sendiri, dan kemudian ada pengaturan non-standar kami (dua database). Pada akhirnya, kami memutuskan untuk menyingkirkan hanami-model , dan kemudian Hanami itu sendiri, menggantinya dengan Sinatra.

Kami memilih Sinatra karena ini adalah perpustakaan yang dipelihara secara aktif yang dibuat 12 tahun yang lalu, dan karena ini adalah salah satu perpustakaan paling populer, semua orang di tim memiliki banyak pengalaman langsung dengannya.

Dependensi yang tidak kompatibel

Ekstraksi Chronicles dimulai pada Juni 2019, dan saat itu, Hanami tidak kompatibel dengan versi terbaru permata dry-rb. Yaitu, versi terbaru Hanami pada saat itu (1.3.1) hanya mendukung validasi kering 0.12, dan kami menginginkan validasi kering 1.0.0. Kami berencana menggunakan kontrak dari validasi kering yang hanya diperkenalkan di 1.0.0.

Juga, Kafka 1.2 tidak kompatibel dengan permata kering, jadi kami menggunakan versi repositorinya. Saat ini, kami menggunakan 1.3.0.rc1, yang bergantung pada permata kering terbaru.

Ketergantungan yang Tidak Perlu

Selain itu, permata Hanami menyertakan terlalu banyak dependensi yang tidak kami rencanakan untuk digunakan, seperti hanami-cli , hanami-assets , hanami-mailer , hanami-view , dan bahkan hanami-controller . Juga, melihat readme model hanami, menjadi jelas bahwa ia hanya mendukung satu database secara default. Di sisi lain, ROM.rb, yang menjadi dasar hanami-model , mendukung konfigurasi multi-database di luar kotak.

Secara keseluruhan, Hanami pada umumnya dan model hanami-model pada khususnya tampak seperti tingkat abstraksi yang tidak perlu.

Jadi, 10 hari setelah kami membuat PR pertama yang berarti untuk Chronicles, kami sepenuhnya mengganti hanami dengan Sinatra. Kami dapat menggunakan Rack murni juga karena kami tidak memerlukan perutean yang rumit (kami memiliki empat titik akhir "statis" - dua titik akhir GraphQL, titik akhir /ping, dan antarmuka web sidekiq), tetapi kami memutuskan untuk tidak terlalu hardcore. Sinatra cocok untuk kita. Jika Anda ingin mempelajari lebih lanjut, lihat tutorial Sinatra dan Sekuel kami.

Kesalahpahaman Skema Kering dan Validasi Kering

Kami membutuhkan waktu dan banyak coba-coba untuk mengetahui cara "memasak" validasi kering dengan benar.

 params do required(:url).filled(:string) end params do required(:url).value(:string) end params do optional(:url).value(:string?) end params do optional(:url).filled(Types::String) end params do optional(:url).filled(Types::Coercible::String) end

Dalam cuplikan di atas, parameter url didefinisikan dalam beberapa cara yang sedikit berbeda. Beberapa definisi setara, dan yang lain tidak masuk akal. Pada awalnya, kami tidak dapat membedakan semua definisi tersebut karena kami tidak sepenuhnya memahaminya. Akibatnya, versi pertama dari kontrak kami cukup berantakan. Seiring waktu, kami belajar cara membaca dan menulis kontrak KERING dengan benar, dan sekarang kontrak tersebut terlihat konsisten dan elegan—bahkan, tidak hanya elegan, kontrak tersebut juga tidak kalah indahnya. Kami bahkan memvalidasi konfigurasi aplikasi dengan kontrak.

Masalah dengan ROM.rb dan Sequel

ROM.rb dan Sequel berbeda dari ActiveRecord, tidak mengherankan. Gagasan awal kami bahwa kami akan dapat menyalin dan menempel sebagian besar kode dari Platform gagal. Masalahnya adalah bagian Platform sangat berat AR, jadi hampir semuanya harus ditulis ulang dalam ROM/Sekuel. Kami berhasil menyalin hanya sebagian kecil kode yang tidak bergantung pada kerangka kerja. Sepanjang jalan, kami menghadapi beberapa masalah yang membuat frustrasi dan beberapa bug.

Memfilter menurut Subquery

Sebagai contoh, saya butuh beberapa jam untuk mengetahui cara membuat subquery di ROM.rb/Sequel. Ini adalah sesuatu yang akan saya tulis bahkan tanpa terbangun di Rails: scope.where(sequence_code: subquery ). Di Sekuel, ternyata tidak semudah itu.

 def apply_subquery_filter(base_query, params) subquery = as_subquery(build_subquery(params)) base_query.where { Sequel.lit('sequence_code IN ?', subquery) } end # This is a fixed version of https://github.com/rom-rb/rom-sql/blob/6fa344d7022b5cc9ad8e0d026448a32ca5b37f12/lib/rom/sql/relation/reading.rb#L998 # The original version has `unorder` on the subquery. # The fix was merged: https://github.com/rom-rb/rom-sql/pull/342. def as_subquery(relation) attr = relation.schema.to_a[0] subquery = relation.schema.project(attr).call(relation).dataset ROM::SQL::Attribute[attr.type].meta(sql_expr: subquery) end

Jadi, alih-alih satu baris sederhana seperti base_query.where(sequence_code: bild_subquery(params)) , kita harus memiliki selusin baris dengan kode non-sepele, fragmen SQL mentah, dan komentar multibaris yang menjelaskan apa yang menyebabkan kasus malang ini mengasapi.

Asosiasi dengan Bidang Gabung Non-sepele

Relasi entry ( tabel performed_actions ) memiliki bidang id utama. Namun, untuk bergabung dengan tabel *taggings , ia menggunakan kolom sequence_code . Di ActiveRecord, ini diungkapkan dengan sederhana:

 class PerformedAction < ApplicationRecord has_many :feed_taggings, class_name: 'PerformedActionFeedTagging', foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code', end class PerformedActionFeedTagging < ApplicationRecord db_belongs_to :performed_action, foreign_key: 'performed_action_sequence_code', primary_key: 'sequence_code' end

Dimungkinkan juga untuk menulis hal yang sama di ROM.

 module Chronicles::Persistence::Relations::Entries < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_actions, as: :entries) do attribute :id, ROM::Types::Integer attribute :sequence_code, ::Types::UUID primary_key :id associations do has_many :access_taggings, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code end end end module Chronicles::Persistence::Relations::AccessTaggings < ROM::Relation[:sql] struct_namespace Chronicles::Entities auto_struct true schema(:performed_action_access_taggings, as: :access_taggings, infer: false) do attribute :performed_action_sequence_code, ::Types::UUID associations do belongs_to :entry, foreign_key: :performed_action_sequence_code, primary_key: :sequence_code, null: false end end end

Ada masalah kecil dengan itu, meskipun. Itu akan dikompilasi dengan baik tetapi gagal saat runtime ketika Anda benar-benar mencoba menggunakannya.

 [4] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings).limit(1).to_a E, [2019-09-05T15:54:16.706292 #20153] ERROR -- : PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform... ^ HINT: No operator matches the given name and argument types. You might need to add explicit type casts.: SELECT <..snip..> FROM "performed_actions" INNER JOIN "performed_action_access_taggings" ON ("performed_actions"."id" = "performed_action_access_taggings"."performed_action_sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedFunction: ERROR: operator does not exist: integer = uuid LINE 1: ...ion_access_taggings" ON ("performed_actions"."id" = "perform...

Kami beruntung bahwa jenis id dan sequence_code berbeda, jadi PG membuat kesalahan jenis. Jika jenisnya sama, siapa yang tahu berapa jam saya akan menghabiskan debugging ini.

Jadi, entries.join(:access_taggings) tidak berfungsi. Bagaimana jika kita menentukan kondisi join secara eksplisit? Seperti dalam entries.join(:access_taggings, performed_action_sequence_code: :sequence_code) , seperti yang disarankan oleh dokumentasi resmi.

 [8] pry(main)> Chronicles::Persistence.relations[:platform][:entries].join(:access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a E, [2019-09-05T16:02:16.952972 #20153] ERROR -- : PG::UndefinedTable: ERROR: relation "access_taggings" does not exist LINE 1: ...."updated_at" FROM "performed_actions" INNER JOIN "access_ta... ^: SELECT <snip> FROM "performed_actions" INNER JOIN "access_taggings" ON ("access_taggings"."performed_action_sequence_code" = "performed_actions"."sequence_code") ORDER BY "performed_actions"."id" LIMIT 1 Sequel::DatabaseError: PG::UndefinedTable: ERROR: relation "access_taggings" does not exist

Sekarang ia berpikir bahwa :access_taggings adalah nama tabel untuk beberapa alasan. Baik, mari kita tukar dengan nama tabel yang sebenarnya.

 [10] pry(main)> data = Chronicles::Persistence.relations[:platform][:entries].join(:performed_action_access_taggings, performed_action_sequence_code: :sequence_code).limit(1).to_a => [#<Chronicles::Entities::Entry id=22 subject_g ... updated_at=2012-05-10 08:46:43 UTC>]

Akhirnya, itu mengembalikan sesuatu dan tidak gagal, meskipun berakhir dengan abstraksi yang bocor. Nama tabel tidak boleh bocor ke kode aplikasi.

Interpolasi Parameter SQL

Ada fitur dalam pencarian Chronicles yang memungkinkan pengguna untuk mencari berdasarkan payload. Kueri terlihat seperti ini: {operation: :EQ, path: ["flag", "gid"], value: "gid://plat/Flag/1"} , di mana path selalu berupa array string, dan nilai adalah nilai JSON yang valid.

Di ActiveRecord, tampilannya seperti ini:

 @scope.where('payload -> :path #> :value::jsonb', path: path, value: value.to_json)

Di Sequel, saya tidak berhasil menginterpolasi dengan benar :path , jadi saya harus menggunakan itu:

 base_query.where(Sequel.lit("payload #> '{#{path.join(',')}}' = ?::jsonb", value.to_json))

Untungnya, path di sini divalidasi dengan benar sehingga hanya berisi karakter alfanumerik, tetapi kode ini masih terlihat lucu.

Sihir Senyap dari pabrik ROM

Kami menggunakan permata rom-factory untuk menyederhanakan pembuatan model kami dalam pengujian. Namun, beberapa kali kode tidak berfungsi seperti yang diharapkan. Bisakah Anda menebak apa yang salah dengan tes ini?

 action1 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'deleted'] action2 = RomFactory[:action, app: 'plat', subject_type: 'Job', action: 'updated'] expect(action1.id).not_to eq(action2.id)

Tidak, harapannya tidak gagal, harapannya baik-baik saja.

Masalahnya adalah baris kedua gagal dengan kesalahan validasi batasan unik. Alasannya adalah bahwa action bukanlah atribut yang dimiliki model Action . Nama aslinya adalah action_name , jadi cara yang tepat untuk membuat tindakan akan terlihat seperti ini:

 RomFactory[:action, app: 'plat', subject_type: 'Job', action_name: 'deleted']

Karena atribut yang salah ketik diabaikan, atribut tersebut kembali ke default yang ditentukan di pabrik ( action_name { 'created' } ), dan kami memiliki pelanggaran batasan unik karena kami mencoba membuat dua tindakan yang identik. Kami harus berurusan dengan masalah ini beberapa kali, yang terbukti melelahkan.

Untungnya, itu diperbaiki di 0.9.0. Dependabot secara otomatis mengirimi kami permintaan tarik dengan pembaruan perpustakaan, yang kami gabungkan setelah memperbaiki beberapa atribut salah ketik yang kami miliki dalam pengujian kami.

Ergonomi Umum

Ini mengatakan itu semua:

 # ActiveRecord PerformedAction.count _# => 30232445_ # ROM EntryRepository.new.root.count _# => 30232445_

Dan perbedaannya bahkan lebih besar dalam contoh yang lebih rumit.

Bagian yang Baik

Bukan semua rasa sakit, keringat, dan air mata. Ada banyak, banyak hal baik dalam perjalanan kami, dan itu jauh lebih besar daripada aspek negatif dari tumpukan baru. Jika bukan itu masalahnya, kami tidak akan melakukannya sejak awal.

Uji kecepatan

Dibutuhkan 5-10 detik untuk menjalankan seluruh rangkaian pengujian secara lokal, dan selama RuboCop. Waktu CI jauh lebih lama (3-4 menit), tetapi ini bukan masalah karena kami tetap dapat menjalankan semuanya secara lokal, berkat itu, kemungkinan kegagalan apa pun pada CI jauh lebih kecil.

Permata penjaga telah dapat digunakan kembali. Bayangkan Anda dapat menulis kode dan menjalankan tes pada setiap penyimpanan, memberi Anda umpan balik yang sangat cepat. Ini sangat sulit untuk dibayangkan ketika bekerja dengan Platform.

Menyebarkan Waktu

Waktu untuk menerapkan aplikasi Chronicles yang diekstraksi hanya dua menit. Tidak secepat kilat, tapi tetap tidak buruk. Kami sangat sering menerapkan, sehingga perbaikan kecil pun dapat menghasilkan penghematan besar.

Kinerja Aplikasi

Bagian paling intensif kinerja dari Chronicles adalah Entry search. Untuk saat ini, ada sekitar 20 tempat di bagian belakang Platform yang mengambil entri riwayat dari Chronicles. Ini berarti bahwa waktu respons Chronicles berkontribusi pada anggaran 60 detik Platform untuk waktu respons, jadi Chronicles harus cepat.

Meskipun ukuran log tindakan sangat besar (30 juta baris, dan terus bertambah), waktu respons rata-rata kurang dari 100 md. Lihatlah grafik yang indah ini:

Bagan kinerja aplikasi

Rata-rata, 80-90% waktu aplikasi dihabiskan di database. Seperti itulah seharusnya bagan kinerja yang tepat.

Kami masih memiliki beberapa kueri lambat yang mungkin memerlukan waktu puluhan detik, tetapi kami sudah memiliki rencana untuk menghilangkannya, memungkinkan aplikasi yang diekstrak menjadi lebih cepat.

Struktur

Untuk tujuan kami, validasi kering adalah alat yang sangat kuat dan fleksibel. Kami melewatkan semua input dari dunia luar melalui kontrak, dan itu membuat kami yakin bahwa parameter input selalu terbentuk dengan baik dan dari jenis yang terdefinisi dengan baik.

Tidak perlu lagi memanggil .to_s.to_sym.to_i dalam kode aplikasi, karena semua data dibersihkan dan diketik di batas aplikasi. Dalam arti tertentu, ini membawa jenis kewarasan yang kuat ke dunia Ruby yang dinamis. Saya tidak bisa merekomendasikannya cukup.

Kata-kata Terakhir

Memilih tumpukan non-standar tidak semudah yang terlihat pada awalnya. Kami mempertimbangkan banyak aspek saat memilih kerangka kerja dan pustaka yang akan digunakan untuk layanan baru: tumpukan teknologi aplikasi monolit saat ini, keakraban tim dengan tumpukan baru, cara mempertahankan tumpukan yang dipilih, dan seterusnya.

Meskipun kami mencoba untuk membuat keputusan yang sangat hati-hati dan diperhitungkan sejak awal - kami memilih untuk menggunakan tumpukan Hanami standar - kami harus mempertimbangkan kembali tumpukan kami di sepanjang jalan karena persyaratan teknis proyek yang tidak standar. Kami berakhir dengan Sinatra dan tumpukan berbasis KERING.

Apakah kami akan memilih Hanami lagi jika kami mengekstrak aplikasi baru? Mungkin iya. Kami sekarang tahu lebih banyak tentang perpustakaan dan pro dan kontra, sehingga kami dapat membuat keputusan yang lebih tepat sejak awal setiap proyek baru. Namun, kami juga serius mempertimbangkan untuk menggunakan aplikasi Sinatra/DRY.rb biasa.

Secara keseluruhan, waktu yang diinvestasikan untuk mempelajari kerangka kerja, paradigma, atau bahasa pemrograman baru memberi kami perspektif baru tentang tumpukan teknologi kami saat ini. Itu selalu baik untuk mengetahui apa yang tersedia di luar sana untuk memperkaya kotak peralatan Anda. Setiap alat memiliki kasus penggunaan uniknya sendiri—oleh karena itu, mengenalnya lebih baik berarti memiliki lebih banyak alat yang Anda inginkan dan mengubahnya menjadi lebih cocok untuk aplikasi Anda.