Traefik on k8s で let's encrypt のワイルドカード TLS 証明書を自動発行する

Traefik (proxy)k8s Ingress controller として使うと、ワイルドカード証明書の自動発行が簡単そうなのでやってみた。 Traefik 公式ドキュメント が、わかりにくい、というか、設定のための情報が散逸してたり、そもそもプロダクトとして必ずしも k8s を前提としているわけではないのでじゃあ k8s 向けにはどうすんだ、みたいなのが難しかったので、やったことのメモ書き。

状況設定としては

  • LAN 内に 192.168.0.100 を IP としてもつサーバがあり、そこで k0s をシングルノードで動かしている
  • example.com という domain を保持している
  • LAN 内の別の PC から、サーバに対して https://nantoka.wildcard.example.com/ でアクセスしたい

みたいな感じ。

インストール

公式 Docker image を使って自力で manifest 書いてもなんとか動くとは思うんだけど、 CRD (Custom Resource Definitions) とかあるしけっこう骨だと思われるので、素直に公式の Helm chart を使ってインストールする。

基本的には公式ドキュメントの k8s 向けインストール手順に従えばいい。

公式レポジトリを

$ helm repo add traefik https://helm.traefik.io/traefik

追加し

$ helm inspect values traefik/traefik > values.yaml

のようにして設定ファイルを作成しておく。

設定を指定しつインストール

$ helm install -f values.yaml traefik traefik/traefik

インストール後に設定ファイル values.yaml を更新してそれを反映するときは

$ helm upgrade -f values.yaml traefik traefik/traefik

とする。

service.externalIPs を指定して外部からのアクセスをうけつける

これで Ingress Controller としては立ち上がったので通常の Ingress を作成するとクラスタ外部からアクセスできるようになる、はず、だが、 公式 Helm chart でインストールするとデフォルトでは Traefik の Service が外部に露出していない。

さきほど作成した values.yamlservice.externalIPs に露出する IP を指定する。

service:
  externalIPs:
    - 192.168.0.100

これでクラスタ外から Ingress / Service 経由で Pod 等にアクセスできるようになる。

Traefik の dashboard にアクセスする

https://doc.traefik.io/traefik/getting-started/install-traefik/#exposing-the-traefik-dashboard

いったんはこれの port-forward で dashboard にアクセスする。

Helm chart でインストールすると dashboard 用の Traefik IngressRoute がインストールされてはいるが、 entrypoint が traefik になっており、これは Helm chart でセットアップされる port としては expose: false になっているためそのままでは外部からアクセスできない。

このへんは TLS まわりのセットアップがおわってから整備していくことにする。

DNS の設定

LAN 内の別の PC から、サーバに対して https://nantoka.wildcard.example.com/ でアクセスしたい

なので、 DNS サーバで *.wildcard.example.com の A レコードを (今回の例だと) 192.168.0.100 に設定する。

Persistent Volume の設定

Traefik は Let's Encrypt (および ACME protocol サポートしている TLS 証明書発行者) の証明書の自動発行・自動更新に対応していると冒頭に書いたが、 そのためには Traefik サーバ側で発行された証明書を管理するために永続ストレージが必要となる。

k8s クラスタで PersistentVolumeClaim (PVC) に対応した PersistentVolume があるのであれば、 values.yaml

persistence:
  enabled: true

のように書くと、 PersistentVolume が /data というパス (実際には values.yamlpersistence.path で指定されている) にマウントされる。 ( traefik-helm-chart/values.yaml at 5d97a2e30076302950c31fc9a98f267bdd624fe8 · traefik/traefik-helm-chart · GitHub 参照)

hostPath Volume を利用する場合

自分の環境の場合、シングルノードでもありまだちゃんとした PersistentVolume はセットアップしていなかったので、いったん hostPath な Volume を利用することにした。

この場合、以下のような values.yaml を書くことになる。

deployment:
  additionalVolumes:
    - name: acmeStore
      hostPath:
        path: /volumes/acmeStore
        type: Directory

additionalVolumeMounts:
  - name: acmeStore
    mountPath: /acmeStore

deployment.additionalVolumes[].hostPath.path は適宜 k8s ノード側のパスを指定すること (この例だと /volumes/acmeStore を用意した)。 公式の Helm chart だと /data ディレクトリをマウント先としているため、そことかぶらないようにした。

(ちなみにこのへんの設定は、結局 traefik-helm-chart/_podtemplate.tpl at master · traefik/traefik-helm-chart · GitHub などのファイルを読み解いた。このへんも公式 Helm chart のわかりづらいところだと思う)

また、 公式 Helm chart だと、プロセスが uid=65532, gid=65532 で動くので、そのアカウントにとって writable なディレクトリにしておく必要がある。

$ sudo chown 65532:65532 /volumes/acmeStore

証明書ストアの場所を Traefik に設定する

証明書ストアの場所だが https://doc.traefik.io/traefik/https/acme/#storage に書いてあるように、 Traefik の設定としては、たとえば以下のように書く必要がある。

certificatesResolvers:
  myresolver:
    acme:
      storage: /acmeStore/acme.json

が、これはあくまで Traefik の static configuration に書いておく必要があるのであって、これをそのまま values.yaml に書くのではない (わかりづらい)。

残念ながら現在の公式 Helm chart では certficicatesResolvers の便利な書き方がサポートされているわけではないので、以下のように書く必要がある。

additionalArguments:
  - "--certificatesResolvers.le.acme.storage=/acmeStore/acme.json"

certificatesResolver の名前としては、公式ドキュメントに倣って le (Let's Encrypt の略かな?) としたが、なんでも構わない。

ACME Challenge の設定

Let's Encrypt でワイルドカード証明書を取得するためには、 DNS-01 Challenge を利用する必要がある。

https://doc.traefik.io/traefik/https/acme/#dnschallenge

かんたんにいうと、 対象となるドメインの TXT レコードを設定し Let's Encrypt 側にそれを確認してもらう Challenge である。

なので HTTP-01 Challenge や TLS-ALPN-01 Challenge と異なり、今回のように対象ドメインの解決先が private IP でも利用できる (副産物であるが)。

Challenge の設定をするためには、 Traefik の設定として下記のように設定する。

certificatesResolvers:
  myresolver:
    acme:
      storage: /acmeStore/acme.json  # 設定済
      email: yourmail@example.com
      dnsChallenge:
        provider: ***provider***

さきほど説明したように、これはあくまで Traefik の設定なので、 Helm chart でインストールしている場合は以下のように additionalArguments で設定する必要がある。

additionalArguments:
  - "--certificatesResolvers.le.acme.storage=/acmeStore/acme.json"  # 設定済
  - "--certificatesResolvers.le.acme.email=youremail@example.com"
  - "--certificatesResolvers.le.acme.dnsChallenge:provider=***provider***"

さて、上記の例で ***provider*** と書いてあるところは、 DNS プロバイダを指定する。 どのような DNS プロバイダがサポートされているかは、以下に記載されている (ライブラリとして LEGO を利用しているようだ)。

https://doc.traefik.io/traefik/https/acme/#providers

今回自分は LuaDNS を利用したが、もちろん Route 53 や Google Cloud DNS (Google Domains の DNS ではないことに注意 *1 ) もサポートされている、だけではなくさくらのクラウドIIJ もサポートされているようだ。 また、 仮にサポートされていないとしても外部プログラムを用いて TXT レコードを設定することができれば利用できそうである。

LuaDNS の場合、

additionalArguments:
  - "--certificatesResolvers.le.acme.dnsChallenge:provider=luadns"

のように書く。

また、 LuaDNS 用の設定として環境変数を指定する必要がある。 環境変数values.yml に以下のように記述する。

env:
  - name: LUADNS_API_USERNAME
    value: "***username***"   # 実質 LuaDNS でのアカウントの e-mail アドレス
  - name: LUADNS_API_TOKEN
    value: "***API token***"

アプリケーションの公開

ということで、ようやくアプリケーションを LAN 内に公開できるようになった。

なんとなく定番っぽい whoami イメージを立ち上げることにする。

Deployment と Service については、特筆するべきことはないと思う。

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami-deployment
spec:
  selector:
    matchLabels:
      app: whoami
  replicas: 1
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
      - name: whoami
        image: jwilder/whoami
        ports:
        - containerPort: 8000

Service

apiVersion: v1
kind: Service
metadata:
  name: whoami-service
  labels:
    app: whoami
spec:
  selector:
    app: whoami
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8000

Ingress

TLS 証明書自動発行のためには、 通常 Ingress として利用される networking.k8s.io/v1Ingress ではなく Traefik の CustomResource である traefik.containo.us/v1alpha1 の IngressRoute を利用する必要がある。

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: whoami-ingress
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`whoami.wildcard.example.com`)
      kind: Rule
      services:
        - name: whoami-service
          port: 80
  tls:
    certResolver: le
    domains:
      - main: "wildcard.example.com"
        sans:
          - "*.wildcard.example.com"

spec.tls.certResolver のところで、上記で設定した certificatesResolvers である le を指定している。

また、 domains のところで指定したとおり、

  • wildcard.example.com をメインの domain としつつ
  • SANs (サブジェクト代替名) としてワイルドカード*.wildcard.example.com を含む

TLS 証明書が Let's encrypt から発行される。

ここは main として *.wildcard.example.com を指定しても (ドキュメントによれば) うまくいくと思うが、 ドキュメントのサンプル設定にしたがってこのようにした。

この IngressRoute resource により、クライアントから https://whoami.wildcard.example.com/ にアクセスすると

  • まだ TLS 証明書を発行していなければ発行する
  • すでに発行されているが古ければ再発行する
  • さもなければすでに発行されている証明書を利用する

といった動作となる。

Traefik dashboard を LAN からアクセスできるようにする

これで任意の *.wildcard.example.com に対して TLS アクセスができるようになった。

さきほどまで port-forward で利用していた Traefik dashboard も IngressRoute で公開してみる。

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: dashboard-traefik
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`traefik.wildcard.example.com`) && (PathPrefix(`/dashboard`) || PathPrefix(`/api`))
      kind: Rule
      services:
        - name: api@internal
          kind: TraefikService
  tls:
    certResolver: le
    domains:
      - main: "wildcard.example.com"
        sans:
          - "*.wildcard.example.com"

このリソースを作成することで、 https://traefik.wildcard.example.com/dashboard/ にアクセスすると dashboard にアクセスできるようになる。

すこしだけ IngressRoute の TLS 設定を簡略化する

毎回毎回証明書の main と SANs を指定していくのはダルいし、もしかすると別のリソースで間違った SANs を指定してしまうかもしれない。

entryPoints の static configuration として TLS 設定をしておくと、各 IngressRoute のほうの設定が少し楽になる。

values.ymlports: に entryPoint の設定があるので、

ports:
  websecure:
    port: 8443
    expose: true
    exposedPort: 443
    protocol: TCP
    tls:
      enabled: false
      options: ""
      certResolver: ""
      domains: []

となっているところを

ports:
  websecure:
    tls:
      enabled: false
      options: ""
      certResolver: le
      domains:
        - main: "wildcard.example.com"
          sans:
            - "*.wildcard.example.com"

のようにする。

ports.websecure.tls.enabled は false のままでよいと思う。 もしかすると k8s クラスタで利用する TLS 証明書が単一の場合はここを true にしておくことで、 IngressRoute 側で一切指定をしなくても TLS 証明書つきアクセスを提供できるのかもしれない。 https://doc.traefik.io/traefik/user-guides/crd-acme/#traefik-routers を参照したところ spec.tls.certResolver の設定だけやってますね。 時間ができたらやってみる。

これで IngressRoute のほうは

spec:
  tls:
    certResolver: le
    domains:
      - main: "wildcard.example.com"

のように指定するだけで、ワイルドカード証明書を利用できるようになった。

まあ一行減っただけだし、そもそも main としてワイルドカードドメインを指定していれば減るわけでもないので、ふつうはここまでやる必要はないのかもしれない。

*1:Google Domains の DNS は TXT レコードの API 更新が存在しないため現在はサポートされていない

react-scripts の dev server を fastify(-webpack-hmr) で動かす

とりあえずやってみたらパッと見動いた、くらいの内容で、ただのメモ書きです。

API server と create-react-apps で生成した Vue app を dev server で同居させるには、 本来的には dev server からの proxy でなんとかする するのがスジっぽい。

でも、たとえば本番は build した内容を static contents として (API server と同居させて) serve するのに、開発環境は proxy する (しかも dev server のほうが表に立つ) のはどうなんだという気がしなくもない。

Next.js なら GitHub - fastify/fastify-nextjs: React server side rendering support for Fastify with Next を使えば fastify から serve できるっぽいんだけど (これが hot reloading に対応しているかは不明)、 create-react-app (が利用する react-scripts dev server) ではどうすればいいのかわからない。

なので、 なんとかならんかなーと思ってやってみた。

create-react-app/start.js at main · facebook/create-react-app · GitHub をコピペしつつ GitHub - lependu/fastify-webpack-hmr: Webpack hot module reloading for Fastify をくみこんでみただけです。

react-scripts の dev server、デフォルトで react-dev-utils の create-react-app/webpackHotDevClient.js at main · facebook/create-react-app · GitHub を利用してるっぽいんだけど、それを利用するのはうまくいかなかった。

なのでこれだとエラー画面とかでないんじゃないかな…… (2021-09-17 追記: ビルドエラーはちゃんとでました)

TypeScript で書きたかったんだけど、 型定義ないやつあったり @types が古かったりしたんで、とりあえず (もとの JS のまま) コピペ。

process.env.BABEL_ENV = 'development';
process.env.NODE_ENV = 'development';

const fs = require('fs');
const {
  choosePort,
  createCompiler,
  prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const paths = require('react-scripts/config/paths');
const configFactory = require('react-scripts/config/webpack.config');
const webpack = require('webpack');
const fastify = require('fastify');
const hmr = require('fastify-webpack-hmr');

async function main() {
  const useYarn = fs.existsSync(paths.yarnLockFile);

  const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
  const HOST = process.env.HOST || '0.0.0.0';

  const port = await choosePort(HOST, DEFAULT_PORT);
  if (port === null) {
    return;
  }

  const config = configFactory('development');
  const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
  const appName = require(paths.appPackageJson).name;

  const useTypeScript = fs.existsSync(paths.appTsConfig);
  const urls = prepareUrls(
    protocol,
    HOST,
    port,
    paths.publicUrlOrPath.slice(0, -1)
  );

  config.entry = [
    'webpack-hot-middleware/client?reload=true&timeout=1000',
    config.entry,
  ];
  config.plugins.push(new webpack.HotModuleReplacementPlugin());

  const compiler = createCompiler({
    appName,
    config,
    urls,
    useYarn,
    useTypeScript,
    webpack,
  });

  const server = fastify({
    logger: true,
  });

  server.register(hmr, { compiler, webpackDev: {}, webpackHot: {} });

  server.listen(port, HOST);
}

main();

2021-09-17 追記

React Router: Declarative Routing for React.js はうまくいかなかった (/ 以外の route に遷移して reload すると 404 になってしまう)。 シンプルに解決するのは難しいっぽい (Support for React Router Routes · Issue #8 · lependu/fastify-webpack-hmr · GitHub) ので、

  • どうせ dev server なのであきらめる
    • 別段これでいいと思う
  • HashRouter を使う

しかなさそう。

pug でカスタムタグをあつかう

ゴール

form-button(label="Label")

のような入力を与えたときに

<button type="button">Label</button>

のような出力を得る。

pug の処理の流れ

おおまかにいうと、 字句解析 → 構文解析 → コード生成、の順をおって最終成果物 (html) が生成されている。 各フェーズは npm module (pug のソースコード的には packages ディレクトリ) に分割されており、個別の処理を追うのはそこまで難しくない。また、全体の処理は pug/lib/index.js を読むと流れがわかる。

pug-lexer (字句解析器)

以下、下記の pug ソースを処理していくこととする。

div.container#main(style="margin: 2rem;")
  h1 Heading
  //- comment
  | Hello, #{ world + '?' }!

まずは pug-lexer による字句解析フェーズ。

const lex = require('pug-lexer');

const source = `
div.container#main(style="margin: 2rem;")
  h1 Heading
  //- comment
  | Hello, #{ world + '?' }!
`;

console.log(JSON.stringify(lex(source), null, '  '));

この出力は以下のような感じ (長過ぎるので抜粋)。

[
  {
    "type": "newline",
    "loc": {
      "start": {
        "line": 2,
        "column": 1
      },
      "end": {
        "line": 2,
        "column": 1
      }
    }
  },
  {
    "type": "tag",
    "loc": {
      "start": {
        "line": 2,
        "column": 1
      },
      "end": {
        "line": 2,
        "column": 4
      }
    },
    "val": "div"
  },
  // ...
]

pug-strip-comment (コメントの削除)

pug-strip-comment によってコード中のコメントを削除する。

たいした内容ではないので、このフェーズのコードは省略する。

pug-parser (構文解析器)

lexer により解析された token の構文解析をおこなうのが pug-parser である。

const lex = require('pug-lexer');
const stripComments = require('pug-strip-comments');
const parse = require('pug-parser');

const source = `...`;

console.log(JSON.stringify(parse(stripComments(lex(source))), null, '  '));

少し長くなるが、結果を掲出する。

{
  "type": "Block",
  "nodes": [
    {
      "type": "Tag",
      "name": "div",
      "selfClosing": false,
      "block": {
        "type": "Block",
        "nodes": [
          {
            "type": "Tag",
            "name": "h1",
            "selfClosing": false,
            "block": {
              "type": "Block",
              "nodes": [
                {
                  "type": "Text",
                  "val": "Heading",
                  "line": 3,
                  "column": 6
                }
              ],
              "line": 3
            },
            "attrs": [],
            "attributeBlocks": [],
            "isInline": false,
            "line": 3,
            "column": 3
          },
          {
            "type": "Text",
            "val": "Hello, ",
            "line": 5,
            "column": 5
          },
          {
            "type": "Code",
            "val": " world + '?' ",
            "buffer": true,
            "mustEscape": true,
            "isInline": true,
            "line": 5,
            "column": 12
          },
          {
            "type": "Text",
            "val": "!",
            "line": 5,
            "column": 28
          }
        ],
        "line": 2
      },
      "attrs": [
        {
          "name": "class",
          "val": "'container'",
          "line": 2,
          "column": 4,
          "mustEscape": false
        },
        {
          "name": "id",
          "val": "'main'",
          "line": 2,
          "column": 14,
          "mustEscape": false
        },
        {
          "name": "style",
          "val": "\"margin: 2rem;\"",
          "line": 2,
          "column": 20,
          "mustEscape": true
        }
      ],
      "attributeBlocks": [],
      "isInline": false,
      "line": 2,
      "column": 1
    }
  ],
  "line": 0
}

これが pug における、いわゆる AST (抽象構文木) となる。

pug-load (ローダ)

字句解析からスタートしたが、 pug には includesextends といった、他のファイルを参照するしくみがある。

これを実現するため、実際の pug では、ファイル読み込みを担う pug-load から lexer や parser を呼び出す形になっている。

(もちろん、 pug-load を利用せずに、これまでみてきたように lexer や parser を直接呼び出すやりかたでも正常に動作する)

pug-link (最適化)

pug-load により、外部ファイルを参照して include したり extend したりすることができるようになっているわけであるが、その参照先を実際に埋め込んだり flat 化したりするのが pug-link 、らしい。 しかしちゃんと調べていない。

pug-code-gen (コード生成)

AST をもとに、 JavaScript コードを生成するのが pug-code-gen である。

const lex = require('pug-lexer');
const stripComments = require('pug-strip-comments');
const parse = require('pug-parser');
const generateCode = require('pug-code-gen');

const source = `...`;

const code = generateCode(parse(stripComments(lex(source))), {
  pretty: true,
  compileDebug: false,
});
console.log(code);

実行結果は以下のようになる。

function template(locals) {var pug_html = "", pug_mixins = {}, pug_interp;;var locals_for_with = (locals || {});(function (world) {var pug_indent = [];
pug_html = pug_html + "\n\u003Cdiv class=\"container\" id=\"main\" style=\"margin: 2rem;\"\u003E\n  \u003Ch1\u003EHeading\u003C\u002Fh1\u003EHello, " + (pug.escape(null == (pug_interp = world + '?') ? "" : pug_interp)) + "!\n\u003C\u002Fdiv\u003E";}.call(this,"world" in locals_for_with?locals_for_with.world:typeof world!=="undefined"?world:undefined));;return pug_html;}

関数オブジェクトが戻るわけではなく、 JavaScriptソースコードが文字列形式で戻ることに注意。

見づらいので整形すると、以下のとおり。

function template(locals) {
  let pug_html = '';
  const pug_mixins = {};
  let pug_interp;
  const locals_for_with = locals || {};
  (function (world) {
    const pug_indent = [];
    pug_html = `${pug_html}\n\u003Cdiv class="container" id="main" style="margin: 2rem;"\u003E\n  \u003Ch1\u003EHeading\u003C\u002Fh1\u003EHello, ${pug.escape(
      (pug_interp = `${world}?`) == null ? '' : pug_interp
    )}!\n\u003C\u002Fdiv\u003E`;
  }.call(
    this,
    'world' in locals_for_with
      ? locals_for_with.world
      : typeof world !== 'undefined'
      ? world
      : undefined
  ));
  return pug_html;
}

レンダリング

ブラウザ上でレンダリングする場合、単純に上記で得られた JavaScript コードを実行すればレンダリングできる。

const compiled = new Function('', `${code};return template;`);

console.log(compiled());

Node.js 上で実行する場合、ブラウザでは用意されている関数等が足りていないので pug-runtime によるラッパーを利用してコードを生成する必要がある。

const runtimeWrap = require('pug-runtime/wrap');

const compiled = runtimeWrap(code);

console.log(compiled());

実行結果は (code-gen で pretty: true を指定したため) 以下のようになる。

<div class="container" id="main" style="margin: 2rem;">
  <h1>Heading</h1>Hello, undefined?!
</div>

カスタムタグの実装

ゴールで明示したとおり、

form-button(label="Label")

のようなカスタムタグ (form-button) を記述したときに

<button type="button">Label</button>

のような出力を得たい。

そのためになにをやればよいかというと、 AST を解析して、 form-button というタグが指定された場合に、 <button> タグとして出力される AST node 群で差し替えればよい。

差し替えするノードの算出

これまで見てきたのと同じしくみをもちいて、差し替え後の AST を取得する。

const lex = require('pug-lexer');
const stripComments = require('pug-strip-comments');
const parse = require('pug-parser');

const macro = `
button(type="button") {{label}}
`;

console.log(JSON.stringify(parse(stripComments(lex(macro))), null, '  '));

結果は、

{
  "type": "Block",
  "nodes": [
    {
      "type": "Tag",
      "name": "button",
      "selfClosing": false,
      "block": {
        "type": "Block",
        "nodes": [
          {
            "type": "Text",
            "val": "{{label}}",
            "line": 2,
            "column": 23
          }
        ],
        "line": 2
      },
      "attrs": [
        {
          "name": "type",
          "val": "\"button\"",
          "line": 2,
          "column": 8,
          "mustEscape": true
        }
      ],
      "attributeBlocks": [],
      "isInline": false,
      "line": 2,
      "column": 1
    }
  ],
  "line": 0
}

差し替えするときには、大外の Block ノードは不要なので、下位の nodes の先頭を用いればよい。

したがって、引数 label を与えられたときに、変換後の node を返す関数は以下のようになる。

function renderMacro(label) {
  return {
    type: 'Tag',
    name: 'button',
    selfClosing: false,
    block: {
      type: 'Block',
      nodes: [
        {
          type: 'Text',
          val: label,
        },
      ]
    },
    attrs: [
      {
        name: 'type',
        val: '"button"',
        mustEscape: true,
      },
    ],
    attributeBlocks: [],
    isInline: false,
  };
}

pug-walk によるトラバーサル

AST の差し替えは、自力で再帰を利用したりしてトラバーサルするのも手であるが、便利なツールが pug ファミリーに存在する。 それが pug-walk である。

walk 関数に、もとの AST と、ツリーをたどるときに呼ばれる関数をわたして呼び出すと、変換後の AST が返る。といいたいところだが、残念ながら mutable な関数なので、もとの AST 自身も変換される。

pug-walk を利用した変換器は以下のようになる。

const walk = require('pug-walk');

function renderMacro(label) {
  return {
    // 略
  };
}

function stripQuote(src) {
  return src.replace(/^"(.*)"$/, '$1');
}

const source = `
div
  form-button(label="Label")
`;

const ast = walk(parse(stripComments(lex(source))), null, (node, replace) => {
  if (node.name === 'form-button') {
    const targetAttrs = node.attrs.filter(it => {
      return it.name === 'label';
    });
    const label = targetAttrs.length > 0 ? stripQuote(targetAttrs[0].val) : 'LABEL';
    replace(renderMacro(label));
  }
});

console.log(runtimeWrap(generateCode(ast, { pretty:true }))());

結果は、

<div>
  <button type="button">Label</button>
</div>

無事ゴールが達成できた。

pug の plugin

以上のように、 pug-parser や pug-code-gen を自力で呼び出して AST を変換すればカスタムタグを実装することができるが、実は pug には plugin system があり、これを利用することで、 pug の処理の途中に介入することができる。

pug の plugin system については、なぜか公式ドキュメントで言及されていない気がするが、 pug の compileBody() メソッド を読むとその挙動 (仕様) がわかる。

だいたい以下のような処理を経るようだ。

  • preLex plugin (引数: source string)
  • lex phase
  • postLex plugin (引数: tokens)
  • stripComments phase
  • preParse plugin (引数: tokens)
  • parse phase
  • postParse plugin (引数: ast)
  • preLoad plugin (引数: ast)
  • (load 処理)
  • postLoad plugin (引数: ast)
  • preFilters plugin (引数: ast)
  • handleFilters phase
  • postFilters plugin (引数: ast)
  • preLink plugin (引数: ast)
  • link phase
  • postLink plugin (引数: ast)
  • preCodeGen plugin (引数: ast)
  • generateCode phase
  • postCodeGen plugin (引数: JavaScript source string)
  • execute phase

pug plugin として実装する

上記のように ast をさわれる plugin phase はいくつかあるのだが、今回のカスタムタグについては、とりあえず preCodeGen phase にしかけることにした。

const pug = require('pug');
const walk = require('pug-walk');

function renderMacro(label) {
  return {
    // 略
  };
}

function stripQuote(src) {
  return src.replace(/^"(.*)"$/, '$1');
}

const source = `
div
  form-button(label="Label")
`;

console.log(
  pug.render(source, {
    plugins: [
      {
        preCodeGen: (ast, options) => {
          return walk(ast, null, (node, replace) => {
            if (node.name === 'form-button') {
              const targetAttrs = node.attrs.filter(it => {
                return it.name === 'label';
              });
              const label = targetAttrs.length > 0 ? stripQuote(targetAttrs[0].val) : 'LABEL';
              replace(renderMacro(label));
            }
          });
        },
      },
    ],
  })
);

これで自力で lexer や parser をよびだすことなく、処理途中で ast に手を加えることができるようになった。

vscode + vue.js + typescript + eslint + prettier + airbnb で自分向きの環境を整える (2)

vscode + vue.js + typescript + eslint + prettier + airbnb で自分向きの環境を整える (1) - daily dayflower の続き。

ゴール再掲

  • vue-cli (ui) で生成したプロジェクトをベースにしたい
  • TypeScript つかいたい
  • スタイルガイドのベースとしては airbnb
  • スタイルガイドにそぐわない記述をしたときには、エディタ中に波線がでて、間違っていることをおしえてほしい
  • 保存をしたときには、自動的にスタイルを修正してほしい
  • (できれば整形コマンドを手で実行したときにも、同じスタイルに修正してほしい)
  • できれば package.json に設定を集約したい 各種設定ファイルは package.json から独立させる

最後の項目は方針を変え、 eslint 等の設定を独立ファイルに置くようにした。 (他のプロジェクトでも独立して扱っていることが多いので)

アプローチを変え、 vue-cli でプロジェクトを作成し、不満なところを解消していくことにした。

vue-cli でのプロジェクト生成

  • Features
    • TypeScript
    • Linter / Formatter
  • Linter / Formatter config
    • ESLint + Prettier
  • Additional lint features
    • Lint on save
  • placing config for Babel, ESLint, etc.
    • In dedicated config files

この時点で不満なこと

  • ts ファイル
    • 不整合があっても波線もでない
  • vue ファイルの ts
    • 不整合があると波線はでる
    • 保存時に整形されない

vue ファイルで保存時に整形されるようにする

vscode の設定 (.vscode/settings.json) に以下を追加する。

{
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    }
}

ts ファイルに linter がかかるようにする

eslintrc で parser として @typescript-eslint/parser を指定する。

--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -11,6 +11,7 @@ module.exports = {
     "@vue/prettier/@typescript-eslint"
   ],
   parserOptions: {
+    "parser": "@typescript-eslint/parser",
     ecmaVersion: 2020
   },
   rules: {

vue.js の場合 root 直下の parser 設定子ではなく parserOptions の下に設定することに注意。

(root 直下の parser には vue-eslint-parser が指定されている)

Airbnb style を適用する

この時点で lint rule としては vue の essential と eslint の recommended のみが指定された状態である。 もうすこし厳しい (ポリシーに色のある) Airbnb style を適用する。

$ npm i -D eslint-config-airbnb-base eslint-plugin-import

(eslint-plugin-importeslint-config-airbnb のために必要)

eslintrc の extend にも指定する。

--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -6,6 +6,7 @@ module.exports = {
   extends: [
     "plugin:vue/essential",
     "eslint:recommended",
+    "airbnb-base",
     "@vue/typescript/recommended",
     "@vue/prettier",
     "@vue/prettier/@typescript-eslint"

prettier を設定する

@vue/prettier (@vue/eslint-config-prettier) が extend している eslint-config-prettier により、Prettier と競合する eslint の設定が消されている。 (たとえば、 Airbnb style では single quote 推奨だが、 double quote が強制されている)

よって、 Prettier で設定できる範囲の Airbnb style を指定する必要がある。

.prettierrc として以下のファイルを置く。

{
    "printWidth": 100,
    "semi": true,
    "singleQuote": true,
    "trailingComma": "es5",
    "arrowParens": "avoid"
}

semi はもともと有効になっているが、念のために記述した。 また、 arrowParens も、 (デフォルトは always であるにもかかわらず) どれかの plugin の影響で avoid になっているが、これも念のために記述した。

eslintrc.js を eslintrc.json に変換する

eslintrc.js だと vscode の autocompletion (IntelliSense) が効かない ( .eslintrc.js does not have suggestions · Issue #124 · microsoft/vscode-eslint · GitHub ) ので、 json 形式に変換する。

ただ、 js だと process.env をみて no-console, no-debugger あたりの設定を変更することができていたのが、静的な json で書く結果、固定値しか指定できなくなってしまう。

まとめ

これで当初のゴールが達成されたと思う。

ただし

  • (できれば整形コマンドを手で実行したときにも、同じスタイルに修正してほしい)

については、未検証。

最終的な .eslintrc.json は以下のようになった。

{
  "root": true,
  "env": {
    "node": true
  },
  "extends": [
    "plugin:vue/essential",
    "eslint:recommended",
    "airbnb-base",
    "@vue/typescript/recommended",
    "@vue/prettier",
    "@vue/prettier/@typescript-eslint"
  ],
  "parserOptions": {
    "parser": "@typescript-eslint/parser",
    "ecmaVersion": 2020
  },
  "rules": {
    "no-console": "warn",
    "no-debugger": "warn"
  }
}

実際にコードを書いていくと、 rules にいろいろ指定する必要があるが、それはまたおいおい。

vscode + vue.js + typescript + eslint + prettier + airbnb で自分向きの環境を整える (1)

タイトルと同じようなキーワードでググれば情報はいっぱいでてくるんだけど、鮮度とかの問題もあるのか、やりたいことに違いがあるのか、サイトによって若干内容に差がある。なので、まずは自分なりの requirement を決めて、それを満たすような vscode を整えたい。

つまり、これは個人的な備忘録であって、他の人の参考になるよう書かれた情報ではないというエクスキューズです*1

自分のゴール

  • vue-cli (ui) で生成したプロジェクトをベースにしたい
  • TypeScript つかいたい
  • スタイルガイドのベースとしては airbnb
  • スタイルガイドにそぐわない記述をしたときには、エディタ中に波線がでて、間違っていることをおしえてほしい
  • 保存をしたときには、自動的にスタイルを修正してほしい
  • (できれば整形コマンドを手で実行したときにも、同じスタイルに修正してほしい)
  • できれば package.json に設定を集約したい*2

つまり IntelliJ で SaveAction plugin 使って自動整形を有効にしたときと同じような感覚がほしい。

今日の進捗

まずは vue-cli ベースのコードではなく、 TypeScript ではなく JavaScript でゴールに近いことができることをめざす。

vscodeプラグインをインストール
devDependencies に追加
{
  "devDependencies": {
    "eslint": "^6.8.0",
    "eslint-config-airbnb-base": "^14.1.0",
    "eslint-config-prettier": "^6.11.0",
    "eslint-plugin-import": "^2.20.2",
    "eslint-plugin-prettier": "^3.1.3",
    "prettier": "^2.0.5"
  }
}

eslint-plugin-import がなぜ必要なのかわかってないが、これいれないとなぜかうまくいかなかった。eslint-config-airbnb-base が必要としている?

eslint-plugin-prettier は使わないほうがいいよーという記事もあったんだけど、自分のやりたいことの感じだと、あったほうがよかった。このへんは後述している。

eslint の設定

ゴールに書いたように、いまのところ package.json に eslint の設定も書いている。

{
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true,
      "browser": true
    },
    "extends": [
      "airbnb-base",
      "prettier"
    ],
    "parserOptions": {
      "ecmaVersion": 2020
    },
    "plugins": [
      "prettier"
    ],
    "rules": {
      "prettier/prettier": [
        "error"
      ]
    }
  }
}

上でも書いたように eslint-plugin-prettier 使ってる。
まだおためしコードしか書いていない規模感だから問題ないだけかもしれない。

vscode の設定

いったんはワークスペースの設定 (.vscode/settings.json) に書いてる。

{
    "editor.formatOnSave": false,
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    },
    "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    }
}

formatOnSave を無効化しつつ、 save 時の code action として eslint の fixAll を走らせて、 eslint plugin で prettier を追加しているので、見た目の reformat も走る、みたいなことになっているのじゃないかなと自分では思っている。

ふりかえり

eslint-plugin-prettier を外しつつ vscode の editor.formatOnSave を true にするという方策もありそう (というか最近の主流?) だけど、それすると自分の環境では prettier 的に不正な入力時の波線がでなかった (もちろん eslint 的に不正な入力には波線がでる) ので、このような設定になった。

どうせ保存するときに auto format かかるし、波線にここまでこだわってもしかたがないのかもしれない。

*1:そもそも Visual Studio CodeJavaScript (TypeScript) エコシステムもまったくくわしくない。

*2:別段こだわりはないが、 vue-cli でのデフォだったので

vue-codemirror を試す

CodeMirror っていう JavaScript 製のエディタコンポーネントの Vue.js 用コンポーネント vue-codemirror を触ってみたときの備忘録。

最終的なコードはここにある。

github.com

vue-codemirror をプロジェクトに読み込む

import Vue from 'vue'
import VueCodemirror from 'vue-codemirror'

import 'codemirror/lib/codemirror.css'

Vue.use(VueCodemirror)

こんな感じ。

これを plugins/vue-codemirror.js に切り出して、 main.js

import '@/plugins/vue-codemirror'

コンポーネントの利用

ドキュメント通りでできるけど、このあとのベースラインとして。

<template lang="pug">
  #app
    codemirror(v-model="code" :options="cmOptions")
</template>

<script>
export default {
  name: 'App',

  data() {
    return {
      code: '',

      cmOptions: {
      }
    }
  }
}
</script>

インデントまわりのカーソル移動をいい感じにする

これでだいたいエディタっぽいアプリをつくることはできるんだけど、インデントまわりに不満がある。個人的には以下のようにしたい。

  • インデントはタブではなく、ホワイトスペースを利用してほしい
  • インデントの先頭でバックスペースを押したとき、文字単位ではなくインデント単位で直前の空白を削除してほしい

CodeMirror のオプションを指定したらいい感じになるかなと思ったけど、うまくいかなかった。調べたら以下のような先達の tips があった。

Setting for using spaces instead of tabs · Issue #988 · codemirror/CodeMirror · GitHub

しかしこの通りにやってもうまくいかなかったので、ベースを利用しつつ、自分の好みになるように書いてみた。

Vue.use(VueCodemirror, {
  options: {
    // based on https://github.com/codemirror/CodeMirror/issues/988#issuecomment-549644684
    extraKeys: {
      Tab: (cm) => {
        if (cm.getMode().name === 'null') {
          cm.execCommand('insertTab');
        } else {
          if (cm.somethingSelected()) {
            cm.execCommand('indentMore');
          } else {
            cm.execCommand('insertSoftTab');
          }
        }
      },
      Backspace: (cm) => {
        if (!cm.somethingSelected()) {
          let cursorsPos = cm.listSelections().map((selection) => selection.anchor);
          let indentUnit = cm.options.indentUnit;
          let shouldDelChar = false;

          for (let cursorPos of cursorsPos) {
            const { start, end, string, type } = cm.getTokenAt(cursorPos)
            if (type !== 'indent' && type !== null
              || start !== 0 || end !== cursorPos.ch || end === 0
              || string.trimStart() !== ''
              || end % indentUnit > 0) {
              shouldDelChar = true
              break
            }
          }

          if (!shouldDelChar) {
            cm.execCommand('indentLess');
          } else {
            cm.execCommand('delCharBefore');
          }
        } else {
          cm.execCommand('delCharBefore');
        }
      },
      'Shift-Tab': (cm) => cm.execCommand('indentLess')
    }
  }
})

保存アクションで dirty flag を消す

CodeMirror には、 CMD-S (CTRL-S) で保存するキーバインドがデフォルトで定義されており、その際呼び出されるアクションとして save という (カラの) アクションが定義されている。

また、 CodeMirror コンポーネント自体にも save という (カラの) メソッドが定義されている (上記のアクションとは別物)。

これらをくみあわせて、保存アクションをしたときに dirty flag をリセットするようにしたい。

ちなみに CodeMirror には開発者が活用できる以下のような内部状況フラグがある。

まずは save action の上書きから。

// default save action の上書き
VueCodemirror.CodeMirror.commands.save = (cm) => {
  // 保存されるときに "clean" 扱いにする
  cm.markClean()

  // 現状ではなにも内容の定義されていない、コンポーネントの save メソッドを呼び出しておく
  cm.save(cm)
}

つぎはコンポーネント側。

    codemirror#editor(ref="cmComponent" v-model="code" :options="cmOptions" @input="cmChanged")
export default {
  name: 'App',

  data() {
    return {
      code: '',
      isClean: true,

      cmOptions: {
        // ...
      }
    }
  },

  computed: {
    cmComponent() {
      return this.$refs.cmComponent
    }
  },

  mounted() {
    this.cmComponent.codemirror.save = (cm) => {
      this.isClean = cm.isClean()
    }
  },

  methods: {
    cmChanged() {
      this.isClean = this.cmComponent.codemirror.isClean()
    }
  }
}

コンポーネントの save メソッド側で、 手元のコンポーネントの clean フラグに CodeMirror の isClean フラグの状況を反映させる。 また、 CodeMirror コンポーネントの change 時にもそのフラグの状況を反映させる。

そもそも CodeMirror の clean フラグを使わなくっても、 CMD-S を自前でハンドリングしたり、 change のときだけ not clean にしたりすればいいんじゃ?って思うかもだけど、実は CodeMirror 側では、 Undo 等して、編集差分が発生しなくなったときに clean になったりする。なので、 clean フラグ自体は CodeMirror 側に負わせるのがよい。

(とはいえ、 save action とコンポーネントの save method をどちらもオーバライドしているこのやりかたはすこしくどすぎるかも。 save action の上書きだけでよさそう。あと、 change イベントと save action をハンドリングして内部のフラグをもってきているから、いまいちリアクティブみがない。もっとよい方法ないのかな。)

集大成として Markdown editor もどき

以上のサンプルコードとして、 Markdown editor もどきをつくった。

再掲になるけど、

github.com

https://user-images.githubusercontent.com/42583/77849162-8715a600-7204-11ea-8501-943fa39cdea4.png

べつだんリアルタイムプレビューを出すのはそこまで難しくないんだけど、 clean フラグのとりまわしをやりたかったので、 あえて CMD-S を押さないとプレビューに反映されないようになっている。

Spring Boot で ApplicationContext を初期化せずに application.properties の内容を取得する

このへんのソースを読むと、一応以下のように書けるっぽい?

なんか目的外使用っぽくてよくないですけど。

StandardEnvironment env = new StandardEnvironment();
new ConfigFileApplicationListener().postProcessEnvironment(env, new SpringApplication());

log.info("{}", env.getProperty("spring.datasource.url"));

ちなみにやりたかったことは、テストコードで ApplicationContext を利用せずに MyBatis の mapper を利用したかった。

StandardEnvironment env = new StandardEnvironment();
new ConfigFileApplicationListener().postProcessEnvironment(env, new SpringApplication());

DataSource dataSource = DataSourceBuilder.create()
        .type(Class.forName(env.getProperty("spring.datasource.type")).asSubclass(DataSource.class))
        .url(env.getProperty("spring.datasource.url"))
        .username(env.getProperty("spring.datasource.username"))
        .password(env.getProperty("spring.datasource.password"))
        .driverClassName(env.getProperty("spring.datasource.driver-class-name"))
        .build();

Configuration mybatisConf = new Configuration(new Environment("test", new JdbcTransactionFactory(), dataSource));
mybatisConf.setMapUnderscoreToCamelCase(true);
mybatisConf.addMapper(HogeHogeMapper.class);

SqlSession sqlSession = new SqlSessionFactoryBuilder()
        .build(mybatisConf)
        .openSession();
try {
    HogeHogeMapper mapper = sqlSession.getMapper(HogeHogeMapper.class);

    // ...
} finally {
    sqlSession.close();
}

でもここまで書いて結果的には利用しなかった。