Back Up to Speed
Posted March 5, 2022 by Richard Leek ‐ 6 min read
After a break from the project I spent the last few days actually implementing that actor pattern I was so excited about! There were a few hurdles along the way, but the server is so much cleaner now.
I took a couple of weeks off from the project because I got married! woo 🎉
A few weeks ago I wrote an article about the actor design pattern and how excited I was to implement it into reoserv.
In this article I will describe how the migration went and what I learned along the way.
The biggest decision to make when implementing an actor system is who owns what data.
Ownership before actor model
Things started from main and ownership stemmed downwards like a traditional program
fn main()
- players - a hash map of PlayerId/Tx (the write half of a mpsc channel)
- active_account_ids - a vec of logged in account ids
- characters - a vec of logged in character data
- world - the world struct
- maps - a hash map of id/map file
- pub data - all of the pub files
fn handle_player(world, players, characters, active_account_ids, socket, pool)
Ownership after actor model
The ownership model is a lot more flat. A single world struct (inside it’s own task) and individual player structs (also in their own tasks) own all of their data and are interacted with via message passing (made convient via the handle structs).
fn main()
- world_handle - a world “handle” creates the world struct/task and keeps the write half of the mpsc channel
- world - the world struct
- player - the player struct
The two important fields above are the player_handles and world_handle. These are convenience structs that own only a single field. The Tx half of the Player’s or World’s mpsc. The struct has a wrapper function for each command the underlying actor handles.
Actor Handle function example
Here’s an example from world_handle that is responsible for processing a login request.
pub async fn login(
&mut self,
name: String,
password: String,
) -> Result<(login::Reply, EOShort), Box<dyn std::error::Error + Send + Sync>> {
// Setup the oneshot channel so we can wait for the response from the world
let (tx, rx) = oneshot::channel();
// Send the command with the request parameters and our oneshot's write half
let _ = self.tx.send(Command::Login {
name,
password,
respond_to: tx,
});
// Return the result of the world's internal login request handler
rx.await.unwrap()
}
Inside the world struct we have a function that handles any incoming commands
pub async fn handle_command(&mut self, command: Command) {
match command {
/* other commands */
Command::Login {
name,
password,
respond_to,
} => {
// Get a MySQL connection from the pool
let mut conn = self.pool.get_conn().await.unwrap();
// Get a mutable lock on the accounts field so we can
// check if the account is already logged in and
// add the new account if login is successful
let mut accounts = self.accounts.lock().await;
// Verifies the username/password, checks for bans, checks if account is already logged in
let result = login(&mut conn, &name, &password, &mut accounts).await;
// Send the response back to the oneshot receiver
let _ = respond_to.send(result);
}
/* other commands */
}
}
The handle struct makes your higher level code a lot neater and easier to understand because you’re not setting up oneshot channel’s or manually sending commands to raw Tx’s all over the place every time you want to communicate with an actor.
Account create handler without the WorldHandle
// Setup the oneshot channel so we can wait for the response from the world
let (tx, rx) = oneshot::channel();
// Send the command with the request parameters and our oneshot's write half
let _ = world_tx.send(Command::Login {
name,
password,
respond_to: tx,
});
// Return the result of the world's internal login request handler
let result = rx.await.unwrap();
let reply = match result {
Ok(reply) => reply,
Err(e) => {
error!("Create account failed: {}", e);
// Not an ideal reply but I don't think the client has a "creation failed" handler
Reply::no(AccountReply::NotApproved)
}
};
Account create handler with the WorldHandle
let reply = match world.create_account(create.clone(), player_ip).await {
Ok(reply) => reply,
Err(e) => {
error!("Create account failed: {}", e);
// Not an ideal reply but I don't think the client has a "creation failed" handler
Reply::no(AccountReply::NotApproved)
}
};
Hurdles
The biggest problem I had initially is that I couldn’t figure out a way to pass the PlayerHandle to the packet handler function.
The player and task are spawned from within the PlayerHandle constructor itself. It’s not like I could pass the PlayerHandle to the task before it even exists.
impl PlayerHandle {
pub fn new(player_id: EOShort, socket: TcpStream, world: WorldHandle) -> Self {
// Setup the player's mpsc channel
let (tx, rx) = mpsc::unbounded_channel();
// Create the actual player struct
let player = Player::new(player_id, socket, rx, world);
tokio::task::Builder::new()
.name("run_player")
.spawn(run_player(player, tx.clone())); // <- I would need to pass the handle here
Self { tx }
}
/*snip*/
async fn run_player(mut player: Player, player: mpsc::UnboundedSender<Command>) {
loop {
if let Some(packet) = player.queue.get_mut().pop_front() {
tokio::task::Builder::new()
.name("handle_packet")
.spawn(handle_packet(
packet,
player.id,
player.clone(), // <- Same crappy raw mpsc write half
player.world.clone(),
));
}
}
}
Without this I was stuck with manually setting up oneshot channels and passing messages like I showed above. I went with that approach initially because I couldn’t find a way to make it work.
That is until.. earlier today! It hit me! I can just make a constructor of PlayerHandle that takes an existing Tx instead of creating a new one!
impl PlayerHandle {
pub fn new(player_id: EOShort, socket: TcpStream, world: WorldHandle) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let player = Player::new(player_id, socket, rx, world);
tokio::task::Builder::new()
.name("run_player")
.spawn(run_player(player, PlayerHandle::for_tx(tx.clone())));
Self { tx }
}
fn for_tx(tx: mpsc::UnboundedSender<Command>) -> Self {
Self { tx }
}
/*snip*/
async fn run_player(mut player: Player, player_handle: PlayerHandle) {
loop {
if let Some(packet) = player.queue.get_mut().pop_front() {
tokio::task::Builder::new()
.name("handle_packet")
.spawn(handle_packet(
packet,
player.id,
player_handle.clone(), // <- Awesome convenient wrapper!
player.world.clone(),
));
}
}
}
}
Now I have an actually useful implementation of the actor pattern and I can happily continue implementing the rest of the server! 😎
I hope you enjoyed reading about this as much as I enjoyed implementing it!
Stay tuned for more development news!