タイダログ

もっと怠けますか? (y/n)

F# + Fable でネットワークシミュレータを作った

F# + Fable で、ブラウザ上で動くネットワークシミュレータを作りました。

私のサイト "taidalab" で 2023/04/01 (JST) に公開予定です。

taidalog.html.xdomain.jp

作業環境

いきさつ

「情報Ⅰのネットワークの単元、教えるの難しいな。どうやって生徒にイメージを持ってもらって理解してもらおう。実際に手を動かして体験してほしいけど……」と思っていたら、Twitter で他の先生方も同じことを考えていらっしゃったので、じゃあ作ろうと思い立った次第です。

コンセプト

生徒が自分で手を動かして、試行錯誤を通してネットワークの仕組みを学ぶことを第一にしました。言葉だけで「IP アドレスが~」とか「ルータが~」と説明されても訳わからんでしょうし、面白くないじゃないですか。それより実際にやってみたらいいんですよ。そんなわけでこのツールを作りました。

できること

まずは動画でイメージを掴んでください。※以下の動画は開発中の画面です。

  • ブラウザ上でネットワーク機器(クライアントやルータ、ハブ)同士を LAN ケーブルで接続して、IP アドレスを用いた通信を模倣することができます。
  • ネットワーク機器の役割の違いを学ぶことができます。たとえば、ネットワークを越える通信は、クライアント・ハブ→できない、ルータ→できる、となっています。
  • 機器ごとに IP アドレスとサブネットマスクを設定できます。もちろん、その設定によって機器同士の通信の可否が決まります。

できないこと

  • デフォルトゲートウェイの指定はできません。
  • 実際に利用した経路の可視化はできません。疎通したかどうかの結果のみを表示します。

できるようにしたいこと

  • ケーブルを速くドラッグすると位置がズレるので、ズレないようにしたい。
  • 問題作成機能を実装したい。ボタンを押すと機器がランダムに配置されて、「この機器からあの機器に対して通信できるようにネットワークを構築しましょう」みたいな問題文が出ると楽しそう。

コードの説明

コードはこちら。

github.com

レコード×モジュール

今回は、デバイスや 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 型のレコードを文字列にする際のフォーマットを定めているだけです。)

IPv4AreaPoint 自体もレコードです。たとえば 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

ドラッグアンドドロップで配置する

機器やケーブルをドラッグアンドドロップで配置する方法は、以下のページを参考にしました。ありがとうございました!

qiita.com

www.m3tech.blog

ドラッグでケーブルを伸ばす

LAN ケーブルの端をドラッグして伸ばせるようにしました。

そもそも LAN ケーブルの端とは何ぞやという話ですが、今回は SVGpolyline 要素を使って 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 関数も作りたかったけど、時間がなかったのでまた今度にします。

結び

なんだかとってもとりとめがない記事になったぞ……いつものことか。

ゲームっぽいものの作成は今回が初めてだったので、いろいろと試行錯誤することができました。今後も機能の追加や改善を続けるので、その都度書き直します。

参考