30代からのプログラミング学び直し!

10年エンジニアやってるけどいまだになんもわからん

JavaScriptのServer-Sent Eventsを使ってローカルで動作するチャットっぽい画面を作る

やりたいこと

サーバー送信イベント - Web API | MDN

  • Server-Sent Eventsとは、ロングポーリングのための便利なインターフェース。ロングポーリングとは、Webサーバとのネットワーク接続を開き続けて、サーバから必要なときにクライアントにデータを送信できる技術のこと。プッシュ通知とかに使われる。
  • これの使い方を知るために、ローカルで動作する簡単なチャット画面を作りたい。
  • 複数のブラウザやブラウザのタブから入力されたテキストを、リアルタイムで反映させる。
  • クライアントサイド は localhost:8000 で動かす。サーバーサイドはNode.jsで作成し、localhost:8080で動かす。クロスオリジンになるので、レスポンスヘッダに "Access-Control-Allow-Origin": "*" を設定する。(ローカルでしか動かさないので許して…。)

画面は簡単にこんな感じ。LINEみたいにポコポコメッセージが出る。

こちらの本のサンプルコードを大いに参考にさせていただきました🙇

さっそく実装

クライアントサイド(HTML+JavaScript)

new EventSource(url) がとってもキモとなる部分。このEventSourceオブジェクトが、サーバから送信されたイベントを受け取ってくれる。

localChat.html

<html>
  <head>
    <title>SSE Chat</title>
    <style>
      /* CSSは割愛します */
    </style>
  </head>
  <body>
    <input id="input" />
    <script>
      // 初期表示時に名前を入力する
      let name = prompt("表示名を入力してください");
      let input = document.querySelector("#input");
      input.focus();
      // EventSourceを使って新着メッセージの通知を受け取る
      let chat = new EventSource("http://localhost:8080/chat");
      chat.addEventListener("chat", (event) => { // このchatというイベントは、サーバ側で定義したもの
        // データから名前部分とメッセージ部分を取り出す
        let dataName = event.data.substring(0, event.data.indexOf(":"));
        let text = event.data.substring(event.data.indexOf(":") + 1);
        // 要素を作成してデータを追加する
        let div = document.createElement("div");
        div.classList.add("msg");
        if (dataName === name) {
          // 自分が送信したメッセージだったらクラス追加してスタイルを変える
          div.classList.add("me");
        }
       let nameSpan = document.createElement("span");
        nameSpan.classList.add("name");
        nameSpan.append(dataName);
        let textSpan = document.createElement("span");
        textSpan.classList.add("text");
        textSpan.append(text);
        div.append(nameSpan, textSpan);
        input.before(div);
      });
      // inputに入力したら、fetchでサーバにユーザーのメッセージを送信する
      input.addEventListener("change", () => {
        fetch("http://localhost:8080/chat", {
          method: "POST",
          body: `${name}: ${input.value}`,
        }).catch((e) => console.error); // レスポンスは無視。ただしエラーはログに出す。
        input.value = "";
      });
    </script>
  </body>
</html>

クライアントサイドは、 http-server -p 8000 で動かして、ブラウザから http://localhost:8000/localChat.html でアクセスできるようにしました。(サーバで動かさなくても、単にfile:// でアクセスしても動きます。)

サーバーサイド(Node.js)

ソースコード内でサーバを立てているので、Nodeを実行するだけで、http://localhost:8080/chat でクライアントからアクセスできるようになります。

const http = require("http");
const url = require("url");

// イベントを送信するServerResponseオブジェクトの配列
let clients = [];

// サーバサイドは http://localhost:8080/chat でアクセスできるようにする
let server = new http.Server();
server.listen(8080);

// サーバが新しいリクエストを受け取った時の処理
server.on("request", (request, response) => {
  let pathname = url.parse(request.url).pathname;
  if (pathname === "/chat") {
    if (request.method === "GET") {
      // クライアントが新しいEventSourceオブジェクトを生成したとき(またはEventSourceが自動的に再接続されたとき)
      acceptNewClient(request, response);
      return;
    }
    if (request.method === "POST") {
      // ユーザーが新しいメッセージを入力したとき
      broadcastNewMessage(request, response);
      return;
    }
  }
  // それ以外の接続は404エラー
  response.writeHead(404).end();
});

function acceptNewClient(request, response) {
  // レスポンスオブジェクトを記憶する
  clients.push(response);
  // クライアントが接続を終了した場合は、記憶したクライアントの配列から削除する
  request.connection.on("end", () => {
    clients.splice(clients.indexOf(response), 1);
    response.end();
  });
  // ヘッダを設定し、このクライアントだけに最初のチャットイベントを送信する
  response.writeHead(200, {
    "Content-Type": "text/event-stream",
    Connection: "keep-alive",
    "Cache-Control": "no-cache",
    "Access-Control-Allow-Origin": "*", // 本番環境では設定不可
  });
  response.write("event: chat-start\ndata: Connected\n\n"); // クライアント側では特にキャッチしない
  //接続を維持するために、意図的に response.end() を呼び出さない
}

async function broadcastNewMessage(request, response) {
  // ユーザのメッセージを取得する
  request.setEncoding("utf8");
  let body = "";
  for await (let chunk of request) {
    body += chunk;
  }
  // ボディを読み込んだら、空のレスポンスを送信し接続を閉じる
  response
    .writeHead(200, {
      "Access-Control-Allow-Origin": "*", // 本番環境では設定不可
    })
    .end();
  // メッセージをtext/event-stream形式で作成する
  let message = "data: " + body.replace("\n", "\ndata: ");
  // メッセージデータに「chat」イベントを表す接頭辞と終了のための改行2個を付与する
  let event = `event: chat\n${message}\n\n`;
  // 待受中のすべてのクライアントにこのイベントを送信する
  clients.forEach((client) => client.write(event));
}

チャットみたいな非同期処理って、実装が難しそうなイメージがありましたが、けっこう簡単に短いコードで書けました!EventSource様様だ〜〜✨ あくまでもテスト用にやってみただけなので、実運用に耐えれるコードではないと思いますが、ポコポコメッセージが出る感じが実現できて楽しかったです!

Node.js ネットワーク通信で非同期に取得した値をreturnする関数を作る

Node.jsで、ネットワークから値を取得したときにはもちろん非同期処理となるわけですが、取得した値をどうやって呼び出し元に返すか、のまとめです。

最初は httpsモジュールを使っていたのですが、どうやら最近のNode.js(v18以降)は fetch() が使えるようになったようなので、そちらを使います。(やったー!)

Node.js — Node v18.0.0 (Current)

URLは、JSONPlaceholderというJSONのモックを返してくれるサービスを使ってます。(便利〜ありがたや……)

JSONPlaceholder - Free Fake REST API

何も考えてない実装(NG例)
function getData(url) {
  fetch(url)
    .then((response) => {
      return response.json();
    })
    .then((json) => {
      // ここでデータ取得して処理することはできるけど、この値をreturnするにはどうすれば…?
      console.log(json);
    })
    .catch((e) => console.error(e));
}
const url = "https://jsonplaceholder.typicode.com/users";
console.log(getData(url)); // => undefined

関数内の then() で全部処理するのは使い勝手が悪いので、データをreturnしたい……。

方法1: Promiseコンストラクタを使う

Promiseコンストラクタを使うと、自分でPromiseオブジェクトを作成できます。これをreturnすることで、 呼び出し元で then()を使えるようにします。

Promise() コンストラクター - JavaScript | MDN

Promiseコンストラクタに関数を渡すときに、 resolve rejectの2つのパラメータを用意します(名前はなんでもいい)。正常に完了した時は resolveを呼んで、失敗した時は rejectを呼ぶことで、呼び出し元の then() でこれらの引数で渡した値がキャッチできる!

function getData(url) {
  return new Promise((resolve, reject) => {
    fetch(url)
      .then((response) => {
        return response.json();
      })
      .then((json) => {
        resolve(json);
      })
      .catch((e) => reject(e));
  });
}
const url = "https://jsonplaceholder.typicode.com/users";
getData(url).then((result) => console.log(result)); // => 取得できた
方法2: async/awaitを使う

ES2018~追加された asyncawaitを使うことで、より簡単に実装できる!今はこの方法が一番メジャーっぽいですね。 awaitはPromiseオブジェクトを受け取っていい感じに処理してくれます。 エラー処理は、呼び出し元でするようにしてます。

async function getData(url) {
  let response = await fetch(url);
  let json = await response.json();
  return json;
}
const url = "https://jsonplaceholder.typicode.com/users";
getData(url)
  .then((result) => console.log(result)) // => 取得できた
  .catch((e) => console.error(e));

非同期処理はいまだに慣れなくて一瞬戸惑うけど、簡単に書けるんですな〜!

余談ですが、 awaitasyncで宣言された関数の中でしか使えない、と思っていたのですが、 Node.js環境(v14.8以降)では、トップレベルで awaitが使えるんですね〜。(ChatGPTくんが教えてくれた)

JavaScriptのイテレータとジェネレータをちょっと理解した

イテレータとジェネレータという言葉だけで、なんか難しそう…と思っていたのですが、ちゃんと勉強してみたらそんなに恐れることでもなかったです。

反復可能(イテラブル)とは

イテレータは、オブジェクトを反復可能にするよ。反復可能っていうのは、for/ofでループ(巡回)したりスプレッド演算子で展開できたりするってこと。 配列は反復可能。文字列やSetオブジェクトやMapオブジェクトも反復可能。それ以外のオブジェクトはそのままだと反復できない。

console.log(...[1, 2, 3]); // => 1 2 3
console.log(...{ a: "aaa", b: "bbb" }); // TypeError!
イテレータでオブジェクトリテラルを反復可能にしてみる

オブジェクトを反復可能にするには、最低限、以下の3つを設定すればok。これはお作法だと思っとく。

  • [Symbol.iterator]というSymbolの名前を持つメソッドを用意する。
  • その中で、 next() というメソッドを持つイテレータオブジェクトを必ず返す。
  • next()メソッドは、 value プロパティと done プロパティ(論理値型)を持つオブジェクトを返す。反復処理では、doneプロパティがtrueになるまで、next()オブジェクトが繰り返し呼び出される。

オブジェクトを反復した時に、プロパティのキー/値の配列を返却するようにしてみる。

let obj1 = {
  a: "aaa",
  b: "bbb",
  [Symbol.iterator]() {
    let e = Object.entries(this);
    let i = 0;  // next() が呼ばれる度にインクリメントする
    // イテレータオブジェクトを返却する
    return {
      next() {
        return i < e.length ? { value: e[i++] } : { done: true };
      },
      // これはなくても(このコードでは)動くけど、イテレータ自体も反復可能にしておくと便利
      [Symbol.iterator]() {
        return this;
      },
    };
  },
};
console.log(...obj1); // => [ 'a', 'aaa' ] [ 'b', 'bbb' ] : エラーにならなくなった!

本当はもっと複雑な設定とかあるけど、これが一番シンプルな実装。

ジェネレーターでもっと簡単に実装する

イテレータ便利だけど、ループが途中でbreakされた場合とか色々考えるとちょっと複雑になってしまう。ので、もっと簡単にイテレータを作成するためにジェネレータがある。 ジェネレータは、 function*(functionキーワードは省略できたりする) と yield(または yield*)というキーワードを使う。

さっきのコードをジェネレータで書き換えるとこうなる

let obj2 = {
  a: "aaa",
  b: "bbb",
  *[Symbol.iterator]() {
    for (let e of Object.entries(this)) {
      yield e;
    }
  },
};
console.log(...obj2); // => [ 'a', 'aaa' ] [ 'b', 'bbb' ]

next()とかvalue doneとかを自分で設定する必要がなくなって、yield文の値が、イテレータの戻り値になるので、簡潔に書ける!「ループされた時には、yieldの値を呼び出しますからね!」っていう設定だととりあえず思っとく。

ちなみに yield* は、全部の yieldをまとめて返してくれるので、再帰処理とかに便利。

ジェネレータを関数とかクラスとかで使う

以上の例はオブジェクトリテラルでやってみたけど、実際には関数とかクラスとかで使うことの方が多そう。なのでいくつか例を書いておく。

関数 (引数で与えた範囲の整数を列挙する)

function* range(from, to) {
  for (let i = Math.ceil(from); i <= to; i++) yield i;
}
console.log(...range(3, 5)); // => 3 4 5

クラス(コンストラクタで与えた範囲の整数を列挙する)

class Range {
  constructor(from, to) {
    this.from = from;
    this.to = to;
  }
  *[Symbol.iterator]() {
    for (let x = Math.ceil(this.from); x <= this.to; x++) yield x;
  }
}
console.log(...new Range(3, 5)); // => 3 4 5

オブジェクトのメソッド省略記法(プロパティのキー/値を列挙する)

let obj = {
  a: "aaa",
  b: "bbb",
  *keyvalues() {
    for (let e of Object.entries(this)) {
      yield e;
    }
  },
};
console.log(...obj.keyvalues()); // => [ 'a', 'aaa' ] [ 'b', 'bbb' ] [ 'keyvalues', [GeneratorFunction: keyvalues] ]

簡単に書くとこんな感じ。 ジェネレータを使い所で適切に使うことができるようになれば、脱初心者!って感じがします🤓

JavaScriptでオブジェクトをデバッグするときは console.table() が便利

console.table() がめっちゃ便利なの知らなかった!

console.table() - Web API | MDN

console.table()は配列orオブジェクトを引数で受け取り、表形式で出力してくれるConsole APIのようです。

let obj1 = { a: "aaa", b: "bbb", c: "ccc" };
console.table(obj1);
// 出力結果(Node.jsでやってます)
┌─────────┬────────┐
│ (index) │ Values │
├─────────┼────────┤
│ a       │ 'aaa'  │
│ b       │ 'bbb'  │
│ c       │ 'ccc'  │
└─────────┴────────┘

プロパティを持つオブジェクトならなんでもOKのようだ。

class Size {
  constructor(w, h) {
    this.width = w;
    this.height = h;
  }
}
let size = new Size(100, 200);
console.table(size);
// 出力結果
┌─────────┬────────┐
│ (index) │ Values │
├─────────┼────────┤
│ width   │ 100    │
│ height  │ 200    │
└─────────┴────────┘

console.table()が真価を発揮するのは複数のオブジェクトを比較したいとき!

let obj1 = { a: "aaa", b: "bbb", c: "ccc" };
let obj2 = { a: "aaa", b: "bbb", p: "ppp", c: "ccc", d: "ddd" };
let obj3 = { a: "aaa", b: "zzz", d: "ddd" };
console.table({ obj1, obj2, obj3 });
// 出力結果
┌─────────┬───────┬───────┬───────┬───────┬───────┐
│ (index) │ a     │ b     │ c     │ p     │ d     │
├─────────┼───────┼───────┼───────┼───────┼───────┤
│ obj1    │ 'aaa''bbb''ccc' │       │       │
│ obj2    │ 'aaa''bbb''ccc''ppp''ddd' │
│ obj3    │ 'aaa''zzz' │       │       │ 'ddd' │
└─────────┴───────┴───────┴───────┴───────┴───────┘

めちゃわかりやすいやん!変更加えた前後で同一のオブジェクトを比較したい時とかよくあるので、使えそう。(今まで目視で頑張っていた……)

さらに、第2引数で出力するプロパティを絞ることもできる。

console.table({ obj1, obj2, obj3 }, ["a", "b"]);
// 出力結果
┌─────────┬───────┬───────┐
│ (index) │ a     │ b     │
├─────────┼───────┼───────┤
│ obj1    │ 'aaa''bbb' │
│ obj2    │ 'aaa''bbb' │
│ obj3    │ 'aaa''zzz' │
└─────────┴───────┴───────┘

console.log()だけでゴリ押しからそろそろ脱却したいですな〜

JavaScriptのclass定義をインスタンスフィールド構文で今時っぽく書く

JavaScriptのクラス定義の書き方が、ES2022以降に変化したことを知った(遅い)ので、そのまとめです。 主にクラスフィールドの定義を、スタイリッシュに分かりやすく書けるようになったみたいです。

パブリッククラスフィールド

パブリッククラスフィールド - JavaScript | MDN

従来

クラスのインスタンスに対してフィールド(プロパティ)を定義したい時、従来はコンストラクタやメソッドの中でフィールドを初期化する必要があった。

class Size {
  constructor(w, h) {
    this.width = w;
    this.height = h;
  }
}
現在

コンストラクタの外でインスタンスフィールドを直接初期化できるようになった。(この書き方をしてもコンストラクタの一部として実行されるらしい。)先頭に記述することで、どのフィールドがインスタンスの状態を保持しているかが分かりやすくなった。

class Size {
  width = 0;
  height = 0;
  constructor(w, h) {
    this.width = w;
    this.height = h;
  }
}

プライベートフィールド

プライベートプロパティ - JavaScript | MDN

ES2022以降は、プライベートなインスタンスフィールドが利用できるようになった。フィールドの名前を#から始めると、そのフィールドはクラス本体内では利用可能だが、外からは見えなくなりアクセス・変更できなくなる。 プライベートフィールドを定義したときはゲッター関数も定義しておくのが良い。

class Size {
  #width = 0;
  #height = 0;
  constructor(w, h) {
    this.#width = w;
    this.#height = h;
  }
  get width() {
    return this.#width;
  }
  get height() {
    return this.#height;
  }
}
let size = new Size(100, 200);
console.log(size.width); // => 100
console.log(size.#width); // => アクセスできないので構文エラーになる

静的フィールド

static - JavaScript | MDN

従来

クラスに対して静的フィールドを定義したい場合、クラスを定義した後にクラス本体の外側で静的フィールドを定義していた。

class Size {
  constructor(w, h) {
    this.width = w;
    this.height = h;
  }
}
Size.ZERO = new Size(0, 0); // 静的フィールド定義
現在

パブリックフィールドやプライベートフィールドを宣言するときにstaticを使えるようになった。staticを前置すると、インスタンスのプロパティではなくコンストラクタ関数のプロパティとして生成される。

class Size {
  width = 0;
  height = 0;
  static ZERO = new Size(0, 0); // クラス本体中で静的フィールドを定義。Size.ZEROで呼び出す。
  // 以下省略
}

令和っぽいコードを書きたいですな〜!

History: pushState() した後はpopstateイベントでブラウザバックに対応する

前回の記事の内容では不十分だったので、その続き。 (すぐに記事にしたかったのに色々あって1ヶ月も空いてしまった)

aya-cat-g-tech.hatenadiary.jp

非同期でページング処理をしている画面にて、History: pushState()を使ってURLにページ番号を付与するようにしましたが、ブラウザバックのことを考慮していませんでした。 何もしないでいると、ブラウザバック時にはブラウザのアドレスバーは変化するが、画面は何も変わらないということになってしまいます。

なので、popstateイベントを検知するようにしてブラウザバックに対応します。

Window: popstate イベント - Web API | MDN

ページングのコールバック関数内でhistory.pushState()を設定する(おさらい)

clickCallback(pageNum) {
      let url = new URL(location.href);
      url.searchParams.set('page', pageNum);
      history.pushState(
        null,
        '',
        `${url.pathname}?${url.searchParams.toString()}`
      );
},

popstateのイベントリスナーを設定しておく。Vue.jsだったらcreated()内とかに書く。 以下コードはVue.jsで、this.currentPageに現在のページ番号を設定しリアクティブに描写し直しています。

window.addEventListener('popstate', () => {
      let page = new URLSearchParams(location.search).get('page');  // パラメータからページ番号取得
      this.currentPage = Number(page) || 1;
      if (this.isEmpty(this.list[this.currentPage])) {
        // リロードを挟んだ時などを考慮して、結果がなければ非同期で取得する処理も書いておくと良い
      }
});

ページ番号はURLのパラメータから取得するようにしました。 以下のように、history.pushState()の第1引数で状態を渡すこともできるようですが、これだとブラウザバックの途中でリロードを挟まれた時などにうまく動作しないので、パラメータから取得するのが一番確実な感じがしました。

リロードとかされるとうまくいかなかった例

// ページング時の処理
clickCallback(pageNum) {
      let url = new URL(location.href);
      url.searchParams.set('page', pageNum);
      history.pushState(
        { page: pageNum },
        '',
        `${url.pathname}?${url.searchParams.toString()}`
      );
},
// イベントリスナー
window.addEventListener('popstate', (event) => {
      this.currentPage = event.state.page;
});

不妊治療のために休職することになりました

来月から仕事を休職させてもらうことになりました🙋‍♀️

休職の理由は、不妊治療と仕事との両立で、メンタルが限界になってしまったことです。

不妊治療は1年半ほど続けています。タイミング法や人工授精を1年続けましたが、全くなんの成果も得られず、昨年冬から体外受精に挑むことになりました。
体外受精となった途端、通院の日数や投与する薬などが大幅に増えました。1回目の体外受精は陰性。再度採卵からやり直し、2回目の体外受精でようやく初めての妊娠判定もらえて喜んでいたのも束の間、2週間後に胎嚢の確認ができず化学流産となりました。

流産を告げられた時、なんだかどっと疲れてしまい、「あ、これ無理だ」と感じました。今までは不妊治療にそれほどストレスは感じていないと思っていたし、仕事との両立もできていると自分では思っていたのですが。張り詰めていたものが緩んだというか、もう何も頑張れないな〜という気持ちになってしまったのです。

実は私は、1年半前に不妊治療を開始するタイミングで、会社に相談し、正社員から時短の契約社員へと雇用形態を変更してもらっていました。ほぼフルリモートワークで、フレックスで、さらに時短勤務で中抜け等も自由にでき、治療と両立する仕事環境としては本当にこれ以上ないくらい恵まれていたと思います。システムエンジニアならではの自由な働き方を享受していました。それでも、無理になってしまいました。

一番のストレスは、仕事量の調整でした。
不妊治療は、体の周期に合わせて治療するため、先が見えず予定が立てにくいです。そして、薬の投与中や妊娠中は体調がすぐれないことも多々あります。その中で、「この期間は具合が悪いかもしれないからこの仕事は断ろう」などと先を予想してセーブをしていく必要があるのですが(これをしないと後で辛くなる)、その『満足に仕事ができない状態』が徐々に自分で自分を責めるような形で辛くなってきたのかなと思います。(個人が自己責任でマネジメントをしなければいけない職場の風土も問題だとは思うのですが…。)

そして、今回の化学流産。妊娠時にはよくあることで、一般的には流産の回数にはカウントすらされないようです。それでもやはり、辛いものは辛い。仕事の調整のために上司にだけは早くに妊娠報告をしていたのですが、流産を伝えるのも辛い。気を遣わせてしまうし迷惑をかけるのも辛い。しかも、「あと何回もこれを繰り返す可能性があるのか…」と思うと…。悩んだ末に、仕事を辞めるしかないという結論に達しました。

フルタイム正社員などで働きながら不妊治療をして妊娠してる人も世の中にはたくさんいると思いますが、私には難しかったです。世の中の女性、本当にすごいと思います。

以上のようなかなり後ろ向きな気持ちで、退職を申し出たのですが、ありがたいことに休職の選択肢を提示してもらいました。治療がうまくいけば、安定期に入ってから復職してもいいし、復職せずにそのまま産休・育休を取ることも可能だと説明を受けました。会社としては、エンジニアが不足している今、融通を利かせても人材を確保しておきたいという狙いがあると思います。私としても、産休・育休を取れる可能性を残せることが魅力的で、お言葉に甘えて休職させていただくことにしました。

休職できる期間は約半年。不妊治療には心許ない期間ですが…。この期間は、会社によって異なると思います。)休職中は無給で、毎月社会保険を会社に振り込む必要があります。お金には変えられないことなので、貯金でなんとかやっていくつもりです。少子化対策というのなら、不妊治療で傷病手当金が受け取れるようになればいいのにな〜。

まずはゆっくり休んで、ストレスのない中で不妊治療をしてみて、今後のことはそれから考えようと思います。治療がうまくいけば、少し復帰して育休を取りたいし、うまくいかなかった場合は、不妊治療を諦めるのか、今の仕事に復帰するか、はたまた転職等して新たにやり直すか、今はまだ分からない状況です。

ちょうどリスキリングをしたいと思っていた時期でもあるので、これを機に休職中はできるだけ勉強して技術ブログも更新していきたいです。(最近更新できていなかった…。)ゲームもたくさんしたい!

せっかく与えていただいた休職という機会なので、前向きに過ごしてこれからを考える時間にしたいです🌟