Anvil MC Bedrock
Motivation
Bien qu'il existe un serveur officiel Minecraft Bedrock Edition, il est lent, gourmand en mémoire et pas extensible. C'est pour cela que j'ai choisi Rust, un langage performant, sûr et massivement parallélisable pour programmer cette implémentation qui se veut performante et adaptée aux processeurs modernes. C'était un gros bémol de MCBE qui est maintenant comblé.
Comment obtenir les spécifications du protocole
J'ai commencé par analyser les requêtes envoyées par le client et le serveur officiel. J'ai tout de suite remarqué que le protocole était basé sur un fork de raknet. J'ai donc cherché des implémentations de raknet en rust mais je n'en ai trouvé aucune. J'ai donc dû l'implémenter moi-même.
Le principe de la runtime tokio
Tokio est la runtime asynchrone la plus populaire en Rust. Si vous ne savez pas ce qu'est une runtime
asynchrone, laissez-moi vous rappeler le principe.
Une runtime asynchrone est un gestionnaire de
regroupement de threads appelé thread-pool capable de gérer l'exécution de tâches en parallèle. Si vous
avez déjà fait du Go, vous êtes probablement familiarisés avec le principe de Green Thread. Et bien une
runtime en rust utilise le même principe, elles permettent de créer des threads sans surcoûts pour de
petites tâches.
C'est une des raisons qui font de rust un excellent langage pour le réseau et la
gestion de systèmes critiques. Par exemple, CloudFlare utilise Rust avec Actix pour tous ses serveurs.
Actix est un framework HTTP basé sur Tokio qui a l'avantage d'être sécurisé et le framework le plus
rapide d'après TechEmpower https://www.techempower.com/benchmarks/#section=data-r18&hw=cl&test=composite.
Cette
runtime permet avec une simplicité déconcertante de créer des threads "jetables".
// Cette macro procédurale permet à Tokio de gérer et synchroniser le main dans la runtime
#[tokio::main]
async fn main() {
// On crée un Green Thread
tokio::spawn(async {
// Nous sommes dans le green thread
// Si vous êtes familiers avec JS ou Go, les async, yield et await sont les mêmes ici.
});
}
Elle supporte aussi une implémentation de nombreux protocoles réseau comme UDP ou TCP optimisés pour la pipeline tokio.
Le serveur UDP Principal + les canaux
Pour créer un socket UDP avec Tokio:
// On ouvre un socket UDP en 0.0.0.0:19312
let udp = UdpSocket::bind("0.0.0.0:19312".parse().expect("L'adresse IP est invalide")).await.expect("Impossible d'ouvrir un serveur à l'adresse");
Pour gérer la réception d'un paquet
loop {
let mut buffer = vec![0; 1024*128];
let (size, peer) = udp.recv_from(&mut buffer);
tokio::spawn(async {
// On crée un itérateur sur notre buffer qui prendra uniquement les `size` premiers bytes.
let mut iter = buffer.iter().take(size);
// On gère le décodage du paquet ici
});
}
Une autre approche au problème est de créer des micro-services communiquant entre eux avec des canaux. La
STD de rust fournit une solution appelée mspc néanmoins crossbeam-channel est
fréquemment utilisé car plus performant et pratique.
On pourrait aussi utiliser un système hybride:
Par exemple gérer toutes les nouvelles connexions dans un green thread et avoir un service qui s'occupe
d'actualiser le monde. La communication entre les deux pourrait se faire à l'aide d'un canal.
struct World {
receiver: Receiver<Modification>,
blocks: HashMap<[u8; 3], u8>
}
impl World {
fn tick_world(&mut self) {
while let Ok(e) = self.receiver.try_recv() {
let Modification {coord, r#type} = e;
println!("Block en {:?} modifié en {:?}",coord,r#type);
self.blocks.insert(coord,r#type);
}
}
}
enum Modification {
PlaceBlock {
coord: [u8; 3]
// Le r# est là pour dire au compilateur de traiter le type qui suit comme un
// littéral et pas un mot-clé
r#type: u8
}
}
#[tokio::main]
async fn main() {
let (receiver,sender) = crossbeam_channel::unbounded();
tokio::spawn(async {
let mut world = World {
receiver,
blocks: Default::default()
};
loop {
world.tick_world();
yield;
}
});
loop {
// On crée un buffer de 4096 bytes sur le stack
let mut buffer = vec![0; 4096];
// On attend une requête réseau
let (size, peer) = udp.recv_from(&mut buffer);
// On prend seulement les `size` premiers bytes du buffer
let mut iter = buffer.iter().take(size);
// On vérifie que l'iter a un premier byte de valeur 0x3B l'id du paquet de modification de block
if let Some(0x3B) = iter.next() {
// Si la fonction renvoie None on affiche "Paquet de modification invalide!" sinon on passe au Serveur la modification au travers du canal
if let Some(e) = handle_block_update_packet(iter) {
sender.send(e);
} else {
println!("Paquet de modification invalide!");
}
}
}
}
fn handle_block_update_packet(mut iter: impl Iter<Item = u8>) -> Option<Modification> {
// Grâce au ? si l'iter n'a pas d'élément suivant la fonction retournera None
Some(Modification {
coord: [iter.next()?,iter.next()?,iter.next()?],
r#type: iter.next()?
})
}
Lien de
l'implémentation utilisée sur Anvil MC Bedrock
L'implémentation que j'utilise est
légèrement différente
Comme vous avez pu le voir c'est relativement facile de créer des
architectures, sûres, performantes et robustes en Rust. Nous sommes de plus assurés qu'il ne peut pas y
avoir de data-race ou de concurrent access exception comme Rust check tout à la compilation. Si une
faille se trouve quelque part, le programme ne se compilera pas!
Comparaison de performances entre le serveur officiel et Anvil MC Bedrock.
Ici un graphique du temps par requête en ms. Benchmark réalisé sur un Ryzen 3600XT (6 cores, 12 threads,
4.5GHz)