
Ini adalah kelanjutan dari artikel
"Masalah pemrosesan batch permintaan dan solusinya .
" Disarankan agar Anda membiasakan diri dengan bagian pertama, karena ini menjelaskan secara rinci esensi masalah dan beberapa pendekatan untuk solusinya. Di sini kita melihat metode lain.
Rekap tugas singkat
Ada obrolan untuk mengoordinasikan dokumen dengan seperangkat peserta yang telah ditentukan. Pesan berisi teks dan file. Dan, seperti dalam obrolan biasa, pesan dapat berupa balasan dan penerusan.
Model Pesan Obrolandata class ChatMessage (
// nullable persist
val id : Long ? = null ,
/** */
val author : UserReference ,
/** */
val message : String ,
/** */
// - JPA+ null,
val files : List < FileReference > ? = null ,
/** , */
val replyTo : ChatMessage ? = null ,
/** , */
val forwardFrom : ChatMessage ? = null
)
Melalui Injeksi Ketergantungan, kami dapat menerapkan layanan eksternal berikut:interface ChatMessageRepository {
fun findLast ( n : Int ) : List < ChatMessage >
}
data class FileHeadRemote (
val id : FileReference ,
val name : String
)
interface FileRemoteApi {
fun getHeadById ( id : FileReference ) : FileHeadRemote
fun getHeadsByIds ( id : Set < FileReference > ) : Set < FileHeadRemote >
fun getHeadsByIds ( id : List < FileReference > ) : List < FileHeadRemote >
}
data class UserRemote (
val id : UserReference ,
val name : String
)
interface UserRemoteApi {
fun getUserById ( id : UserReference ) : UserRemote
fun getUsersByIds ( id : Set < UserReference > ) : Set < UserRemote >
fun getUsersByIds ( id : List < UserReference > ) : List < UserRemote >
}
Kita perlu mengimplementasikan kontroler REST:
interface ChatRestApi {
fun getLast ( n : Int ) : List < ChatMessageUI >
}
Dimana:/** */
data class ReferenceUI (
/** url */
val ref : String ,
/** */
val name : String
)
data class ChatMessageUI (
val id : Long ,
/** */
val author : ReferenceUI ,
/** */
val message : String ,
/** */
val files : List < ReferenceUI >,
/** , */
val replyTo : ChatMessageUI ? = null ,
/** , */
val forwardFrom : ChatMessageUI ? = null
)
Pada bagian sebelumnya, kami melihat implementasi naif dari layanan menggunakan pemrosesan batch dan beberapa cara untuk mempercepatnya. Metode ini sangat sederhana, tetapi penerapannya tidak memberikan kinerja yang cukup baik.
Tingkatkan paket
Masalah utama dari solusi naif adalah ukuran kecil dari paket.
Untuk mengelompokkan panggilan ke dalam paket yang lebih besar, Anda perlu mengakumulasi permintaan. Baris ini tidak menyiratkan akumulasi permintaan:
author = userRepository . getUserById ( author ) . toFrontReference () ,
Sekarang di runtime kami tidak ada tempat khusus untuk menyimpan daftar pengguna - itu sedang dibentuk secara bertahap. Ini harus diubah.
Pertama, Anda perlu memisahkan logika akuisisi data dari pemetaan dalam metode ChatMessage.toFrontModel:
private fun ChatMessage . toFrontModel (
getUser : ( UserReference ) -> UserRemote ,
getFile : ( FileReference ) -> FileHeadRemote ,
serializeMessage : ( ChatMessage ) -> ChatMessageUI
) : ChatMessageUI =
ChatMessageUI (
id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
author = getUser ( author ) . toFrontReference () ,
message = message ,
files = files ?. let {
it . map ( getFile ) . map { it . toFrontReference () }
} ?: listOf () ,
forwardFrom = forwardFrom ?. let ( serializeMessage ) ,
replyTo = replyTo ?. let ( serializeMessage )
)
Ternyata fungsi ini hanya bergantung pada tiga fungsi eksternal (dan bukan pada seluruh kelas, seperti di awal).
Setelah pengerjaan ulang seperti itu, badan fungsi tidak menjadi kurang jelas, dan kontrak menjadi lebih keras (ini memiliki pro dan kontra).
Bahkan, Anda tidak dapat melakukan penyempitan kontrak dan meninggalkan ketergantungan pada antarmuka. Hal utama adalah bahwa tidak ada yang berlebihan di dalamnya, karena kita perlu melakukan implementasi alternatif.
Karena fungsi serializeMessage mirip dengan fungsi rekursif, ini dapat dilakukan sebagai rekursi eksplisit pada langkah pertama refactoring:
class ChatRestController (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) : ChatRestApi {
override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. map { it . toFrontModel () }
Saya membuat sebuah rintisan untuk metode toFrontModel, yang sejauh ini bekerja persis sama seperti dalam implementasi naif pertama kami (implementasi ketiga fungsi eksternal tetap sama).
private fun ChatMessage . toFrontModel () : ChatMessageUI =
toFrontModel (
getUser = userRepository ::getUserById ,
getFile = fileRepository ::getHeadById ,
serializeMessage = { it . toFrontModel () }
)
Tetapi kita perlu memastikan bahwa fungsi getUser, getFile dan serializeMessage bekerja secara efisien, yaitu, mereka mengirim permintaan ke layanan yang sesuai dalam paket dengan ukuran yang tepat (secara teoritis, ukuran ini dapat berbeda untuk setiap layanan) atau umumnya satu permintaan per layanan, jika permintaan tidak terbatas diizinkan.
Cara termudah untuk mencapai pengelompokan ini adalah jika kami memiliki semua pertanyaan yang diperlukan sebelum memulai pemrosesan. Untuk melakukan ini, sebelum memanggil toFrontModel, kumpulkan semua tautan yang diperlukan, lakukan pemrosesan batch, dan kemudian gunakan hasilnya.
Anda juga dapat mencoba skema dengan akumulasi permintaan dan eksekusi bertahapnya. Namun, skema semacam itu akan membutuhkan eksekusi asinkron, tetapi untuk saat ini kami akan fokus pada yang sinkron.
Jadi, untuk mulai menggunakan pemrosesan batch, dengan satu atau lain cara kita harus mencari tahu di muka sebanyak mungkin permintaan (lebih disukai semua) yang harus kita buat. Jika kita berbicara tentang pengontrol REST, alangkah baiknya untuk menggabungkan permintaan untuk setiap layanan sepanjang sesi.
Kelompokkan semua panggilanDalam beberapa situasi, semua data yang diperlukan dalam sesi dapat diperoleh dengan segera dan tidak akan menyebabkan masalah dengan sumber daya baik dari pemrakarsa permintaan atau dari pelaksana. Dalam hal ini, kami tidak dapat membatasi ukuran paket untuk memanggil layanan dan segera menerima semua data sekaligus.
Asumsi lain yang membuat hidup lebih mudah adalah mengasumsikan bahwa inisiator memiliki sumber daya yang cukup untuk memproses semua data. Permintaan ke layanan eksternal juga dapat dikirim dalam paket terbatas, jika mereka memerlukannya.
Penyederhanaan logika dalam hal ini menyangkut bagaimana tempat-tempat di mana data dibutuhkan akan dibandingkan dengan hasil panggilan. Jika kami menganggap bahwa sumber daya penggagas sangat terbatas, dan pada saat yang sama mencoba untuk meminimalkan jumlah panggilan eksternal, kami mendapatkan tugas yang agak sulit untuk pemotongan grafik yang optimal. Kemungkinan besar, Anda hanya perlu mengorbankan kinerja untuk mengurangi konsumsi sumber daya.
Kami akan mempertimbangkan bahwa secara khusus dalam proyek demo kami inisiator tidak terbatas pada sumber daya, ia dapat menerima semua data yang diperlukan dan menyimpannya sampai akhir sesi. Jika ada masalah dengan sumber daya, kami hanya akan membuat pagination yang lebih kecil.
Karena dalam praktik saya, pendekatan semacam itu sangat dibutuhkan, contoh-contoh lebih lanjut akan menyangkut pilihan ini.
Kita dapat membedakan metode-metode seperti itu untuk mendapatkan kumpulan pertanyaan besar:
- rekayasa terbalik;
- heuristik bisnis;
- agregat dalam gaya DDD;
- proksi dan panggilan ganda.
Mari kita lihat semua opsi pada contoh proyek kami.
Rekayasa terbalik
Kumpulkan semua permintaanKarena kita memiliki kode untuk mengimplementasikan semua fungsi yang terlibat dalam mengumpulkan informasi dan mengubahnya untuk frontend, kita dapat melakukan reverse engineering dan dari kode ini kita dapat memahami permintaan apa yang akan:
class ChatRestController (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) : ChatRestApi {
override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
// , forward reply
val allMessages = messages . asSequence () . flatMap {
sequenceOf ( it , it . forwardFrom , it . replyTo ) . filterNotNull ()
} . toSet ()
val allUserReq = allMessages . map { it . author }
val allFileReq = allMessages . flatMap { it . files ?: listOf () } . toSet ()
Semua permintaan dikumpulkan, sekarang Anda perlu melakukan pemrosesan batch yang sebenarnya.
Untuk allUserReq dan allFileReq, kami membuat kueri eksternal dan mengelompokkannya berdasarkan id. Jika tidak ada batasan pada ukuran paket, maka akan terlihat seperti ini:
userRepository . getUsersByIds ( allMessages . map { it . author } . toSet ())
. associateBy { it . id } ::get
fileRepository . getHeadsByIds ( allMessages . flatMap { it . files ?: listOf () } . toSet ())
. associateBy { it . id } ::get
Jika ada batasan, maka kodenya akan berupa:
val userApiChunkLimit = 100
allMessages . map { it . author } . asSequence () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id } ::get
Sayangnya, tidak seperti Stream, Sequence tidak dapat dengan mudah beralih ke permintaan paket paralel.
Jika Anda menganggap kueri paralel sebagai valid dan perlu, Anda dapat melakukan, misalnya, ini:
allMessages . map { it . author } . parallelStream () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id } ::get
Dapat dilihat bahwa tidak ada yang berubah banyak. Menggunakan sejumlah sihir Kotlin tertentu membantu kami dalam hal ini:
fun < T , R > Stream < out T >. chunked ( size : Int , transform : ( List < T > ) -> R ) : Stream < out R > =
batches ( this , size ) . map ( transform )
fun < T > Stream < out Collection < T >>. flatten () : Stream < T > =
flatMap { it . stream () }
fun < T , K > Stream < T >. associateBy ( keySelector : ( T ) -> K ) : Map < K , T > =
collect ( Collectors . toMap ( keySelector , { it } ))
Sekarang tinggal menyatukan semuanya:override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
// , forward reply
val allMessages = messages . asSequence () . flatMap { message ->
sequenceOf ( message , message . forwardFrom , message . replyTo )
. filterNotNull ()
} . toSet ()
messages . map ( ValueHolder < ( ChatMessage ) -> ChatMessageUI > () . apply {
value = memoize { message : ChatMessage ->
message . toFrontModel (
// ,
getUser = allMessages . map { it . author } . parallelStream () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id } ::get . orThrow { IllegalArgumentException ( "User $ it" ) } ,
//
getFile = fileRepository . getHeadsByIds ( allMessages . flatMap { it . files ?: listOf () } . toSet ())
. associateBy { it . id } ::get . orThrow { IllegalArgumentException ( "File $ it" ) } ,
//
serializeMessage = value
)
}
} . value )
}
Penjelasan dan PenyederhanaanHal pertama yang pasti akan menarik perhatian Anda adalah fungsi memoize. Faktanya adalah bahwa fungsi serializeMessage hampir pasti akan dipanggil beberapa kali untuk pesan yang sama (karena membalas dan meneruskan). Tidak jelas mengapa kita harus melakukan toFrontModel secara terpisah untuk setiap pesan tersebut (dalam beberapa kasus ini mungkin diperlukan, tetapi bukan milik kita). Oleh karena itu, Anda dapat melakukan memoisasi untuk fungsi serializeMessage. Ini diterapkan, misalnya, sebagai berikut:
fun < A , R > memoize ( func : ( A ) -> R ) = func as? Memoize2 ?: Memoize2 ( func )
class Memoize2 < A , R > ( val func : ( A ) -> R ) : ( A ) -> R , java . util . function . Function < A , R > {
private val cache = hashMapOf < A , R > ()
override fun invoke ( p1 : A ) = cache . getOrPut ( p1 , { func ( p1 ) } )
override fun apply ( t : A ) : R = invoke ( t )
}
Selanjutnya, kita perlu membangun fungsi serializeMessage yang di-memo, tetapi pada saat yang sama akan digunakan di dalamnya. Penting untuk menggunakan contoh fungsi yang sama persis di dalam, jika tidak semua memoisasi akan sia-sia. Untuk menyelesaikan tabrakan ini, kami menggunakan kelas ValueHolder, yang hanya menyimpan referensi ke nilai (Anda dapat mengambil sesuatu yang standar, misalnya, AtomicReference). Untuk mempersingkat catatan rekursi, Anda dapat melakukan ini:
inline fun < A , R > recursiveMemoize ( crossinline func : ( A , ( A ) -> R ) -> R ) : ( A ) -> R =
ValueHolder < ( A ) -> R > () . apply {
value = memoize { a -> func ( a , value ) }
} . value
Jika Anda bisa memahami silogisme panah ini pertama kali - selamat, Anda adalah programmer fungsional :-)
Sekarang kode akan terlihat seperti ini:
override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
// , forward reply
val allMessages = messages . asSequence () . flatMap { message ->
sequenceOf ( message , message . forwardFrom , message . replyTo )
. filterNotNull ()
} . toSet ()
// ,
val getUser = allMessages . map { it . author } . parallelStream () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id } ::get . orThrow { IllegalArgumentException ( "User $ it " ) }
//
val getFile = fileRepository . getHeadsByIds ( allMessages . flatMap { it . files ?: listOf () } . toSet ())
. associateBy { it . id } ::get . orThrow { IllegalArgumentException ( "File $ it" ) }
messages . map ( recursiveMemoize { message , memoized : ( ChatMessage ) -> ChatMessageUI ->
message . toFrontModel (
getUser = getUser ,
getFile = getFile ,
//
serializeMessage = memoized
)
} )
Anda juga dapat melihat orThrow, yang didefinisikan seperti ini:
/** [exception] , null */
fun < P , R > (( P ) -> R ? ) . orThrow ( exception : ( P ) -> Exception ) : ( P ) -> R =
{ p -> invoke ( p ) . let { it ?: throw exception ( p ) } }
Jika tidak ada data id kami di layanan eksternal dan ini dianggap sebagai situasi hukum, Anda perlu menanganinya dengan cara yang berbeda.
Setelah perbaikan ini, runtime getLast diperkirakan sekitar 300 ms. Selain itu, waktu ini akan tumbuh sedikit, bahkan jika permintaan tidak lagi sesuai dengan batasan ukuran paket (karena paket diminta secara paralel). Biarkan saya mengingatkan Anda bahwa tujuan minimum kami adalah 500 ms, dan 250 ms dapat dianggap sebagai pekerjaan normal.
ParalelismeTetapi Anda harus pindah. Panggilan ke userRepository dan fileRepository sepenuhnya independen, dan mereka dapat dengan mudah diparalelkan, secara teori mendekati 200 ms.
Misalnya, melalui fungsi gabungan kami:override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
// , forward reply
val allMessages = messages . asSequence () . flatMap { message ->
sequenceOf ( message , message . forwardFrom , message . replyTo )
. filterNotNull ()
} . toSet ()
join ( {
// ,
allMessages . map { it . author } . parallelStream () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id }
} , {
//
fileRepository . getHeadsByIds ( allMessages . flatMap { it . files ?: listOf () } . toSet ())
. associateBy { it . id }
} ) . let { ( users , files ) ->
messages . map ( recursiveMemoize { message , memoized : ( ChatMessage ) -> ChatMessageUI ->
message . toFrontModel (
getUser = users ::get . orThrow { IllegalArgumentException ( "User $ it" ) } ,
getFile = files ::get . orThrow { IllegalArgumentException ( "File $ it " ) } ,
//
serializeMessage = memoized
)
} )
}
}
Seperti yang diperlihatkan oleh praktik, eksekusi memakan waktu sekitar 200 ms, dan sangat penting bahwa waktu tidak tumbuh banyak dengan peningkatan jumlah pesan.
MasalahnyaSecara umum, kode tersebut, tentu saja, menjadi kurang dapat dibaca daripada versi pertama kami yang naif, tetapi bagus bahwa serialisasi itu sendiri (implementasi toFrontModel) tidak banyak berubah dan tetap sepenuhnya dapat dibaca. Seluruh logika kerja licik dengan layanan eksternal tinggal di satu tempat.
Kelemahan dari pendekatan ini adalah abstraksi kami sedang berjalan.
Jika kita perlu melakukan perubahan pada toFrontModel, kita hampir pasti harus membuat perubahan pada fungsi getLast, yang melanggar
prinsip substitusi Barbara Liskov (Prinsip Pergantian Liskov).
Sebagai contoh, kami sepakat untuk mendekripsi file terlampir hanya di pesan utama, tetapi tidak di balasan dan ke depan (balas / teruskan), atau hanya dalam tanggapan dan ke depan dari tingkat pertama. Dalam hal ini, setelah membuat perubahan pada kode toFrontModel, Anda harus membuat koreksi terkait dalam kode pengumpulan permintaan untuk file. Selain itu, koreksi akan menjadi nontrivial:
fileRepository . getHeadsByIds (
allMessages . flatMap { it . files ?: listOf () } . toSet ()
)
Dan di sini kita dengan lancar mendekati masalah lain yang terkait erat dengan yang sebelumnya: operasi kode yang benar secara keseluruhan tergantung pada keaksaraan dari rekayasa balik. Dalam beberapa kasus kompleks, kode mungkin tidak berfungsi dengan benar karena pengumpulan kueri yang salah. Tidak ada jaminan bahwa Anda akan dapat dengan cepat menghasilkan unit test yang akan mencakup semua situasi rumit seperti itu.
KesimpulanPro:
- Cara yang jelas untuk menerima permintaan sebelumnya, yang mudah dipisahkan dari kode utama.
- Tidak adanya memori dan waktu overhead yang hampir lengkap terkait dengan penggunaan hanya data yang akan diterima.
- Penskalaan yang baik dan kemampuan untuk membangun layanan, yang secara teori akan bertanggung jawab untuk waktu yang dapat diprediksi, terlepas dari ukuran permintaan dari luar.
Cons:
- Kode yang cukup rumit untuk pemrosesan batch itu sendiri.
- Pekerjaan besar dan bertanggung jawab atas analisis permintaan dalam implementasi yang ada.
- Abstraksi yang mengalir dan, sebagai konsekuensinya, kerapuhan seluruh skema dalam kaitannya dengan perubahan dalam implementasi.
- Kesulitan dalam dukungan: kesalahan dalam blok prediksi kueri sulit dibedakan dari kesalahan dalam kode utama. Idealnya, Anda perlu menggunakan tes unit dua kali lebih banyak, sehingga proses dengan kesalahan dalam produksi akan dua kali lebih sulit.
- Mematuhi prinsip-prinsip SOLID ketika menulis kode: kode harus disiapkan untuk mengasingkan logika pemrosesan batch. Pengenalan prinsip-prinsip ini saja akan memberikan beberapa keuntungan, jadi minus ini adalah yang paling tidak signifikan.
Penting untuk dicatat bahwa Anda dapat menggunakan metode ini tanpa melakukan reverse engineering. Kita perlu mendapatkan kontrak getLast, di mana kontrak untuk perhitungan awal permintaan tergantung (selanjutnya - prefetch). Dalam hal ini, kami melakukan ini dengan melihat implementasi getLast (reverse engineering). Namun, dengan pendekatan ini, kesulitan muncul: mengedit dua bagian kode ini harus selalu sinkron, dan tidak mungkin untuk memastikan ini sama sekali (ingat kode hash dan sama dengan, ada hal yang persis sama). Pendekatan selanjutnya, yang ingin saya tunjukkan, dirancang untuk menyelesaikan masalah ini (atau setidaknya meringankan).
Heuristik bisnis
Memecahkan masalah kontrakBagaimana jika kita beroperasi bukan dengan kontrak yang pasti dan, dengan demikian, dengan serangkaian permintaan yang tepat, tetapi dengan perkiraan? Selain itu, kami akan membangun satu set perkiraan sehingga mencakup set yang tepat dan didasarkan pada karakteristik area subjek.
Jadi, alih-alih ketergantungan kontrak prefetch pada getLast, kami menetapkan ketergantungan keduanya pada beberapa kontrak umum yang akan ditentukan oleh pengguna. Kesulitan utama adalah bagaimana mewujudkan kontrak umum ini dalam bentuk kode.
Cari batasan yang bermanfaatMari kita coba lakukan ini dengan contoh kita.
Dalam kasus kami, ada fitur bisnis berikut:
- daftar peserta obrolan sudah ditentukan sebelumnya;
- obrolan sepenuhnya terisolasi satu sama lain;
- bersarang rantai balasan / maju kecil (~ 2–3 pesan).
Dari pembatasan pertama, Anda tidak perlu berkeliaran di sekitar pesan, lihat apa yang ada di pengguna, pilih yang unik dan buat permintaan untuk mereka. Anda dapat dengan mudah meminta daftar yang telah ditentukan. Jika Anda setuju dengan pernyataan ini, maka saya menangkap Anda.
Padahal, semuanya tidak sesederhana itu. Daftar dapat ditentukan sebelumnya, tetapi bisa ada ribuan pengguna. Hal-hal seperti itu perlu diklarifikasi. Dalam kasus kami, biasanya akan ada dua atau tiga peserta obrolan, jarang lebih. Jadi sangat diterima untuk menerima data tentang semuanya.
Lebih lanjut, jika daftar pengguna obrolan telah ditentukan sebelumnya, tetapi informasi ini tidak ada dalam layanan pengguna (yang sangat mungkin), maka tidak akan ada artinya dari informasi tersebut. Kami akan membuat permintaan tambahan untuk daftar pengguna obrolan, dan kemudian Anda masih harus membuat permintaan ke layanan pengguna.
Misalkan informasi tentang koneksi pengguna dan obrolan disimpan dalam layanan pengguna. Dalam kasus kami, ini benar, karena koneksi ditentukan oleh hak-hak pengguna. Maka bagi pengguna akan menghasilkan kode prefetch seperti:
Mungkin mengejutkan di sini bahwa kami tidak melewati pengenal obrolan apa pun. Saya melakukan ini dengan sengaja agar tidak mengacaukan kode sampel.
Sepintas, tidak ada yang mengikuti dari pembatasan kedua. Bagaimanapun, saya masih tidak bisa mendapatkan sesuatu yang berguna darinya.
Kami telah menggunakan batasan ketiga sebelumnya. Ini dapat berdampak signifikan pada cara kami menyimpan dan menerima percakapan. Kami tidak akan mulai mengembangkan topik ini, karena tidak ada hubungannya dengan pengontrol REST dan pemrosesan batch.
Apa yang harus dilakukan dengan file? Saya ingin mendapatkan daftar semua file obrolan dalam satu permintaan sederhana. Di bawah ketentuan API, kita hanya perlu header file, tanpa badan, jadi ini tidak terlihat seperti tugas yang intensif sumber daya dan berbahaya bagi penelepon.
Di sisi lain, kita harus ingat bahwa kita tidak menerima semua pesan obrolan, tetapi hanya N terakhir, dan dapat dengan mudah ternyata tidak mengandung file sama sekali.
Tidak ada jawaban universal: semuanya tergantung pada spesifikasi bisnis dan kasus penggunaan. Saat membuat solusi produk, Anda bisa mendapat masalah jika Anda meletakkan heuristik untuk satu kasus penggunaan, dan kemudian pengguna akan bekerja dengan fungsionalitas dengan cara yang berbeda. Untuk demo dan presales, ini adalah pilihan yang baik, tetapi saat ini kami sedang mencoba untuk menulis layanan produksi yang jujur.
Jadi, sayangnya, dimungkinkan untuk melakukan heuristik bisnis untuk file di sini hanya berdasarkan pada hasil operasi dan pengumpulan statistik (atau setelah penilaian ahli).
Karena kami masih ingin menerapkan metode kami, misalkan statistik menunjukkan yang berikut:
- Percakapan umum dimulai dengan pesan yang berisi satu atau lebih file, diikuti oleh pesan balasan tanpa file.
- Hampir semua pesan datang dalam percakapan biasa.
- Jumlah file unik yang diharapkan dalam satu obrolan adalah ~ 20.
Oleh karena itu untuk menampilkan hampir semua pesan, Anda perlu mendapatkan tajuk beberapa file (karena ChatMessageUI diatur sedemikian rupa) dan jumlah total file kecil. Dalam hal ini, tampaknya masuk akal untuk menerima semua file obrolan dalam satu permintaan. Untuk melakukan ini, kita harus menambahkan yang berikut ke API kami untuk file:
fun getHeadsByChat () : List < FileHeadRemote >
Metode getHeadsByChat tidak terlihat terlalu jauh dan dibuat murni karena keinginan kami untuk mengoptimalkan kinerja (walaupun ini juga merupakan alasan yang bagus). Cukup sering, di ruang obrolan dengan file, pengguna ingin melihat semua file yang digunakan, dan dalam urutan mereka ditambahkan (oleh karena itu, kami menggunakan Daftar).
Implementasi koneksi eksplisit semacam itu akan memerlukan penyimpanan informasi tambahan dalam layanan file atau dalam aplikasi kita. Itu semua tergantung pada bidang tanggung jawab siapa, menurut pendapat kami, informasi yang berlebihan tentang koneksi file dengan obrolan ini harus disimpan. Itu berlebihan karena pesan sudah dikaitkan dengan file, dan itu, pada gilirannya, dikaitkan dengan obrolan. Anda tidak dapat menggunakan denormalisasi, tetapi ekstrak informasi ini dengan cepat dari pesan, yaitu, di dalam SQL, segera terima semua file di seluruh obrolan (ini ada di aplikasi kami) dan minta semuanya sekaligus dari layanan file. Opsi ini akan bekerja lebih buruk jika ada banyak pesan obrolan, tetapi kami tidak perlu denasionalisasi. Saya akan menyembunyikan kedua opsi di belakang getHeadsByChat.
Kode tersebut adalah sebagai berikut:
override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
join (
{ userRepository . getUsersByChat () . associateBy { it . id } } ,
{ fileRepository . getHeadsByChat () . associateBy { it . id } }
)
. let { //
}
}
Dapat dilihat bahwa dibandingkan dengan versi sebelumnya, sangat sedikit yang berubah dan hanya bagian prefetch yang terpengaruh, yang hebat.
Kode prefetch menjadi jauh lebih pendek dan lebih jelas.
Waktu eksekusi tidak berubah, yang logis, karena jumlah permintaan tetap sama. Secara teoritis ada beberapa kemungkinan kasus ketika penskalaan akan lebih baik daripada rekayasa balik yang jujur (hanya karena menghapus tautan penghitungan yang rumit). Namun, situasi yang berlawanan kemungkinannya sama: heuristik terlalu banyak bertengkar. Seperti yang ditunjukkan oleh praktik, jika Anda berhasil menghasilkan heuristik yang memadai, maka tidak boleh ada perubahan khusus dalam waktu eksekusi.
Namun, ini belum semuanya. Kami tidak memperhitungkan bahwa sekarang menerima data terperinci tentang pengguna dan file tidak terkait dengan menerima pesan dan permintaan dapat diluncurkan secara paralel:
Opsi ini memberikan 100 ms stabil per permintaan.
Kesalahan HeuristikBagaimana jika, ketika menggunakan heuristik, set kueri tidak lebih besar, tetapi sedikit lebih kecil dari yang seharusnya? Untuk sebagian besar opsi, heuristik seperti itu akan berfungsi, tetapi akan ada pengecualian di mana Anda harus membuat permintaan terpisah. Dalam praktik saya, keputusan seperti itu tidak berhasil, karena setiap pengecualian memiliki dampak besar pada kinerja, dan pada akhirnya beberapa pengguna membuat permintaan yang seluruhnya terdiri dari pengecualian. Saya akan mengatakan bahwa dalam situasi seperti itu lebih baik menggunakan teknik reverse, bahkan jika algoritma pengumpulan kueri menyeramkan dan tidak dapat dibaca, tetapi, tentu saja, semuanya tergantung pada kekritisan layanan.
KesimpulanPro:
- Logika heuristik bisnis mudah dibaca dan biasanya sepele. Ini bagus untuk memahami batasan penerapan, memverifikasi dan memodifikasi kontrak prefetch.
- Skalabilitas sama baiknya dengan teknik terbalik.
- Koherensi kode atas data berkurang, yang dapat menyebabkan paralelisasi kode yang lebih baik.
- Logika prefetch, seperti logika utama controller REST, didasarkan pada persyaratan. Ini adalah nilai tambah yang lemah jika persyaratan sering berubah.
Cons:
- Dari persyaratan tidak mudah untuk mendapatkan heuristik untuk prediksi permintaan. Klarifikasi persyaratan mungkin diperlukan, sejauh yang tidak kompatibel dengan tangkas.
- Anda bisa mendapatkan data tambahan.
- Untuk memastikan bahwa kontrak prefetch berfungsi secara efektif, denormalisasi penyimpanan data mungkin akan diperlukan. Ini adalah minus yang lemah, karena optimasi ini mengikuti dari logika bisnis dan karena itu, kemungkinan besar, akan diklaim oleh proses yang berbeda.
Dari contoh kita, kita dapat menyimpulkan bahwa menerapkan pendekatan ini sangat sulit dan gamenya tidak bernilai lilin. Bahkan, dalam proyek bisnis nyata, jumlah pembatasan sangat besar dan dari tumpukan ini Anda sering berhasil mendapatkan sesuatu yang berguna, yang memungkinkan Anda untuk mempartisi data atau memprediksi statistik. Keuntungan utama dari pendekatan ini adalah bahwa pembatasan yang digunakan ditafsirkan oleh bisnis, oleh karena itu mereka mudah dipahami dan divalidasi.Biasanya masalah terbesar ketika mencoba menggunakan pendekatan ini adalah pemisahan kegiatan. Pengembang harus tenggelam dalam logika bisnis dan mengajukan pertanyaan analis yang mengklarifikasi pertanyaan, yang memerlukan tingkat inisiatif tertentu.Unit bergaya DDD
Dalam proyek besar, Anda sering dapat melihat penggunaan praktik DDD, karena mereka memungkinkan Anda untuk menyusun kode secara efisien. Tidak perlu menggunakan semua templat DDD dalam proyek - terkadang Anda bisa mendapatkan pengembalian yang bagus bahkan dari pengenalan satu. Pertimbangkan konsep DDD sebagai agregat. Agregat adalah penyatuan entitas yang terhubung secara logis, pekerjaan yang dilakukan hanya melalui akar agregat (biasanya ini adalah entitas yang berada di atas grafik konektivitas entitas).Dari sudut pandang memperoleh data, hal utama dalam agregat adalah bahwa seluruh logika bekerja dengan daftar entitas ada di satu tempat - agregat. Ada dua pendekatan untuk apa yang harus ditransfer ke unit selama pembangunannya:- Kami mentransfer fungsi ke unit untuk mendapatkan data eksternal. Logika untuk menentukan data yang diperlukan tinggal di dalam unit.
- Kami mentransfer semua data yang diperlukan. Logika untuk menentukan data yang diperlukan tinggal di luar agregat.
Pilihan pendekatan sangat tergantung pada seberapa mudah prefetch dapat dipindahkan di luar agregat. Jika logika prefetch didasarkan pada heuristik bisnis, biasanya mudah memisahkannya dari agregat. Mengambil logika di luar lingkup agregat berdasarkan analisis penggunaannya (rekayasa terbalik) bisa berbahaya, karena kita dapat mendistribusikan kode yang terkait secara logis ke dalam kelas yang berbeda.Logika memperbesar kueri di dalam agregat.Mari kita coba membuat sketsa agregat yang sesuai dengan konsep "obrolan". Kelas ChatMessage, UserReference, FileReference kami sesuai dengan model penyimpanan, sehingga dapat diubah namanya dengan beberapa awalan yang sesuai, tetapi kami memiliki proyek kecil, jadi mari kita biarkan apa adanya. Kami menyebutnya Obrolan rakitan, dan komponennya adalah ChatPage dan ChatPageMessage: Sejauh ini, cukup banyak duplikasi yang tidak berarti yang diperoleh. Ini karena fakta bahwa model subjek kami mirip dengan model penyimpanan dan keduanya mirip dengan model frontend. Saya menggunakan kelas FileHeadRemote dan UserRemote secara langsung, agar tidak menulis terlalu banyak kode, meskipun biasanya dalam domain Anda harus menghindari menggunakan kelas-kelas tersebut secara langsung.interface Chat {
fun getLastPage ( n : Int ) : ChatPage
}
interface ChatPage {
val messages : List < ChatPageMessage >
}
data class ChatPageMessage (
val id : Long ,
val author : UserRemote ,
val message : String ,
val files : List < FileHeadRemote >,
val replyTo : ChatPageMessage ? ,
val forwardFrom : ChatPageMessage ?
)
, REST- :
class ChatRestController (
private val chat : Chat
) : ChatRestApi {
override fun getLast ( n : Int ) =
chat . getLastPage ( n ) . toFrontModel ()
private fun ChatPage . toFrontModel () =
messages . map { it . toFrontModel () }
private fun ChatPageMessage . toFrontModel () : ChatMessageUI =
ChatMessageUI (
id = id ,
author = author . toFrontReference () ,
message = message ,
files = files . toFrontReference () ,
forwardFrom = forwardFrom ?. toFrontModel () ,
replyTo = replyTo ?. toFrontModel ()
)
}
, : , , , . , prefetch . , (Single Responsibility Principle, SRP).
, .
, -.class ChatImpl (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) : Chat {
override fun getLastPage ( n : Int ) = object : ChatPage {
override val messages : List < ChatPageMessage >
get () =
runBlocking ( IO ) {
val prefetch = async (
{ userRepository . getUsersByChat () . associateBy { it . id } } ,
{ fileRepository . getHeadsByChat () . associateBy { it . id } }
)
withContext ( IO ) { messageRepository . findLast ( n ) }
. map (
prefetch . await () . let { ( users , files ) ->
recursiveMemoize { message , memoized : ( ChatMessage ) -> ChatPageMessage ->
message . toDomainModel (
getUser = users ::get . orThrow { IllegalArgumentException ( "User $ it " ) } ,
getFile = files ::get . orThrow { IllegalArgumentException ( "File $ it " ) } ,
//
serializeMessage = memoized
)
}
}
)
}
}
}
private fun ChatMessage . toDomainModel (
getUser : ( UserReference ) -> UserRemote ,
getFile : ( FileReference ) -> FileHeadRemote ,
serializeMessage : ( ChatMessage ) -> ChatPageMessage
) = ChatPageMessage (
id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
author = getUser ( author ) ,
message = message ,
files = files ?. map ( getFile ) ?: listOf () ,
forwardFrom = forwardFrom ?. let ( serializeMessage ) ,
replyTo = replyTo ?. let ( serializeMessage )
)
Ternyata fungsi getLastPage itu sendiri memiliki strategi akuisisi data, termasuk prefetch, dan fungsi toDomainModel adalah murni teknis dan bertanggung jawab untuk mengubah model yang tersimpan menjadi model domain.Saya menulis ulang panggilan paralel ke userRepository, fileRepository, dan messageRepository dalam bentuk yang lebih akrab bagi Kotlin. Saya berharap kelengkapan kode tidak menderita karena ini.Secara umum, metode seperti itu sudah berfungsi penuh, kinerja ketika menerapkannya akan sama dengan penggunaan sederhana teknik terbalik atau heuristik bisnis.: ChatPage Chat, getLast(), . :
interface Chat {
fun getPage () : ChatPage
}
, Chat ChatPage:
class ChatPageImpl (
private val messageData : List < ChatMessage >,
private val userData : List < UserRemote >,
private val fileData : List < FileHeadRemote >
) : ChatPage {
override val messages : List < ChatPageMessage >
get () =
messageData . map (
( userData . associateBy { it . id } to fileData . associateBy { it . id } )
. let { ( users , files ) ->
recursiveMemoize { message , self : ( ChatMessage ) -> ChatPageMessage ->
message . toDomainModel (
getUser = users ::get . orThrow () ,
getFile = files ::get . orThrow () ,
//
serializeMessage = self
)
}
}
)
}
prefetch, :
fun chatPagePrefetch (
pageSize : Int ,
messageRepository : ChatMessageRepository ,
userRepository : UserRemoteApi ,
fileRepository : FileRemoteApi
) =
runBlocking ( IO ) {
async (
{ userRepository . getUsersByChat () } ,
{ fileRepository . getHeadsByChat () } ,
{ messageRepository . findLast ( pageSize ) }
)
}
, , prefetch. DDD Application Services.
class ChatService (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) {
private fun chatPagePrefetch ( pageSize : Int ) =
runBlocking ( IO ) {
async (
{ messageRepository . findLast ( pageSize ) } ,
{ userRepository . getUsersByChat () } ,
{ fileRepository . getHeadsByChat () }
) . await ()
}
fun getLastPage ( n : Int ) : ChatPage =
chatPagePrefetch ( n )
. let { ( messageData , userData , fileData ) ->
ChatPageImpl ( messageData , userData , fileData )
}
}
Nah, pengontrolnya tidak akan banyak berubah, Anda hanya perlu menggunakan ChatService :: getLastPage alih-alih Chat :: getLastPage. Artinya, kode akan berubah seperti ini:class ChatRestController (
private val chat : ChatService
) : ChatRestApi
Kesimpulan- Log prefetch dapat ditempatkan di dalam unit atau di tempat yang terpisah.
- Jika logika prefetch sangat terkait dengan logika internal agregat, lebih baik untuk tidak mengeluarkannya, karena ini dapat mengganggu enkapsulasi. Saya pribadi tidak melihat banyak gunanya memindahkan prefetch dari agregat, karena ini sangat membatasi kemungkinan dan meningkatkan koherensi kode secara implisit.
- Organisasi agregat sendiri memiliki efek positif pada kinerja pemrosesan batch, karena kontrol atas permintaan besar menjadi lebih dan tempat untuk logika prefetch menjadi sangat jelas.
Pada bab selanjutnya, kami akan mempertimbangkan implementasi prefetch yang tidak dapat diimplementasikan secara terpisah dari fungsi utama.Proxy dan panggilan ganda
Memecahkan masalah kontrakSeperti yang telah kita lihat di bagian sebelumnya, masalah utama kontrak prefetch adalah bahwa ia sangat terkait dengan kontrak fungsi yang harus disiapkan datanya. Untuk lebih tepatnya, itu tergantung pada data apa fungsi utama mungkin perlu. Bagaimana jika kita tidak mencoba untuk memprediksi, tetapi mencoba melakukan rekayasa balik menggunakan kode itu sendiri? Dalam situasi sederhana, pendekatan proksi yang biasa digunakan dalam pengujian dapat membantu kami. Perpustakaan seperti Mockito menghasilkan kelas dengan implementasi antarmuka, yang juga dapat mengakumulasi informasi tentang panggilan. Pendekatan serupa digunakan di perpustakaan kami .Jika Anda memanggil fungsi utama dengan repositori proksi dan mengumpulkan informasi tentang data yang diperlukan, maka Anda dapat memperoleh data ini dalam bentuk paket dan memanggil kembali fungsi utama untuk mendapatkan hasil akhir.Kondisi utama adalah sebagai berikut: data yang diminta tidak boleh mempengaruhi permintaan berikutnya. Proxy tidak akan mengembalikan data nyata, tetapi hanya beberapa bertopik, sehingga semua yang bercabang dan menerima data yang terkait menghilang.Dalam kasus kami, ini berarti bahwa tidak berguna untuk mempostingkan repositori messageRep, karena permintaan lebih lanjut dibuat berdasarkan hasil dari permintaan pesan. Ini bukan masalah, karena kami hanya punya satu permintaan untuk messageRepository, jadi tidak diperlukan pemrosesan batch di sini.Karena kami akan mem-proksi fungsi sederhana UserReference-> UserRemote dan FileReference-> FileHeadRemote, Anda hanya perlu mengumpulkan dua daftar argumen.Hasilnya, kami mendapatkan yang berikut:class ChatRestController (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) : ChatRestApi {
override fun getLast ( n : Int ) : List < ChatMessageUI > {
val messages = messageRepository . findLast ( n )
//
fun transform (
getUser : ( UserReference ) -> UserRemote ,
getFile : ( FileReference ) -> FileHeadRemote
) : List < ChatMessageUI > =
messages . map (
recursiveMemoize { message , self ->
message . toFrontModel ( getUser , getFile , self )
}
)
//
val userIds = mutableSetOf < UserReference > ()
val fileIds = mutableSetOf < FileReference > ()
transform (
{ userIds += it ; UserRemote ( 0L , "" ) } ,
{ fileIds += it ; FileHeadRemote ( 0L , "" ) }
)
return runBlocking ( IO ) {
//
async (
{ userRepository . getUsersByIds ( userIds ) . associateBy { it . id } ::get . orThrow () } ,
{ fileRepository . getHeadsByIds ( fileIds ) . associateBy { it . id } ::get . orThrow () }
) . await () . let { ( getUser , getFile ) ->
transform ( getUser , getFile )
}
}
}
}
, , , -, . , , ( ).
-, . , . , , , , - .
KesimpulanPro:
- prefetch - .
- prefetch.
- , -.
Cons:
- .
- , .
, , . , -: , ( , ).
, prefetch .
, - , prefetch .
Kesimpulan
, . , ( ).
, . : ( , ), , , .
Cara yang paling jelas untuk menumpuk kueri adalah membalikkan kode yang ada untuk mencari kueri berat. Kelemahan utama dari pendekatan ini adalah peningkatan konektivitas kode implisit. Alternatifnya adalah dengan menggunakan informasi tentang fitur bisnis untuk membagi data menjadi potongan-potongan, yang sering dibagi dan keseluruhan. Kadang-kadang, untuk pemisahan yang efektif seperti itu, perlu untuk mendenormalkan penyimpanan, tetapi jika ini berhasil, logika pemrosesan batch akan ditentukan oleh area subjek, yang bagus.Cara yang tidak terlalu jelas untuk mendapatkan semua permintaan adalah dengan menerapkan dua pass. Pada tahap pertama, kami mengumpulkan semua permintaan yang diperlukan, pada tahap kedua kami bekerja dengan data yang sudah diterima. Penerapan pendekatan ini dibatasi oleh persyaratan independensi permintaan satu sama lain.