Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

WebSocketでカンタンなゲームを作った

火をつけて消えないように薪を足し続けるだけのゲームを作りました。サイトに接続している人なら誰でも薪を足したり、火をおこせます。

http://ws.pandanoir.net/

WebSocket通信をどう使っているか

WebSocketは一度確立したコネクションを切断しないでそのまま繋ぎ続ける技術で、クライアント―サーバー間の双方向通信が可能になります。

たとえばクライアントAが火を起こすと、次のような処理をします。

  1. クライアントAが「着火した」とサーバーに送る
  2. サーバーが全クライアントに「着火した」と送る
  3. 全クライアントの情報が更新される

薪が足されたときも同様です。

火が消えたときはサーバーが全クライアントに「火が消えた」と送るだけです。各クライアントが消えたかは関係ありません。

使用した技術

WebSocketとVue、Vuexを組み合わせて作りました。

今回は「残り時間」を表示しなければならないので、Reactive Time with Vue.js - Cushionを参考にして現在時刻を更新しています。

// store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import fire from './modules/fire.js';

Vue.use(Vuex);

const store = new Vuex.Store({
    modules: { fire },
});

store.dispatch('fire/start'); // 時刻の更新を開始する

export default store;
// store/modules/fire.js
import socket from '../socket';
const state = {
    existsFire: false,
    extinguishTime: 0,
    startTime: Infinity,
    maxRecord: 0,
    now: +new Date(), // 現在時刻を数値で格納
};

const getters = {
    restTime({extinguishTime, now}) {
        return Math.max( Math.ceil((extinguishTime - now) / 1000), 0 );
    },
    remainTime({now, startTime}) {
        return Math.max( Math.floor((now - startTime) / 1000), 0 );
    },
    /* ... */
};

const actions = {
    start({commit}) {
        // これがdispatchされると時刻の更新が始まる
        const fps = 32;
        setInterval(() => commit('updateTime'), 1000 / fps); // 1秒間に時刻を32回更新するように設定
    },
    /* ... */
};

// mutations
const mutations = {
    updateTime(state) {
        // 上のsetIntervalに設定されているように、この関数が1秒に32回呼び出される
        state.now = +new Date();
    },
    /* ... */
};

export default {
    namespaced: true,
    state,
    getters,
    actions,
    mutations
};

また、Socket.ioの部分は、イベントを受け取るとstoreにアクションを発行するよう設定しています。

// store/socket.js
import io from 'socket.io-client';
import store from './';

const socket = io();

socket.on('info', (data) => store.commit('fire/getServerState', {payload: data}));
socket.on('fire_extinguished', (data) => store.commit('fire/fireExtinguished', {payload: data}));
socket.on('fire', (data) => store.commit('fire/litFire', {payload: data}));
socket.on('add_fuel', (data) => store.commit('fire/addFuel', {payload: data}));

export default socket;

ほかはただ表示するためのコンポーネントを作っただけで技術的に面白い部分はないので、割愛します。

なぜrequire('lodash/zipWith')で関数ひとつだけ読み込めるのか

lodashというユーティリティライブラリがあります。lodashは便利な関数をたくさん提供しています。たとえば配列をシャッフルするshuffle()や、配列の差をとるdifference()などが挙げられます。

lodashは300個以上の関数を提供しています。しかし、実際に使うのはそのうちせいぜい10個です。コードサイズが大きくなってしまうのはJavaScript的に大罪です。そこで、必要な関数だけピックアップする方法が提供されています。それがタイトルにもあるrequire('lodash/zipWith')といった書き方です。

今回はどうしてそれで300を超える関数が読み込めるようになっているのか解説します。

lodashモジュールの様々な読み込み方

lodashは以下のように複数の読み出し方に対応しています。

const {zipWith} = require('lodash'); // lodashのすべてが読み込まれてしまっている
const {zipWith} = require('lodash/array'); // lodashのうち配列操作系だけ読み込む
const zipWith = require('lodash/zipWith'); // 関数をピンポイントで読み込む

ES Modulesを使う書き方もできます(node_modules/配下を参照するので、Node.jsでのみ動作します)。

import * as _ from 'lodash';
import * as lodashArray from 'lodash/array';
import zipWith from 'lodash/zipWith';

では、どうしてこのような読み込み方ができるのでしょうか?実際にインストールしたパッケージを見てみます。

f:id:panda_noir:20190419212255p:plain

なんと631個もJavaScriptファイルが置かれています!そして、それぞれ関数に対応したファイル、arrayに対応したファイルがあります。

つまり、require('lodash/array')ではlodashパッケージ内にあるarray.jsが読み込まれます。同様にrequire('lodash/zipWith')ではzipWith.jsが読み込まれます。

仕様ではどうなっているのか?

Node.jsのドキュメントのModulesページを見てみます。

どうやら、require('example-module/path/to/file')./node_moduels/example-module/path/to/fileと同様のようです(現在のディレクトリのnode_modulesになければ見つかるまで親ディレクトリを辿っていくようです)。

つまり、上のrequire('lodash/array')はドキュメントで保証されている動作らしいです。

これって名前空間なのでは?

このrequire('lodash/array')という書き方、lodashという名前空間のarrayを読み込むと見えませんか?実際の動作も名前空間にみえます。非常に面白いですね。

ただ、実際にコレをやっているパッケージをあまり見ない気がします。そもそもやる必要があるパッケージが少ないというのがありますが。

気がついたらimmerが独自クラスのインスタンスもサポートしていた

immerという、ミュータブルな操作を書く感覚でイミュータブルな操作が行えるライブラリがあります。

import {produce} from 'immer';

// 破壊的に配列をシャッフルする関数
const shuffle=(a,i=a.length,j) => {for(;[a[i],a[j]]=[a[j=0|Math.random()*i],a[--i]],i;);};

const arr = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
const shuffledArray = produce(arr, (draftArray) => {
    shuffle(draftArray);
}); // シャッフルされた配列が得られる

以前のimmerはネイティブの配列とオブジェクトしか扱えませんでした。しかし、久しぶりに見てみたらインスタンスにも適用できるようになっていました(ほとんどのインスタンスは対応していますが、すべてに対応しているわけではないです)。

1. インスタンスに対応

immerのインスタンス対応はMyClass.prototype[immerable] = trueか、myInstance[immerable] = trueとするだけで対応できます。

import {produce, immerable} from 'immer';
class Vector{
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    setZ(z) {
        this.z = z;
    }
}

const vector = new Vector(1, 2);
vector[immerable] = true; // これだけでimmerで使えるようになります

const after = produce(vector, draft => {
    draft.x += 4;
    draft.y += 4;
    draft.setZ(7);
});

console.log(vector, after);

immerableはprototypeに直接設定しても問題ありません。(というかこっちのほうが恐らく主流です)

Vector.prototype[immerable] = true;

クラスがgetterやsetterを使っている場合には使えないようです。

2. draftを外部に出せるcreateDraft()

draftを外に出すと、ネストが浅くなるだけでなく既存のコードをほんの少し変えるだけでimmutable化できるようになります。たとえばshuffle()という破壊的変更を加える関数を考えます。

const shuffle = (arr) => {
    // 破壊的にarrをシャッフルする
    for (let i = arr.length - 1; i > 0; i = 0 | i - 1) {
        const j = 0 | Math.random() * (i+1);
        [arr[i], arr[j]] = [arr[j], arr[i]];
    }
};
const arr = [1, 2, 3, 4, 5];
shuffle(arr);

このコードにcreateDraftを加えてちょっと書き直せば、arrが破壊されなくなります。

import {createDraft, finishDraft} from 'immer';
const shuffle = (arr) => {
    // 破壊的にarrをシャッフルする
    for (let i = arr.length - 1; i > 0; i = 0 | i - 1) {
        const j = 0 | Math.random() * (i+1);
        [arr[i], arr[j]] = [arr[j], arr[i]];
    }
};
const arr = [1, 2, 3, 4, 5];
const draft = createDraft(arr); // 追加
shuffle(draft); // draftを渡すように変更する
const shuffledArr = finishDraft(draft); // 追加

ただ、個人的にはネストされたほうが「このエリアはimmerのエリアだな」と分かりやすくなるのでcreateDraftよりproduceが好きです。

Nginxのincludeディレクティブはいちいちフルパスを書かなくてもいい

ずっとNginxのincludeディレクティブは絶対パスをちゃんと書かないといけないと思っていましたが、どうやら--prefixで指定したディレクトリからの相対パスでも書けるようです。

$ nginx -V
nginx version: nginx/1.15.10
built by gcc 4.8.5 20150623 (Red Hat 4.8.5-36) (GCC)
built with OpenSSL 1.0.2k-fips  26 Jan 2017
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx

configure arguments内の--prefix=/etc/nginxから見た相対パスを書くことが出来ます。たとえば以下の2つは同じです。

include /etc/nginx/conf.d/default.conf;
include conf.d/default.conf;

というか、すでにPHPを使っている場合はこの記法を使っているはずです。

include fastcgi_params;
include /etc/nginx/fastcgi_params; # こう書いてもいい

超!体系的に学ぶWebアプリケーション開発

この記事はWebサーバーを建てるところからWebアプリケーションを開発できるようになることを目標としています。

目次

  • 目次
  • この記事の目標
  • [超!基本]
    • Webページが表示されるまで
    • HTMLとは?
    • JavaScriptとは?
  • [超!Webサーバー]
    • Webサーバーとは?
    • ファイアウォールによるコントロール
  • [超!Webアプリケーション]
    • React+TypeScriptを始めてみる
      • 1. Node.jsをインストールする
      • 2. 作業用ディレクトリを作る
      • 3. npmのセットアップを行う
      • 4. ReactやTypeScript、Webpackをインストールする
    • React+TypeScriptでHello World
  • [超!通信]
    • HTTPSとHTTPの違い
    • HTTP通信の流れ
    • HTTP/2とは?
    • WebSocketとは?

この記事の目標

この記事では、Webフレームワークに頼らずにWebサーバーを建て、Webアプリケーション開発の環境構築まで行って、Web開発の基礎を固めることを目的とします。

  • Webサーバーの動作を理解する
  • Webアプリケーションの開発の始め方を理解する
  • LaravelやRuby on RailsなどWebアプリケーションフレームワークに通じる基礎を理解する
  • WebSocketやHTTP/2など最新技術がどこで動くのか理解する
続きを読む