2-2.イーサーネットケーブルの実装
今回は、イーサーネットケーブルをRustで実装していきます。 そして、今回は我が編集部の調査班にも入ってもらい、がっつりと調査してもらった内容は別セクションとしてお届けします。今回の調査も、相当面白いものになり、相当深いです。でも、これを読めば一気にRustの上級者になれるようなレベルに仕上がっています。ほぼここを知らずに、あるいはなんとなく書いてる人も多いと思いますので、冬休みにでもじっくり読んでみてください。わからないことがあった時、ん?と疑問に思ったときにそちらものぞいて寄り道していってください。
では、早速イーサーネットケーブルの実装に入ります。
今回ソースは、src/layer1/component/ethernet_cable.rs
に作成していきます。
[packet-pilot]
├── Cargo.lock
├── Cargo.toml
├── css
├── index.html
├── js
├── pkg
├── src
│ ├── layer1
│ │ ├── component
│ │ │ ├── ethernet_cable.rs (イーサネットケーブルのコンポーネントをここに)
│ │ │ └── mod.rs
│ │ ├── mod.rs
│ │ ├── packets
│ │ │ ├── mod.rs
│ │ │ └── physical_layer_frame.rs
│ │ └── receive_callback.rs (callbackの定義はココ)
前回定義した内容をもとに、EthernetCableを表現する構造体を考えていきます。
このように定義していました。そして、データを受信する側のルールとして、 今後各ネットワークコンポーネントを作っていく上で「データの流れ」のルールを定めました。 ・電気信号を流すのに、まずどちらの端から流れてきたのか?を確認して、逆の端につながっているIDのデバイスに、データを送る。 ・送る時は、送り先のデバイス側に用意されている、電気信号を受ける処理を呼び出す。 ことで、実現しようと考えてみました。 それぞれ接続されるネットワークコンポーネントでは、例えば、「receive_frameという信号を受け取る口を用意しておく」というルールです。このように前回は、定義してきました。
接続する元の方では、接続先の信号を受け取る口
を保持しておく必要がありますね。
イーサーネットケーブルは、電気信号を受けたら、今度は受けたのとは逆の端につながっているコンポーネントの受け取る口に電気信号を流していきますので。
これらを加味して、このようにRustで構造体を定義してみました。
#[derive(Clone)]
pub struct EthernetCableState {
pub id : String,
pub endpoint1_component_id : Option<String>,
pub endpoint1_callback : Option<PhysicalLayerCallback>,
pub endpoint2_component_id : Option<String>,
pub endpoint2_callback : Option<PhysicalLayerCallback>,
pub connected : bool,
}
#[derive(Clone)]
pub struct EthernetCable {
state : Arc<Mutex<EthernetCableState>>,
}
いきなり、今までとはカタチが違ってきてます。 絵にするとこういう感じになっています。
イーサーネットケーブルを表すフィールドはState=EthernetCableStateの中にあり、 それをEhternetCable構造体で囲っている感じです。 しかも、stateは、
Arc<Mutex<EthernetCableState>>
という感じで、Arc
とMutex
で2重に囲われています。
なぜこんな面倒なことをするのか?詳細は、調査班が調べてくれた「2-3.Arc、Mutex、dyn、Send、Sync、vtable、全部まとめて調査しました(by調査班)」にお任せしますので、ここでは簡単に引用すると、
データの共有(=Arc)
と、データの共用(=Mutex)
というセットで使っています。
相変わらず調査班はマトを得たいいこと言いますね。こちらを読んでいただくとこの言葉の意味が得心できると思います。
ここでは簡単に、調査班が調べてくれた内容から引用します。
Arcとは?
Rustでは、通常、データは1つのオーナー(持ち主)しか持てない
というルールが決められています。
例えば、ある関数で作ったデータは、その関数の中でしか使えないというルールがあります。
これは、メモリ安全性を保証するためのRustの独特の仕組みです。
Arcは、Atomic Reference Countingと呼ばれるもので、複数の所有者で同じデータを共有できるスマートポインタという仕組みです。
今回、EthernetCableをArcにした理由は?
・Javascript側とWASM側で複数のスレッドで動く可能性があるから。
・シミュレーターでは各ネットワークコンポーネントが非同期に動く=WebWorkerなどで別スレッドで動く
・複数のスレッド、ネットワークコンポーネントから参照されてもID=1のケーブルは「1つ」でないと電気信号のやり取りができなくなるから。
複数の所有者がいるの?
はい!います。
この絵を見てください。
1つのケーブルが、PCと、L2 Switchの2つに繋がっていますね。そもそもケーブルの働きとして2つのものを繋げてその間に電気信号を流すことですから、このように、最低でも2つの所有者がいるわけです。
シミュレーターの中では、現実世界と同じく、同じプログラムの中で、PCスレッドとL2 Switchスレッドがそれぞれの時間軸で非同期
に動きます。もちろんイーサーネットケーブルも独自の時間軸で電気信号を自分の帯域スピードで送受信します。
しかしこの2つを繋げている、イーサーネットケーブルは1つしかありません。いえ1つでないと困るのです。
この絵のように、それぞれが独自のケーブルを持っても、通信できませんよね。
ですので、今回、EthernetCableをARCにしたのはそういう理由です。
1つにして、それぞれの所有者からのリクエストに応え、安全に電気信号を送受信するためなのです。
Mutexとは?
さて、次にMutexとは?
ARCでは、複数の所有者からの参照
はうまく管理できるのですが、複数の所有者からの更新
は管理できません。
そこで、登場するのが、Mutex
という仕組みです。排他制御と呼ばれていて
複数のスレッドが同時に更新処理を行いデータが壊れないように、1つづつロックして処理を行っていく仕組みです。
共有データを、安全に共用するための仕組みですね。
PhysicalLayerCallback
さて他にも、みたことがないような型がありますね。
pub endpoint1_callback : Option<PhysicalLayerCallback>,
pub endpoint2_callback : Option<PhysicalLayerCallback>,
このフィールドは、
・イーサーネットケーブルの端の1つに繋がったコンポーネントが、
・データを受信するときはここで受信するからここにデータを送ってね。という関数を、
・イーサーネットケーブル側で覚えておくためのフィールドになります。
イーサーネットケーブルの両端を表すendpoint1とenpoint2でそれぞれにつながっているコンポーネントの データ受信用関数を持っているのですね。
PhysicalLayerCallback
は、われわれで定義したもので、実態は、このようになっています。
pub type PhysicalLayerCallback = Arc<dyn Fn(PhysicalLayerFrame) + Send + Sync>;
Arc
で囲んでいるということは、複数で共有する可能性があるからですね。
上図の場合、L2Switchが持っている受信用関数を、Ethernetcableも持ちますからね。
dyn
は、"dynamic" の略で、コンパイル時ではなく、実行時に具体的な実装が決定される=動的ディスパッチ(実行時ポリモーフィズム)を可能にするものです。(トレイト・オブジェクトを示しています)
Fn
は、Rustの関数トレイトの1つで、「この型は関数のように呼び出せる」という意味です。
Fn(PhysicalLayerFrame)
ということは、PhysicalLayerFrameを引数として受け取る関数orクロージャということを表しています。
Send
は、その型がスレッド間で所有権を移動できることを意味します。
例: スレッドAからスレッドBにデータを渡す場合、データがSendを実装している必要があります。
今回のケースではEthernetCableからL2SwitchにPhysicalLayerFrameというデータを渡しますね。
Sync
は、その型が複数のスレッドで同時に参照されても安全であることを意味します。
例: 共有データを複数スレッドで読み取る場合、そのデータがSyncを実装している必要があります。
ということで、今回のイーサーネットケーブルは、
#[derive(Clone)]
pub struct EthernetCableState {
pub id : String,
pub endpoint1_component_id : Option<String>,
pub endpoint1_callback : Option<PhysicalLayerCallback>,
pub endpoint2_component_id : Option<String>,
pub endpoint2_callback : Option<PhysicalLayerCallback>,
pub connected : bool,
}
#[derive(Clone)]
pub struct EthernetCable {
state : Arc<Mutex<EthernetCableState>>,
}
というカタチにしました。
EthernetCableのハタラキ
まず、Displayを実装しておきましょう。イーサーネットケーブルの現在の内容を表示します。 State=本体側は、
impl fmt::Display for EthernetCableState {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// 各コールバックのポインタアドレスを取得
let endpoint1_callback_ptr = self.endpoint1_callback
.as_ref()
.map(|cb| cb.as_ref() as *const dyn Fn(PhysicalLayerFrame));
let endpoint2_callback_ptr = self.endpoint2_callback
.as_ref()
.map(|cb| cb.as_ref() as *const dyn Fn(PhysicalLayerFrame));
write!(
f,
"###Ethernet Cable= \n\
#id : {}\n\
#endpoint1_component_id : {:?}\n\
#endpoint1_callback : {}\n\
#endpoint2_component_id : {:?}\n\
#endpoint2_callback : {}\n\
#connected : {}\n",
self.id,
self.endpoint1_component_id,
endpoint1_callback_ptr
.map(|ptr| format!("{:p}", ptr))
.unwrap_or_else(|| "None".to_string()),
self.endpoint2_component_id,
endpoint2_callback_ptr
.map(|ptr| format!("{:p}", ptr))
.unwrap_or_else(|| "None".to_string()),
self.connected,
)
}
}
そして、EthernetCableは、中のStateの実装をそのまま再利用するかたちにしました。
impl fmt::Display for EthernetCable {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
// ミューテックスをロックして状態にアクセス
let state = self.state.lock().unwrap();
// EthernetCableStateのDisplayの実装を再利用
write!(f, "{}", *state)
}
}
イーサーネットケーブルのはたらきとしては、 ・新規にケーブルを配置した時newされる ・ケーブルを機器に接続。→両端がつながったらフラグ立てる ・電気信号を流す ・送られるデータはどちらの端から来たか? ・信号の送り元がエンドポイント1なら宛先はエンドポイント2になる ・逆から(エンドポイント2から)きたならエンドポイント1へ送る。 ・両方のエンドポイントが接続されていない場合は何もしない ・送り先のデバイスの電気信号を受ける処理を呼び出す という風になります。
それぞれを順番に実装していきます。
新規にケーブルを配置した時newされる
[EthernetCable]
/// 新規にケーブルを配置したとき
pub fn new(id:Option<String>) -> Self {
EthernetCable{
state: Arc::new(Mutex::new(EthernetCableState::new(id))),
}
}
[EthernetCableState]
fn new(id:Option<String>) -> Self {
let cable_id = id.unwrap_or_else(|| format!("cable-{}",rand::thread_rng().gen_range(9..9999)));
EthernetCableState {
id : cable_id,
endpoint1_component_id : None,
endpoint1_callback : None,
endpoint2_component_id : None,
endpoint2_callback : None,
connected : false,
}
}
ここでは、EthernetCable側でStateを新規に作り出しています。その際、idを指定していた場合は指定されたidで、なければランダムに作り出したcable-9999のような文字列をidとしています。
生成したケーブルのIdを取得
指定したケーブルオブジェクトのIdを取得する関数も用意しておきます。
/// そのケーブルのIdを取得
pub fn get_id(&self) -> String {
let state = self.state.lock().unwrap();
state.id.clone()
}
EthernetCableの中の本体であるstate
にアクセスするときは、Mutexのルールに従って、
lockを取得する必要があります。
self.state.lock().unwrap()
としているところですね。
これを取得してから、データにアクセスできるようになります。EthernetCable側からstate内のフィールドをいじったり、
関数を呼び出す際はこのようにlock()を取得することを忘れずに入れておきましょう。
ケーブルを機器に接続。→両端がつながったらフラグ立てる
/// ケーブルの接続どちらかの端がまずどちらかに繋がるのでOptionにしてコンポーネントのIdを渡す
pub fn connect(&self, ep1_connect_id: Option<String>, ep2_connect_id: Option<String>) {
let mut state = self.state.lock().unwrap();
state.endpoint1_component_id = ep1_connect_id;
state.endpoint2_component_id = ep2_connect_id;
if state.endpoint1_component_id.is_some() && state.endpoint2_component_id.is_some() {
state.connected = true;
}
}
あまり同時にケーブルを繋げるシチュエーションってないですよね。PCとL2 SwitchがあったらPCに繋げてから、L2 Switchとか、 順番になると思いましたので、ここでは、繋げるコンポーネントのidを、Option型にしておきました。
明示的にそれぞれ個別繋げる場合もあるかと思いますので、個別の接続関数を用意しておきます。
pub fn connect_endpoint1(&self, ep1_connect_id: Option<String>) {
debug("EthernetCable::connect_endpoint1() called.");
let mut state = self.state.lock().unwrap();
state.endpoint1_component_id = ep1_connect_id;
if state.endpoint1_component_id.is_some() && state.endpoint2_component_id.is_some() {
debug(&format!("EthernetCable({})::bothe connected.",state.id));
state.connected = true;
}
}
pub fn connect_endpoint2(&self, ep2_connect_id: Option<String>) {
debug("EthernetCable::connect_endpoint2() called.");
let mut state = self.state.lock().unwrap();
state.endpoint2_component_id = ep2_connect_id;
if state.endpoint1_component_id.is_some() && state.endpoint2_component_id.is_some() {
debug(&format!("EthernetCable({})::bothe connected.",state.id));
state.connected = true;
}
}
そして、それぞれの端につながっているコンポーネントが何なのか?を調べたい時もありますね。 その時のために、今つながっているコンポーネントのidを返す関数も用意しておきます。
pub fn get_endpoint1_component_id(&self) -> Option<String> {
let state = self.state.lock().unwrap();
state.endpoint1_component_id.clone()
}
pub fn get_endpoint2_component_id(&self) -> Option<String> {
let state = self.state.lock().unwrap();
state.endpoint2_component_id.clone()
}
次に、このシミュレーションの世界では、イーサーネットケーブルを接続した時のルールがありましたね。「データの流れ」
のルールです。
「それぞれのネットワークコンポーネントがデータを受けるための受口を用意して、それを接続するケーブルに伝えておく」
ということが必要になります。
どこに電気信号を流せばいいのか?
をイーサーネットケーブルに伝えてもらう関数が必要になります。
/// ケーブル接続時に、データきたらここに渡してねというcallbackをsetする
/// これをケーブルに伝えておくことで、データきた時イーサーネットケーブルは指定されている
/// PhysicalLayerCallbackを呼び出す
pub fn set_callback(&self, id:String,callback:PhysicalLayerCallback){
debug("EthernetCable::set_callback() called.");
let mut state = self.state.lock().unwrap();
if state.endpoint1_component_id.is_none() && state.endpoint2_component_id.is_none() {
//両方ともまだつながっていないのでセットできません
return;
}
if let Some(ep1_id) = &state.endpoint1_component_id{
if *ep1_id == id{
debug("EthernetCable::set_callback() set endpoint1 callback.");
state.endpoint1_callback = Some(callback.clone());
}
}
if let Some(ep2_id) = &state.endpoint2_component_id{
if *ep2_id == id{
debug("EthernetCable::set_callback() set endpoint2 callback.");
state.endpoint2_callback = Some(callback.clone());
}
}
}
この関数の引数は、接続するネットワークコンポーネントのidとそのコンポーネントが用意しているデータ受信用のコールバックです。 どちらのケーブルにつながっているコンポーネントか?を見て、そのidのコンポーネントのコールバックとして、endpoint1_callbackか、endpoint2_callbackに保持しておきます。
電気信号を流す
はい。そしてメインの機能である電気信号を流すですね。
/// データを送信する。上位層から呼ばれる関数。
/// このケーブルにPacketを流したい上位層のコンポーネントがこの関数を呼び出すことで、 ケーブルの先に電気信号を流す
pub fn transmit_signal(&self, from_id:String, frame: PhysicalLayerFrame) {
debug("EthernetCable::transmit_signal() called.");
debug(&format!("EthernetCable::transmit_signal() frame={:?}",frame));
let state = self.state.lock().unwrap();
// 両端がつながっていなかったら終了
if !state.connected && !state.endpoint1_callback.is_none() && !state.endpoint2_callback.is_none(){
debug(("both endpoint not connected."));
return;
}
// 送られるデータはどちらのendpointから来たか探す
let ep1 = state.endpoint1_component_id.clone().unwrap();
let ep2 = state.endpoint2_component_id.clone().unwrap();
debug(&format!("EthernetCable::transmit_signal() from_id={:?}",from_id));
debug(&format!("EthernetCable::transmit_signal() ep1={:?}",ep1));
debug(&format!("EthernetCable::transmit_signal() ep2={:?}",ep2));
let other_endpoint = if from_id == ep1 {
debug("from ep1 --> callback to ep2");
state.endpoint2_callback.clone().unwrap()
} else if from_id == ep2 {
debug("from ep2 --> callback to ep1");
state.endpoint1_callback.clone().unwrap()
} else {
// エラーハンドリング: どちらのエンドポイントにも一致しない場合
debug("Unexpected endpoint ID");
return;
};
// 送り先のデバイスのCallBackを呼び出し信号を送る
other_endpoint(frame);
}
この関数のパラメータは、from_id=送信元のコンポーネントのidと、frame=実際に送信するデータです。
この中では、
電気信号を流そうとしても、その先がつながっていなかったら伝える先がいないので、そこで終了しています。
そして、ケーブルのどちらの端からきたものなのかをみて、その反対側のコンポーネントの受信用コールバックを呼び出します。
デバッグ用関数
最後におまけで、今回各所で、debug
という関数が呼ばれています。
debug("EthernetCable::transmit_signal() called.");
これは、showTerminal
というJS側で定義した関数を呼び出し、「Packet Pilot Terminal」に状況を
表示するための関数です。こちらは、また、WASM実装回の時、どのようにしているかをご紹介します。
// -- for WASM debug
pub fn debug(s: &str) {
let message= format!("\r\n------------\r\n[Debug] {}\r\n------------\r\n",s);
showTerminal(&message);
}
今回、イーサーネットケーブルでの、実装は以上になります。 Arc、Mutex、Fn、Sync、SendなどRustでよく使う部分について、ここでしっかり自分のものにしておいてください。 調査班が調べてくれた「2-3.Arc、Mutex、dyn、Send、Sync、vtable、全部まとめて調査しました(by調査班)」をじっくり読んでみてください。
では、次回は、このイーサーネットケーブルをWASM化して、JS側の実装を行います。
ネットワークのこと、Packet、アドレスのことについて頭に入れておきたい場合は、本誌Vol.1で復習しておいてくだださい。
ネットワークを流れるPacketをプログラムから操作できるようになりたいという人には必見のマガジンです。