Rust はシンプルな KV サーバーの基本プロセスを構築します (1,2)

実際の運用に KV サーバーを選択する理由 それは十分にシンプルでありながら十分に複雑なサービスだからです。要件を整理するには、作業で使用されている Redis/Memcached などのサービスを参照してください。

コア機能は、さまざまなコマンドに従ってデータの保存、読み取り、監視などの操作を実行することです。クライアントは
ネットワーク経由で KV サーバーにアクセスし、コマンドを含むリクエストを送信し、結果を取得できなければなりません。
データは次の場所に保存される必要があります。必要に応じて KV サーバーをメモリ内に保存するか、ディスクに保存します。

短くて大まかな実装から始めましょう。タスクを完了するために KV サーバーを構築している場合、実際、初期バージョンは 200 ~ 300 行のコードで完成できますが、そのようなコードを維持するのは大変なことになります。未来。多くの詳細を省略したスパゲッティ バージョンを見てみましょう。プロセスに注目するには、私の注釈に従ってください。

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(),
    }
}

このコードは入力から出力まで一気に行っているので、このように書くと確かに早く終わるのですが、「終わったら洪水」という感じです。コードをコピーした後、2 つのウィンドウを開いて「cargo run --example naive_server」と「cargo run --example client」をそれぞれ実行します。サーバーを実行しているウィンドウに次の出力が表示されます。

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: [] }

全体的な機能は完成したと考えられますが、将来的にこの KV サーバーに新しい機能を追加し続ける場合は、このコードを前後に変更する必要があります。さらに、すべてのロジックが一緒に圧縮されており、「ユニット」がまったく存在しないため、単体テストを行うのは簡単ではありません。ただし、メインプロセスをできるだけ単純にするために、将来的にはさまざまなロジックをさまざまな機能に徐々に分離することができます。ただし、これらは依然として結合されており、大規模なリファクタリングを行わない限り、本当の問題はまだ解決できません。
したがって、開発にどのような言語を使用する場合でも、そのようなコードをできるだけ避ける必要があり、自分でコードを書かないだけでなく、コードレビューで他の人がコードを書いているのを見つけた場合も厳しくチェックする必要があります。

アーキテクチャとデザイン それでは、優れた実装とは何でしょうか? 優れた実装では、要件を分析した後、システムの主要なプロセスから開始し、クライアントのリクエストから最終的なクライアントが応答を受け取るまでの主要なステップが何であるかを把握し、これらのステップに基づいて何が必要かを検討します。バインディング、メインインターフェイスとトレイトの構築; これらのことが慎重に検討されるまで待ってから、最終的に実装を検討してください。いわゆる「決めてから行動する」ということです。初めに KV サーバーの要件を分析しましたが、次に主要なプロセスを整理します。まず自分で考えてから、概略図を参照してギャップがあるかどうかを確認できます。
ここに画像の説明を挿入します
このプロセスには、さらなる調査が必要な重要な問題がいくつかあります。
クライアントとサーバーは通信にどのプロトコルを使用しますか? TCP? gRPC? HTTP? 1 つのタイプをサポートしますか? それとも複数のタイプをサポートしますか?
クライアントとサーバー間の対話のためのアプリケーション層プロトコルはどのように定義されていますか? シリアル化/逆シリアル化を行うにはどうすればよいですか? Protobuf、JSON、または Redis RESP を使用する必要がありますか? それとも複数のタイプをサポートできますか?
サーバーはどのようなコマンドをサポートしていますか? 最初のバージョンで最初にサポートされるのはどれですか? 特定の処理ロジックでは、他のプロセスに通知して追加の処理を実行できるように、処理中にフックを追加していくつかのイベントを発行する必要がありますか?
これらのフックはプロセス全体を事前に終了できますか? ストレージについては、さまざまなストレージ エンジンをサポートする必要がありますか? たとえば、MemDB (メモリ)、RocksDB (ディスク)、SledDB (ディスク) などです。MemDB の場合、WAL (Write-Ahead Log) とスナップショットのサポートを検討する必要がありますか?
システム全体を設定できますか? たとえば、サービスはどのポートとストレージ エンジンを使用しますか?
建築をうまくやりたいなら、これらの質問をして、その答えを見つけることが重要です。
プロダクト マネージャーは、これらの質問の多くに対する答えを手助けすることはできません。そうしないと、プロダクト マネージャーの答えがあなたを迷わせることになることに注意してください。アーキテクトとして、私たちはシステムが将来の変化にどのように対応するかについて責任を負う必要があります。

私の考えは次のとおりです。参考にしてください。

  1. KV サーバーなどの高いパフォーマンスが必要なシナリオでは、通信に TCP プロトコルを優先する必要があります。したがって、当面は TCP のみをサポートし、将来的には必要に応じて HTTP2/gRPC などのさらに多くのプロトコルをサポートできるようになります。また、将来的にはセキュリティ要件が追加される可能性があるため、TLS などのセキュリティ プロトコルがプラグ アンド プレイであることを確認する必要があります。つまり、ネットワーク層には柔軟性が必要です。
  2. アプリケーション層プロトコルは、protobuf を使用して定義できます。protobuf は、プロトコルの定義と、それをシリアル化および逆シリアル化する方法を直接扱いますRedis の RESP は優れていますが、欠点も明らかです。コマンドには追加の解析が必要で、コマンドやデータを区切るために大量の \r\n が使用されるため、帯域幅も無駄になります。JSON を使用すると、より多くの帯域幅が浪費され、特にデータ量が多い場合、JSON の解析効率は高くありません。protobuf は、KV サーバーなどのシナリオに非常に適しています。柔軟で、下位互換性があり、アップグレード可能で、解析効率が高く、生成されたバイナリは帯域幅を非常に節約します。唯一の欠点は、別のプロトコルにコンパイルするために追加のツール protoc が必要なことです。言語。protobuf が最初の選択肢ですが、将来的に Redis クライアントと相互運用するには RESP のサポートが必要になる可能性があります。
  3. サーバーでサポートされているコマンドについては、Redis コマンド セットを参照してください最初のバージョンでは、HSET、HMSET、HGET、HMGET などの HXXX コマンドが初めてサポートされます。コマンドからコマンドへの応答まで、それを抽象化するトレイトを作成できます。
  4. これらのフックは、クライアントのコマンドを受信した後の OnRequestReceived、クライアントのコマンドを処理した後の OnRequestExecuted、応答を送信する前の BeforeResponseSend、応答を送信した後の AfterResponseSend の処理プロセスに追加される予定ですこのようにして、処理プロセスの主要なステップがイベントによって公開されるため、KV サーバーは非常に柔軟になり、呼び出し元がサービスの初期化時に追加の処理ロジックを挿入するのに便利になります。
  5. ストレージには十分な柔軟性が必要です。ストレージのトレイトを作成して、その基本的な動作を抽象化することができます。最初は MemDB を実行するだけで済みます。将来的には、永続化をサポートするストレージが必ず必要になります。
  6. 構成サポートは必要ですが、優先度は高くありません。基本的なプロセスが完了し、使用中に十分な問題点が見つかったら、構成ファイルの処理方法を検討できます。

これらの課題がまとまれば、制度の基本的な考え方が明らかになる。最初にいくつかの重要なインターフェイスを定義し、次にこれらのインターフェイスを注意深く調べることができます。最も重要なインターフェイスは、3 つの本体間のインターフェイスです。クライアントとサーバー間のインターフェイスまたはプロトコル、サーバーとコマンド処理プロセス間のインターフェイス、およびサーバーとストレージ間のインターフェイスです。
(要約すると、ネットワーク層は柔軟である必要があり、さまざまなネットワーク プロトコルを使用できること、情報定義では protobuf を使用できること、柔軟性を維持するためにフックを使用してサーバー処理プロセスを追加できること、柔軟性を維持するためにストレージはトレイトを使用して基本的な動作を抽象化できること、ということになります。 、構成にも柔軟性があります) つまり、プロトコルは柔軟で、処理プロセスは柔軟で、ストレージの動作も柔軟です)

クライアントとサーバー間のプロトコル

クライアントとサーバー間のメッセージがどのように定義されているかを検討します (protobuf)。protobuf
プロトコルに関する追加知識
(原理、json および xml との長所と短所の比較、最適化方法、インストール方法と使用方法)

インストール方法:
まずソース コードをダウンロードし、
tar -xzf protobuf-2.1.0.tar.gz を解凍し
、コンパイルしてインストールします。/configure
make
make
install
protobuf は、Google が開発したデータ交換フォーマット ツールです。シリアル化と逆シリアル化、データ ストレージ、通信プロトコルに使用できます。次に、 proto ファイルには主に何が含まれているか
を見てみましょう。キーワード message: は、複数のメッセージ フィールドで構成されるエンティティ構造を表します。メッセージ フィールド (フィールド): データ型、フィールド名、フィールド ルール (初期化する必要がある、オプションの初期化、反復可能)、一意のフィールド ID、およびデフォルト値が含まれます。

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

フィールド ルール フィールド ルールは省略できますが、デフォルトは必須です。

これを書いた後は、開発者とビジネス向けですが、保管や輸送に使用したい場合は、シリアル化および逆シリアル化する必要があります。
これらは、protoc コンパイラーを使用して Google によってすでにパッケージ化されています。次のコマンド行を実行します;
protoc -I= SRCDIR − − cppout = SRC_DIR --cpp_out=SRC _DIR _c pp _ああうーん_= DST_DIR $SRC_DIR/xxx.proto
protoc --cpp_out=.test.proto #
cpp_out の実際の実行は C++ コードの生成を意味し、 . はコードが現在のディレクトリに配置されることを意味します。
実行後、2 つのスタブ ファイル pb.h pb.c が生成されます。定義された各メッセージは、サービスのクラスとスタブ クラスを生成します。スタブ クラスはチャネル ポインタをバインドし、callmethod メソッドを呼び出します。クライアントは、スタブを使用してサービスを呼び出す限り、パラメータを送信する一連の操作を完了します。方法サービス クラスの場合は、ビジネス メソッドも書き直す必要があります。

protobuf の利点について:
スペースは数倍から 10 倍、時間は数十倍です。
スペース
の利点 データ量が少ないのは、シリアル化後に Protobuf によって生成されるバイナリ メッセージが非常にコンパクトであるためです。 Protobuf エンコーディング方式で採用された賢いリトルエンディアン
時間的な利点は次のとおりです。XML 解析では、文字列をドキュメント オブジェクト構造モデルに変換し、そのモデルから指定されたノードの文字列を読み取り、それを対応する型の変数に変換する必要があります。XML ファイルをドキュメント オブジェクト構造モデルに変換するプロセスでは、通常、字句解析や文法解析など、CPU を大量に消費する複雑な計算を完了する必要があります。Protobuf のシリアル化と逆シリアル化では、対応するノード属性 {}、:、およびその他のシンボル、および冗長な説明情報フィールド情報の説明を解析する必要がないため、シリアル化と逆シリアル化の時間がより効率的になります。
クロスプラットフォームおよびクロス言語;非常に優れた互換性. メッセージ形式が変更された場合、解析コードを書き直す必要はなく、proto ファイル内で直接コードを追加または削減するだけです。
コード生成メカニズムは非常に優れており、たとえば、送信者は order.SerailzeToString(&sOrder) に直接シリアル化し、
受信者は order.ParseFromString(sOrder) に直接逆シリアル化します。自分で解析コードを書く必要がないのはありがたいことであり、とても良いことです。コードを取得するには、get と set を呼び出すだけです。

protobuf の欠点としては
、バイナリでエンコードされているため可読性が低く、自己記述が不足しており、メッセージの意味を理解するために proto ファイル サークルと連携していないことです。したがって、xml は設定ファイルに関しては比較的明確です。

したがって、protobuf の焦点はデータのシリアル化送信または保存 (xml) にあり、json は開発とビジネスのインターフェイスとしての構造化データにあります。

エンコードとデコードの原理の紹介
前述したように、proto エンコードはスペースを節約し、デコードは非常に便利です。具体的にはどんな感じなのでしょうか?
エンコーディングの原則に関して: タグ値を使用してバイト ストリームを形成し、フィールド番号 (フィールドの番号と Wire_type) をキーとして使用します。キーの最後の 3 ビットは Wire_type を表します。通常、整数型は Varints エンコーディングです。 Varints は、1 つ以上のバイトを使用して整数をシリアル化する方法であるコンパクトな数値表現です。可変長エンコーディングであり、解析が非常に簡単で、ビット演算も実装できます。
varints エンコードでは、各バイトの最上位ビットをフラグ ビットとして使用し、残りの 7 ビットにはデジタル値そのものが 2 の補数形式で格納されます。最上位ビットが 1 の場合、その後にバイトがあることを意味します。最上位ビットは 0 で、数値の最後のバイトを表します。他にも zigzang などのエンコーディングがあります。
また、protobuf は json や XML に比べて {、}、: 記号が少ないため、容量が削減されます。protobuf は、タグ値 (TLV) のエンコード実装であり、区切り文字の使用を減らし、データ ストレージをよりコンパクトにします。
この 3 つのポイントにより、プロトの省スペース化が実現します。(バイナリ、タグ値構造を使用、コンパクト、区切り文字などなし、可変長バリアントエンコーディングを使用、ジグザン)

デコードも簡単で、proto ファイルの定義に従ってバイト ストリームから直接読み取ることができます。区切り文字、説明情報、構造情報などを解析する必要はありません。

XML の例
XML を使用して中国の一部の省と都市のデータを表す例は次のとおりです。

<?xml version="1.0"coding="utf-8" ?> 中国 黒竜江省 ハルビン 大慶 広東省 広州 深セン 珠海

このように、xmlにはスペースや記述情報、構造情報が含まれており、解析するのが非常に面倒です。
json は次のとおりです。
{ name: "中国"、省: [ { name: "黒龍江"、都市: { city: ["Harbin", "Daqing"] } }、{ name: "Guangdong"、 city : { city : [" Guangzhou", "Shenzhen", "Zhuhai"] } } , ] }同様の構造情報ですが、区切り文字が異なります。{} は単一の要素を表し、[] は要素のコレクションを表し、xml は要素の集合を表しているようです詳細はこちら 時間的にも空間的にも最悪。説明文も最高です。

















1 つ目はクライアントとサーバー間のプロトコルです。protobuf を使用して、最初のバージョンでサポートされているクライアント コマンドを定義してみましょう。


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;
}

prost を通じて、この protobuf ファイルを Rust コード (主に struct と enum) にコンパイルして使用できます。講義 5 でThumbor の開発について話したときに、prost が protobuf を処理する方法をすでに見たことを覚えておいてください。(これは、rpc で proto ファイルを自動的にコンパイルする C++ の類似点と相違点の類似点です)

CommandService トレイトの
クライアントとサーバー間のプロトコルが確定したら、要求されたコマンドをどのように処理して応答を返すかを考える必要があります。現在、9 つのコマンドをサポートする予定ですが、将来的にはさらに多くのコマンドがサポートされる可能性があります。したがって、すべてのコマンドを均一に処理し、処理結果を返すトレイトを定義するのが最善ですコマンドを処理するときは、リクエストに含まれるパラメータに従ってデータを読み取ったり、リクエスト内のデータをストレージ システムに保存したりできるように、コマンドをストレージに関連付ける必要があります。したがって、この特性は次のように定義できます。


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

この特性とこの特性を実装するすべてのコマンドを使用すると、ディスパッチ メソッドは次のようなコードになります。


// 从 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(),
    }
}

このようにして、将来新しいコマンドをサポートするときは、コマンドの CommandService を実装することと、ディスパッチ メソッドに新しいコマンドのサポートを追加することの 2 つのことだけを行う必要があります。
基本的な特性の抽象化により、システム拡張が簡単になります。

ストレージ特性
さまざまなストレージ用に設計されたストレージ特性を見てみましょう。これは、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>;
}

CommandService 特性で見たように、クライアント要求を処理するとき、それを処理するのは特定のストアではなく、Storage 特性です。この利点は、将来、ビジネス ニーズに応じてさまざまなシナリオでさまざまなストアを追加する場合、CommandService 関連のコードを変更せずに、ストレージ特性を実装するだけで済むことです。
たとえば、HGET コマンドを実装する場合、Storage::get メソッドを使用してテーブルからデータを取得しますが、これは特定のストレージ ソリューションとは何の関係もありません。

(これは、Rust のトレイトがより高度な抽象化を達成できるという事実からわかります。これは、デザイン パターンの原則 (依存関係逆転の原則、つまり、上位レベルの抽象化と対話するのが最善であるため、スケーラビリティが優れていること

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(),
        }
    }
}

Storage トレイトでほとんどのメソッドを定義できると思いますが、get_iter() インターフェイスについては、Box を返すため混乱するかもしれません。なぜですか? これは特性オブジェクトであると前に述べました (第 13 章)。
ここではイテレータを返したいのですが、次の値を取得するために next() メソッドを呼び出し続けることができる限り、呼び出し元はイテレータの型を気にしません。実装が異なれば異なる反復子が返される可能性があるため、同じインターフェイスを使用してそれを実行したい場合は、特性オブジェクトを使用する必要があります。特性オブジェクトを使用する場合、Iterator は関連付けられた型を持つ特性であるため、呼び出し元が処理のためにこの型を取得できるように、関連付けられた型である項目の型を指定する必要があります。

まとめ
これまで、KV サーバーの主な要件と主要なプロセスを整理し、そのプロセスで発生する可能性のある問題を検討し、クライアントとサーバー間のプロトコル、CommandService トレイト、およびストレージ特性次回も引き続き KV サーバーの実装を進めていきますので、説明を読む前に、まず普段どのように開発しているかを考えてください。


考慮すべき質問: Storage 特性の場合、すべての戻り値で Result が使用されるのはなぜですか? MemTable を実装すると、戻り値はすべて Ok(T) になるようです?
(Storage の特性として、IO 操作の失敗のエラー状況に注意を払う必要があると思いますが、MemTable の実装はすべてメモリ操作であり、失敗することはほとんどないため、Ok(T) を返すだけです)

前回の記事では、KV ストアが開始されたばかりで、基本的なインターフェイスが作成されました。特定の実装コードを書き始める準備はできていますか? 心配しないでください。インターフェイスを定義した後は、急いで実装しないでください。さらにコードを記述する前に、ユーザーの観点からインターフェイスの使い方を体験し、使いやすいかどうかを体験し、設計に必要な箇所を反映することができます。改善されるべきです。前回の講義でインターフェイスを定義した順序に従って、1 つずつテストしてみましょう。まずプロトコル層を構築します。
プロトコル層を実装して検証する
まず、プロジェクトを作成します:cardigan new kv --lib。プロジェクト ディレクトリを入力し、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

次に、プロジェクトのルート ディレクトリに abi.proto を作成し、その中に上記の protobuf コードを配置します。ルート ディレクトリに 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();
}

このコードはすでに講義 5 で説明したもので、build.rs はコンパイル時に実行され、追加の処理を実行します。
ここでは、コンパイルされたコードにいくつかの属性を追加します。たとえば、protobuf のバイト型に対してデフォルトの Vec の代わりに Bytes を生成し、すべての型に対して PartialOrd 派生マクロを追加します。prost-build の拡張機能については、ドキュメントを参照してください。
src/pb ディレクトリを作成することを忘れないでください。そうしないとコンパイルできません。ここで、プロジェクトのルート ディレクトリでカーゴ ビルドを実行すると、すべての protobuf 定義メッセージの Rust データ構造を含む src/pb/abi.rs ファイルが生成されます。src/pb/mod.rs を作成し、abi.rs をインポートし、いくつかの基本的な型変換を行います。


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())),
        }
    }
}

最後に、src/lib.rs に pb モジュールを導入します。

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

このようにして、KV サーバーの最も基本的な protobuf インターフェイスを実行できるコードが完成しました。ルート ディレクトリにサンプルを作成して、クライアントとサーバー間のプロトコルをテストするコードを作成できるようにします。まず、examples/client.rs ファイルを作成し、次のコードを記述します。


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(())
}

このコードは、サーバーのポート 9527 に接続し、HSET コマンドを送信して、サーバーの応答を待ちます。
同様に、examples/dummy_server.rs ファイルを作成し、コードを記述します。


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);
        });
    }
}

このコードでは、サーバーはポート 9527 をリッスンし、クライアント要求に対してステータス = 404 を返します。メッセージは「見つかりません」応答です。
これら 2 つのコードの非同期処理とネットワーク処理が理解できなくても、最初にコードをコピーして実行しても問題ありません。今日のコンテンツはインターネットとは何の関係もありません。処理プロセスに注目する必要があるだけです。ネットワークと非同期処理については今後説明します。
コマンド ライン ウィンドウを開いて次のコマンドを実行します: RUST_LOG=info Cargo run --example dummy_server --quiet。次に、別のコマンド ライン ウィンドウで、RUST_LOG=info Cargo run --example client --quiet を実行します。
この時点で、サーバーとクライアントの両方が互いのリクエストと応答を受信して​​おり、プロトコル層は正常に動作しているように見えます。検証に合格したら、次のステップに進むことができます。これは、プロトコル層の他のコードは単なるワークロードであり、後で必要になったときにゆっくりと実装できるためです。

概要**: protobuf ファイルを作成した後、protos を使用してそれを Rust コード (主に Rust 構造情報) にコンパイルします。次に、hset: ** pub fn new_hset(table: impl Into, key: impl Into, value: Value) -> Self などのコマンドの 1 つを実装し
、型変換を行います。
// HSET コマンドを生成します
let cmd = CommandRequest::new_hset(“table1”, “hello”, “world”.into());
// HSET コマンドを送信します
client.send(cmd).await ? ;
クライアントは情報を受け取った後、404 を返しますが、それを受け取ったということは、この部分には問題がないことを意味します。

Storage 特性の実装と検証
次に、Storage 特性を構築します。
前回の講義では、同時実行をサポートするネストされた im-memory HashMap を使用してストレージ特性を実装する方法について説明しました。Arc>> では同時実行をサポートするこのような HashMap が厳密に必要であり、Rust エコシステムには関連するクレートのサポートが多数あるため、ここではダッシュマップを使用して MemTable 構造を作成し、Storage 特性を実装できます
まずsrc/storageディレクトリを作成し、次にsrc/storage/mod.rsを作成し、そこに先ほどのトレイトコードを入れた後、src/lib.rsに「mod storage」を導入します。この時点でエラーが見つかります: KvError が定義されていません。
それでは、KvError を定義しましょう。講義 18 でエラー処理について説明したときに、thiserror の派生マクロを使用してエラー タイプを定義する方法を簡単に説明しましたが、今日はそれを使用して KvError を定義します。src/error.rs を作成し、次のように入力します。


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),
}

これらのエラーの定義は、実際には実装過程で徐々に追加されますが、説明の便宜上、一度に追加されます。Storage の実​​装では、StorageError のみを考慮し、将来的には他のエラー定義が使用される予定です。
同様に、src/lib.rs に mod エラーを導入すると、src/lib.rs は次のようになります。


mod error;
mod pb;
mod storage;

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

src/storage/mod.rs は次のようになります。


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>;
}

現在、コードにはコンパイル エラーはありません。このファイルの最後にテスト コードを追加して、これらのインターフェイスを使用してみてください。もちろん、まだ MemTable を構築していませんが、Storage 特性を通じて MemTable を使用する方法はすでに知っています。まずはテストを書いて体験してみましょう。


#[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())
            ]
        )
    }
}

実装を作成する前に単体テストを作成するのは、標準的な TDD (テスト駆動開発) 方法です。
私は個人的に TDD の大ファンではありませんが、テスト コードを作成することは、インターフェイスが使いやすいかどうかを検証する良い機会であるため、トレイトを構築した後にこのトレイトのテスト コードを作成します。結局のところ、トレイトの定義に欠陥があり、トレイトを実装した後にのみ変更する必要があるということは認識したくないのですが、現時点では、変更のコストが比較的高くなります。
したがって、トレイトが洗練されると、そのトレイトを使用してテスト コードの作成を開始できるようになります。テストケースを書くときに違和感を感じたり、使用するのに面倒な操作が必要になったりする場合は、トレイトの設計を見直すことができます。

単体テストのコードを注意深く見てみると、私が常に特性インターフェイスをテストするという考えに固執していることがわかります。テストで特性メソッドをテストするには実際のデータ構造が必要ですが、コアのテスト コードは汎用関数を使用するため、これらのコードは特性にのみ関連します。
このようにして、第 1 に、特定のトレイト実装の干渉を回避でき、第 2 に、将来さらにトレイト実装を追加したい場合にテスト コードを共有できます。たとえば、将来的に DiskTable をサポートしたい場合は、いくつかのテスト ケースを追加し、既存の汎用関数を呼び出すだけで済みます。
さて、テストが完了してトレイトの設計に問題がないことを確認したら、具体的な実装を書いてみましょう。src/storage/memory.rs を作成して 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!()
    }
}

この実装コードはget_iter()を除けば非常にシンプルなので、dashmapのドキュメントを見ればすぐに書けると思います。get_iter() は少し書くのが難しいので、今回は脇に置いて、KV サーバーに関する次の記事で説明します。興味があってチャレンジしてみたい方はぜひ挑戦してみてください
実装が完了したら、期待どおりに動作するかどうかをテストできます。src/storage/memory.rs はまだ追加されていないため、cargo はコンパイルしないことに注意してください。src/storage/mod.rs の先頭にコードを追加するには、次のようにします。


mod memory;
pub use memory::MemTable;

このようにして、コードをコンパイルして渡すことができます。get_iter メソッドはまだ実装されていないため、このテストはコメントアウトする必要があります。

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

Cargo test を実行すると、テストが成功したことがわかります。


> 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

ストアテストの概要:
get や set など、特性を保存するにはいくつかのメソッドがあります。急いで実装せず、まずはテストコードを書いてみましょう。もちろん、まだ MemTable を構築していませんが、Storage 特性を通じて MemTable を使用する方法の大まかなアイデアはすでにあります。
たとえば、

 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())));

ご覧のとおり、テスト コードでは、まず新しいメモリ テーブルを作成し (具体的な実装については後で説明します)、次に設定されたインターフェイスをテストします。テストが成功した場合は、インターフェイスに問題がないことを意味します。
これは、安定したテスト コードという TDD の考え方を反映しています。特定の fn テストの場合、特性パラメータはストア: impl ストレージでテストされます。このジェネリック タイプは、将来ディスク ストレージなどの新しいストレージ タイプを追加でき、テスト コードは再利用できます。 。さらに、特性の特定の実装は他の実装方法に影響を与えません。
さらに、コードをテストすることで、設計しようとしているトレイトのメソッドが使いやすいかどうかを感じることもできます。そうでない場合は、すぐに変更してください。トレイト メソッドが実装されるのを待ってから変更する必要はありません。面倒なことになる。

テストコードを書いた後、定義した fn が適切だと感じたので実装を開始しますまず memtable を定義しますこれは実際にはダッシュマップ、同時実行に適したハッシュ テーブル、それぞれ
テーブル名を表します、テーブルのキーと値。
pub struct MemTable { tables: DashMap<String, DashMap<String, Value>>, } table の新しい関数と get_or_create_table() 関数を実装し、次にmemtable の特性によって定義された fn を具体的に実装します



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()))
    }
    }

CommandService トレイトの実装と検証
Storage トレイト 基本的な検証が成功したら、次は CommandService を検証してみましょう。src/service ディレクトリ、src/service/mod.rs および src/service/command_service.rs ファイルを作成し、src/service/mod.rs に次のように記述します。


use crate::*;

mod command_service;

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

サービスを 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::*;

次に、src/service/command_service.rs で、最初にいくつかのテストを作成します。わかりやすくするために、HSET、HGET、HGETALL の 3 つのコマンドを示します。


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, &[]);
    }
}

これらのテストの目的は、製品要件を検証することです。
例: HSET は最後の値を正常に返します (これは Redis とは少し異なり、Redis は影響を受けるキーの数を示す整数を返します) HGET は値を返します
HGETALL
は順序付けされていない Kvpair のグループを返します
現在10.into(): 整数 10 を Value に変換したい、CommandRequest::new_hgetall(“score”): 生成したいなど、いくつかの未定義メソッドがテスト内で使用されているため、テストをコンパイルして渡すことができません。 HGETALL コマンド。

なぜこれを書くのですか?CommandService インターフェイスのユーザーであれば、このインターフェイスを使用するときの全体的な呼び出しの感覚が非常にシンプルかつ明確であることを当然のことながら期待するからです。
インターフェイスが Value を予期しているにもかかわらず、コンテキストで取得した値が 10 または "hello" のような場合、設計者は、呼び出し時に最も便利な From を Value に実装することを検討する必要があります。同様に、CommandRequest データ構造を生成する場合、呼び出しをより明確にするためにいくつかの補助関数を追加することもできます。これまでに 2 回のテストを作成しました。テスト コードの機能については大体理解できたと思います。要約しましょう:

インターフェイスの反復を検証して支援する
製品要件を検証する
コア ロジックを使用することで、周辺ロジックについてよりよく考え、その実装を逆転させるのに役立ちます。
最初の 2 つのポイントは最も基本的なものであり、多くの人々の TDD の理解でもあります。実際には、さらに何かがあります。重要: ポイント 3。前述の補助関数に加えて、テスト コードにはディスパッチ関数も含まれています。これは現在、テストを支援するために使用されています。しかし、その後、そのような補助関数をコア コードにマージできることがわかります。これが「テスト駆動開発」の本質です。

さて、テストによると、関連する周辺ロジックを src/pb/mod.rs に追加する必要があります。最初に CommandRequest のメソッドがいくつかあります。前に new_hset を書きましたが、今回は new_hget と 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)),
            })),
        }
    }
}

次に、From of Value の実装を記述します。


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

現在、テスト コードはコンパイルして合格できますが、特定の実装がまだ行われていないため、テストは明らかに失敗します。特性実装コードを 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(),
        }
    }
}

これは、多くの場所で into() メソッドを使用しますが、Value から CommandResponse への変換、KvError から CommandResponse への変換、Vec から CommandResponse への変換など、対応する変換を実装していないため、当然により多くのコンパイル エラーが発生します。 、など待ってください。
したがって、対応する周辺ロジックを 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
    }
}

上記のインターフェイスの記述からここでの特定の実装まで、このようなパターンを感じ取ったでしょうか。Rustでは、2 つのデータ構造 v1 から v2 の間で変換がある場合は常に、最初に v1.into() を使用してこのロジックを表現できます。 、コードの作成を続けて、 From の実装を完了しますv1 も v2 も自分で定義したデータ構造ではない場合は、いずれかを構造体でラップして、前に説明した孤立ルールをバイパスする必要があります (講義 14)。
このレッスンを終了したら、講義 6 を復習し、そのときに言われたことについて注意深く考えることができます。「処理ロジックのほとんどは、あるインターフェイスから別のインターフェイスにデータを変換することです。」
コードはコンパイルされ、テストに合格するはずです。cargo テストを使用してテストできます。

最後のパズル: Service 構造の実装です。
クライアント/サーバー プロトコル インターフェイス、Storage 特性、CommandService 特性を含むすべてのインターフェイスが検証されました。次のステップは、データ構造を使用してこれらすべてを接続する方法を検討することです。もの。
ユーザーの観点からそれを呼び出す方法をまだ見てみましょう。これを行うには、次のテスト コードを 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, &[]);
}

ここでのassert_res_ok()とassert_res_error()はsrc/service/command_service.rsから移動されたものであることに注意してください。開発プロセスでは、製品コードを常にリファクタリングする必要があるだけでなく、DRY アイデアを実装するためにテスト コードもリファクタリングする必要があります。

本番環境で多くのコードを見てきました。製品の機能は合理的ですが、テスト コードは汚水溜めのようなものです。何年もコピー/ペーストすると汚水溜めのような臭いがします。開発者は皆、新しい機能を追加するときに鼻を覆います。多くの人が離れているとメンテナンスがますます難しくなり、要件が変わるたびに多くのテストコードの変更が必要になり、これは非常に悪いことです。

テストコードの品質も製品コードの品質と同じでなければなりません。優れた開発者によって書かれたテストコードも非常に読みやすいです。上に書いた 3 つのテスト コードを比較して、雰囲気をつかむことができます。
テストを作成するときは、特別な注意を払う必要があります。テスト コードはシステムの安定した部分、つまりインターフェイス周辺でテストする必要があり、実装のテストはできる限り少なくする必要がありますこれは、私が長年の仕事の中で学んだ血のにじむような教訓を深くまとめたものです。

製品コードとテストコードは常にどちらか一方が比較的安定している必要があり、製品コードは需要に応じて変化し続けるため、テストコードはより安定している必要があります。
では、どのようなテストコードが安定しているのでしょうか? インターフェイスをテストするコードは安定しています。インターフェイスが変更されていない限り、特定の実装がどのように変更されても、今日新しいアルゴリズムが導入され、明日実装が書き直されたとしても、テスト コードは依然としてしっかりと機能し、製品品質の監視役として機能します。

さて、コードの作成に戻りましょう。このテストでは、サービス データ構造の使用ブループリントが完成しており、スレッドをまたいで実行でき、execute を呼び出して CommandRequest コマンドを実行し、CommandResponse を返すことができます。
これらの考えに基づいて、Service の宣言と実装を 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(),
    }
}

このコードについては、注目に値することがいくつかあります:
まず、実際のデータ構造を保存する ServiceInner が Service 構造内にあり、Service は ServiceInner を Arc でラップしているだけです。これは Rust の規則でもあり、マルチスレッドでクローンを作成する必要がある本体を内部構造から分離するため、コード ロジックがより明確になります。
eexecute () メソッドは現在ディスパッチを呼び出していますが、将来的には何らかのイベント配布を行う可能性があります。この処理は、SRP (単一責任原則) の原則を反映しています。
ディスパッチとは実際には、テストコードのディスパッチロジックを移動して変更することを意味します。

もう一度、テスト コードをリファクタリングし、そのヘルパー関数を製品コードの一部にしました。コードをコンパイルできない場合は、次のような使用コードが欠落している可能性があります。

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

新しいサーバーの処理ロジック
が完成したので、新しいテスト サーバー コードの例を作成できます。
前のexamples/dummy_server.rsをexamples/server.rsにコピーし、Serviceを導入します。主な変更点は次の3つの文です:

// 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);

自分で変更してみることもできます。完全なコードは次のとおりです。


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);
        });
    }
}

完了したら、コマンド ライン ウィンドウを開いて RUST_LOG=info Cargo run --example server --quiet を実行し、次に別のコマンド ライン ウィンドウで RUST_LOG=info Cargo run --example client --quiet を実行します。この時点で、サーバーとクライアントは両方とも互いのリクエストと応答を受信し、正常に処理しています。KV サーバーの最初のバージョンの基本機能は完了しました。もちろん、これまでに処理されたコマンドは 3 つだけで、残りの 6 つは自分で完了する必要があります。

まとめ
KV サーバーは難しいプロジェクトではありませんが、うまく書くのは簡単ではありません。ステップバイステップで説明に従うと、潜在的な本番環境品質を備えた Rust プロジェクトをどのように開発すべきかを感じることができます。この 2 つの講義では、しっかり理解しておかなければならないポイントが 2 つあります。

1 つ目のポイントは、要件を明確に把握し、不安定な部分 (バリアント) と比較的安定している部分 (不変) を見つける必要があるということです。KV サーバーで不安定な部分は、さまざまな新しいコマンドのサポートとさまざまなストレージのサポートです。そのため、不安定な部分を安定して管理できるように、不安定要素を排除するインターフェースを構築する必要があります。

2 番目のポイントは、コードとテストがインターフェイス上でスパイラル状に実行される可能性があり、TDD を使用すると、このスパイラル状の反復を実行するのに役立ちます。適切に設計されたシステムでは、インターフェイスは安定しており、インターフェイスをテストするコードも安定していますが、実装は不安定になる可能性があります。反復開発のプロセスでは、テストコードとプロダクトコードの両方が最適な方向に開発されるように、常にリファクタリングを行う必要があります。

私たちが作成した KV サーバー (テストも含めて) を見ると、50 行を超える関数やメソッドを見つけるのは困難ですが、コードは非常に読みやすく、理解するのにコメントはほとんど必要ありません。また、すべてのやり取りはインターフェイスを使用して行われるため、今後のメンテナンスや新機能の追加は基本的に OCP の原則に準拠しており、非常に小さな変更が必要なディスパッチ機能を除いて、他の新しいコードは一部のインターフェイスを実装するだけです。

Rust でコードを記述するためのベスト プラクティスを最初に理解できると思います。以前に他の言語で同様のベスト プラクティスを採用したことがある場合は、Rust で同じプラクティスを使用する優雅さを感じることができます; さまざまな理由により、以前にスパゲッティのようなコードを書いたことがある場合は、Rust プログラムを開発するときに、次のことを試すことができますこのより洗練された開発方法を採用するために。

結局のところ、私たちはより高度な武器を手に入れたので、より高度な戦闘方法を使用できるようになりました。

最初の部分の要約:
行動を起こす前に決定を下す: いくつかの主要なインターフェイスを決定します。ネットワーク層、ストレージ層、処理層。ネットワーク層はさまざまなプロトコルに柔軟に適応する必要があります。情報送信形式として Protobuf が使用されます。処理層にはどのようなフックが必要ですか。イベントは処理プロセスの主要なステップで公開されるため、KV サーバーは非常に柔軟です。呼び出し元がサービスの初期化時に挿入するのに便利な追加の処理ロジック。

まず、いくつかの主要なインターフェイス (プロトコル インターフェイス protobuf プロトコル、処理ロジック インターフェイス、およびストレージ インターフェイス) を決定します。より高いレベルの抽象化を実現するためにトレイトを使用する利点を認識し、スケーラビリティを高めるために抽象インターフェイスのみを操作します。

たとえば、将来新しいコマンドをサポートする場合、必要なことは 2 つだけです。コマンドの CommandService を実装することと、ディスパッチ メソッドに新しいコマンドのサポートを追加することですたとえば、関数パラメータを処理する場合、特定のストア タイプではなく、最高レベルのストレージ特性のみが使用されます。この場合、異なるストレージ タイプに直面する場合、コード実行のパラメータを変更する必要はありませ。特定の型のストレージ特性を実装し、hget などの特定の関数を呼び出します。この具象型の hget メソッドを実装します。一種の関数のオーバーロードに相当します。

これはオープンクローズの原則を体現しており、拡張やコードの追加にはオープンですが、修正やコードの変更にはクローズです。

2 番目の部分の概要:
protobuf プロトコルを実装してテストし、クライアントとサーバー間で情報を正常に送信できるようにします。
残りの 2 つのインターフェイスを実装してテストします。コードとテストはインターフェイス上でスパイラル状に実行される可能性があり、TDDを使用すると、このスパイラル状の反復を実行するのに役立ちます。適切に設計されたシステムでは、インターフェイスは安定しており、インターフェイスをテストするコードも安定していますが、実装は不安定になる可能性があります反復開発のプロセスでは、テストコードとプロダクトコードの両方が最適な方向に開発されるように、常にリファクタリングを行う必要があります。

私たちが作成した KV サーバー (テストも含めて) を見ると、50 行を超える関数やメソッドを見つけるのは困難ですが、コードは非常に読みやすく、理解するのにコメントはほとんど必要ありませんまた、すべてのインタラクションはインターフェースを介して行われるため、将来のメンテナンスや新機能の追加は基本的に開閉原則を満たすことになります。

*类比一下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中,是**编译时静态分发**的,编译是就会替换成相应的类型,不是在运行时判断,所以运行时是零成本的。,但是不可避免编译速度就会慢一点了。*

もう一度要約すると、最も重要なことは特性の使用です。新しいコマンドをサポートしたい場合は、commandservice メソッドを実装し、disptch に項目を追加するだけです。また、C++ にはない独自の利点があります
: 2 つの利点: 機能の制約、開始と終了の原則の実装、継承よりも合成の方が優れていること、およびコストがゼロの抽象化です。

1. たとえば、dashmap (実際にはスレッドセーフな hahsmap) のみを使用します。新しいストレージ タイプを実装する場合、execute メソッドでパラメータを変更する必要はありません。パラメータは、ストア特性を実装する型である必要があるだけです。これは、特性機能制約の利点です考えてみてください、C++ の場合、新しいストレージ クラスをパラメータとして渡してコードを変更する必要があります。もう 1 つの利点は、メソッドの汚染を回避し、抽象化による他の未使用関数を導入しない、最小限のインターフェイス原則であることです。これは、C++ 継承システムでは実行できません。これは、基本クラスには、完全に適合しない限り、サブクラスが使用できないメソッドが必要であり、結合が継承よりも優れているという考えを具体化しているためです

2. C++ がパラメータを変更したくない場合。ポリモーフィズムが使用されようとしています。サブクラス オブジェクトは基本クラスのポインタに変換できます。仮想関数を呼び出すと、実際のサブクラスの関数実装が呼び出されます。ポリモーフィズムの実装には、vptr や仮想関数テーブルなどの追加のオーバーヘッドが必要です少なくとも関数を見つけるには 2 つ必要です。Rust のトレイトベースのジェネリックスは、コンパイル中に静的に配布され、コンパイル中に対応する型に置き換えられ、実行時に判断されないため、実行時のコストはゼロです。, ただし、必然的にコンパイル速度は遅くなります。

復習してみましょう。
まず基本的なインターフェイスを決定する必要があります。次に、関数全体を実装する代わりに、テスト、単体テストを作成し、各インターフェイスが使用できるかどうかをテストします。TDD 思考を体現します。サービス全体を作成する前に、各インターフェイスが使用可能であることを確認してください。
tdd: TDD は、単体テストを通じてコードの作成を促進し、リファクタリングを通じてプログラムの内部構造を最適化するだけです。高品質のコード。ケント・ベックの本「テスト駆動開発」を読み、実践的な思考を続けて初めて、ようやくその素顔が垣間見えました。氷山の下に隠された TDD : Kent Beck: 「テスト駆動開発はテスト技術ではありません。それは分析技術であり、設計技術であり、すべての開発活動を組織化するための技術です。」

TDD のルール
TDD のプロセスでは、従うべき 2 つの単純なルールがあります。
自動テストが失敗した場合にのみ、新しいコードを作成します。
設計の重複を排除し (不要な依存関係を削除し)、設計構造を最適化します (コードを徐々に一般化します)。

最初のルールの意味は、毎回テストをパスさせるのに十分なコードを記述し、テストが失敗した場合にのみ新しいコードを記述することです。毎回追加されるコードは小さいため、問題があったとしても、非常に高速で、小さなステップのリズムに確実に従うことができます。2 番目のルールは、小さなステップをより実践的なものにすることです。自動テストのサポートにより、コードの嫌な臭いをリファクタリング プロセスを通じて排除して回避できます。コードが腐らないようにする. 次に、快適な環境を作るためのコードを書きます。
関心事の分離は、これら 2 つのルールに暗黙的に含まれているもう 1 つの非常に重要な原則です。その表現の意味は、まずコーディング段階で「使える」という目標を達成し、リファクタリング段階で「シンプル」という目標を一つずつ追求していくということです!

簡単に言うと、実行不可能/実行可能/リファクタブル - これがテスト駆動開発のスローガンであり、TDD の中核です。

インターフェイス テストの 2 番目の部分の具体的な方法とプロセスをまとめましょう:
プロトコル インターフェイス テスト
. protobuf ファイルを作成した後、protos を使用してそれを Rust コード (主に Rust 構造情報) にコンパイルします。次に、hset: ** pub fn new_hset(table: impl Into, key: impl Into, value: Value) -> Self などのコマンドの 1 つを実装し
、型変換を行います。
// HSET コマンドを生成します
let cmd = CommandRequest::new_hset(“table1”, “hello”, “world”.into());
// HSET コマンドを送信します
client.send(cmd).await ? ;
クライアントは情報を受け取った後、404 を返しますが、それを受け取ったということは、この部分には問題がないことを意味します。

ストレージインターフェーステスト

ストアテストの概要:
get や set など、特性を保存するにはいくつかのメソッドがあります。急いで実装せず、まずはテストコードを書いてみましょう。もちろん、まだ MemTable を構築していませんが、Storage 特性を通じて MemTable を使用する方法の大まかなアイデアはすでにあります。
たとえば、

 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())));

ご覧のとおり、テスト コードでは、まず新しいメモリ テーブルを作成し (具体的な実装については後で説明します)、次に設定されたインターフェイスをテストします。テストが成功した場合は、インターフェイスに問題がないことを意味します。
これは、安定したテスト コードという TDD の考え方を反映しています。特定の fn テストの場合、特性パラメータはストア: impl ストレージでテストされます。このジェネリック タイプは、将来ディスク ストレージなどの新しいストレージ タイプを追加でき、テスト コードは再利用できます。 。さらに、特性の特定の実装は他の実装方法に影響を与えません。
さらに、コードをテストすることで、設計しようとしているトレイトのメソッドが使いやすいかどうかを感じることもできます。そうでない場合は、すぐに変更してください。トレイト メソッドが実装されるのを待ってから変更する必要はありません。面倒なことになる。

テストコードを書いた後、定義した fn が適切だと感じたので実装を開始しますまず memtable を定義しますこれは実際にはダッシュマップ、同時実行に適したハッシュ テーブル、それぞれ
テーブル名を表します、テーブルのキーと値。
pub struct MemTable { tables: DashMap<String, DashMap<String, Value>>, } table の新しい関数と get_or_create_table() 関数を実装し、次にmemtable の特性によって定義された fn を具体的に実装します



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()))
    }
    }

実装を直接記述してテストする場合について考えてみましょう。つまり、最初に memtable を定義し、それに特定の set メソッドを実装してから、新しい memtable を作成して set メソッドを呼び出すテスト コードを記述します。ここで 2 つの問題が発生します。
1. 後でテストして特性設計方法が不合理であることが判明した場合、または一部の方法が単純に不必要であることが判明した場合、以前に作成した実装を変更する必要があり、これはさらに面倒です。

2. 後でディスク ストレージなどの他のストレージ タイプを追加する場合は、テスト コードを変更する必要があり、以前の memtable テスト コードは使用できなくなります。

したがって、最初にテストを作成することで、この問題を解決できます。テストは特性をテストするだけです。一般的な制約パラメータを使用すると、文 let store = MemTable::new() を変更するだけで済みます。set get などのテスト インターフェイス関数は再利用できます。 。したがって、テスト インターフェイスのコードは安定していますが、特定のテスト実装は不安定になる可能性があります。

プロセス インターフェイス テストの処理
も、最初にテストを作成し、memtable を作成し、コマンドを作成してディスパッチし、execute メソッドを呼び出して応答を取得することによって行われます。

** 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!(),
        }
    }

次に、以前の new_hset や new_hget などを改良します。次に、特定の実行を実装します

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(),
        }
    }
}

もちろん、into がたくさんあるため、from からの型変換もさらに多くなります。Rust では、2 つのデータ構造 v1 から v2 の間で変換が行われる場合は常に、最初に v1.into() を使用してこのロジックを表現し、コードを書き続けてから、From の実装を完了することができます (from into は型変換に使用されます)。特性)

全体的なテスト

おすすめ

転載: blog.csdn.net/weixin_53344209/article/details/130033399