Rust construit un processus simple de base pour le serveur KV (1,2)

Pourquoi choisir le serveur KV pour un fonctionnement pratique ? Parce que c’est un service assez simple et assez complexe. Reportez-vous aux services tels que Redis/Memcached utilisés dans votre travail pour trier ses exigences.

La fonction principale est d'effectuer des opérations telles que le stockage, la lecture et la surveillance des données selon différentes commandes ; le
client doit pouvoir accéder au serveur KV via le réseau, envoyer des requêtes contenant des commandes et obtenir des résultats ;
les données doivent être stockées dans le serveur KV selon les besoins, en mémoire ou conservé sur le disque.

Commençons par une implémentation courte et approximative. Si vous construisez un serveur KV pour accomplir cette tâche, en fait, la version initiale peut être complétée par deux à trois cents lignes de code, mais ce code sera un désastre à maintenir dans le avenir. Regardons une version spaghetti qui omet beaucoup de détails. Vous pouvez suivre mes annotations pour vous concentrer sur le processus :

use anyhow::Result;
use async_prost::AsyncProstStream;
use dashmap::DashMap;
use futures::prelude::*;
use kv::{
    
    
    command_request::RequestData, CommandRequest, CommandResponse, Hset, KvError, Kvpair, Value,
};
use std::sync::Arc;
use tokio::net::TcpListener;
use tracing::info;

#[tokio::main]
async fn main() -> Result<()> {
    
    
    // 初始化日志
    tracing_subscriber::fmt::init();

    let addr = "127.0.0.1:9527";
    let listener = TcpListener::bind(addr).await?;
    info!("Start listening on {}", addr);

    // 使用 DashMap 创建放在内存中的 kv store
    (DashMap试图使用起来非常简单,并且可以直接替代RwLock<HashMap<K, V>>。
    为了完成这些,所有的方法都使用&self,而不是修改采用&mut self的方法。
    这允许您将DashMap放在Arc<T>中,并在线程之间共享它,同时可以修改它。)
    let table: Arc<DashMap<String, Value>> = Arc::new(DashMap::new());

    loop {
    
    
        // 得到一个客户端请求
        let (stream, addr) = listener.accept().await?;
        info!("Client {:?} connected", addr);

        // 复制 db,让它在 tokio 任务中可以使用
        let db = table.clone();

        // 创建一个 tokio 任务处理这个客户端
        tokio::spawn(async move {
    
    
            // 使用 AsyncProstStream 来处理 TCP Frame
            // Frame: 两字节 frame 长度,后面是 protobuf 二进制
            let mut stream =
                AsyncProstStream::<_, CommandRequest, CommandResponse, _>::from(stream).for_async();

            // 从 stream 里取下一个消息(拿出来后已经自动 decode 了)
            while let Some(Ok(msg)) = stream.next().await {
    
    
                info!("Got a new command: {:?}", msg);
                let resp: CommandResponse = match msg.request_data {
    
    
                    // 为演示我们就处理 HSET
                    Some(RequestData::Hset(cmd)) => hset(cmd, &db),
                    // 其它暂不处理
                    _ => unimplemented!(),
                };

                info!("Got response: {:?}", resp);
                // 把 CommandResponse 发送给客户端
                stream.send(resp).await.unwrap();
            }
        });
    }
}

// 处理 hset 命令
fn hset(cmd: Hset, db: &DashMap<String, Value>) -> CommandResponse {
    
    
    match cmd.pair {
    
    
        Some(Kvpair {
    
    
            key,
            value: Some(v),
        }) => {
    
    
            // 往 db 里写入
            let old = db.insert(key, v).unwrap_or_default();
            // 把 value 转换成 CommandResponse
            old.into()
        }
        v => KvError::InvalidCommand(format!("hset: {:?}", v)).into(),
    }
}

Ce code est très simple, de l'entrée à la sortie, en une seule fois. S'il est écrit ainsi, la tâche peut effectivement être accomplie rapidement, mais elle a le sentiment "une fois terminé, il y aura une inondation". Après avoir copié le code, ouvrez deux fenêtres et exécutez respectivement "cargo run --example naive_server" et "cargo run --example client". Vous pouvez voir l'impression suivante dans la fenêtre exécutant le serveur :

Sep 19 22:25:34.016  INFO naive_server: Start listening on 127.0.0.1:9527
Sep 19 22:25:38.401  INFO naive_server: Client 127.0.0.1:51650 connected
Sep 19 22:25:38.401  INFO naive_server: Got a new command: CommandRequest {
    
     request_data: Some(Hset(Hset {
    
     table: "table1", pair: Some(Kvpair {
    
     key: "hello", value: Some(Value {
    
     value: Some(String("world")) }) }) })) }
Sep 19 22:25:38.401  INFO naive_server: Got response: CommandResponse {
    
     status: 200, message: "", values: [Value {
    
     value: None }], pairs: [] }

Bien que la fonction globale soit considérée comme terminée, si vous souhaitez continuer à ajouter de nouvelles fonctions à ce serveur KV à l'avenir, vous devrez modifier ce code d'avant en arrière. De plus, il n'est pas facile de faire des tests unitaires, car toute la logique est compressée ensemble et il n'y a pas d'« unité » du tout. Bien que différentes logiques puissent être progressivement séparées en différentes fonctions à l'avenir pour rendre le processus principal aussi simple que possible. Cependant, ils sont toujours couplés et sans refactorisation majeure, le véritable problème ne peut toujours pas être résolu.
Par conséquent, quel que soit le langage utilisé pour le développement, nous devons faire de notre mieux pour éviter ce type de code. Non seulement nous ne devons pas l'écrire nous-mêmes, mais nous devons également nous renseigner strictement lorsque nous rencontrons d'autres personnes qui l'écrivent lors de la révision du code.

Architecture et conception Alors, qu'est-ce qui fait une bonne mise en œuvre ? Une bonne mise en œuvre doit commencer par le processus principal du système après avoir analysé les exigences, et déterminer quelles sont les principales étapes depuis la demande du client jusqu'au client final recevant la réponse ; puis, sur la base de ces étapes, réfléchir à ce qui doit être Retardé. Liaison, construction des principales interfaces et caractéristiques, attendez que ces éléments soient soigneusement examinés, puis envisagez enfin la mise en œuvre. C'est ce qu'on appelle « prendre une décision et ensuite agir ». Les exigences du serveur KV ont été analysées au début, nous allons maintenant régler le processus principal. Vous pouvez d'abord y réfléchir vous-même, puis vous référer au diagramme schématique pour voir s'il y a des lacunes :
Insérer la description de l'image ici
certains problèmes clés de ce processus nécessitent une exploration plus approfondie :
quel protocole le client et le serveur utilisent-ils pour communiquer ? TCP ? gRPC ? HTTP ? Prend en charge un type ou plusieurs types ?
Comment est défini le protocole de couche application pour l’interaction entre client et serveur ? Comment faire la sérialisation/désérialisation ? Devriez-vous utiliser Protobuf, JSON ou Redis RESP ? Ou peut-il prendre en charge plusieurs types ?
Quelles commandes le serveur prend-il en charge ? Lesquels sont pris en charge en premier dans la première version ? Dans la logique de traitement spécifique, est-il nécessaire d'ajouter des hooks et de publier certains événements pendant le traitement afin que d'autres processus puissent être notifiés et effectuer des traitements supplémentaires ?
Ces hooks peuvent-ils mettre fin à l’ensemble du processus à l’avance ? Pour le stockage, devons-nous prendre en charge différents moteurs de stockage ? Par exemple, MemDB (mémoire), RocksDB (disque), SledDB (disque), etc. Pour MemDB, devrions-nous envisager de prendre en charge les WAL (Write-Ahead Log) et les instantanés ?
L’ensemble du système peut-il être configuré ? Par exemple, quel port et quel moteur de stockage le service utilise-t-il ?
Si vous voulez bien faire de l’architecture, il est important de se poser ces questions et d’y trouver les réponses.
Il convient de noter que le chef de produit ne peut pas vous aider à répondre à bon nombre de ces questions, sinon ses réponses vous induiront en erreur. En tant qu'architecte, nous devons être responsables de la manière dont le système réagira aux changements à venir.

Voici mes réflexions, vous pouvez vous référer à :

  1. Pour les scénarios tels que le serveur KV qui nécessitent des performances élevées, le protocole TCP doit être prioritaire pour la communication. Nous ne prenons donc en charge que TCP pour le moment et pouvons prendre en charge davantage de protocoles si nécessaire à l'avenir, tels que HTTP2/gRPC. En outre, il pourrait y avoir des exigences supplémentaires en matière de sécurité à l'avenir, nous devons donc nous assurer que les protocoles de sécurité tels que TLS peuvent être plug-and-play. En bref, la couche réseau doit être flexible.
  2. Les protocoles de couche application peuvent être définis à l'aide de protobuf. protobuf aborde directement la définition d'un protocole et comment le sérialiser et le désérialiser . Le RESP de Redis est bon, mais ses défauts sont également évidents : les commandes nécessitent une analyse supplémentaire et un grand nombre de \r\n sont utilisés pour séparer les commandes ou les données, ce qui gaspille également de la bande passante. L'utilisation de JSON gaspille davantage de bande passante et l'efficacité de l'analyse de JSON n'est pas élevée, en particulier lorsque la quantité de données est importante. protobuf est très approprié pour des scénarios tels que le serveur KV. Il est flexible, rétrocompatible et évolutif, a une efficacité d'analyse élevée et le binaire généré permet d'économiser beaucoup de bande passante. Le seul inconvénient est qu'il nécessite un protocole d'outil supplémentaire pour compiler en différents langues. Bien que protobuf soit le premier choix, RESP devra peut-être encore être pris en charge afin d'interagir avec le client Redis à l'avenir.
  3. Pour les commandes prises en charge par le serveur, nous pouvons nous référer au jeu de commandes Redis . La première version prend d'abord en charge les commandes HXXX, telles que HSET, HMSET, HGET, HMGET, etc. D’une commande à l’autre, vous pouvez créer un trait pour l’abstraire.
  4. Ces hooks sont prévus pour être ajoutés au processus de traitement : OnRequestReceived après réception de la commande du client, OnRequestExecuted après traitement de la commande du client, BeforeResponseSend avant l'envoi de la réponse, et AfterResponseSend après l'envoi de la réponse. De cette manière, les principales étapes du processus de traitement sont exposées par des événements, ce qui rend notre serveur KV très flexible et pratique pour l'appelant qui peut injecter une logique de traitement supplémentaire lors de l'initialisation du service.
  5. Le stockage doit être suffisamment flexible. Vous pouvez créer un trait pour le stockage afin d'abstraire son comportement de base . Vous pouvez simplement faire MemDB au début. À l'avenir, vous aurez certainement besoin d'un stockage prenant en charge la persistance.
  6. La prise en charge de la configuration est requise, mais la priorité n'est pas élevée. Une fois le processus de base terminé et suffisamment de problèmes découverts lors de l'utilisation, vous pouvez réfléchir à la manière de gérer le fichier de configuration.

Lorsque ces questions seront finalisées, l’idée de base du système sera disponible. Nous pouvons d'abord définir plusieurs interfaces importantes , puis examiner attentivement ces interfaces. Les interfaces les plus importantes sont les interfaces entre les trois corps principaux : l'interface ou protocole entre le client et le serveur, l'interface entre le serveur et le processus de traitement des commandes et l'interface entre le serveur et le stockage.
(Le résumé est que la couche réseau doit être flexible et peut utiliser différents protocoles réseau, la définition des informations peut utiliser protobuf, les processus de traitement du serveur peuvent être ajoutés avec des hooks pour maintenir la flexibilité et le stockage peut utiliser des traits pour abstraire les comportements de base afin de maintenir la flexibilité. Enfin , il y a aussi une flexibilité dans la configuration) En bref, c'est-à-dire que le protocole est flexible, le processus de traitement est flexible et le comportement de stockage est flexible )

Protocole entre client et serveur

Considérez comment sont définis les messages entre le client et le serveur (protobuf).Quelques
connaissances supplémentaires sur le protocole protobuf
(principes, comparaison des avantages et des inconvénients avec json et xml, méthodes d'optimisation, comment l'installer et l'utiliser)

Comment installer :
Téléchargez d'abord le code source,
puis décompressez tar -xzf protobuf-2.1.0.tar.gz
, puis compilez et installez./configure
make
make
install
protobuf est un outil de format d'échange de données développé par Google. Peut être utilisé pour la sérialisation et la désérialisation, le stockage de données et les protocoles de communication.
Voyons ensuite ce qui est principalement inclus dans le fichier proto ?
Le mot-clé message : représente la structure de l'entité, qui est composée de plusieurs champs de message.
Champ de message (champ) : comprend le type de données, le nom du champ, les règles du champ (doivent être initialisées, initialisation facultative, répétable), l'identifiant de champ unique et la valeur par défaut

message xxx {
    
    
  // 字段规则:required -> 字段只能也必须出现 1 次
  // 字段规则:optional -> 字段可出现 0 次或1次
  // 字段规则:repeated -> 字段可出现任意多次(包括 0)相当于数组
  // 类型:int32、int64、sint32、sint64、string、32-bit ....
  // 字段编号:0 ~ 536870911(除去 19000 到 19999 之间的数字)
  字段规则 类型 名称 = 字段编号;
}

Règles de champ Les règles de champ peuvent être omises et la valeur par défaut est requise.

Après avoir écrit ceci, il s'adresse aux développeurs et aux entreprises. Si vous souhaitez l'utiliser pour le stockage ou le transport, vous devez le sérialiser et le désérialiser.
Ceux-ci ont déjà été empaquetés par Google, à l'aide du compilateur de protocoles. Exécutez la ligne de commande suivante :
protoc -I= SRCDIR − − cppout = SRC_DIR --cpp_out=SR CJe Rc p potu _= DST_DIR $SRC_DIR/xxx.proto
protoc --cpp_out=.test.proto # L'exécution réelle
de cpp_out signifie générer du code C++, et .signifie que le code est placé dans le répertoire courant.
Après l'exécution, deux fichiers stub pb.h pb.c seront générés. Chaque message défini générera une classe et une classe stub pour le service. La classe stub liera un pointeur de canal et appellera la méthode callmethod. Le client effectuera une série d'opérations de transmission de paramètres tant qu'il utilise le stub pour appeler le méthode. Pour les classes de service, vous devez également réécrire les méthodes métier.

Concernant les avantages de protobuf :
l'espace est plusieurs fois à dix fois, et l'avantage temporel est des dizaines de fois : avantage spatial
La petite quantité de données est due au fait que le message binaire généré par Protobuf après sérialisation est très compact, ce qui bénéficie du
très Little-endian astucieux adopté par Protobuf Méthode d'encodage .
L'avantage en termes de temps est le suivant : l'analyse XML nécessite de convertir la chaîne en un modèle de structure d'objet de document, puis de lire la chaîne du nœud spécifié à partir du modèle, puis de la convertir en une variable du type correspondant. Le processus de conversion de fichiers XML en modèles de structure d'objet de document nécessite généralement l'exécution de calculs complexes qui consomment beaucoup de CPU, tels que l'analyse lexicale et grammaticale. La sérialisation et la désérialisation Protobuf n'ont pas besoin d'analyser les attributs de nœud correspondants {}, : et d'autres symboles, ainsi que les descriptions d'informations des champs d'informations de description redondantes , de sorte que le temps de sérialisation et de désérialisation est plus efficace.
Multiplateforme et multilingue ; très bonne compatibilité . Si le format du message change, il n'est pas nécessaire de réécrire le code d'analyse, il suffit de l'ajouter ou de le réduire directement dans le fichier proto.
Le mécanisme de génération de code est très bon : par exemple, l'expéditeur sérialise directement dans order.SerailzeToString(&sOrder) ;
le récepteur désérialise directement dans order.ParseFromString(sOrder). Vous n'êtes pas obligé d'écrire le code d'analyse vous-même, ce qui est ingrat, ce qui est très bien. Pour obtenir le code, appelez simplement get et set.

Concernant les défauts de protobuf
, il a une mauvaise lisibilité car il est codé en binaire ; il manque d'auto-description et ne coopère pas avec le cercle de protofichiers pour comprendre la signification du message. Par conséquent, XML est relativement clair en termes de fichiers de configuration ;

Par conséquent, protobuf se concentre sur la transmission sérialisée ou le stockage de données, qui sont au format XML, et json se concentre sur les données structurées en tant qu'interface pour le développement et les affaires ;

Introduction aux principes d'encodage et de décodage
Comme mentionné précédemment, le proto-encodage permet d'économiser de l'espace et le décodage est très pratique. A quoi ça ressemble concrètement ?
Concernant le principe d'encodage : utilisez tag-value pour former un flux d'octets, utilisez le numéro de champ (numéro de champ et wire_type) comme clé, les 3 derniers bits de la clé représentent le wire_type, généralement le type entier est un encodage Varints. Varints est une représentation numérique compacte, qui est une méthode de sérialisation d'entiers utilisant un ou plusieurs octets. Il s'agit d'un codage de longueur variable. Il est également très facile à analyser et des opérations sur les bits peuvent être implémentées.
Le codage Varints utilise le bit le plus significatif de chaque octet comme bit d'indicateur, et les 7 bits restants stockent la valeur numérique elle-même sous la forme d'un complément à 2. Lorsque le bit le plus significatif est 1, cela signifie qu'il y a des octets qui le suivent. le bit le plus significatif est 0, il représente le dernier octet du nombre. Il existe d'autres encodages tels que zigzang, etc.
De plus, protobuf a moins de symboles {, } et : que json et XML, donc le volume sera réduit. protobuf est une implémentation de codage de valeur de balise (TLV), qui réduit l'utilisation de délimiteurs et rend le stockage des données plus compact.
Ces trois points réunis font gagner de la place au proto. ( Binaire, utilisant une structure de valeur de balise, compact, sans délimiteurs ou quoi que ce soit ; utilisant un codage de variantes de longueur variable, zigzang )

Le décodage est également simple : il peut être lu directement à partir du flux d'octets et selon la définition du fichier proto. Pas besoin d'analyser les délimiteurs, les informations de description, les informations de structure, etc.

Exemple xml
L'utilisation de XML pour représenter les données de certaines provinces et villes de Chine est la suivante :

<?xml version="1.0" encoding="utf-8" ?> Chine Heilongjiang Harbin Daqing Guangdong Guangzhou Shenzhen Zhuhai

Comme vous pouvez le voir, XML contient des espaces, des informations de description et des informations de structure, et il est très difficile à analyser.
Le json est le suivant
{ nom : "Chine", provinces : [ { nom : "Heilongjiang", villes : { ville : ["Harbin", "Daqing"] } }, { nom : "Guangdong", villes : { ville : [" Guangzhou", "Shenzhen", "Zhuhai"] } } , ] } sont également des informations structurelles similaires, mais les séparateurs sont différents. {} représente un seul élément, [] représente une collection d'éléments et xml semble décrire plus d'informations, donc Le pire dans le temps et dans l'espace. Le descriptif est également le meilleur.

















Le premier est le protocole entre client et serveur. Essayons d'utiliser protobuf pour définir les commandes client prises en charge par notre première version :


syntax = "proto3";

package abi;

// 来自客户端的命令请求
message CommandRequest {
    
    
  oneof request_data {
    
    
    Hget hget = 1;
    Hgetall hgetall = 2;
    Hmget hmget = 3;
    Hset hset = 4;
    Hmset hmset = 5;
    Hdel hdel = 6;
    Hmdel hmdel = 7;
    Hexist hexist = 8;
    Hmexist hmexist = 9;
  }
}

// 服务器的响应
message CommandResponse {
    
    
  // 状态码;复用 HTTP 2xx/4xx/5xx 状态码
  uint32 status = 1;
  // 如果不是 2xx,message 里包含详细的信息
  string message = 2;
  // 成功返回的 values
  repeated Value values = 3;
  // 成功返回的 kv pairs
  repeated Kvpair pairs = 4;
}

// 从 table 中获取一个 key,返回 value
message Hget {
    
    
  string table = 1;
  string key = 2;
}

// 从 table 中获取所有的 Kvpair
message Hgetall {
    
     string table = 1; }

// 从 table 中获取一组 key,返回它们的 value
message Hmget {
    
    
  string table = 1;
  repeated string keys = 2;
}

// 返回的值
message Value {
    
    
  oneof value {
    
    
    string string = 1;
    bytes binary = 2;
    int64 integer = 3;
    double float = 4;
    bool bool = 5;
  }
}

// 返回的 kvpair
message Kvpair {
    
    
  string key = 1;
  Value value = 2;
}

// 往 table 里存一个 kvpair,
// 如果 table 不存在就创建这个 table
message Hset {
    
    
  string table = 1;
  Kvpair pair = 2;
}

// 往 table 中存一组 kvpair,
// 如果 table 不存在就创建这个 table
message Hmset {
    
    
  string table = 1;
  repeated Kvpair pairs = 2;
}

// 从 table 中删除一个 key,返回它之前的值
message Hdel {
    
    
  string table = 1;
  string key = 2;
}

// 从 table 中删除一组 key,返回它们之前的值
message Hmdel {
    
    
  string table = 1;
  repeated string keys = 2;
}

// 查看 key 是否存在
message Hexist {
    
    
  string table = 1;
  string key = 2;
}

// 查看一组 key 是否存在
message Hmexist {
    
    
  string table = 1;
  repeated string keys = 2;
}

Grâce à prost, ce fichier protobuf peut être compilé en code Rust (principalement struct et enum) pour notre usage. N'oubliez pas que lorsque nous avons parlé du développement de thumbor dans la leçon 5, nous avons déjà vu comment prost gère protobuf. (Voici une analogie avec les similitudes et les différences du C++ compilant automatiquement des fichiers proto dans rpc)

Une fois le protocole entre le client et le serveur du trait CommandService
finalisé, il est nécessaire de réfléchir à la manière de traiter la commande demandée et de renvoyer une réponse. Nous prévoyons actuellement de prendre en charge 9 commandes, et d'autres seront probablement prises en charge à l'avenir. Par conséquent, il est préférable de définir un trait pour traiter toutes les commandes de manière uniforme et renvoyer les résultats du traitement . Lors du traitement de la commande, celle-ci doit être liée au stockage, afin que les données puissent être lues en fonction des paramètres contenus dans la demande, ou que les données de la demande puissent être stockées dans le système de stockage. Ce trait peut donc être défini comme suit :


/// 对 Command 的处理的抽象
pub trait CommandService {
    
    
    /// 处理 Command,返回 Response
    fn execute(self, store: &impl Storage) -> CommandResponse;
}

Avec ce trait et chaque commande implémentant ce trait, la méthode de répartition peut être un code similaire à celui-ci :


// 从 Request 中得到 Response,目前处理 HGET/HGETALL/HSET
pub fn dispatch(cmd: CommandRequest, store: &impl Storage) -> CommandResponse {
    
    
    match cmd.request_data {
    
    
        Some(RequestData::Hget(param)) => param.execute(store),
        Some(RequestData::Hgetall(param)) => param.execute(store),
        Some(RequestData::Hset(param)) => param.execute(store),
        None => KvError::InvalidCommand("Request has no data".into()).into(),
        _ => KvError::Internal("Not implemented".into()).into(),
    }
}

De cette façon, lorsque nous prendrons en charge de nouvelles commandes à l'avenir, nous n'aurons qu'à faire deux choses : implémenter CommandService pour la commande et ajouter la prise en charge de la nouvelle commande dans la méthode de répartition.
Les abstractions de traits de base facilitent les extensions du système.

Trait de stockage
Regardons le trait de stockage conçu pour différents stockages. Il fournit l'interface principale du magasin KV :


/// 对存储的抽象,我们不关心数据存在哪儿,但需要定义外界如何和存储打交道
pub trait Storage {
    
    
    /// 从一个 HashTable 里获取一个 key 的 value
    fn get(&self, table: &str, key: &str) -> Result<Option<Value>, KvError>;
    /// 从一个 HashTable 里设置一个 key 的 value,返回旧的 value
    fn set(&self, table: &str, key: String, value: Value) -> Result<Option<Value>, KvError>;
    /// 查看 HashTable 中是否有 key
    fn contains(&self, table: &str, key: &str) -> Result<bool, KvError>;
    /// 从 HashTable 中删除一个 key
    fn del(&self, table: &str, key: &str) -> Result<Option<Value>, KvError>;
    /// 遍历 HashTable,返回所有 kv pair(这个接口不好)
    fn get_all(&self, table: &str) -> Result<Vec<Kvpair>, KvError>;
    /// 遍历 HashTable,返回 kv pair 的 Iterator
    fn get_iter(&self, table: &str) -> Result<Box<dyn Iterator<Item = Kvpair>>, KvError>;
}

Comme vous l'avez vu dans le trait CommandService, lors du traitement des requêtes des clients, c'est le trait Storage qui s'en occupe, et non un magasin spécifique. L'avantage est qu'à l'avenir, si vous ajoutez différents magasins dans différents scénarios en fonction des besoins de l'entreprise, il vous suffira d'implémenter le trait Storage pour eux sans modifier le code lié à CommandService.
Par exemple, lors de l'implémentation de la commande HGET, nous utilisons la méthode Storage::get pour obtenir les données de la table, ce qui n'a rien à voir avec une solution de stockage spécifique :

( Cela peut être vu du fait que les traits de rust peuvent permettre d'obtenir des abstractions plus avancées, ce qui est cohérent avec un principe des modèles de conception (le principe d'inversion de dépendance, c'est-à-dire qu'il est préférable d'interagir avec l'abstraction de niveau supérieur, donc que l'évolutivité est meilleure )

impl CommandService for Hget {
    
    
    fn execute(self, store: &impl Storage) -> CommandResponse {
    
    
        match store.get(&self.table, &self.key) {
    
    
            Ok(Some(v)) => v.into(),
            Ok(None) => KvError::NotFound(self.table, self.key).into(),
            Err(e) => e.into(),
        }
    }
}

Je pense que vous pouvez définir la plupart des méthodes dans le trait Storage, mais vous risquez d'être confus au sujet de l'interface get_iter() car elle renvoie une boîte. Pourquoi ? J'ai mentionné auparavant (Leçon 13) qu'il s'agit d'un objet trait.
Ici, nous voulons renvoyer un itérateur. L'appelant ne se soucie pas de son type, tant qu'il peut continuer à appeler la méthode next() pour obtenir la valeur suivante. Différentes implémentations peuvent renvoyer différents itérateurs. Si nous voulons utiliser la même interface pour le transporter, nous devons utiliser un objet trait. Lorsque vous utilisez un objet trait, étant donné que Iterator est un trait avec un type associé, vous devez spécifier le type du type associé Item, afin que l'appelant puisse obtenir ce type pour le traitement.

Résumé
Jusqu'à présent, nous avons réglé les principales exigences et le processus principal du serveur KV, examiné les problèmes pouvant survenir au cours du processus et finalisé trois interfaces importantes : le protocole entre le client et le serveur, le trait CommandService et le Caractère de stockage . La prochaine leçon continuera à implémenter le serveur KV. Avant de lire l'explication, vous pouvez d'abord réfléchir à la façon dont vous le développez habituellement.

Questions
à réfléchir : Pour le trait Storage, pourquoi toutes les valeurs de retour utilisent-elles Result ? Lors de l'implémentation de MemTable, il semble que tous les retours soient Ok(T) ?
(Je pense que le stockage, en tant que trait, doit prêter attention à la situation d'erreur d'échec de l'opération d'E/S, alors que l'implémentation de MemTable concerne toutes les opérations de mémoire et n'échoue presque jamais, alors retournez simplement Ok(T))

Dans le dernier article, notre boutique KV vient de démarrer et l'interface de base a été écrite. Êtes-vous prêt à commencer à écrire du code d’implémentation spécifique ? Ne vous inquiétez pas, après avoir défini l'interface, ne vous précipitez pas pour l'implémenter.Avant d'écrire plus de code, nous pouvons découvrir comment utiliser l'interface du point de vue de l'utilisateur, si elle est facile à utiliser et réfléchir aux besoins de conception. être amélioré. Testons un par un selon l'ordre de définition de l'interface dans le cours précédent : Nous construisons d'abord la couche de protocole.
Implémentez et vérifiez la couche protocolaire.
Créez d'abord un projet : cargo new kv --lib. Entrez le répertoire du projet et ajoutez des dépendances dans Cargo.toml :


[package]
name = "kv"
version = "0.1.0"
edition = "2018"

[dependencies]
bytes = "1" # 高效处理网络 buffer 的库
prost = "0.8" # 处理 protobuf 的代码
tracing = "0.1" # 日志处理

[dev-dependencies]
anyhow = "1" # 错误处理
async-prost = "0.2.1" # 支持把 protobuf 封装成 TCP frame
futures = "0.3" # 提供 Stream trait
tokio = {
    
     version = "1", features = ["rt", "rt-multi-thread", "io-util", "macros", "net" ] } # 异步网络库
tracing-subscriber = "0.2" # 日志处理

[build-dependencies]
prost-build = "0.8" # 编译 protobuf

Créez ensuite abi.proto dans le répertoire racine du projet et placez-y le code protobuf ci-dessus. Dans le répertoire racine, créez build.rs :

fn main() {
    
    
    let mut config = prost_build::Config::new();
    config.bytes(&["."]);
    config.type_attribute(".", "#[derive(PartialOrd)]");
    config
        .out_dir("src/pb")
        .compile_protos(&["abi.proto"], &["."])
        .unwrap();
}

Ce code a déjà été vu dans la leçon 5, build.rs est exécuté au moment de la compilation pour effectuer un traitement supplémentaire.
Ici, nous ajoutons quelques attributs supplémentaires au code compilé. Par exemple, générez des octets au lieu du Vec par défaut pour le type octets de protobuf et ajoutez des macros dérivées de PartialOrd pour tous les types. Concernant l'extension de prost-build, vous pouvez lire la documentation.
Pensez à créer le répertoire src/pb, sinon vous ne pourrez pas compiler. Désormais, faire cargo build dans le répertoire racine du projet générera le fichier src/pb/abi.rs, qui contient les structures de données Rust de tous les messages définis par protobuf. Nous créons src/pb/mod.rs, importons abi.rs et effectuons quelques conversions de types de base :


pub mod abi;

use abi::{
    
    command_request::RequestData, *};

impl CommandRequest {
    
    
    /// 创建 HSET 命令
    pub fn new_hset(table: impl Into<String>, key: impl Into<String>, value: Value) -> Self {
    
    
        Self {
    
    
            request_data: Some(RequestData::Hset(Hset {
    
    
                table: table.into(),
                pair: Some(Kvpair::new(key, value)),
            })),
        }
    }
}

impl Kvpair {
    
    
    /// 创建一个新的 kv pair
    pub fn new(key: impl Into<String>, value: Value) -> Self {
    
    
        Self {
    
    
            key: key.into(),
            value: Some(value),
        }
    }
}

/// 从 String 转换成 Value
impl From<String> for Value {
    
    
    fn from(s: String) -> Self {
    
    
        Self {
    
    
            value: Some(value::Value::String(s)),
        }
    }
}

/// 从 &str 转换成 Value
impl From<&str> for Value {
    
    
    fn from(s: &str) -> Self {
    
    
        Self {
    
    
            value: Some(value::Value::String(s.into())),
        }
    }
}

Enfin, dans src/lib.rs, introduisez le module pb :

mod pb;
pub use pb::abi::*;

De cette façon, nous avons du code qui peut exécuter l'interface protobuf la plus basique du serveur KV. Créez des exemples dans le répertoire racine afin de pouvoir écrire du code pour tester le protocole entre le client et le serveur. Nous pouvons d’abord créer un fichier examples/client.rs et écrire le code suivant :


use anyhow::Result;
use async_prost::AsyncProstStream;
use futures::prelude::*;
use kv::{
    
    CommandRequest, CommandResponse};
use tokio::net::TcpStream;
use tracing::info;

#[tokio::main]
async fn main() -> Result<()> {
    
    
    tracing_subscriber::fmt::init();

    let addr = "127.0.0.1:9527";
    // 连接服务器
    let stream = TcpStream::connect(addr).await?;

    // 使用 AsyncProstStream 来处理 TCP Frame
    let mut client =
        AsyncProstStream::<_, CommandResponse, CommandRequest, _>::from(stream).for_async();

    // 生成一个 HSET 命令
    let cmd = CommandRequest::new_hset("table1", "hello", "world".into());

    // 发送 HSET 命令
    client.send(cmd).await?;
    if let Some(Ok(data)) = client.next().await {
    
    
        info!("Got response {:?}", data);
    }

    Ok(())
}

Ce code se connecte au port 9527 du serveur, envoie une commande HSET, puis attend la réponse du serveur.
De même, nous créons un fichier examples/dummy_server.rs et écrivons le code :


use anyhow::Result;
use async_prost::AsyncProstStream;
use futures::prelude::*;
use kv::{
    
    CommandRequest, CommandResponse};
use tokio::net::TcpListener;
use tracing::info;

#[tokio::main]
async fn main() -> Result<()> {
    
    
    tracing_subscriber::fmt::init();
    let addr = "127.0.0.1:9527";
    let listener = TcpListener::bind(addr).await?;
    info!("Start listening on {}", addr);
    loop {
    
    
        let (stream, addr) = listener.accept().await?;
        info!("Client {:?} connected", addr);
        tokio::spawn(async move {
    
    
            let mut stream =
                AsyncProstStream::<_, CommandRequest, CommandResponse, _>::from(stream).for_async();
            while let Some(Ok(msg)) = stream.next().await {
    
    
                info!("Got a new command: {:?}", msg);
                // 创建一个 404 response 返回给客户端
                let mut resp = CommandResponse::default();
                resp.status = 404;
                resp.message = "Not found".to_string();
                stream.send(resp).await.unwrap();
            }
            info!("Client {:?} disconnected", addr);
        });
    }
}

Dans ce code, le serveur écoute le port 9527 et renvoie status = 404 à toute demande client, et le message est une réponse « Non trouvé ».
Si vous ne comprenez pas le traitement asynchrone et réseau dans ces deux morceaux de code, cela n'a pas d'importance, vous pouvez d'abord copier le code et l'exécuter. Le contenu d’aujourd’hui n’a rien à voir avec Internet, il suffit de se concentrer sur le processus de traitement. Le réseau et le traitement asynchrone seront abordés à l'avenir.
Nous pouvons ouvrir une fenêtre de ligne de commande et exécuter : RUST_LOG=info cargo run --example dummy_server --quiet. Ensuite, dans une autre fenêtre de ligne de commande, exécutez : RUST_LOG=info cargo run --example client --quiet.
À ce stade, le serveur et le client ont reçu chacun leurs demandes et réponses, et la couche de protocole semble fonctionner correctement. Une fois la vérification réussie, vous pouvez passer à l'étape suivante, car les autres codes de la couche de protocole ne représentent qu'une charge de travail et peuvent être implémentés lentement si nécessaire ultérieurement.

Résumé** : Après avoir écrit le fichier protobuf, utilisez protos pour le compiler en code rust, principalement des informations sur la structure de rust. Implémentez ensuite l'une des commandes telles que hset: **
pub fn new_hset(table: impl Into, key: impl Into, value: Value) -> Self et effectuez une conversion de type.
Écrivez ensuite au client : // Générez une commande HSET
let cmd = CommandRequest::new_hset("table1", "hello", "world".into());
// Envoyez la commande HSET
client.send(cmd).await ? ;
Le client répond par un 404 après avoir reçu l'information. La recevoir signifie qu'il n'y a pas de problème avec cette partie.

Implémentation et vérification du trait de stockage
Créez ensuite le trait de stockage.
Notre dernière conférence a expliqué comment implémenter la fonctionnalité de stockage à l'aide d'un HashMap im-memory imbriqué qui prend en charge la concurrence. Étant donné qu'Arc>> un tel HashMap qui prend en charge la concurrence est un besoin rigide et que l'écosystème Rust dispose de nombreux supports de caisse associés, nous pouvons ici utiliser dashmap pour créer une structure MemTable afin d'implémenter le trait Storage.
Créez d'abord le répertoire src/storage, puis créez src/storage/mod.rs. Après y avoir mis le code de trait dont nous venons de parler, introduisez "mod storage" dans src/lib.rs. Une erreur sera trouvée à ce stade : KvError n'est pas défini.
Définissons donc KvError. Lors de la discussion sur la gestion des erreurs dans la leçon 18, nous avons brièvement démontré comment utiliser la macro dérivée de thiserror pour définir le type d'erreur. Aujourd'hui, nous allons l'utiliser pour définir KvError. Créez src/error.rs et remplissez :


use crate::Value;
use thiserror::Error;

#[derive(Error, Debug, PartialEq)]
pub enum KvError {
    
    
    #[error("Not found for table: {0}, key: {1}")]
    NotFound(String, String),

    #[error("Cannot parse command: `{0}`")]
    InvalidCommand(String),
    #[error("Cannot convert value {:0} to {1}")]
    ConvertError(Value, &'static str),
    #[error("Cannot process command {0} with table: {1}, key: {2}. Error: {}")]
    StorageError(&'static str, String, String, String),

    #[error("Failed to encode protobuf message")]
    EncodeError(#[from] prost::EncodeError),
    #[error("Failed to decode protobuf message")]
    DecodeError(#[from] prost::DecodeError),

    #[error("Internal error: {0}")]
    Internal(String),
}

Les définitions de ces erreurs sont en fait ajoutées progressivement au cours du processus de mise en œuvre, mais pour des raisons de commodité d'explication, elles sont ajoutées en une seule fois. Pour l'implémentation de Storage, nous nous soucions uniquement de StorageError, et d'autres définitions d'erreurs seront utilisées à l'avenir.
De même, introduisez une erreur de mod sous src/lib.rs. Maintenant, src/lib.rs ressemble à ceci :


mod error;
mod pb;
mod storage;

pub use error::KvError;
pub use pb::abi::*;
pub use storage::*;

src/storage/mod.rs ressemble à ceci :


use crate::{
    
    KvError, Kvpair, Value};

/// 对存储的抽象,我们不关心数据存在哪儿,但需要定义外界如何和存储打交道
pub trait Storage {
    
    
    /// 从一个 HashTable 里获取一个 key 的 value
    fn get(&self, table: &str, key: &str) -> Result<Option<Value>, KvError>;
    /// 从一个 HashTable 里设置一个 key 的 value,返回旧的 value
    fn set(&self, table: &str, key: String, value: Value) -> Result<Option<Value>, KvError>;
    /// 查看 HashTable 中是否有 key
    fn contains(&self, table: &str, key: &str) -> Result<bool, KvError>;
    /// 从 HashTable 中删除一个 key
    fn del(&self, table: &str, key: &str) -> Result<Option<Value>, KvError>;
    /// 遍历 HashTable,返回所有 kv pair(这个接口不好)
    fn get_all(&self, table: &str) -> Result<Vec<Kvpair>, KvError>;
    /// 遍历 HashTable,返回 kv pair 的 Iterator
    fn get_iter(&self, table: &str) -> Result<Box<dyn Iterator<Item = Kvpair>>, KvError>;
}

Le code ne contient actuellement aucune erreur de compilation. Vous pouvez ajouter du code de test à la fin de ce fichier et essayer d'utiliser ces interfaces. Bien sûr, nous n'avons pas encore construit MemTable, mais nous savons déjà comment utiliser MemTable via le trait Storage, donc nous pouvons d'abord écrire un test pour en faire l'expérience :


#[cfg(test)]
mod tests {
    
    
    use super::*;

    #[test]
    fn memtable_basic_interface_should_work() {
    
    
        let store = MemTable::new();
        test_basi_interface(store);
    }

    #[test]
    fn memtable_get_all_should_work() {
    
    
        let store = MemTable::new();
        test_get_all(store);
    }

    fn test_basi_interface(store: impl Storage) {
    
    
        // 第一次 set 会创建 table,插入 key 并返回 None(之前没值)
        let v = store.set("t1", "hello".into(), "world".into());
        assert!(v.unwrap().is_none());
        // 再次 set 同样的 key 会更新,并返回之前的值
        let v1 = store.set("t1", "hello".into(), "world1".into());
        assert_eq!(v1, Ok(Some("world".into())));

        // get 存在的 key 会得到最新的值
        let v = store.get("t1", "hello");
        assert_eq!(v, Ok(Some("world1".into())));

        // get 不存在的 key 或者 table 会得到 None
        assert_eq!(Ok(None), store.get("t1", "hello1"));
        assert!(store.get("t2", "hello1").unwrap().is_none());

        // contains 纯在的 key 返回 true,否则 false
        assert_eq!(store.contains("t1", "hello"), Ok(true));
        assert_eq!(store.contains("t1", "hello1"), Ok(false));
        assert_eq!(store.contains("t2", "hello"), Ok(false));

        // del 存在的 key 返回之前的值
        let v = store.del("t1", "hello");
        assert_eq!(v, Ok(Some("world1".into())));

        // del 不存在的 key 或 table 返回 None
        assert_eq!(Ok(None), store.del("t1", "hello1"));
        assert_eq!(Ok(None), store.del("t2", "hello"));
    }

    fn test_get_all(store: impl Storage) {
    
    
        store.set("t2", "k1".into(), "v1".into()).unwrap();
        store.set("t2", "k2".into(), "v2".into()).unwrap();
        let mut data = store.get_all("t2").unwrap();
        data.sort_by(|a, b| a.partial_cmp(b).unwrap());
        assert_eq!(
            data,
            vec![
                Kvpair::new("k1", "v1".into()),
                Kvpair::new("k2", "v2".into())
            ]
        )
    }

    fn test_get_iter(store: impl Storage) {
    
    
        store.set("t2", "k1".into(), "v1".into()).unwrap();
        store.set("t2", "k2".into(), "v2".into()).unwrap();
        let mut data: Vec<_> = store.get_iter("t2").unwrap().collect();
        data.sort_by(|a, b| a.partial_cmp(b).unwrap());
        assert_eq!(
            data,
            vec![
                Kvpair::new("k1", "v1".into()),
                Kvpair::new("k2", "v2".into())
            ]
        )
    }
}

L'écriture de tests unitaires avant l'écriture de l'implémentation est une méthode TDD (Test-Driven Development) standard.
Personnellement, je ne suis pas un grand fan de TDD, mais j'écrirai du code de test pour ce trait après avoir construit le trait, car l'écriture de code de test est un bon moment pour vérifier si l'interface est facile à utiliser. Après tout, nous ne voulons pas réaliser que la définition d'un trait est erronée et ne doit être modifiée qu'après l'implémentation du trait. À ce stade, le coût du changement sera relativement élevé.
Par conséquent, lorsque le trait est affiné, vous pouvez commencer à écrire du code de test en utilisant le trait. Soyez prudent pendant l'utilisation.Si vous vous sentez mal à l'aise lors de l'écriture de cas de test, ou si vous devez effectuer de nombreuses opérations fastidieuses pour l'utiliser, vous pouvez réexaminer la conception des traits.

Si vous regardez attentivement le code des tests unitaires, vous constaterez que j'adhère toujours à l'idée de tester les interfaces de traits. Bien qu'une structure de données réelle soit nécessaire pour tester la méthode des traits dans le test, le code de test de base utilise des fonctions génériques, ce qui rend ces codes uniquement liés au trait.
De cette façon, d'une part, l'interférence d'une implémentation de trait spécifique peut être évitée, et d'autre part, si vous souhaitez ajouter d'autres implémentations de traits à l'avenir, vous pouvez partager le code de test. Par exemple, si vous souhaitez prendre en charge DiskTable à l'avenir, il vous suffit d'ajouter quelques cas de test et d'appeler les fonctions génériques existantes.
D'accord, après avoir terminé le test et confirmé qu'il n'y a aucun problème avec la conception des traits, écrivons l'implémentation spécifique. src/storage/memory.rs peut être créé pour construire la MemTable :


use crate::{
    
    KvError, Kvpair, Storage, Value};
use dashmap::{
    
    mapref::one::Ref, DashMap};

/// 使用 DashMap 构建的 MemTable,实现了 Storage trait
#[derive(Clone, Debug, Default)]
pub struct MemTable {
    
    
    tables: DashMap<String, DashMap<String, Value>>,
}

impl MemTable {
    
    
    /// 创建一个缺省的 MemTable
    pub fn new() -> Self {
    
    
        Self::default()
    }

    /// 如果名为 name 的 hash table 不存在,则创建,否则返回
    fn get_or_create_table(&self, name: &str) -> Ref<String, DashMap<String, Value>> {
    
    
        match self.tables.get(name) {
    
    
            Some(table) => table,
            None => {
    
    
                let entry = self.tables.entry(name.into()).or_default();
                entry.downgrade()
            }
        }
    }
}

impl Storage for MemTable {
    
    
    fn get(&self, table: &str, key: &str) -> Result<Option<Value>, KvError> {
    
    
        let table = self.get_or_create_table(table);
        Ok(table.get(key).map(|v| v.value().clone()))
    }

    fn set(&self, table: &str, key: String, value: Value) -> Result<Option<Value>, KvError> {
    
    
        let table = self.get_or_create_table(table);
        Ok(table.insert(key, value))
    }

    fn contains(&self, table: &str, key: &str) -> Result<bool, KvError> {
    
    
        let table = self.get_or_create_table(table);
        Ok(table.contains_key(key))
    }

    fn del(&self, table: &str, key: &str) -> Result<Option<Value>, KvError> {
    
    
        let table = self.get_or_create_table(table);
        Ok(table.remove(key).map(|(_k, v)| v))
    }

    fn get_all(&self, table: &str) -> Result<Vec<Kvpair>, KvError> {
    
    
        let table = self.get_or_create_table(table);
        Ok(table
            .iter()
            .map(|v| Kvpair::new(v.key(), v.value().clone()))
            .collect())
    }

    fn get_iter(&self, _table: &str) -> Result<Box<dyn Iterator<Item = Kvpair>>, KvError> {
    
    
        todo!()
    }
}

À l'exception de get_iter(), ce code d'implémentation est très simple. Je pense que vous pouvez l'écrire rapidement si vous regardez la documentation de dashmap. get_iter() est un peu difficile à écrire, alors mettons-le de côté pour l'instant et nous en parlerons dans le prochain article sur le serveur KV. Si cela vous intéresse et souhaitez le défier, n'hésitez pas à l'essayer
. Une fois la mise en œuvre terminée, nous pouvons tester si elle fonctionne comme prévu. Notez que src/storage/memory.rs n'a pas encore été ajouté, donc cargo ne le compilera pas. Pour ajouter du code au début de src/storage/mod.rs :


mod memory;
pub use memory::MemTable;

De cette façon, le code peut être compilé et transmis. La méthode get_iter n'ayant pas encore été implémentée, ce test doit être commenté :

// #[test]
// fn memtable_iter_should_work() {
    
    
//     let store = MemTable::new();
//     test_get_iter(store);
// }

Si vous exécutez cargo test , vous pouvez voir que les tests réussissent :


> cargo test
   Compiling kv v0.1.0 (/Users/tchen/projects/mycode/rust/geek-time-rust-resources/21/kv)
    Finished test [unoptimized + debuginfo] target(s) in 1.95s
     Running unittests (/Users/tchen/.target/debug/deps/kv-8d746b0f387a5271)

running 2 tests
test storage::tests::memtable_basic_interface_should_work ... ok
test storage::tests::memtable_get_all_should_work ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests kv

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Résumé du test de magasin :
il existe certaines méthodes pour stocker les caractéristiques, telles que get, set, etc. Ne nous précipitons pas pour l'implémenter, écrivons d'abord le code de test. Bien sûr, nous n'avons pas encore construit MemTable, mais nous avons déjà une idée générale de la façon d'utiliser MemTable via le trait Storage :
par exemple

 fn memtable_basic_interface_should_work() {
    
    
        let store = MemTable::new();
        test_set(store);
    }

   fn test_set(store: impl Storage) {
    
    
        // 第一次 set 会创建 table,插入 key 并返回 None(之前没值)
        let v = store.set("t1", "hello".into(), "world".into());
        assert!(v.unwrap().is_none());
        // 再次 set 同样的 key 会更新,并返回之前的值
        let v1 = store.set("t1", "hello".into(), "world1".into());
        assert_eq!(v1, Ok(Some("world".into())));

Comme vous pouvez le voir, dans le code de test, créez d'abord une nouvelle table mémoire (l'implémentation spécifique sera discutée plus tard), puis testez l'interface définie. Si le test réussit, cela signifie qu’il n’y a aucun problème avec l’interface.
Cela reflète l'idée TDD d'un code de test stable. Pour les tests fn spécifiques, les paramètres de trait sont testés en magasin : impl Storage. Ce type générique peut ajouter de nouveaux types de stockage tels que le stockage sur disque à l'avenir, et le code de test peut être réutilisé. . De plus, l’implémentation spécifique d’un trait n’affectera pas les autres méthodes d’implémentation.
De plus, tester le code peut également vous permettre de déterminer si la méthode du trait que vous envisagez de concevoir est facile à utiliser. Si ce n'est pas le cas, modifiez-la rapidement. N'attendez pas que la méthode du trait soit implémentée avant de la modifier. être gênant.

Une fois le code de test écrit, je pense que le fn défini est approprié, alors je commence à l'implémenter. Tout d'abord, je dois définir memtable . Il s'agit en fait d'un dashmap, une table de hachage adaptée à la concurrence, qui représente respectivement
le nom de la table. , clé de table et valeur.
pub struct MemTable { tables: DashMap<String, DashMap<String, Value>>, } implémente la nouvelle fonction et la fonction get_or_create_table() pour la table , puis implémente spécifiquement le fn défini par le trait pour la memtable .



impl Storage for MemTable {
    
    
    fn get(&self, table: &str, key: &str) -> Result<Option<Value>, KvError> {
    
    
        let table = self.get_or_create_table(table);//判断表是否存在,不存在创建,存在的话返回这个表
        Ok(table.get(key).map(|v| v.value().clone()))
    }
    }

Implémenter et vérifier le trait CommandService Trait
de stockage Même si la vérification de base est réussie, vérifions maintenant le CommandService. Nous créons le répertoire src/service, ainsi que les fichiers src/service/mod.rs et src/service/command_service.rs, et écrivons dans src/service/mod.rs :


use crate::*;

mod command_service;

/// 对 Command 的处理的抽象
pub trait CommandService {
    
    
    /// 处理 Command,返回 Response
    fn execute(self, store: &impl Storage) -> CommandResponse;
}

N'oubliez pas d'ajouter un service à src/lib.rs :


mod error;
mod pb;
mod service;
mod storage;

pub use error::KvError;
pub use pb::abi::*;
pub use service::*;
pub use storage::*;

Ensuite, dans src/service/command_service.rs, nous pouvons d'abord écrire quelques tests. Par souci de simplicité, voici trois commandes : HSET, HGET et HGETALL :


use crate::*;

#[cfg(test)]
mod tests {
    
    
    use super::*;
    use crate::command_request::RequestData;

    #[test]
    fn hset_should_work() {
    
    
        let store = MemTable::new();
        let cmd = CommandRequest::new_hset("t1", "hello", "world".into());
        let res = dispatch(cmd.clone(), &store);
        assert_res_ok(res, &[Value::default()], &[]);

        let res = dispatch(cmd, &store);
        assert_res_ok(res, &["world".into()], &[]);
    }

    #[test]
    fn hget_should_work() {
    
    
        let store = MemTable::new();
        let cmd = CommandRequest::new_hset("score", "u1", 10.into());
        dispatch(cmd, &store);
        let cmd = CommandRequest::new_hget("score", "u1");
        let res = dispatch(cmd, &store);
        assert_res_ok(res, &[10.into()], &[]);
    }

    #[test]
    fn hget_with_non_exist_key_should_return_404() {
    
    
        let store = MemTable::new();
        let cmd = CommandRequest::new_hget("score", "u1");
        let res = dispatch(cmd, &store);
        assert_res_error(res, 404, "Not found");
    }

    #[test]
    fn hgetall_should_work() {
    
    
        let store = MemTable::new();
        let cmds = vec![
            CommandRequest::new_hset("score", "u1", 10.into()),
            CommandRequest::new_hset("score", "u2", 8.into()),
            CommandRequest::new_hset("score", "u3", 11.into()),
            CommandRequest::new_hset("score", "u1", 6.into()),
        ];
        for cmd in cmds {
    
    
            dispatch(cmd, &store);
        }

        let cmd = CommandRequest::new_hgetall("score");
        let res = dispatch(cmd, &store);
        let pairs = &[
            Kvpair::new("u1", 6.into()),
            Kvpair::new("u2", 8.into()),
            Kvpair::new("u3", 11.into()),
        ];
        assert_res_ok(res, &[], pairs);
    }

    // 从 Request 中得到 Response,目前处理 HGET/HGETALL/HSET
    fn dispatch(cmd: CommandRequest, store: &impl Storage) -> CommandResponse {
    
    
        match cmd.request_data.unwrap() {
    
    
            RequestData::Hget(v) => v.execute(store),
            RequestData::Hgetall(v) => v.execute(store),
            RequestData::Hset(v) => v.execute(store),
            _ => todo!(),
        }
    }

    // 测试成功返回的结果
    fn assert_res_ok(mut res: CommandResponse, values: &[Value], pairs: &[Kvpair]) {
    
    
        res.pairs.sort_by(|a, b| a.partial_cmp(b).unwrap());
        assert_eq!(res.status, 200);
        assert_eq!(res.message, "");
        assert_eq!(res.values, values);
        assert_eq!(res.pairs, pairs);
    }

    // 测试失败返回的结果
    fn assert_res_error(res: CommandResponse, code: u32, msg: &str) {
    
    
        assert_eq!(res.status, code);
        assert!(res.message.contains(msg));
        assert_eq!(res.values, &[]);
        assert_eq!(res.pairs, &[]);
    }
}

Le but de ces tests est de vérifier les exigences du produit,
par exemple : HSET renvoie avec succès la dernière valeur (c'est légèrement différent de Redis, Redis renvoie un entier indiquant le nombre de clés affectées)
HGET renvoie la valeur
HGETALL renvoie un groupe de Kvpairs non ordonnés
Actuellement ceux-ci Le test ne peut pas être compilé et réussi car certaines méthodes non définies y sont utilisées, telles que 10.into() : il veut convertir l'entier 10 en une valeur, CommandRequest::new_hgetall("score") : il veut générer une commande HGETALL.

Pourquoi écris-tu ceci ? Car si vous êtes un utilisateur de l'interface CommandService, vous espérez naturellement qu'en utilisant cette interface, la sensation globale d'appel soit très simple et claire.
Si l'interface attend une valeur, mais que ce que nous obtenons dans le contexte est une valeur telle que 10 ou "bonjour", alors nous, en tant que concepteurs, devons envisager d'implémenter From pour Value, ce qui est le plus pratique lors de l'appel. De même, pour générer la structure de données CommandRequest, vous pouvez également ajouter des fonctions auxiliaires pour rendre l'appel plus clair. Nous avons écrit deux séries de tests jusqu'à présent et je pense que vous avez une compréhension générale de la fonction du code de test. Résumons :

Vérifier et aider l'itération de l'interface
Vérifier les exigences du produit
En utilisant la logique de base, cela nous aide à mieux réfléchir à la logique périphérique et à inverser sa mise en œuvre.
Les deux premiers points sont les plus fondamentaux et correspondent également à la compréhension de nombreuses personnes du TDD. En fait, il y a quelque chose de plus important : Troisième point. En plus des fonctions auxiliaires précédentes, nous voyons également la fonction de répartition dans le code de test, qui est actuellement utilisée pour aider aux tests. Mais vous constaterez ensuite que de telles fonctions auxiliaires peuvent être fusionnées dans le code principal. C'est l'essence même du « développement piloté par les tests ».

D'accord, d'après le test, nous devons ajouter une logique périphérique pertinente à src/pb/mod.rs. Voici d'abord quelques méthodes de CommandRequest. Nous avons déjà écrit new_hset, et maintenant nous ajoutons new_hget et new_hgetall :


impl CommandRequest {
    
    
    /// 创建 HGET 命令
    pub fn new_hget(table: impl Into<String>, key: impl Into<String>) -> Self {
    
    
        Self {
    
    
            request_data: Some(RequestData::Hget(Hget {
    
    
                table: table.into(),
                key: key.into(),
            })),
        }
    }

    /// 创建 HGETALL 命令
    pub fn new_hgetall(table: impl Into<String>) -> Self {
    
    
        Self {
    
    
            request_data: Some(RequestData::Hgetall(Hgetall {
    
    
                table: table.into(),
            })),
        }
    }

    /// 创建 HSET 命令
    pub fn new_hset(table: impl Into<String>, key: impl Into<String>, value: Value) -> Self {
    
    
        Self {
    
    
            request_data: Some(RequestData::Hset(Hset {
    
    
                table: table.into(),
                pair: Some(Kvpair::new(key, value)),
            })),
        }
    }
}

Écrivez ensuite l’implémentation de From of Value :


/// 从 i64转换成 Value
impl From<i64> for Value {
    
    
    fn from(i: i64) -> Self {
    
    
        Self {
    
    
            value: Some(value::Value::Integer(i)),
        }
    }
}

Le code du test peut actuellement être compilé et réussi, mais le test échouera évidemment car l'implémentation spécifique n'a pas encore été effectuée. Nous ajoutons le code d'implémentation du trait sous src/service/command_service.rs :


impl CommandService for Hget {
    
    
    fn execute(self, store: &impl Storage) -> CommandResponse {
    
    
        match store.get(&self.table, &self.key) {
    
    
            Ok(Some(v)) => v.into(),
            Ok(None) => KvError::NotFound(self.table, self.key).into(),
            Err(e) => e.into(),
        }
    }
}

impl CommandService for Hgetall {
    
    
    fn execute(self, store: &impl Storage) -> CommandResponse {
    
    
        match store.get_all(&self.table) {
    
    
            Ok(v) => v.into(),
            Err(e) => e.into(),
        }
    }
}

impl CommandService for Hset {
    
    
    fn execute(self, store: &impl Storage) -> CommandResponse {
    
    
        match self.pair {
    
    
            Some(v) => match store.set(&self.table, v.key, v.value.unwrap_or_default()) {
    
    
                Ok(Some(v)) => v.into(),
                Ok(None) => Value::default().into(),
                Err(e) => e.into(),
            },
            None => Value::default().into(),
        }
    }
}

Cela provoquera naturellement plus d'erreurs de compilation, car nous utilisons la méthode into() à de nombreux endroits, mais n'implémentons pas la conversion correspondante, comme la conversion de Value en CommandResponse, la conversion de KvError en CommandResponse, la conversion de Vec en CommandResponse. , etc. attendez.
Continuez donc à ajouter la logique périphérique correspondante dans src/pb/mod.rs :


/// 从 Value 转换成 CommandResponse
impl From<Value> for CommandResponse {
    
    
    fn from(v: Value) -> Self {
    
    
        Self {
    
    
            status: StatusCode::OK.as_u16() as _,
            values: vec![v],
            ..Default::default()
        }
    }
}

/// 从 Vec<Kvpair> 转换成 CommandResponse
impl From<Vec<Kvpair>> for CommandResponse {
    
    
    fn from(v: Vec<Kvpair>) -> Self {
    
    
        Self {
    
    
            status: StatusCode::OK.as_u16() as _,
            pairs: v,
            ..Default::default()
        }
    }
}

/// 从 KvError 转换成 CommandResponse
impl From<KvError> for CommandResponse {
    
    
    fn from(e: KvError) -> Self {
    
    
        let mut result = Self {
    
    
            status: StatusCode::INTERNAL_SERVER_ERROR.as_u16() as _,
            message: e.to_string(),
            values: vec![],
            pairs: vec![],
        };

        match e {
    
    
            KvError::NotFound(_, _) => result.status = StatusCode::NOT_FOUND.as_u16() as _,
            KvError::InvalidCommand(_) => result.status = StatusCode::BAD_REQUEST.as_u16() as _,
            _ => {
    
    }
        }

        result
    }
}

De l'écriture de l'interface ci-dessus à l'implémentation spécifique ici, je me demande si vous avez ressenti un tel modèle : sous Rust, chaque fois qu'il y a une conversion entre deux structures de données v1 en v2, vous pouvez d'abord utiliser v1.into() pour exprimer cette logique. , continuez à écrire du code, puis terminez l'implémentation de From . Si ni v1 ni v2 ne sont une structure de données définie par vous, alors vous devez envelopper l'une d'entre elles avec une structure pour contourner la règle orpheline mentionnée précédemment (Leçon 14).
Après avoir terminé cette leçon, vous pourrez revoir la leçon 6 et réfléchir attentivement à ce qui a été dit à ce moment-là : « L'essentiel de la logique de traitement consiste à convertir les données d'une interface à une autre. »
Maintenant, le code devrait compiler et réussir le test. Vous pouvez le tester avec cargo test.

Le casse-tête final : l'implémentation de la structure de service.
Bon, toutes les interfaces, y compris l'interface de protocole client/serveur, le trait Storage et le trait CommandService, ont été vérifiées. L'étape suivante consiste à réfléchir à la manière d'utiliser une structure de données pour connecter tous ces éléments. des choses.
Regardez toujours comment l'appeler du point de vue de l'utilisateur. Pour ce faire, nous ajoutons le code de test suivant dans src/service/mod.rs :


#[cfg(test)]
mod tests {
    
    
    use super::*;
    use crate::{
    
    MemTable, Value};

    #[test]
    fn service_should_works() {
    
    
        // 我们需要一个 service 结构至少包含 Storage
        let service = Service::new(MemTable::default());

        // service 可以运行在多线程环境下,它的 clone 应该是轻量级的
        let cloned = service.clone();

        // 创建一个线程,在 table t1 中写入 k1, v1
        let handle = thread::spawn(move || {
    
    
            let res = cloned.execute(CommandRequest::new_hset("t1", "k1", "v1".into()));
            assert_res_ok(res, &[Value::default()], &[]);
        });
        handle.join().unwrap();

        // 在当前线程下读取 table t1 的 k1,应该返回 v1
        let res = service.execute(CommandRequest::new_hget("t1", "k1"));
        assert_res_ok(res, &["v1".into()], &[]);
    }
}

#[cfg(test)]
use crate::{
    
    Kvpair, Value};

// 测试成功返回的结果
#[cfg(test)]
pub fn assert_res_ok(mut res: CommandResponse, values: &[Value], pairs: &[Kvpair]) {
    
    
    res.pairs.sort_by(|a, b| a.partial_cmp(b).unwrap());
    assert_eq!(res.status, 200);
    assert_eq!(res.message, "");
    assert_eq!(res.values, values);
    assert_eq!(res.pairs, pairs);
}

// 测试失败返回的结果
#[cfg(test)]
pub fn assert_res_error(res: CommandResponse, code: u32, msg: &str) {
    
    
    assert_eq!(res.status, code);
    assert!(res.message.contains(msg));
    assert_eq!(res.values, &[]);
    assert_eq!(res.pairs, &[]);
}

Notez que assert_res_ok() et assert_res_error() ici sont déplacés de src/service/command_service.rs. Au cours du processus de développement, non seulement le code du produit doit être constamment refactorisé, mais le code de test doit également être refactorisé pour mettre en œuvre l'idée DRY.

J'ai vu beaucoup de code dans l'environnement de production. Les fonctions du produit sont raisonnables, mais le code de test est comme un puisard. Des années de copier/coller lui donnent une odeur de cloaque. Chaque développeur se couvre le nez lorsqu'il ajoute de nouvelles fonctionnalités. beaucoup de monde rend la maintenance de plus en plus difficile. Chaque fois que les exigences changent, de nombreuses modifications du code de test sont impliquées, ce qui est très mauvais.

La qualité du code de test doit également être la même que la qualité du code produit. Le code de test écrit par de bons développeurs est également très lisible. Vous pouvez comparer les trois morceaux de code de test écrits ci-dessus pour en avoir une idée.
Lors de l'écriture des tests, nous devons prêter une attention particulière : le code de test doit être testé autour de la partie stable du système, c'est-à-dire l'interface, et l'implémentation doit être testée le moins possible . Ceci est un résumé profond des leçons sanglantes que j’ai apprises au cours de tant d’années de travail.

Parce que le code produit et le code test doivent toujours être relativement stables, puisque le code produit continuera à changer en fonction de la demande, le code test doit être plus stable.
Alors, quel type de code de test est stable ? Le code pour tester l'interface est stable. Tant que l'interface reste inchangée, quelle que soit la manière dont l'implémentation spécifique change, même si un nouvel algorithme est introduit aujourd'hui et que l'implémentation est réécrite demain, le code de test peut toujours rester ferme et agir comme un chien de garde pour la qualité du produit.

Bon, revenons à l'écriture du code. Dans ce test, le plan d'utilisation de la structure de données Service a été finalisé. Il peut traverser les threads et appeler exécuter pour exécuter une commande CommandRequest et renvoyer une CommandResponse.
Sur la base de ces idées, ajoutez la déclaration et l'implémentation de Service dans src/service/mod.rs :


/// Service 数据结构
pub struct Service<Store = MemTable> {
    
    
    inner: Arc<ServiceInner<Store>>,
}

impl<Store> Clone for Service<Store> {
    
    
    fn clone(&self) -> Self {
    
    
        Self {
    
    
      inner: Arc::clone(&self.inner),
        }
    }
}

/// Service 内部数据结构
pub struct ServiceInner<Store> {
    
    
    store: Store,
}

impl<Store: Storage> Service<Store> {
    
    
    pub fn new(store: Store) -> Self {
    
    
        Self {
    
    
            inner: Arc::new(ServiceInner {
    
     store }),
        }
    }

    pub fn execute(&self, cmd: CommandRequest) -> CommandResponse {
    
    
        debug!("Got request: {:?}", cmd);
        // TODO: 发送 on_received 事件
        let res = dispatch(cmd, &self.inner.store);
        debug!("Executed response: {:?}", res);
        // TODO: 发送 on_executed 事件

        res
    }
}

// 从 Request 中得到 Response,目前处理 HGET/HGETALL/HSET
pub fn dispatch(cmd: CommandRequest, store: &impl Storage) -> CommandResponse {
    
    
    match cmd.request_data {
    
    
        Some(RequestData::Hget(param)) => param.execute(store),
        Some(RequestData::Hgetall(param)) => param.execute(store),
        Some(RequestData::Hset(param)) => param.execute(store),
        None => KvError::InvalidCommand("Request has no data".into()).into(),
        _ => KvError::Internal("Not implemented".into()).into(),
    }
}

Il y a plusieurs choses à noter à propos de ce code :
Tout d'abord, il y a un ServiceInner à l'intérieur de la structure Service pour stocker la structure de données réelle. Service enveloppe simplement le ServiceInner avec Arc. Il s'agit également d'une convention dans Rust, qui sépare le corps principal qui doit être cloné sous multi-thread de sa structure interne, afin que la logique du code soit plus claire.
La méthode eexecute() appelle actuellement dispatch, mais elle pourrait potentiellement effectuer une distribution d'événements à l'avenir. Ce traitement reflète le principe SRP (Single Responsibility Principe).
La répartition signifie en fait déplacer la logique de répartition du code de test et la modifier.

Une fois de plus, nous avons refactorisé le code de test et intégré ses fonctions d'assistance au code de production. Maintenant, vous pouvez exécuter cargo test pour le tester. Si le code ne peut pas être compilé, il se peut qu'il manque du code d'utilisation, tel que

use crate::{
    
    
    command_request::RequestData, CommandRequest, CommandResponse, KvError, MemTable, Storage,
};
use std::sync::Arc;
use tracing::debug;

Maintenant que la logique de traitement du nouveau serveur
est terminée, vous pouvez écrire un nouvel exemple de code de serveur de test.
Copiez les exemples/dummy_server.rs précédents dans examples/server.rs, puis introduisez Service. Les principaux changements sont trois phrases :

// main 函数开头,初始化 service
let service: Service = Service::new(MemTable::new());
// tokio::spawn 之前,复制一份 service
let svc = service.clone();
// while loop 中,使用 svc 来执行 cmd
let res = svc.execute(cmd);

Vous pouvez essayer de le modifier vous-même. Le code complet est le suivant :


use anyhow::Result;
use async_prost::AsyncProstStream;
use futures::prelude::*;
use kv::{
    
    CommandRequest, CommandResponse, MemTable, Service};
use tokio::net::TcpListener;
use tracing::info;

#[tokio::main]
async fn main() -> Result<()> {
    
    
    tracing_subscriber::fmt::init();
    let service: Service = Service::new(MemTable::new());
    let addr = "127.0.0.1:9527";
    let listener = TcpListener::bind(addr).await?;
    info!("Start listening on {}", addr);
    loop {
    
    
        let (stream, addr) = listener.accept().await?;
        info!("Client {:?} connected", addr);
        let svc = service.clone();
        tokio::spawn(async move {
    
    
            let mut stream =
                AsyncProstStream::<_, CommandRequest, CommandResponse, _>::from(stream).for_async();
            while let Some(Ok(cmd)) = stream.next().await {
    
    
                let res = svc.execute(cmd);
                stream.send(res).await.unwrap();
            }
            info!("Client {:?} disconnected", addr);
        });
    }
}

Une fois terminé, ouvrez une fenêtre de ligne de commande et exécutez : RUST_LOG=info cargo run --example server --quiet, puis dans une autre fenêtre de ligne de commande, exécutez : RUST_LOG=info cargo run --example client --quiet. À ce stade, le serveur et le client ont reçu chacun leurs demandes et réponses et traitent normalement. Les fonctions de base de la première version de notre serveur KV sont complètes ! Bien entendu, seules 3 commandes ont été traitées jusqu'à présent et les 6 autres doivent être complétées par vous-même.

Résumé
Le serveur KV n'est pas un projet difficile, mais il n'est pas facile de bien l'écrire. Si vous suivez l'explication étape par étape, vous comprendrez comment un projet Rust avec un environnement de production potentiel de qualité doit être développé. Dans ces deux conférences, il y a deux points que nous devons bien comprendre.

Le premier point est que vous devez bien comprendre les exigences et découvrir les parties instables (variante) et les parties relativement stables (invariant). Dans le serveur KV, la partie instable est la prise en charge de diverses nouvelles commandes et la prise en charge de différents stockages. Par conséquent, il est nécessaire de construire des interfaces pour éliminer les facteurs instables afin que les parties instables puissent être gérées de manière stable.

Le deuxième point est que le code et les tests peuvent s'enrouler autour de l'interface, et l'utilisation de TDD peut nous aider à réaliser cette itération en spirale. Dans un système bien conçu : l'interface est stable, le code qui teste l'interface est stable et l'implémentation peut être instable. Dans le processus de développement itératif, nous devons constamment refactoriser afin que le code de test et le code produit évoluent dans la direction optimale.

En regardant le serveur KV que nous avons écrit, y compris les tests, il est difficile de trouver une fonction ou une méthode de plus de lignes 50. Le code est très lisible et ne nécessite presque aucun commentaire pour être compris. De plus, comme toutes les interactions se font à l'aide d'interfaces, la maintenance future et l'ajout de nouvelles fonctions respecteront fondamentalement les principes OCP. À l'exception de la fonction de répartition, qui nécessite de très petites modifications, d'autres nouveaux codes implémentent simplement certaines interfaces.

Je pense que vous pouvez avoir une première idée des meilleures pratiques pour écrire du code dans Rust. Si vous avez déjà adopté des bonnes pratiques similaires dans d'autres langages, vous pouvez ressentir l'élégance d'utiliser les mêmes pratiques sous Rust ; si vous avez déjà écrit du code de type spaghetti pour diverses raisons, alors lors du développement de programmes Rust, vous pouvez essayer d'adopter cette méthode de développement plus élégante.

Après tout, maintenant que nous disposons d’armes plus avancées, nous pouvons utiliser des méthodes de combat plus avancées.

Résumé de la première partie : Prendre
des décisions avant d'agir : Déterminer plusieurs interfaces principales. Couche réseau, couche de stockage, couche de traitement. La couche réseau doit s'adapter de manière flexible aux différents protocoles. Protobuf est utilisé comme format de transmission des informations. Quels hooks sont nécessaires dans la couche de traitement ? Les événements sont exposés dans les étapes principales du processus de traitement, afin que notre serveur KV puisse être très flexible. et pratique pour l'appelant à injecter lors de l'initialisation du service.Logique de traitement supplémentaire.

Déterminez d’abord plusieurs interfaces principales : l’interface de protocole protobuf, l’interface logique de traitement et l’interface de stockage. Réalisez les avantages de l'utilisation de traits pour atteindre un niveau d'abstraction plus élevé. Nous interagissons uniquement avec des interfaces abstraites pour une plus grande évolutivité.

Par exemple, lorsque nous prendrons en charge de nouvelles commandes à l'avenir, nous n'aurons qu'à faire deux choses : implémenter CommandService pour la commande et ajouter la prise en charge de la nouvelle commande dans la méthode de répartition . Par exemple, lors du traitement des paramètres de fonction, seul le trait de stockage de niveau le plus élevé est utilisé, pas le type de magasin spécifique. Dans ce cas, face à différents types de stockage, il n'est pas nécessaire de modifier les paramètres d'exécution du code . implémentez le trait de stockage pour le type spécifique et appelez des fonctions spécifiques telles que hget. Implémentez la méthode hget de ce type concret. Équivalent à une sorte de surcharge de fonctions.

Il incarne le principe ouvert-fermé, ouvert aux extensions et aux ajouts de code, et fermé aux modifications et aux changements de code.

Résumé de la deuxième partie :
Implémenter et tester le protocole protobuf pour pouvoir transmettre normalement les informations entre le client et le serveur ;
implémenter et tester les deux interfaces restantes. Le code et les tests peuvent s'enrouler autour de l'interface, et l'utilisation de TDD peut nous aider à effectuer cette itération en spirale. Dans un système bien conçu : l'interface est stable, le code qui teste l'interface est stable et l'implémentation peut être instable . Dans le processus de développement itératif, nous devons constamment refactoriser afin que le code de test et le code produit évoluent dans la direction optimale.

En regardant le serveur KV que nous avons écrit, y compris les tests, il est difficile de trouver une fonction ou une méthode de plus de lignes 50. Le code est très lisible et ne nécessite quasiment aucun commentaire pour être compris . De plus, étant donné que toutes les interactions se font via des interfaces, la maintenance future et l'ajout de nouvelles fonctions respecteront essentiellement le principe d'ouverture et de fermeture.

*类比一下C++如果实现这个和rust相比(思考一下,说出几个点,不用具体实现)
接口的话在c++就要用抽象类实现了,比如声明存储纯虚函数,子类重写实现具体的存储方式。
而rust优秀的地方在于trait更强大, 比如execute可以把存储的trait作为参数,代表,只要具体的类型实现了这个trait就可以作为参数,这样不用为之后新增的存储类型而改变参数了,只要具体类型实现存储trait,符合开闭原则。而且trait还体现了特征约束的功能,必须是实现了trait的具体类型才可以作为参数。
而C++的话,要实现这种抽象,必须把抽象类作为参数传给execute函数,不过抽象类是不能作为函数参数的,所以必须是具体的实现存储类才可以作为函数参数,这样的话有新的存储类型就需要实现新的存储子类,并且为execute修改参数。或者基类不作为抽象类作为参数传给execute,调用时是作为具体类型的子类,这就是利用多态了,子类对象可以转换为基类的指针,调用虚函数时调用实际子类的函数实现,实现多态需要额外的开销的,比如vptr,虚函数表,起码找到函数需要两次。
**rust实现类似的功能是零成本的吗?零成本抽象?**
rust的零成本抽象通过**定义实现trait**。懂了。下面是自己的思考。
就是说C**++要实现运行时多态必须是虚函数表等,有运行时开销。而rust通过trait把公用的函数功能从struct或者说class剥离出来,放在trait,该trait作为函数参数时,后续使用对应的struct必须实现了该trait才可以使用该函数。**
两个优点,第一是实现**泛型约束**,保证你只需要引入你需要的东西,不会因为抽象而引入其他用不到的函数方法,这一点在C++继承体系中就做不到,因为基类肯定有子类用不到的方法,除非完全适配,体现**最小接口原则,避免方法被污染**了。如果说需要实现多个trait就组合一下很方便用+号,而不是多重继承,体现了**组合大于继承**的思想。
第二是基于trait的泛型在rust中,是**编译时静态分发**的,编译是就会替换成相应的类型,不是在运行时判断,所以运行时是零成本的。,但是不可避免编译速度就会慢一点了。*

Pour résumer encore : la chose la plus importante est l'utilisation des traits : si vous souhaitez prendre en charge de nouvelles commandes, il vous suffit d'implémenter la méthode commandservice, puis d'ajouter un élément à disptch. Et il présente des avantages uniques par rapport au C++
 : deux avantages : les contraintes de fonctionnalités, la mise en œuvre du principe d'ouverture et de fermeture, la composition est supérieure à l'héritage ; et c'est une abstraction sans coût.

1. Par exemple, nous utilisons uniquement dashmap (en fait hahsmap thread-safe). Si vous souhaitez implémenter un nouveau type de stockage, la méthode d'exécution n'a pas besoin de modifier les paramètres. Les paramètres doivent seulement être le type qui implémente le trait de magasin. C'est l'avantage des contraintes de caractéristiques de trait . Pensez-y, s'il s'agit de C++, vous devez passer la nouvelle classe de stockage en paramètre et modifier le code ; un autre avantage est le principe de l'interface minimale, qui évite la contamination des méthodes et n'introduit pas d'autres fonctions inutilisées à cause de l'abstraction. Cela ne peut pas être fait dans le système d'héritage C++, car la classe de base doit avoir des méthodes que les sous-classes ne peuvent pas utiliser, à moins qu'elles ne soient complètement adaptées, ce qui incarne l'idée que la combinaison est supérieure à l'héritage .

2. Si C++ ne souhaite pas modifier les paramètres. Le polymorphisme est sur le point d'être utilisé. L'objet de sous-classe peut être converti en pointeur de la classe de base. Lors de l'appel de la fonction virtuelle, l'implémentation de la fonction de la sous-classe réelle est appelée. L'implémentation du polymorphisme nécessite une surcharge supplémentaire, telle que vptr et une table de fonctions virtuelles. Trouvez au moins la fonction Il en faut deux. Les génériques basés sur les traits dans rust sont distribués statiquement lors de la compilation . Ils seront remplacés par les types correspondants lors de la compilation et ne seront pas jugés au moment de l'exécution, il n'y a donc aucun coût au moment de l'exécution. , mais inévitablement la vitesse de compilation sera plus lente.

Passons en revue :
nous devons d'abord déterminer les interfaces de base, puis au lieu d'implémenter la fonction entière, nous écrivons des tests, des tests unitaires et testons si chaque interface peut être utilisée ? Incarnez la pensée TDD. Assurez-vous que chaque interface est disponible avant d'écrire le service global.
tdd : TDD pilote simplement l'écriture de code via des tests unitaires, puis optimise la structure interne du programme via une refactorisation. Il est facile de comprendre qu'il suffit d'écrire des tests unitaires pour piloter du code n'est que lorsque j'ai lu le livre de Kent Beck "Test-Driven Development" et poursuivi ma réflexion pratique que j'ai enfin eu un aperçu du visage de TDD caché sous l'iceberg. : Kent Beck : " Le développement piloté par les tests n'est pas une technologie de test. C'est une technologie d'analyse, une technologie de conception et une technologie permettant d'organiser toutes les activités de développement . "

Règles du TDD
Dans le processus de TDD, il y a deux règles simples à suivre :
N'écrivez du nouveau code que lorsque les tests automatisés échouent.
Éliminez la duplication de conception (supprimez les dépendances inutiles), optimisez la structure de conception (rendre progressivement le code général).

L'implication de la première règle est d'écrire juste assez de code pour que le test réussisse à chaque fois , et d'écrire du nouveau code uniquement lorsque le test échoue. Parce que le code ajouté à chaque fois est petit, même s'il y a un problème, il est difficile de localisez-le. Très rapidement, en garantissant que nous pouvons suivre le rythme des petites étapes ; la deuxième règle est de rendre les petites étapes plus pratiques. Avec le soutien des tests automatisés, nous pouvons éliminer la mauvaise odeur du code grâce au processus de refactorisation pour éviter le code ne pourrit pas . Ensuite, codez pour créer un environnement confortable.
La séparation des préoccupations est un autre principe très important implicite dans ces deux règles. Le sens de son expression est d'abord d'atteindre l'objectif d'un code « utilisable » dans la phase de codage, puis de poursuivre l'objectif du « simple » dans la phase de refactoring, en se concentrant sur une seule chose à la fois ! ! !

En termes simples, non exécutable/exécutable/refactorable - tel est le slogan du développement piloté par les tests et le cœur du TDD.

Résumons les méthodes et processus spécifiques de la deuxième partie du test d'interface :
test d'interface de protocole.Après
avoir écrit le fichier protobuf, utilisez protos pour le compiler en code rust, principalement des informations sur la structure rust. Implémentez ensuite l'une des commandes telles que hset: **
pub fn new_hset(table: impl Into, key: impl Into, value: Value) -> Self et effectuez une conversion de type.
Écrivez ensuite au client : // Générez une commande HSET
let cmd = CommandRequest::new_hset("table1", "hello", "world".into());
// Envoyez la commande HSET
client.send(cmd).await ? ;
Le client répond par un 404 après avoir reçu l'information. La recevoir signifie qu'il n'y a pas de problème avec cette partie.

Test de l'interface de stockage

Résumé du test de magasin :
il existe certaines méthodes pour stocker les caractéristiques, telles que get, set, etc. Ne nous précipitons pas pour l'implémenter, écrivons d'abord le code de test. Bien sûr, nous n'avons pas encore construit MemTable, mais nous avons déjà une idée générale de la façon d'utiliser MemTable via le trait Storage :
par exemple

 fn memtable_basic_interface_should_work() {
    
    
        let store = MemTable::new();
        test_set(store);
    }

   fn test_set(store: impl Storage) {
    
    
        // 第一次 set 会创建 table,插入 key 并返回 None(之前没值)
        let v = store.set("t1", "hello".into(), "world".into());
        assert!(v.unwrap().is_none());
        // 再次 set 同样的 key 会更新,并返回之前的值
        let v1 = store.set("t1", "hello".into(), "world1".into());
        assert_eq!(v1, Ok(Some("world".into())));

Comme vous pouvez le voir, dans le code de test, créez d'abord une nouvelle table mémoire (l'implémentation spécifique sera discutée plus tard), puis testez l'interface définie. Si le test réussit, cela signifie qu’il n’y a aucun problème avec l’interface.
Cela reflète l'idée TDD d'un code de test stable. Pour les tests fn spécifiques, les paramètres de trait sont testés en magasin : impl Storage. Ce type générique peut ajouter de nouveaux types de stockage tels que le stockage sur disque à l'avenir, et le code de test peut être réutilisé. . De plus, l’implémentation spécifique d’un trait n’affectera pas les autres méthodes d’implémentation.
De plus, tester le code peut également vous permettre de déterminer si la méthode du trait que vous envisagez de concevoir est facile à utiliser. Si ce n'est pas le cas, modifiez-la rapidement. N'attendez pas que la méthode du trait soit implémentée avant de la modifier. être gênant.

Une fois le code de test écrit, je pense que le fn défini est approprié, alors je commence à l'implémenter. Tout d'abord, je dois définir memtable . Il s'agit en fait d'un dashmap, une table de hachage adaptée à la concurrence, qui représente respectivement
le nom de la table. , clé de table et valeur.
pub struct MemTable { tables: DashMap<String, DashMap<String, Value>>, } implémente la nouvelle fonction et la fonction get_or_create_table() pour la table , puis implémente spécifiquement le fn défini par le trait pour la memtable .



impl Storage for MemTable {
    
    
    fn get(&self, table: &str, key: &str) -> Result<Option<Value>, KvError> {
    
    
        let table = self.get_or_create_table(table);//判断表是否存在,不存在创建,存在的话返回这个表
        Ok(table.get(key).map(|v| v.value().clone()))
    }
    }

Pensez-y si vous écrivez directement l’implémentation et que vous la testez ensuite. Il s'agit d'abord de définir la table mémoire, d'implémenter une méthode set spécifique pour celle-ci, puis d'écrire le code de test pour créer une nouvelle table mémoire et d'appeler la méthode set. Deux problèmes se posent ici :
1. Si vous testez plus tard et constatez que la méthode de conception des traits est déraisonnable, ou que certaines méthodes sont tout simplement inutiles, vous devrez modifier l'implémentation écrite précédemment, ce qui est plus gênant.

2. Si vous souhaitez ajouter ultérieurement d'autres types de stockage tels que le stockage sur disque, vous devrez modifier le code de test. Le code de test mémorisable précédent ne sera pas utilisable.

Donc écrire le test en premier peut résoudre ce problème. Le test teste uniquement le trait. En utilisant des paramètres de contrainte génériques, il vous suffit de modifier la phrase let store = MemTable::new(). Les fonctions de l'interface de test telles que set get peuvent être réutilisées. . Par conséquent, le code de l'interface de test est stable, mais l'implémentation spécifique du test peut être instable.

Les tests d'interface du processus de traitement
sont également effectués en écrivant d'abord le test, en créant une table mémorisable, en créant une commande, en la donnant à dispatch et en appelant la méthode d'exécution pour obtenir une réponse.

** fn hset_should_work() {
    
    
        let store = MemTable::new();
        let cmd = CommandRequest::new_hset("t1", "hello", "world".into());
        let res = dispatch(cmd.clone(), &store);
        assert_res_ok(res, &[Value::default()], &[]);

        let res = dispatch(cmd, &store);
        assert_res_ok(res, &["world".into()], &[]);
    }**
  fn dispatch(cmd: CommandRequest, store: &impl Storage) -> CommandResponse {
    
    
        match cmd.request_data.unwrap() {
    
    
            RequestData::Hget(v) => v.execute(store),
            RequestData::Hgetall(v) => v.execute(store),
            RequestData::Hset(v) => v.execute(store),
            _ => todo!(),
        }
    }

Ensuite, améliorez les précédents new_hset, new_hget, etc. Ensuite, implémentez l'exécution spécifique

impl CommandService for Hget {
    
    
    fn execute(self, store: &impl Storage) -> CommandResponse {
    
    
        match store.get(&self.table, &self.key) {
    
    
            Ok(Some(v)) => v.into(),
            Ok(None) => KvError::NotFound(self.table, self.key).into(),
            Err(e) => e.into(),
        }
    }
}

Bien sûr, il y a plus de conversions de types impliquées depuis from, car il y en a beaucoup vers. Sous Rust, chaque fois qu'il y a une conversion entre deux structures de données v1 en v2, vous pouvez d'abord utiliser v1.into() pour exprimer cette logique, continuer à écrire du code, puis terminer l'implémentation de From (from into est utilisé pour la conversion de type trait)

essai global

Je suppose que tu aimes

Origine blog.csdn.net/weixin_53344209/article/details/130033399
conseillé
Classement