Tentang apa semua ini?
Ini semua tentang ular. Semua orang ingat apa itu ular: ular bergerak di bidang persegi panjang. Menemukan makanan - tumbuh panjang, menemukan dirinya atau ujung lapangan - mati. Dan pengguna hanya dapat mengirim perintah: kiri, kanan, atas, bawah.
Saya memutuskan untuk menambahkan beberapa tindakan di sini dan membuat ular melarikan diri dari pacman. Dan semua ini pada aktor!
Oleh karena itu, hari ini saya akan menggunakan contoh ular untuk berbicara tentang bagaimana membangun model aktor menggunakan MailboxProcessor
dari perpustakaan standar, poin apa yang harus dicari, dan perangkap apa yang dapat Anda harapkan.
Kode yang ditulis di sini tidak sempurna, mungkin melanggar beberapa prinsip, dan mungkin lebih baik ditulis. Tetapi jika Anda seorang pemula dan ingin berurusan dengan kotak surat - Saya harap artikel ini membantu Anda.
Jika Anda tahu segalanya tentang kotak surat tanpa saya, Anda mungkin bosan di sini.
Mengapa aktor?
Demi latihan. Saya membaca tentang model aktor, menonton video, saya menyukai segalanya, tetapi saya tidak mencobanya sendiri. Sekarang saya mencobanya.
Terlepas dari kenyataan bahwa sebenarnya saya memilih teknologi demi teknologi, konsep ini sangat berhasil jatuh pada tugas ini.
Mengapa MailboxProcessor, dan bukan, misalnya, Akka.net?
Untuk tugas saya, MailboxProcessor
dari stasiun orbital oleh burung pipit, MailboxProcessor
jauh lebih sederhana, dan itu adalah bagian dari perpustakaan standar, jadi Anda tidak perlu menghubungkan paket apa pun.
Tentang prosesor kotak surat dan boilerplate terkait
Intinya sederhana. Kotak surat di dalamnya memiliki loop pesan dan beberapa status. Perulangan pesan Anda akan memperbarui status ini sesuai dengan pesan baru yang tiba.
let actor = MailboxProcessor.Start(fun inbox -> // , // . inbox -- MailboxProcessor let rec messageLoop oldState = async { // let! msg = inbox.Receive()
Harap dicatat messageLoop
rekursif, dan pada akhirnya harus dipanggil lagi, jika tidak hanya satu pesan yang akan diproses, setelah itu aktor ini akan mati. messageLoop
juga asinkron, dan setiap iterasi berikutnya dilakukan ketika pesan baru diterima: let! msg = inbox.Receive()
let! msg = inbox.Receive()
.
Dengan demikian, seluruh muatan logis masuk ke fungsi updateState
, yang berarti bahwa untuk membuat kotak surat prosesor, kita bisa membuat fungsi konstruktor yang menerima fungsi pembaruan status dan keadaan nol:
// applyMessage // (fun inbox -> ...) let buildActor applyMessage zeroState = MailboxProcessor.Start(fun inbox -> let rec loop state = async{ let! msg = inbox.Receive() let newState = applyMessage state msg return! loop newState } loop zeroState )
Keren! Sekarang kita tidak perlu terus-menerus memonitor agar tidak lupa return! loop newState
return! loop newState
. Seperti yang Anda tahu, seorang aktor menyimpan negara, tetapi sekarang sama sekali tidak jelas bagaimana cara mendapatkan keadaan ini dari luar. Kotak surat prosesor memiliki metode PostAndReply
, yang menggunakan AsyncReplyChannel<'Reply> -> 'Msg
fungsi Pesan Pesan sebagai input. Pada awalnya itu membuat saya pingsan - benar-benar tidak jelas dari mana mendapatkan fungsi ini. Tetapi pada kenyataannya, semuanya ternyata lebih sederhana: semua pesan harus dibungkus dengan pembungkus DU, karena kita sekarang mendapatkan 2 operasi pada aktor kita: mengirim pesan itu sendiri dan meminta keadaan saat ini. Begini tampilannya:
// . // Mail<_,_> , Post & Get -- . // F# , // compare & equals . // -- . // [<Struct>] . type Mail<'msg, 'state> = | Post of 'msg | Get of AsyncReplyChannel<'state>
Fungsi konstruktor kami sekarang terlihat seperti ini:
let buildActor applyMessage zeroState = MailboxProcessor.Start(fun inbox -> let rec loop state = async{ let! msg = inbox.Receive() // , // . -- , // . // -- // . ! match msg with | Post msg -> let newState = applyMessage state msg return! loop newState | Get channel -> channel.Reply state return! loop state } loop zeroState )
Sekarang, untuk bekerja dengan kotak surat, kita perlu membungkus semua pesan kita di Mail.Post
ini. Agar tidak menulis ini setiap waktu, lebih baik membungkusnya dalam aplikasi kecil:
module Mailbox = let buildAgent applyMessage zeroState = MailboxProcessor.Start(fun inbox -> let rec loop state = async{ let! msg = inbox.Receive() match msg with | Post msg -> let newState = applyMessage state msg return! loop newState | Get channel -> channel.Reply state return! loop state } loop zeroState ) let post (agent: MailboxProcessor<_>) msg = Post msg |> agent.Post let getState (agent: MailboxProcessor<_>) = agent.PostAndReply Get let getStateAsync (agent: MailboxProcessor<_>) = agent.PostAndAsyncReply Get // Single Case Discriminated Union. // MailboxProcessor API. , // - , , // . , // . type MailAgent<'msg, 'state> = MailAgent of address:string * mailbox:MailboxProcessor<Mail<'msg, 'state>> // API with member this.Post msg = // let (MailAgent (address,this)) = this Mailbox.post this msg member this.GetState() = let (MailAgent (address,this)) = this Mailbox.getState this member this.GetStateAsync() = let (MailAgent (address,this)) = this Mailbox.getStateAsync this member this.Address = let (MailAgent (address, _)) = this address member this.Dispose() = let (MailAgent (_, this)) = this (this:>IDisposable).Dispose() interface IDisposable with member this.Dispose() = this.Dispose()
Saya akan memberi tahu Anda address:string
sedikit lebih lambat, tetapi untuk saat ini boilerplate kami sudah siap.
Sebenarnya, ular itu
Dalam ular ada ular, pengguna dengan perintahnya, bidang dan transisi reguler ke bingkai berikutnya.
Ini semua bersama-sama dan perlu diwarnai oleh aktor kita.
Layout awal saya adalah sebagai berikut:
- Aktor dengan penghitung waktu. Menerima pesan mulai / hentikan / jeda. Setiap n milidetik, mengirim pesan
Flush
ke aktor Flush
. Menyimpan System.Timers.Timer
sebagai kondisi - Tim aktor Menerima pesan dari pengguna
Move Up/Down/Left/Right
, AddPerk Speed/Attack
(ya, ular saya dapat dengan cepat merangkak dan menyerang penjahat) dan Flush
dari timer. Ini menyimpan daftar perintah sebagai keadaan, dan dengan flush daftar ini di-reset. - Aktor itu adalah ular. Ini menyimpan keadaan ular - tunjangan, panjang, arah, tikungan dan koordinat.
Ia menerima daftar pesan dari aktor perintah, pesan Tick
(untuk menggerakkan ular 1 sel ke depan), dan pesan GrowUp
dari aktor lapangan ketika menemukan makanan. - Aktor lapangan. Ini menyimpan peta sel, mengambil keadaan ular dalam pesan dan menggambar koordinat pada gambar yang ada. Ini juga mengirim
GrowUp
aktor ular dan perintah Stop
ke timer jika permainan berakhir.
Seperti yang Anda lihat, bahkan dengan sejumlah kecil entitas, peta pesan sudah non-sepele. Dan sudah pada tahap ini kesulitan muncul: faktanya adalah bahwa secara default F # tidak memungkinkan dependensi siklik. Pada baris kode saat ini, Anda hanya dapat menggunakan kode yang ditulis di atas, dan hal yang sama berlaku untuk file dalam proyek. Ini bukan bug, tapi fitur, dan saya sangat menyukainya, karena membantu menjaga kode tetap bersih, tetapi apa yang harus dilakukan ketika tautan siklik diperlukan oleh desain? Tentu saja, Anda dapat menggunakan rec namespace
- dan kemudian di dalam satu file Anda dapat merujuk semua yang ada di file ini, yang saya gunakan.
Kode ini diperkirakan akan berantakan, tetapi sepertinya itu satu-satunya pilihan. Dan itu berhasil.
Masalah dunia luar
Semuanya bekerja selama seluruh sistem aktor diisolasi dari dunia luar, dan saya hanya merusak dan menampilkan garis-garis di konsol. Ketika tiba saatnya untuk mengimplementasikan dependensi dalam bentuk fungsi updateUI
, yang seharusnya updateUI
ulang untuk setiap centang, saya tidak bisa menyelesaikan masalah ini dalam implementasi saat ini. Tidak jelek atau cantik - tidak mungkin. Dan kemudian saya ingat akku - setelah semua, di sana Anda dapat menghasilkan aktor di sepanjang jalan, dan saya memiliki semua aktor saya dijelaskan pada tahap kompilasi.
Solusinya jelas - gunakan akku! Tidak, tentu saja, Akka masih berlebihan, tetapi saya memutuskan untuk menjilat poin-poin tertentu dari sana - yaitu, untuk menciptakan sistem aktor di mana Anda dapat secara dinamis menambahkan aktor baru dan permintaan aktor yang ada di alamat.
Karena aktor sekarang ditambahkan dan dihapus dalam runtime, tetapi diperoleh dengan alamat daripada tautan langsung, Anda harus memberikan skenario di mana alamat terlihat di mana-mana dan aktor tidak ada di sana. Mengikuti contoh acca yang sama, saya menambahkan sebuah kotak untuk surat mati, dan saya mendesainnya melalui DU favorit saya:
// Agent<_,_> -- , , // , . // , -- Box (mailagent), // , , , , // Deadbox. MailAgent, . // . // -- . type Agent<'message,'state> = | Box of MailAgent<'message,'state> | DeadBox of string * MailAgent<string * obj, Map<string,obj list>> with member this.Post msg = match this with | Box box -> box.Post msg | DeadBox (address, deadbox) -> (address, box msg) |> deadbox.Post interface IDisposable with member this.Dispose() = match this with | Box agent -> agent.Dispose() | DeadBox (_,agent) -> agent.Dispose()
Dan sistem itu sendiri terlihat seperti ini:
// . -- . type MailboxNetwork() as this = // . ! [<DefaultValue>] val mutable agentRegister: ConcurrentDictionary<string, obj> // do this.agentRegister <- ConcurrentDictionary<string, obj>() // , // Map -- let deadLettersFn deadLetters (address:string, msg:obj) = printfn "Deadletter: %s-%A" address msg match Map.tryFind address deadLetters with // | None -> Map.add address [msg] deadLetters // -- | Some letters -> // -- Map.remove address deadLetters |> Map.add address (msg::letters) let deadLettersAgent() = ("deadLetters", Map.empty |> Mailbox.buildAgent deadLettersFn) |> MailAgent member this.DeadLetters = deadLettersAgent() // - , member this.Box<'message,'state>(address) = match this.agentRegister.TryGetValue address with | (true, agent) when (agent :? MailAgent<'message,'state>) -> // , , let agent = agent :?> MailAgent<'message, 'state> Box agent | _ -> DeadBox (address, this.DeadLetters) // -- member this.KillBox address = this.agentRegister.TryRemove(address) |> ignore member this.RespawnBox (agent: MailAgent<'a,'b>) = this.KillBox agent.Address this.agentRegister.TryAdd (agent.Address, agent) |> ignore interface IDisposable with member this.Dispose() = for agent in this.agentRegister.Values do match agent with | :? IDisposable as agent -> agent.Dispose() | _ -> ()
Di sinilah address:string
sama address:string
, yang saya tulis di atas, berguna. Dan lagi itu berhasil, ketergantungan eksternal sekarang mudah untuk mendapatkan di mana Anda perlu. Fungsi konstruktor aktor sekarang menerima sistem aktor sebagai argumen dan mendapatkan alamat yang diperlukan dari sana:
// - ( ) - let gameAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<Command list, GameState>(gameAddress) // message loop let commandAgentFn (mailboxNetwork: MailboxNetwork) commands msg = let gameAgent = gameAgent mailboxNetwork match msg with | Cmd cmd -> cmd::commands | Flush -> commands |> gameAgent.Post []
Perlahan
Untuk alasan yang jelas, selama debugging, saya menyetel game ke kecepatan rendah: penundaan antara kutu lebih dari 500 milidetik. Jika Anda mengurangi penundaan menjadi 200, maka pesan mulai datang terlambat, dan tim dari pengguna bekerja dengan penundaan, yang merusak seluruh permainan. Lalat tambahan di salep adalah fakta bahwa timer menerima perintah berhenti jika kehilangan beberapa kali. Untuk pengguna, ini tidak muncul dengan cara apa pun, tetapi bagaimanapun, ada beberapa jenis bug.
Kebenaran yang tidak menyenangkan adalah bahwa para aktor, tentu saja, hebat, tetapi panggilan metode langsung jauh lebih cepat. Oleh karena itu, terlepas dari kenyataan bahwa menyimpan ular itu sendiri di aktor yang terpisah terasa nyaman dari sudut pandang pengorganisasian kode, saya harus meninggalkan ide ini atas nama kecepatan, karena untuk 1 jam permainan pengiriman pesannya terlalu intens:
- Pengguna mengirimkan sejumlah perintah langsung ke aktor perintah.
- Timer mengirimkan tanda centang ke aktor tim dan, dalam implementasi awal, juga ke aktor ular sehingga ia memindahkan ular ke sel berikutnya
- Aktor perintah mengirim daftar perintah untuk ular ketika pesan terkait berasal dari timer.
- Aktor ular, setelah memperbarui kondisinya sesuai dengan 2 pesan teratas, mengirim negara ke aktor lapangan.
- Aktor lapangan menggambar semuanya. Jika ular menemukan makanan, maka ia mengirim pesan
GrowUp
ke aktor ular, setelah itu ia mengirim negara baru kembali ke aktor lapangan.
Dan untuk semua ini ada 1 siklus jam, yang tidak cukup, dengan mempertimbangkan sinkronisasi akun di bagian dalam MailboxProcessor
. Selain itu, dalam implementasi saat ini, timer mengirim pesan berikutnya setiap n milidetik, terlepas dari apa pun, jadi jika kita tidak masuk ke pengukuran 1 kali, pesan mulai menumpuk, dan situasinya memburuk. Akan jauh lebih baik untuk "meregangkan" ukuran khusus ini, memproses semua yang telah menumpuk, dan melanjutkan.
Versi final
Jelas, skema pesan perlu disederhanakan, sementara sangat diinginkan untuk meninggalkan kode sesederhana dan dapat diakses mungkin - relatif berbicara, saya tidak ingin mendorong semuanya menjadi 1 aktor dewa, dan kemudian tidak ada banyak akal di aktor.
Oleh karena itu, melihat daftar aktor saya, saya menyadari bahwa yang terbaik adalah mengorbankan aktor ular terlebih dahulu. Pengatur waktu diperlukan, buffer perintah pengguna juga diperlukan untuk mengakumulasikannya secara real time, tetapi tuangkan hanya sekali per ketukan, dan tidak ada kebutuhan obyektif untuk menyimpan ular di aktor yang terpisah, ini dilakukan hanya untuk kenyamanan. Selain itu, dengan memegangnya dengan aktor lapangan akan dimungkinkan untuk memproses skrip GrowUp
tanpa penundaan. Tick
pesan untuk ular juga tidak masuk akal, karena ketika kita mendapatkan pesan dari aktor tim, itu berarti ketukan baru telah terjadi. Menambahkan ini peregangan beat jika terjadi penundaan, kami memiliki perubahan berikut:
- Kami menghapus pesan
Tick
& GrowUp
. - Kami memegang aktor ular ke aktor lapangan - dia sekarang akan menyimpan "tapla" dari negara-negara ini.
- Kami menghapus
System.Timers.Timer
dari aktor penghitung waktu. Alih-alih, skema kerjanya adalah sebagai berikut: ketika ia menerima perintah Start
, ia mengirimkan Flush
aktor tim. Dia mengirim daftar perintah ke bidang + aktor ular, aktor terakhir memproses semua ini dan mengirim pesan Next
ke timer, sehingga meminta centang baru darinya. Timer, setelah menerima Next
menunggu Thread.Sleep(delay)
dan memulai seluruh lingkaran lagi. Semuanya sederhana.
Untuk meringkas.
- Dalam implementasi sebelumnya, 500 ms adalah penundaan minimum yang diijinkan. Dalam penundaan saat ini, Anda dapat menghapusnya sama sekali - aktor lapangan akan membutuhkan ketukan baru saat siap. Mengumpulkan pesan mentah dari tindakan sebelumnya tidak lagi memungkinkan.
- Peta olahpesan sangat disederhanakan - alih-alih grafik yang kompleks, kami memiliki loop paling sederhana.
- Penyederhanaan ini menyelesaikan bug ketika penghitung waktu
Stop
beberapa kali jika hilang. - Daftar pesan telah dikurangi. Lebih sedikit kode - lebih sedikit kejahatan!
Ini terlihat seperti ini:
let [<Literal>] commandAddress = "command" let [<Literal>] timerAddress = "timer" let [<Literal>] gameAddress = "game" // - let commandAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<CommandMessage, Command list>(commandAddress) let timerAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<TimerCommand, TimerState>(timerAddress) let gameAgent (mailboxNetwork: MailboxNetwork) = mailboxNetwork.Box<Command list, GameState>(gameAddress) // message loop let gameAgentFn (mailboxNetwork: MailboxNetwork) updateUi gameState cmd = let timerAgent = timerAgent mailboxNetwork // match gameState.gameFrame with // | Frame field -> // let gameState = Game.updateGameState gameState cmd timerAgent.Post Next // updateUi gameState // gameState // ! | End (Win _) -> timerAgent.Post PauseOrResume Game.updateGameState gameState cmd // | _ -> timerAgent.Post Stop // gameState // message loop let commandAgentFn (mailboxNetwork: MailboxNetwork) commands msg = let gameAgent = gameAgent mailboxNetwork match msg with | Cmd cmd -> cmd::commands // | Flush -> commands |> gameAgent.Post // [] // message loop let timerAgentFn (mailboxNetwork: MailboxNetwork) (state: TimerState) cmd = let commandAgent = commandAgent mailboxNetwork match cmd with | Start -> commandAgent.Post Flush; {state with active = true} | Next -> if state.active then // , Threading.Thread.Sleep(state.delay) commandAgent.Post Flush; state | Stop -> printfn "Stop received"; { state with active = false } | PauseOrResume -> if not state.active then //
Referensi