WebRTC DataChannel で 1:1 接続をやってみた

まだプロトコルを正しく理解しておらず、とにかく動くところまで。そのメモ。

こんな感じ

https://i.gyazo.com/fb201b876ce130021c991a823e905d8d.gif

コード

GitHub - mizchi-sandbox/hello-data-channel

simple-peer と react で雑に一筆書きした。simple-peer は webtorrent などでも使われていて、筋が良さそう。

none => initiator | receiver => connected と mode が遷移する。

// <div class="root"></div> みたいなDOMがあることを前提

import Peer from "simple-peer";
import React from "react";
import ReactDOM from "react-dom";

let peer: Peer.Instance = null as any;

class Initiator extends React.Component<
  {
    onHandshaked: () => void;
  },
  {
    incoming: string;
    outgoing: null | string;
  }
> {
  state = {
    incoming: "",
    outgoing: null
  };

  textareaRef: any = React.createRef();

  componentDidMount() {
    peer = new Peer({ initiator: true, trickle: false });

    peer.on("error", (err: any) => {
      console.error(err);
    });

    peer.on("signal", (data: any) => {
      console.log("signal");
      this.setState(s => ({ ...s, outgoing: data }));
    });

    peer.on("connect", () => {
      console.log("CONNECT");
      // peer.send("hey, how is it going?");
      this.props.onHandshaked();
    });

    // peer.on("data", (data: any) => {
    //   console.log("data: " + data);
    // });
    return peer;
  }

  render() {
    return (
      <div>
        {this.state.outgoing && (
          <>
            <textarea
              ref={this.textareaRef as any}
              style={{ width: 600, height: 150, background: "#ddd" }}
              value={JSON.stringify(this.state.outgoing)}
              onChange={() => {
                /**/
              }}
            />

            <button
              onClick={() => {
                this.textareaRef.current.select();
                document.execCommand("copy");
              }}
            >
              Copy to clipboard
            </button>
            <hr />
            <textarea
              style={{ width: 600, height: 150 }}
              value={this.state.incoming}
              onChange={ev => {
                this.setState({ incoming: ev.target.value });
              }}
            />
            <button
              onClick={() => {
                peer.signal(JSON.parse(this.state.incoming));
              }}
            >
              signal
            </button>
          </>
        )}
      </div>
    );
  }
}

class Receiver extends React.Component<
  {
    onHandshaked: () => void;
  },
  {
    incoming: string;
    outgoing: null | string;
  }
> {
  state = {
    mode: null,
    handshaked: false,
    incoming: "",
    outgoing: null
  };

  textareaRef: any = React.createRef();

  componentDidMount() {
    peer = new Peer({ initiator: false, trickle: false });
    peer.on("error", (err: any) => {
      console.log("error", err);
    });

    peer.on("signal", (data: any) => {
      console.log("signal");
      this.setState(s => ({ ...s, outgoing: data }));
    });

    peer.on("connect", () => {
      console.log("CONNECT");
      this.props.onHandshaked();
    });

    return peer;
  }

  render() {
    return (
      <div>
        <div>
          <textarea
            style={{ width: 600, height: 150 }}
            value={this.state.incoming}
            onChange={ev => {
              this.setState({ incoming: ev.target.value });
            }}
          />

          <button
            onClick={() => {
              peer.signal(JSON.parse(this.state.incoming));
            }}
          >
            signal
          </button>
        </div>

        <div>
          {this.state.outgoing && (
            <>
              <textarea
                ref={this.textareaRef as any}
                style={{ width: 600, height: 150, background: "#ddd" }}
                value={JSON.stringify(this.state.outgoing)}
                onChange={() => {
                  /**/
                }}
              />

              <button
                onClick={() => {
                  this.textareaRef.current.select();
                  document.execCommand("copy");
                }}
              >
                Copy to clipboard
              </button>
            </>
          )}
        </div>
      </div>
    );
  }
}

class Chat extends React.Component<
  any,
  {
    text: string;
    comments: Array<{
      owner: string;
      text: string;
      date: number;
    }>;
  }
> {
  state = { text: "", comments: [] };

  componentDidMount() {
    peer.on("data", (data: any) => {
      const json = JSON.parse(data);
      this.setState({ comments: json });
    });
  }

  render() {
    return (
      <div>
        <textarea
          value={this.state.text}
          onChange={ev => {
            this.setState({ text: ev.target.value });
          }}
        />

        <button
          onClick={() => {
            const comment = {
              owner: (peer as any)._id,
              text: this.state.text,
              date: Date.now()
            };
            const newComments = [comment, ...this.state.comments];
            peer.send(JSON.stringify(newComments));
            this.setState({ text: "", comments: newComments });
          }}
        >
          send
        </button>

        <ul>
          {this.state.comments.map((c: any, index) => {
            return (
              <li key={index}>
                {c.owner}: {c.text}: {c.date}
              </li>
            );
          })}
        </ul>
      </div>
    );
  }
}

class App extends React.Component<
  {},
  {
    mode: "initiator" | "receiver" | "none" | "connected";
  }
> {
  state = {
    mode: "none"
  } as any;

  render() {
    switch (this.state.mode) {
      case "none": {
        return (
          <div>
            <button
              onClick={() => {
                this.setState({ mode: "initiator" });
              }}
            >
              Create connection
            </button>

            <button
              onClick={() => {
                this.setState({ mode: "receiver" });
              }}
            >
              Receive connection
            </button>
          </div>
        );
      }
      case "initiator": {
        return (
          <Initiator
            onHandshaked={() => {
              this.setState({ mode: "connected" });
            }}
          />
        );
      }
      case "receiver": {
        return (
          <Receiver
            onHandshaked={() => {
              this.setState({ mode: "connected" });
            }}
          />
        );
      }
      case "connected": {
        return <Chat />;
      }
    }
  }
}

ReactDOM.render(
  <>
    <h1>Handshaker</h1>
    <hr />
    <App />
  </>,
  document.querySelector(".root")
);

動いてるけど、Receiver 側でこんなエラーが出る

error Error: Ice connection failed.
    at makeError (main.16f9df1b.js:7031)
    at Peer._onIceStateChange (main.16f9df1b.js:6674)
    at RTCPeerConnection.Peer.self._pc.oniceconnectionstatechange (main.16f9df1b.js:6203)

stun しか設定してないので、そのせいだろうか。 trickle: false も怪しいが。プロトコルをちゃんと理解してないのでちゃんと調べる。

offer answer の流れを今はコピペしているが、自動化するには別途 firebase などでデータ受け渡さないといけないのかなーという気がする。そんなに難しくないので、あとでやる。

WEB+DB PRESS Vol.106 で仮想DOMアルゴリズムについて書かせていただきました

特集書かせていただきました

WEB+DB PRESS Vol.106

WEB+DB PRESS Vol.106

ターゲットは「仮想DOMアルゴリズムそのものに興味がある人」と「Reactのキャッチアップに遅れた人」

よくあるReactについての記事ではなく、仮想DOMアルゴリズムとそれによって実現されるFluxを解説する話です。Reactのコード例は出ますが、あくまで仮想DOMアルゴリズムそのものについて書いています。Vue や Flutter にもそのまま応用できるはずです。

本特集の内容を要約すると、仮想DOMアルゴリズムとは「木構造のダブルバッファリングで更新処理を枝刈りしたものを、その更新結果でGUIコンポーネントのライフサイクルを表現する」という話。

個人的な主張としては、Reactの仮想DOMアルゴリズムはWebだけに閉じた話ではなく、これからのGUIアーキテクチャを変えるもので、ReactやVueを使わなくとも、今学んでおくことは価値があると思います。

奇しくも Android iOSアプリ設計と同じ回ということで、GUIパラダイムについて掴んでおきたい人は読んでみてはいかがでしょうか。

よろしくおねがいします。

tweetdeck のタイムライン表示を user css で夜フクロウ風にした

usersteam 止まって、tweetdeck に移ったが、あまりにTLの一覧性が悪すぎたのでやった

こんな感じ

昔使ってた stylish が問題起こしてストアから削除されてたので、 stylebot を使った。

雑なCSS

.column:nth-child(1) {
    width: 600px;
}

.js-stream-item-content {
    padding: 0;
}

.fullname:not(:hover) {
    display: none;
}

.item-box:not(:hover) {
    height: 37px;
    overflow-x: auto;
    overflow-y: hidden;
}

.item-box:not(:hover) .tweet-context {
    display: none;
}

追記: ユーザータイムライン

1番目のカラム(設定によるがユーザータイムライン)だけに適用するようにした。Mentionなどの他のタブはたたむ必要ないと感じたので

.column:nth-child(1) {
    width: 600px;
}

.column:nth-child(1) .js-stream-item-content {
    padding: 0;
}

.column:nth-child(1) .fullname:not(:hover) {
    display: none;
}

.column:nth-child(1) .item-box:not(:hover) {
    height: 37px;
    overflow-x: auto;
    overflow-y: hidden;
}

.column:nth-child(1) .item-box:not(:hover) .tweet-context {
    display: none;
}

手足がもがれる感覚

最近乗り換えたもの

ただ、これらにかなり不満がある。

情報には上流がある。情報の上流は、一次情報が乱雑に転がってる未整備の荒野みたいなもので、下流はその二次情報やまとめみたいなもの。キャズム理論でいうと、一次情報に接する人がイノベーター、イノベーターを観測する人がアーリーアダプター、イノベーターまたはアーリーアダプターによる編集物を閲覧する人がマジョリティという分類になると思う。

自分が思う近年のインターネットの問題は、一次情報を整理していたイノベーターやアーリーアダプターに対するサービスが本格的に終わってきたこと。

最初に挙げた3つのサービスは、どれもサービス終了やサポート終了などの必要に駆られて乗り換えたものではあるが、体験自体はかなり悪化している。最初は慣れの問題かと思ったが、やはりそうでもない。

Tweetdeck のUIは一見 Twitterジャンキー向けのような見た目だが、実際には大量のデータを捌くことが想定されておらず、高速なタイムラインの読み込みに難がある。一ツイートあたりの専有面積が大きく、また長時間起動しているとメモリリークして使い物にならない。XULAPIによってブラウザに対する完全なコントロール得られたFirefoxの拡張Vimperatorは、セキュリティサンドボックスの理由で機能しなくなった。Livedor Reader, というか RSSリーダーは退潮トレンドで、FacebookTwitterのようなソーシャルメディアのレコメンデーション、パーソナライゼーションによって代替されようとしている。

要は、生のデータを大量にさばいて、そこから価値ある情報を見つけ出す作業が個々人に求められなくなってきた。そのかわり、サービス側が提供するレコメンデーションを使え、ということになっている。設計側の気持ちもわかる。広告を差し込んだり、より多くのアクティビティを取得するには、API経由だと満足ではない。既に流行ってしまってレートマジョリティにリーチしているTwitterのようなサービスは、施策の効果を最大化するのにアーリーアダプターにリーチする動機がない。

流行りのグロースハックは、明言されていないが結果として反 Open API を促している。ただし、サービス側でそれらの設計がうまくいっているサービスは少ない。というかそれに十分なデータを得て機能するだけのステージに達しているサービスが少ない。

結果として、イノベーター、アーリーアダプターは自分に合わせたカスタマイズが出来ず、またレートマジョリティ向けの、もっというとモバイルユーザー向けの低密度な情報摂取UIを強要され、情報の出口の、最初の一歩が機能不全になるのではないか、というか既になっているのではないか。

ソーシャルメディア優位の今、発信側にとっては、富めるものはさらに富み、貧しいものは勝負のステージに乗るための最初の一歩が、より厳しくなっている。バズにのるための最初の一歩は、発信力があるメディアへいかにリーチできるかが勝負になってしまった。質はその後で求められるもの。

そういった中で、自分で発見することをやめさせられ、サービス側に与えられる情報だけを摂取しろと言われ、能動的な情報ジャンキー向けのサービスやアプリが次々と停止していくのは、自分にとって「手足がもがれる感覚」がある。

Just Monika

前評判はチラチラ聞いていたが、ドキドキ文芸部やってみた。 …すごかった

store.steampowered.com

無料。だが舐めてはいけない。これはギャルゲの皮を被った、それ以上の何か。

式日本語版はないが、有志の日本語翻訳の出来が良いので、それで。

https://i.gyazo.com/6f80a36c6a260333f3aadb4f3c256e74.png

Steamのゲームをそこそこやってる人なら、ただのギャップによる看板詐欺なだけでは、この「圧倒的に高評」を取るのがいかに難しいか、わかってくれると思う。

タグ、「精神的恐怖」は嘘偽りない看板。ただ、それを感じるところに辿り着くまでは結構長かった。可愛らしいキャラクターが徐々に豹変していくのは、ひぐらしを思い出させる。また、おそらく OFF、 Oneshot といった海外フリーゲームのメタ言及の文脈も組んでると思われる。

怖いだけではなく、考えさせられるゲームでもあって、最後にメインテーマのボーカル版が流れる演出で泣いてしまった。


メタ言及は創作の華だが、それにうまくいく作品は少ない。最近だとダンガンロンパV3が失敗していた。あれは失敗だったと思う。

創作の質が、読み手に与えるインパクトに比例するなら、これはここ最近では一番の傑作。

オブジェクト指向の呪いと、その避け方

このテーマで書く前に、まず、最初に自分に多少の偏りがあることを認めておかなくてはなりません。

階層化されたツリー構造(GUI/リレーショナルな参照構造)に埋め込まれる状態はコード品質を悪化させるので、できるだけ出現するべきではない。 ただし、状態は確実に存在する。だからこそ慎重に扱うべきだ、という派閥です

アンチパターン: 特に理由もないクラスメソッドへの所属

何かのバリデータを実装したいとします。

その関数がどこに所属するかについて、よく見るこれらの実装は全部アンチパターンといっていいと思います

export class Validator {
  static validate() {...}
}

export class Validator {
  validate() {...}
}

export default ({
  validate: () => ...
})

正解

export function validate() {
  ...
}

状態は割れ窓です。最終的な出力に関与される余地は、できるだけ減らしたほうがいいです。(詳しくは後述します)

また、名前空間がほしいだけのクラスも不要です。そもそもvalidateにもっと厳密な名前をつけるか、import 時にエイリアスを付ける、といった解決策はいくらでもあります。参照スコープが限定されているモジュールシステムの中ではあまり厳密ではありません。

new でヒープに積むから効率がどうこう、実際あまり問題じゃありません。ほとんどの場合、それを気にするのは早すぎる最適化です。パフォーマンスチューニングは良いコードだったらいくらでもやる余地はあって、今回は忘れるべきです。

古いJavaのような、クラスにしかメソッドが所属できないモジュールシステムばかりの時代じゃありません。 クラスは基本的に不要だと思います

状態は割れ窓

思いつき限り最悪のコードを書きます。

const validator = new Validator({defaultWithXXX: true})
validator.ignoreMethodOptions = true
const result = validator.validate({ withXXX: false })

オプションがたくさんあるから手続き的に組み立てたいんだ!という主張があるとしたら、オプションを組み立てる部分を別関数にするべきだと思います。 僕だったら高階関数でこうしますが…

const buildValidator = (options) => (input) => ...
const validate = buildValidator({...})

これは関数スコープにoptionsを保持するので不変であることは保証されます。とはいえ、カリー化のないJSで高階関数をやるのは型の支援がないと難しいので、 僕も Flow/TypeScript で型が保証されてる時にしかやりません。

アンチパターン: クラスと継承

現代では、継承は基本的に使うべきではない。ということは同意が取れることとします。基本的には、「継承よりコンポジション」です。

それでもやらないといけないとしたら、ストラテジーパターンを想定したライブラリから、一回だけ、です。それも、プラットフォームが提供するような練られた実装だけから、です。

class Foo extends View {
  render(){...}
}

たしかに継承は、ライブラリなどのよく練られたAPIの一回目の継承は規約として強烈なのですが、それが多段に継承されると protected が乱用され閉鎖原則が破綻しがちで、経験上この先は最悪なコードしか見ません。リスコフの置換原則が守られないのは、歴史が明らかにしています。GUIでの標準コンポーネントを多段継承するのは最悪で、React / Backbone / Android / Flash / Unity で地獄を見ました。

(Rails の Controller 継承は悩ましくて、認証漏れを起こすぐらいだったら規約で縛るのもアリな気がするんですが、読み解くのオーバーヘッド大きくてあんまり好きじゃなくて、それこそ mixin とかでどうにかなるような…)

Go や Rust など、最近の言語ではそもそも継承は実装されないことも増えてきました。コンポジションの手段として Scala では trait, Swift では protocol が提供されているので、基本的に避けられると思います。完全コンストラクタ制約があれば実質イミュータブルみたいなもんでしょう。

イミュータブルだと思いこむ

副作用ではなくGCに頼り切ってイミュータブルなオブジェクトを返す、という実装のが最近は推奨されると思います。

function setA(obj, a) {
  return {...obj, a}
}

基本的にすべてをイミュータブルだと思って使って、古い参照はGCに落としてもらうことが前提です。これである時点でのその参照へのアクセスは保証されます。GC負荷が…と気にするのも早すぎる最適化ですね。

イミュータブル参照を守っていると、リスナーの関数クロージャでアクセスするオブジェクトがアクセスするたびに値が変わる、といったことは避けられます。redux の reducer なんて、それを実現するだけの関数ですからね。

POJOJSONのような、薄いオブジェクトを扱っていると、シリアライズしやすいというのあります。JSはJSONがあるので特殊な環境といえばそうなんですが、他の言語でも ORM にマップするときや、通信のためにシリアライズするときなんかも有用でしょう。クラスのインスタンスから関数以外のプロパティを落としてシリアライズするのは簡単ですが、その逆のデシリアライザを常に用意するのは大変なので、そもそも切り離す、という感じです。

データと実装を切り離して、常にデータだけをシリアライズする、というアプローチは、データの可用性を大きく高めてくれます。批判があるとしたら、それは「ドメインモデル貧血症」じゃないか?というのがありそうですが、それは名前空間の所属だけの問題だと、自分は思います。

ただ、これも型がある環境じゃないとやりづらいとは思います。

最後に

もっといろんな言語の立場を書きたかったけど、JSの立場により過ぎた気します。

書き始めた理由としては、以下の2つの記事が念頭にあります。

qiita.com

ubiteku.oinker.me

この2つの記事に同意してるわけじゃないですが、そういう意見が出る時代だろうという認識です。

最近読んだ本の中では、オブジェクト指向を振り返るにあたって、「Game Programming Patterns ソフトウェア開発の問題解決メニュー」 が最高の本でした。

ゲームプログラミングと銘打ってますが(実際にゲーム特有のパターンはあるものの)、単にゲームを題材にした、ステートフルな対象をどう扱うか、という本で、ゲームでよくあるC++の実装例だけではなく、筆者が関数型や動的型付けならこうなるからこのパターンは不要と切り捨てたりするので、非常にバランスが良いです。とくに継承批判とシングルトン批判が強烈です。

日頃思ってたことを書いてみたけど、すべての立場の想定反論を用意できたわけではないので、反論がたくさんありそう。ファイッ

IPFSについて勉強した

ipfs.io

IPFS とは / BitTorrent とどう違うのか

P2P分散ファイルシステム

BitTorrentがトラッカーファイルと呼ばれる単位でファイル共有を行うのに対し、IPFS は内部が別のオブジェクトを指し示すポインタ(ディレクトリ相当) or またはバイナリ(ファイル相当)のものがDAG構造をとっていて、それぞれにユニークなキーが振られている。

IPFSの内部構造は Git の内部オブジェクトと非常に似ている。分散ファイルシステムとしての Git との概念的な差はほとんどなくて、両者ともにコンテンツアドレッシング方式の Merkle DAG 構造ということができる。調べた感じ各種ツリーフォーマットの表現の差や、オブジェクトの圧縮方法が違うだけ、といっていってよさそう。

IPFSで配布する際は、URLのような名前空間に対して保存するのではなく、オブジェクトまたはその集合による Merkle によって一意な sha256 のキーが決まり、それが一意性を示すポインタとなる。なのでsha256一致させる高コストな攻撃をされない限り、コンテンツに対してユニークなキーといっていい。

IPFSのP2Pノードとしての振る舞いは BitTorrentに似ており、各ノードがほしいブロックリストと、提供できるブロックリストを公開し、その希少度や見返りのインセンティブによってスコアが付けられる(BitSwapプロトコル)

なので、要約すると Git の分散ファイルシステム + BitTorrentインセンティブ設計に基づいたファイルブロック交換システムといって良さそう。

P2Pなので、アップロードしたファイルはネットワーク参加者全員の同意を取らないと消せない(=ほぼ消せない)。

libp2p(-js) というミドルウェア層があり、p2pネットワーク内での webrtc/websocket/http を抽象している。IPを利用しているにすぎず、よりメタなプロトコルを採用することも、理論上は可能。

公開してみた

まず、 brew install ipfs などして ipfs コマンドを入れるとする。これで入るのはGoのリファレンス実装で、他にJS実装がそこそこ。CとPythonが In Progress.

こんな感じでアップロードして、ノードに参加し広めることができる

mkdir pub-to-ipfs
echo hello > hello.txt
ipfs add -r .
ipfs daemon # IPFSネットワークの参加ノードとなり、ファイルを配布する

https://ipfs.io/ がデフォルトのゲートウェイで、 https://ipfs.io/ipfs/<ObjectID> という風にアクセスするとその中身が取れる。ipfs daemonlocalhost:8080 にローカルのゲートウェイを立ててくれる。

試しに自分のツイッターのアイコンを投げてみた。アクセスできると思う https://ipfs.io/ipfs/QmdqSc9ZKcdzrdfjRmMLms4jH3JtFQCBALyJNTSaEwi59m/saboten.png

Git をホスティングする

https://kotet.github.io/2018/06/04/ipfs-git-readonly.html が非常に参考になった

手元にあった next-editor のソースを投げてみた。 .git オブジェクトをそのまま投げる。

cd .git
ipfs add . -r

Git オブジェクトは 5MB程度なのを確認した。

git clone https://ipfs.io/ipfs/QmQFVzbiueHb6CtuN7aa6W67Uzr9kaeVKsVYLHoFLWDcxz --single-branch

僕の環境で ipfs daemon のノードのPeerが800程度で、Twitter で頼んでこれを clone してもらったところ、およそ1分~6分という結果になった。使い物になるかどうか、微妙なラインでなんとも言えない…。

(アクセス絞るのに --depth 1 しようとしたが、素朴なファイルホスティングだと smart http プロトコルにならず、 dumb http プロトコルだと --depth が使えなかった)

動的なコンテンツを追跡するには ipns というものを使うらしい。まだ使ってないので後で調べる。

わからなかったこと / 次に調べること

やりたかったこととして、 https://next-editor.app は完全な静的サイトで、pure js な Git 実装を積んでて、PWAでオフラインで動くように作っているのだが、 GitHub にアップロードするにはアクセストークンなりCORS迂回のゲートウェイなりの準備が大変。なので、手軽にGitを共有する対象として IPFS へのアップロードする選択肢を用意できるかどうかを検証していた。というか Git って本来分散システムだからGitHubが権威サーバーである必要もないと思っているのだが…。

クライアント単独で git clone し publish までいけたら夢があるし、たぶんいける。検証中。

上記の方法で、サーバーを立ててゲートウェイを通してアップロードできるのはわかった。しかしもっというとブラウザで完全に完結させたくて js-ipfs でIPFSのオブジェクトを作ってブラウザで単独のノードとしてそれを配布したかったが、そのやり方が分からなかった。概念上 WebRTC でP2P への参加ノードになれるはずだが…

共有するためのオブジェクトを作るまではできてて、こんな感じ。

import pify from "pify";
import IPFS from "ipfs";

const streamFiles = (root, files, cb) => {
  const stream = node.files.addReadableStream();
  stream.on("data", data => {
    console.log(`Added ${data.path} hash: ${data.hash}`);
    // The last data event will contain the directory hash
    if (data.path === root) {
      cb(null, data.hash);
    }
  });
  files.forEach(file => stream.write(file));
  stream.end();
};

const repoPath = "ipfs-" + Math.random();
const node = new IPFS({ repo: repoPath });
global.ipfs = node; // DEBUG

node.on("ready", async () => {
  const root = "xxx";
  const files = [
    {
      path: `${root}/file1.txt`,
      content: node.types.Buffer.from("one", "utf8")
    },
    {
      path: `${root}/file2.txt`,
      content: node.types.Buffer.from("two", "utf8")
    }
  ];

  const directoryHash = await pify(streamFiles)(root, files);
  const newFiles = await pify(node.ls)(directoryHash);

  console.log(`${root} - ${directoryHash}`);
  newFiles.forEach(f => console.log(f.name, f.path, f.hash));
});

もうちょい調べる。