30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

『仕組みと使い方がわかる Docker&Kubernetesのきほんのきほん』を読んだ

今まで読んできた Docker の入門書や入門記事のなかで一番分かりやすく、ようやく Docker に入門できた気がする。
Docker を学ぼうとして何度も挫折してきた人におすすめ。

本書の「はじめに」に書かれている通り入門者をターゲットにしており、それを徹底した内容になっている。
フルカラーで図が多く、1 ページあたりの情報量は少ない。また、脱落者を減らすためだと思われるが、同じ内容について何度も記述しているため、本の厚さ(約 320 ページ)のわりに、得られる情報は少ない。
そのため、多くの情報を求めている人にとっては不満だと思う。本書を読んでも、本当に初歩的なことしか得られない。
しかし、自分のように Docker に入門できず、Docker が何なのか理解できず、苦しんできた人にとっては、素晴らしい本だと思う。ちゃんと入門させてくれる。

Docker の入門記事は次から次へと出てくる印象があるが、それくらい理解しづらい技術ということなんだと思う。
Docker には様々なメリットがあり、そして様々なところで使われており、だから分かりづらいのかもしれない。人によって、記事によって、文脈が違いすぎる。何のために何を期待して Docker を使っているのかが、違いすぎる。そしてそれに対して明示的に説明されない。だから分かりづらく、記事を読んでも文意や論点を掴めず却って混乱する。
そんなことを考えていたら、本書にまさにそういった趣旨のことが書かれていた。

Docker の理解を妨げる要因は、個々のメリットだけが語られて、「なぜそのメリットや性質が発生するのかの仕組み」を、語る機会が少ない点にあります。(p36)

本書ではきちんと、「仕組み」について語っている。
Docker とはどのようなもので、それの何がありがたくて、何のためもので、どういう仕組みで動いているのか、順を追って説明してくれている。
もちろん初心者向けの説明であり、詳しい人から見るとかなり不十分な説明なんだと思う。だが初心者にとって大切なのは大まかな仕組みを頭のなかに描けるようになることであり、細かい知識は後回しでよい。

今まで読んだ入門書のなかにはただコマンドを羅列するだけのようなものもあったが、それでは頭に入ってこない。書かれているコマンドを打ち込めば動作するので何かした気持ちにはなれるが、そのコマンドが何のために行うものなのかは、全く理解できていない。そしてそれでは、記憶に定着しない。
大切なのはコマンドを覚えることではなく、本書のタイトル通り、Docker そのものの仕組みや使い方を理解することだと思う。それさえ分かっていれば、コマンドは知らなくてもよい。必要になったときに調べればいいし、調べた結果出てくるコマンドの説明も、理解できる。自分の目的(やりたいこと)に見合ったコマンドなのかも判断できる。Docker に限らないが、その技術の概要さえ分かっていれば、演繹的に考えることができるようになり、理解や思考がスムーズになる。

メリットをただ羅列するのではなく、なぜそのメリットが発生するのか、どういう仕組みでそれを実現させているのかが書かれており、その技術を使ったときに具体的に何が起きているのかを自分なりにイメージできるようになる。それが良い入門書だと思うが、本書も自分にとってそういった「良い入門書」だった。
これまで Docker に入門しようとして失敗してきた人は、ぜひ本書を読んでみて欲しい。

Chrome に新しく実装された App History API で SPA を作ってみる

Single Page Application(以下、SPA)を構成する技術要素のひとつに、History API がある。この機能によってページ遷移の履歴を管理できるようになり、SPA を成立させることができる。
しかしこの History API はお世辞にも使いやすいとは言い難い。作られた当初は SPA のような使われ方を想定していなかったと思われるので仕方ないのだが、実際に History API で SPA を作ろうとすると、かなり苦労する。そのため History API を直接触ることは稀で、React Router や Next.js が提供している機能を使うことが多い。
History API については以前ブログに書いたので、これを読むと雰囲気を掴めると思う。

そこで、もっと SPA を作るのに適した API を作ろうということで提案されているのが、App History API である。

WICG で議論が行われており、Chrome への実装が進んでいる。Chrome Canary では既に使えるようになっている。

この記事では App History API の機能を見ていき、実際に SPA を作ってみる。

注意点として、この API はまだ実験的なものであり、標準化されて他のブラウザにも実装されていくのかは分からないし、仕様もまだまだ変化していくと思われる。そもそも Chrome での扱いも今後どうなっていくのか分からない。取り敢えず Chrome 95 以降では、後述するフラグを有効にすることで使えるようになるようだが。
そのため実用性のある内容ではない。あくまでも、個人的に興味があるから触ってみたに過ぎない。

動作確認は Google Chrome Canary の96.0.4647.2で行っている。

事前準備

Chrome Canary を入手する。
https://www.google.com/intl/ja/chrome/canary/

Chrome Canary を起動したらchrome://flags/にアクセスし、Experimental Web Platform featuresフラグを有効にして再起動する。
これで App History API を使えるようになった。

index.jsという JavaScript ファイルを用意する。このファイルに App History API を使ったプログラムを書いていくが、この時点ではまだ何も書かなくてよい。

次に、ウェブサーバを立てる。この記事ではv1.14.0の Deno を使っているが、同等の機能を提供できるものなら何でもよい。
以下の内容のserver.tsを用意して$ deno run --watch --allow-net --allow-read server.tsを実行すると、サーバが起動する。

import { listenAndServe } from "https://deno.land/std@0.107.0/http/mod.ts";

const JS_FILE_URL = "/index.js";

const indexHtml = `
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>App History API: index</title>
</head>
<body>
  <p>This is index page.</p>
  <p><a href="/foo">to foo</a></p>
  <script src="${JS_FILE_URL}"></script>
</body>
</html>
`;

const fooHtml = `
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>App History API: foo</title>
</head>
<body>
<p>This is foo page.</p>
<p><a href="/">to index</a></p>
<script src="${JS_FILE_URL}"></script>
</body>
</html>
`;

listenAndServe(":8080", async (req) => {
  const { pathname } = new URL(req.url);
  switch (true) {
    case pathname === "/": {
      return new Response(indexHtml, {
        status: 200,
        headers: { "Content-Type": "text/html" },
      });
    }

    case pathname === "/foo": {
      return new Response(fooHtml, {
        status: 200,
        headers: { "Content-Type": "text/html" },
      });
    }

    case pathname === JS_FILE_URL: {
      const js = await Deno.readFile(`.${JS_FILE_URL}`);
      return new Response(js, {
        status: 200,
        headers: { "Content-Type": "text/javascript" },
      });
    }

    default: {
      return new Response("Not Found\n", {
        status: 404,
        headers: { "Content-Type": "text/plain" },
      });
    }
  }
});

http://localhost:8080/にアクセスすると、/fooへのリンクが表示される。
これをクリックするとサーバに HTTP リクエストが送られ、サーバが返した/fooの HTML をブラウザが表示する。この HTML には/へのリンクがあるが、これをクリックしたときも同様の挙動となる。
これは SPA との対比で Multiple Page Application(MPA)と呼ばれる、「一般的」なウェブサイトである。
このウェブサイトに App History API を導入し、どのように挙動が変わるのかを見ていく。

transitionWhile によるナビゲーションの制御

これからindex.jsにコードを書いていくが、まずは App History API を使える環境かを確認してから、処理を書いていく。

function hasAppHistory() {
  return globalThis && "appHistory" in globalThis;
}

function main() {
  if (!hasAppHistory()) return;

  // ここに処理を書いていく
}

main();

appHistoryオブジェクトにはnavigateイベントがあり、これはその名の通りナビゲーションが行われるときに発生する。
イベントハンドラを以下のように設定すれば、リンクをクリックした際にnavigate!というダイアログが表示され、それから画面の遷移が発生する。

appHistory.addEventListener("navigate", () => {
  alert("navigate!");
});

イベントハンドラの引数はAppHistoryNavigateEventで、このオブジェクトが持っているtransitionWhileメソッドを使うことでナビゲーション時の挙動を制御できる。

appHistory.addEventListener("navigate", (e) => {
  console.log(e); // AppHistoryNavigateEvent
  console.log(e.transitionWhile); // ƒ transitionWhile() { [native code] }
});

ナビゲーション時に行いたい処理をtransitionWhileに渡せばよいのだが、引数に文を渡すことは当然できないので、以下のように関数に処理を書き、その関数をtransitionWhileに渡すときに実行すればよい。

以下の場合、リンクをクリックすると画面にはabcと表示される。従来のナビゲーションは行われないので、HTTP リクエストは発生しない。そしてアドレスバーを見ると、きちんとリンク先のものに変化している。
つまり、従来の History API のpushStateと同等の処理をしつつ、表示内容の制御も行えていることになる。

appHistory.addEventListener("navigate", (e) => {
  const myNavigation = () => {
    document.querySelector("body").replaceWith("abc");
  };

  e.transitionWhile(myNavigation());
});

myNavigationを書き換えて、HTML の再読み込みなしに//fooを行き来できるようにする。

appHistory.addEventListener("navigate", (e) => {
  const myNavigation = async () => {
    const resource = await (await fetch(e.destination.url)).text();

    const parser = new DOMParser();
    const { title, body } = parser.parseFromString(resource, "text/html");

    document.title = title;
    document.querySelector("body").replaceWith(body);
  };

  e.transitionWhile(myNavigation());
});

AppHistoryNavigateEventdestination.urlに遷移先の URL が格納されているので、fetchでその URL のリソースを取得する。
あとはそれを使って表示内容やページタイトルを書き換えればよい。

これで、ページのリロードを行わずに遷移できる。
また、ブラウザバックやフォワードも問題なく動作している。History API ではpopstateイベントを使って対応する必要があったが、これも不要になる。

navigatesuccess と navigateerror

appHistoryオブジェクトにはnavigateイベントの他に、navigatesuccessイベントやnavigateerrorイベントがある。

navigatesuccessイベントはその名の通り、ナビゲーションが成功したときに発生する。
以下のコードの場合、リンクをクリックすると画面にはabcが表示され、ログにはsuccess!が流れる。

appHistory.addEventListener("navigate", (e) => {
  const myNavigation = () => {
    document.querySelector("body").replaceWith("abc");
  };

  e.transitionWhile(myNavigation());
});

appHistory.addEventListener("navigatesuccess", () => {
  console.log("success!");
});

appHistory.addEventListener("navigateerror", () => {
  console.log("error!");
});

ではnavigateerrorはナビゲーション中に例外が発生したときに発生するのかというと、そうではない。myNavigationのなかで例外を投げても、navigateerrorイベントは発生しない。
myNavigationのなかで発生した例外をキャッチしたければ、以下のように書く必要がある。

appHistory.addEventListener("navigate", (e) => {
  const myNavigation = () => {
    throw new Error("xyz");
  };

  try {
    e.transitionWhile(myNavigation());
  } catch (e) {
    console.log(e.message); // xyz
  }
});

navigateerrorがいつ発生するのかというと、transitionWhilePromiseを渡し、それがリジェクトされた際に発生する。
そのため以下のコードのときにリンクをクリックすると、画面にはabcが表示されログにはerror! xyzが流れる。

appHistory.addEventListener("navigate", (e) => {
  e.transitionWhile(
    new Promise((_, reject) => {
      document.querySelector("body").replaceWith("abc");
      reject(new Error("xyz"));
    })
  );
});

appHistory.addEventListener("navigatesuccess", () => {
  console.log("success!");
});

appHistory.addEventListener("navigateerror", (e) => {
  console.log("error!", e.error.message);
});

Promise のなかで例外を投げるとリジェクトされるので、以下のコードでも同じ結果になる。

e.transitionWhile(
  new Promise(() => {
    document.querySelector("body").replaceWith("abc");
    throw new Error("xyz");
  })
);

実際に SPA を作ってみる

React と App History API を組み合わせて簡単な SPA を作ってみた。
冒頭の記事のなかで History API を使って作ったものと、全く同じ内容。App History API を使えばpushStatepopstateを考えなくてよいため、かなりシンプルになっている。

import React, { useState, useEffect } from "react";

export const App = () => {
  const [pathname, setPathname] = useState(window.location.pathname);

  useEffect(() => {
    appHistory.addEventListener("navigate", (e) => {
      const myNavigation = () => {
        setPathname(new URL(e.destination.url).pathname);
      };

      e.transitionWhile(myNavigation());
    });
  }, []);

  const Content = () => {
    switch (pathname) {
      case "/foo":
        return <div>foo</div>;
      case "/bar":
        return <div>bar</div>;
      default:
        return <div>Not Found</div>;
    }
  };

  return (
    <div>
      <Content />
      <nav>
        <a href="/foo">to foo</a> <a href="/bar">to bar</a>
      </nav>
    </div>
  );
};

もちろん、React のような UI ライブラリを使わずに書くこともできる。
以下は、API から JSON を取得して UI を作る、というよくある SPA。
transitionWhileのなかでルーティングを行い、パスに応じた処理を行っている。

// index.js

import {
  createIndexElement,
  createMemberElement,
  createNotFoundElement,
} from "./element.js";

export const MEMBERS = ["alice", "bob", "carol"];
export const ALLOW_PAGE_URL_LIST = [
  "/",
  ...MEMBERS.map((member) => `/${member}`),
];

function hasAppHistory() {
  return globalThis && "appHistory" in globalThis;
}

async function router(pathname) {
  if (!ALLOW_PAGE_URL_LIST.includes(pathname)) {
    renderApp(createNotFoundElement());
    return;
  }

  if (pathname === "/") {
    renderApp(createIndexElement());
    return;
  }

  const json = await (await fetch(`/api${pathname}`)).json();
  const { id, name } = json;
  renderApp(createMemberElement(id, name));
}

function renderApp(dom) {
  const app = document.querySelector("#app");
  app.textContent = "";
  app.appendChild(dom);
}

function main() {
  if (!hasAppHistory()) return;

  router(window.location.pathname);

  appHistory.addEventListener("navigate", (e) => {
    const myNavigation = () => {
      const { pathname } = new URL(e.destination.url);
      return router(pathname);
    };

    e.transitionWhile(myNavigation());
  });
}

main();
// element.js

import { MEMBERS } from "./index.js";

export function createIndexElement() {
  const ul = document.createElement("ul");
  MEMBERS.forEach((member) => {
    const anchor = document.createElement("a");
    anchor.setAttribute("href", `/${member}`);
    anchor.textContent = member;
    const li = document.createElement("li");
    li.appendChild(anchor);
    ul.appendChild(li);
  });
  const anchor = document.createElement("a");
  anchor.setAttribute("href", `/foo`);
  anchor.textContent = "foo";
  const li = document.createElement("li");
  li.appendChild(anchor);
  ul.appendChild(li);
  return ul;
}

export function createMemberElement(id, name) {
  const div = document.createElement("div");

  const idSpan = document.createElement("span");
  idSpan.textContent = `id: ${id}`;
  div.appendChild(idSpan);

  let br = document.createElement("br");
  div.appendChild(br);

  const nameSpan = document.createElement("span");
  nameSpan.textContent = `name: ${name}`;
  div.appendChild(nameSpan);

  br = document.createElement("br");
  div.appendChild(br);

  const anchor = document.createElement("a");
  anchor.setAttribute("href", `/`);
  anchor.textContent = "to index page";
  div.appendChild(anchor);
  return div;
}

export function createNotFoundElement() {
  const div = document.createElement("div");

  const span = document.createElement("span");
  span.textContent = "Nof Found.";
  div.appendChild(span);

  const br = document.createElement("br");
  div.appendChild(br);

  const anchor = document.createElement("a");
  anchor.setAttribute("href", `/`);
  anchor.textContent = "to index page";
  div.appendChild(anchor);

  return div;
}
// server.ts

import { listenAndServe } from "https://deno.land/std@0.107.0/http/mod.ts";

import { MEMBERS, ALLOW_PAGE_URL_LIST } from "./index.js";

const INDEX_JS_FILE_URL = "/index.js";
const ELEMENT_JS_FILE_URL = "/element.js";

const ALLOW_API_URL_LIST = MEMBERS.map((member) => `/api/${member}`);

const html = `
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>App History API</title>
</head>
<body>
  <h1>Sample</h1>
  <div id="app"></div>
  <script type="module" src="${INDEX_JS_FILE_URL}"></script>
</body>
</html>
`;

const apiEndPointToJson = new Map([
  ["/api/alice", { id: 1, name: "Alice" }],
  ["/api/bob", { id: 2, name: "Bob" }],
  ["/api/carol", { id: 3, name: "Carol" }],
]);

listenAndServe(":8080", async (req) => {
  const { pathname } = new URL(req.url);
  switch (true) {
    case ALLOW_PAGE_URL_LIST.includes(pathname): {
      return new Response(html, {
        status: 200,
        headers: { "Content-Type": "text/html" },
      });
    }

    case ALLOW_API_URL_LIST.includes(pathname): {
      return new Response(JSON.stringify(apiEndPointToJson.get(pathname)), {
        status: 200,
        headers: { "Content-Type": "application/json" },
      });
    }

    case pathname === INDEX_JS_FILE_URL: {
      const js = await Deno.readFile(`.${INDEX_JS_FILE_URL}`);
      return new Response(js, {
        status: 200,
        headers: { "Content-Type": "text/javascript" },
      });
    }

    case pathname === ELEMENT_JS_FILE_URL: {
      const js = await Deno.readFile(`.${ELEMENT_JS_FILE_URL}`);
      return new Response(js, {
        status: 200,
        headers: { "Content-Type": "text/javascript" },
      });
    }

    default: {
      return new Response("Not Found\n", {
        status: 404,
        headers: { "Content-Type": "text/plain" },
      });
    }
  }
});

参考資料