F# + Fable で、ブラウザ上で動くネットワークシミュレータを作りました。
私のサイト "taidalab" で 2023/04/01 (JST) に公開予定です。
作業環境
- Windows 10 Home 21H2 (OS Build 19044.2728)
- Fable 3.7.20
- F# 6.0
- dotnet 6.0.310
- npm 6.14.6
- Visual Studio Code 1.76.2
- Ionide for F# v7.5.2
- FireFox 111.01
いきさつ
「情報Ⅰのネットワークの単元、教えるの難しいな。どうやって生徒にイメージを持ってもらって理解してもらおう。実際に手を動かして体験してほしいけど……」と思っていたら、Twitter で他の先生方も同じことを考えていらっしゃったので、じゃあ作ろうと思い立った次第です。
コンセプト
生徒が自分で手を動かして、試行錯誤を通してネットワークの仕組みを学ぶことを第一にしました。言葉だけで「IP アドレスが~」とか「ルータが~」と説明されても訳わからんでしょうし、面白くないじゃないですか。それより実際にやってみたらいいんですよ。そんなわけでこのツールを作りました。
できること
まずは動画でイメージを掴んでください。※以下の動画は開発中の画面です。
更新のお知らせ (4.0.0-alpha6)
— タイダログ (@taidalog) 2023年3月19日
- LAN ケーブルの角度を自由に設定できるようにしました。
ようやく LAN ケーブルが完成!
…と言いつつ、まだ挙動がおかしいところがあるので修正します。
あと何をしたらいいかわからなくなってきたぞ…https://t.co/ZhnmPvQb8H#情報科 #情報1 #fsharp pic.twitter.com/gAWdN66X5U
更新のお知らせ (4.0.0-alpha7)
— タイダログ (@taidalog) 2023年3月21日
- UI を日本語にしました。
- 画面をスクロールした状態でケーブルの長さを変えると、編集状態から抜け出せなくなるバグを修正しました。
今回は使い勝手の向上です。
Result 型を使いました。楽しかったです!https://t.co/p4CjFB9XL3…#情報科 #情報1 #fsharp pic.twitter.com/5aTbskxkrw
- ブラウザ上でネットワーク機器(クライアントやルータ、ハブ)同士を LAN ケーブルで接続して、IP アドレスを用いた通信を模倣することができます。
- ネットワーク機器の役割の違いを学ぶことができます。たとえば、ネットワークを越える通信は、クライアント・ハブ→できない、ルータ→できる、となっています。
- 機器ごとに IP アドレスとサブネットマスクを設定できます。もちろん、その設定によって機器同士の通信の可否が決まります。
できないこと
- デフォルトゲートウェイの指定はできません。
- 実際に利用した経路の可視化はできません。疎通したかどうかの結果のみを表示します。
できるようにしたいこと
- ケーブルを速くドラッグすると位置がズレるので、ズレないようにしたい。
- 問題作成機能を実装したい。ボタンを押すと機器がランダムに配置されて、「この機器からあの機器に対して通信できるようにネットワークを構築しましょう」みたいな問題文が出ると楽しそう。
コードの説明
コードはこちら。
レコード×モジュール
今回は、デバイスや LAN ケーブルなど、複数の種類のデータを扱う必要性がありました。そのため、それぞれのデータを表すレコード型を定義して使用しました。
たとえばクライアントを表す Client
型のレコードは以下のように定義しています。
[<StructuredFormatDisplay("{DisplayText}")>] type Client = { Id : string Name : string IPv4 : IPv4 SubnetMask : IPv4 NetworkAddress : IPv4 Area : Area Position : Point } member this.DisplayText = this.ToString() override this.ToString() = sprintf "Id = %s; Name = %s; IPv4 = %O; SubnetMask = %O; Area = %O; Position = %O" this.Id this.Name this.IPv4 this.SubnetMask this.Area this.Position
なんやかんや書いていますが、とりあえず以下の部分をご覧ください。「Client
型とは、string
型の Id と Name、IPv4
型の IPv4 と SubnetMask とNetworkAddress、Area
型の Area と Point
型の Position というデータの集合である」と定義しています。
type Client = { Id : string Name : string IPv4 : IPv4 SubnetMask : IPv4 NetworkAddress : IPv4 Area : Area Position : Point }
(その前後の箇所は、単に Client
型のレコードを文字列にする際のフォーマットを定めているだけです。)
IPv4
や Area
、Point
自体もレコードです。たとえば IPv4
型の定義は以下の通りです。
type IPv4 = { Octet1 : byte Octet2 : byte Octet3 : byte Octet4 : byte }
そんなこんなで様々なレコードを定義した後は、レコードと同名のモジュールを作り、その中で、レコードにまつわる関数を定義します。
// `Client` 型のレコード type Client = { Id : string Name : string IPv4 : IPv4 SubnetMask : IPv4 NetworkAddress : IPv4 Area : Area Position : Point } // `Client` 型のレコードにまつわる関数を入れるモジュール module Client = // `Client` 型のレコードを作成して返す関数 let create id name ipv4 subnetMask area position : Client = 略 // `HTMLElement` から `Client` 型のレコードを作成して返す関数 let ofHTMLElement (elm: Browser.Types.HTMLElement) : Client = 略 // `Client` 型のレコードから `HTMLElement` を作成して返す関数 let toHTMLElement (client: Client) : Browser.Types.HTMLElement = 略
このように、クライアントにまつわるデータの集合(=レコード)と、クライアントにまつわる関数の集合(=モジュール)に同じ名前を付けることで、必要な関数を見つけやすくなったり、何をしているコードなのかが後から見た時にわかりやすくなったりしました(当社比)。F# の世界ではよくある手法らしいです。
以下のコードは、Client
型のレコードを作成した後、HTMLElement
に変換しています。わかりやす~い。
nextNumber |> (fun n -> Client.create id $"クライアント(%d{n})" "10.0.0.1" "255.255.255.0" { Area.X = 0.; Y = 0.; Width = 100.; Height = 100. } { Point.X = 0. + playAreaRect.left; Y = 0. + playAreaRect.top }) |> Client.toHTMLElement
ドラッグアンドドロップで配置する
機器やケーブルをドラッグアンドドロップで配置する方法は、以下のページを参考にしました。ありがとうございました!
ドラッグでケーブルを伸ばす
LAN ケーブルの端をドラッグして伸ばせるようにしました。
そもそも LAN ケーブルの端とは何ぞやという話ですが、今回は SVG の polyline
要素を使って LAN ケーブルを実装したので、polyline
要素の points
属性の値を両端の座標としました。points
属性は 5,5 195,45
のように x1,y1 x2,y2
の形式にしてあるので、これを使います。points
属性(をいろいろ処理して)から求めた両端の位置と、カーソルの位置との距離を計算し、カーソルが半径 5px 以内にあればその端をクリックしていることにしました。
端をクリックしていることにしたのはいいんですが、その後の処理、つまりケーブルを伸ばしたり角度を変えたりする処理がものすごく大変でした。ケーブルが左上から右下に伸びている場合、その右下の端を、左上の端より左または上に行かない範囲で伸ばすのは簡単なんですが、左上方向の端より左または上に行ったり、あるいは左上の端の方を動かそうとすると途端に難しくなります。疲れたので今度説明します。もうね、場合分けが大変。
疎通判定 (ping)
送信元機器から送信先機器に対して通信するには、
- 送信元から送信先まで途切れることなく LAN ケーブルで繋がっている
- ネットワークアドレスが異なる機器同士は、ルータが仲介している
という条件を満たす必要があります。今回は、送信先から一手で行ける(=LAN ケーブル1本で繋がっている)機器を見て、それが送信先であれば通信成功、そうでなければその機器からさらに一手で行ける機器を見て……と再帰的に繰り返すことにしました。みんな大好き再帰関数。
それが以下のコードです。
getNetNeighbors
関数は、「一手で行ける機器」を列挙する関数extendRoute
関数は、これまで辿った経路の最後に、「一手で行ける機器」を追加して経路を延長する関数ping
関数は、通信成功または TTL が 0 になるまで上記2つの関数を再帰的に呼び出し続ける関数
です。
let getNetNeighbors (cables: Cable list) (devices: Device list) (route: Device list) : Device list = let current = route |> List.last let last = route |> List.filter (Device.isHub >> not) |> List.tryLast cables |> List.filter (Cable.connectedTo current) |> List.collect (fun c -> devices |> List.filter (fun next -> (List.contains next route) = false) |> List.filter (fun next -> Cable.connectedTo next c) |> List.filter (fun next -> Device.isHub next || Device.isRouter current || match last with | None -> false | Some last' -> List.intersection (Device.networkAddresses last') (Device.networkAddresses next) <> [])) let extendRoute (cables: Cable list) (devices: Device list) (route: Device list) : Device list list = route |> getNetNeighbors cables devices |> List.map (fun x -> route @ [x]) let ping (cables: Cable list) (devices: Device list) (ttl: int) (destinationIPv4: IPv4) (source: Device) : bool = let rec ping' (cables: Cable list) (devices: Device list) (ttl: int) (destinationIPv4: IPv4) (route: Device list) : bool = let routes = extendRoute cables devices route let found = routes |> List.map List.last |> List.exists (Device.hasIPv4 destinationIPv4) if found then true else if ttl = 0 then false else routes |> List.exists (ping' cables devices (ttl - 1) destinationIPv4) ping' cables devices ttl destinationIPv4 [source]
tracert
関数も作りたかったけど、時間がなかったのでまた今度にします。
結び
なんだかとってもとりとめがない記事になったぞ……いつものことか。
ゲームっぽいものの作成は今回が初めてだったので、いろいろと試行錯誤することができました。今後も機能の追加や改善を続けるので、その都度書き直します。