Diary of a Perpetual Student

Perpetual Student: A person who remains at university far beyond the normal period

社会人アプリケーションエンジニア2年目の振る舞い方

本職はアイドルのつもりだが、一応アプリケーションエンジニアもやっている、ということで。

今半期の public な振り返りの代わりに、最近どういう風に仕事をしているのかをまとめていく。参考までに、前半期の振り返りエントリはこちら:

blog.arthur1.dev

目的を意識してコーディング・コードレビューのやり方を変化させる

これまでの自分は、コーディングもコードレビューもかなり長い時間を掛けて取り組んでいた。どう実装したら美しいのか、既存のコードから浮かび上がるメンタルモデルとマッチしているか、などと、深く考えれば考えるほどドツボにはまってしまっていた。

今期の自分は結構吹っ切れていることが多かった。素早く価値を届けたいのだからこのあたりは見逃そうとか、理想はこうだけど今はこれがシンプルで最速だね、という気持ちでシュッとコードを書いたりレビューしたりしていた。動けば OK の気持ち。

一方で、リファクタリングが目的となる Pull Request については、過度な共通化をしていないか、抽象化の度合いが大き過ぎ(あるいは小さすぎ)やしないか、などをじっくりと考えた。コードレビューでは「なぜそうするのか」を明記すると変更を受け入れられやすい。目的にフォーカスして思考を集中させるのを続けたことで、良いコード設計のための思考を具体的に言葉で説明する能力も上がったんじゃないかなあと思う。

材料が揃っているなら自分で決める

なにか物事を決めるときに多数決を取るのをやめた。そもそも自分は多数決は好きではないが、これを仕事に持ち込んだ形。ちょっとしたことが決まらずに仕事が進まないもどかしさを感じていたのかもしれない。リモートワークだと全員の反応を伺うのも難しいし尚更である。

決定のための材料を集めて、自力で集めたもので十分と感じるならばそれで決めてしまい、不十分だと感じたら他の人からも材料を集めてから決めるようにした。2択あって「どっちがいいと思いますか?」という質問をするのを極力控え、自分が重み付けした価値の和が最大になるような択を選んで自信を持って提示するようにした。

ちょうどそんな振る舞いを意識的に始めた頃、たまたま GitLab の意思決定に関する記事を読んで、ある程度一緒じゃんと思った。

about.gitlab.com

触ったところを少しだけ良くしていく

自分が価値提供のために触ったところで、古い仕組みや直すべき箇所を見つけたら、素早く価値提供する妨げにならない程度に良くする活動をした。レガシーを取り除くためだけの仕事では現場が疲弊していくことが知られているので、あくまで仕事の延長上で気づき、すぐできそうなものをやるというスタイルにした。

これは、多機能な SaaS でも頻繁に使われる機能は一部に偏っているだろうという仮説に基づいている。届けられる価値などから決められた優先度順に並び替えられた Product Backlog Item をこなしていくと、ユーザーがたくさん使う機能に関するコードの改修頻度が高いはずだ。SaaS におけるアジャイル開発の前提として未来永劫にわたって完成という状態になることはないと思っている。その場所やその周りは今回だけでなく今後も改修される可能性が高いだろう、だから綺麗にしておこう、という理屈。

と書いたものの、実際はここまで考えて取り組んでいるわけでは決してなくて、後付けで理由を取り繕うスキルが比較的高いというだけである。

良い状態にするためには、何を以て良いとするかを知らなければならない。自分はどの分野においてもスペシャリストではないし、今後も何かに尖るつもりはない。チームにはそれぞれの関心のある技術領域を深く勉強している人たちがいるので、そういう人たちが分かりやすく啓蒙してくれるのに従ってやっている。

これらすべての振る舞いの根底にあるもの

このエントリにやたらと出てくるワード、それは「価値」である。前半期の振り返りにこんなことを書いた。

来期どういう風に生きたいかはまだあんまり考えていないのですが、とにかく価値提供したい、という気持ちが今は強いです。

FY2023上半期を振り返る・広く浅く - Diary of a Perpetual Student

この発言の裏には、前半期はあんまり価値提供できなかったね、という意図がある。今半期は、前半期に引き続きメインの仕事以外の課外活動を引き受けつつ、価値提供のためにもそれなりに頑張れた。来半期にはこれらの取り組みをもっと省エネに持続可能にできたらいいなと思う。そうすると自然とトランスファー可能なスキルになるのではないか。

PR: 課外活動といえば……

「Mackerel Meetup #14」を2023年7月11日(火)に開催します!サーバー監視 SaaS・Mackerelのオフラインイベントが4年ぶりに復活しました。限定ノベルティの配布もあります。ぜひ生の声をお聞かせください。

mackerelio.connpass.com

Meetup 復活記念のブログ連載もやっています。第1回は私 id:arthur-1 が OpenTelemetry 対応の開発について書きました。

mackerel.io

ブレザーを着たい

でふと思い出したこと。

自分の出た高校は蛮カラな気風で、制服も当然学ランであった。ブレザー良いなあと当時からずっと思っていたのだが、残念ながら自分の地元には公立で校則が緩くて偏差値が高く学ランじゃないところはなかった。

一回だけ演劇の役者として健康的な男子高校生の役をやったことがあって、その時に他校のブレザーを着て非常にテンションが高まっていた。

気づいたらとっくにアラサーで、制服ディズニーなんて歳でもなくなってきた。若いうちにしかできないことは若いうちにやっておきましょう。

自宅から締め出されるインシデントの報告(2ヶ月ぶり2度目)

サマリー

2023/06/07 17:55 - 2023/06/07 21:15

外出中にスマートフォンを破壊し、家の鍵の開錠ができなくなった。代替のスマートフォンを調達して問題は解消された。

タイムライン

  • 2023/06/07 17:55 在宅勤務を中抜けして行った病院からの帰り道、道端で転びスマートフォンを破壊する
    • 有機 EL のディスプレイを叩き割るとほぼ全く画面が映らないということを初めて知る
    • スマートフォンのアプリで特定の操作をしないと家の鍵が開かない仕掛けになっている
  • 2023/06/07 18:05 自宅付近のネットカフェにインシデント対策本部を設置。情報収集を始める
    • 「ここをキャンプ地とする」
    • Google への2段階認証を突破する手段がなく行き詰まる
    • とりあえず Twitter で帰宅が遅れる旨を同僚に伝達しようとするも、同様に 1password へのログインができずパスワードが分からない
  • 2023/06/07 18:55 本部を一時離脱
  • 2023/06/07 19:15 家電量販店で、スマートフォンの画面をディスプレイに移し、マウスで操作するための機材一式を買う
  • 2023/06/07 19:25 本部帰着。上記機材を試してみるも、 Pixel シリーズは HDMI での画面出力非対応ということが判明
    • 全てがどうでも良くなってきて、普段全く読まない漫画という文化に触れてみんとす
  • 2023/06/07 20:30 再び本部を離れ、ブックオフで中古のスマートフォンを購入する。また、SIM カードを取り出すためのピンも調達する
  • 2023/06/07 21:00 本部でスマートフォンのセットアップ完了。SMS を受け取り Google の2段階認証を突破
    • アプリを入れて、家の鍵を会場できる状態になる
  • 2023/06/07 21:15 無事帰宅

被害額

今後の対策

以上

Next.js の Static Site Generation (SSG) = Static Exports だと思い込んでいたが違った

blog.arthur1.dev

上記エントリ公開時の原題は「【令和最新版】Next.js の Static Generation で i18n すると疲弊する【App Router 対応】」であったが、現在は表現を訂正し上記のようになっている。その経緯についてのまとめ。

すれ違いと気づき

先ほどのエントリを公開したところ、middleware でパスを置換すれば Static Site Generation (SSG) でも綺麗に書けそうというご指摘をいただいた。それを聞いた僕は「あれ待てよ?middleware 使うと静的ファイルに書き出せないよな?」と思ったのである。

実際にドキュメントにはこう書いてある。

Features that require a Node.js server, or dynamic logic that cannot be computed during the build process, are not supported:

  • Internationalized Routing

  • Middleware

https://nextjs.org/docs/pages/building-your-application/deploying/static-exports#unsupported-features

このやり取りのすれ違いの原因は、自分が SSG = Static Exports だと思い込んでいたことにあると後に気づいた。実際、先のエントリの訂正前の文章では Static Exports ではなくほとんど Static Generation という表現を用いて説明していた。

では Static Generation とは何か

試しに、next.config.js から output: 'export', の行を消して build してみると、静的な HTML ファイルを out/ ディレクトリに書き出さないのにも関わらず、SSG という文字列が現れていた。

Route (app)                                Size     First Load JS
┌ ○ /                                      0 B                0 B
├ ● /[lang]                                149 B          77.3 kB
├   └ /en
├ ● /[lang]/sample                         148 B          77.3 kB
├   └ /en/sample
├ ○ /favicon.ico                           0 B                0 B
└ ○ /sample                                0 B                0 B
+ First Load JS shared by all              77.1 kB
  ├ chunks/346-cf8b208bfc478e69.js         24.7 kB
  ├ chunks/3d471aef-9bd9359a60e55170.js    50.5 kB
  ├ chunks/main-app-a12cf6d5802ab2cc.js    207 B
  └ chunks/webpack-23701e5bacce31e3.js     1.65 kB

Route (pages)                              Size     First Load JS
─ ○ /404                                   179 B          83.9 kB
+ First Load JS shared by all              83.7 kB
  ├ chunks/main-7447017dc4365986.js        81.9 kB
  ├ chunks/pages/_app-a8c73752e3b5827f.js  191 B
  └ chunks/webpack-23701e5bacce31e3.js     1.65 kB

○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

SSG はレンダリングモードの1つで、getStaticProps 関数を用い、ページ生成に必要なデータをビルド時にあらかじめリクエストしておく手法を指すということである。実際、Static Exports はしていないので out/ ディレクトリにファイルは生成されないが、.next/server の中に生成された HTML ファイルが埋め込まれていた。next start してサーバを動かした時にこの HTML ファイルが帰るという仕組みだろう。

一方で、Static Exports は、next.config.js に output: 'export', と書き、Node.js が動くサーバを必要としない環境向けに HTML などのリソースを out/ ディレクトリに書き出すようにすることを指すのである。

実際、レンダリングモードが SSR にならざるを得ないアプリケーションを Static Exports することはないだろうから、混同するのも仕方ないとは思う。

余談: SSG か SG か

Next.js のドキュメントの表現が一時期 Static Site Generation ではなく Static Generation だった時期があったはず。その頃の影響を受けて僕は頑なに Static Generation (SG) と呼んでいたのだけれど、App Router の色々でまた改められていて SSG と読んだ方が良さそうだった。SG 警察引退です。

【令和最新版】Next.js の Static Exports で i18n すると疲弊する【App Router 対応】

  • 2023-06-04 18:00: 誤りのある表現を修正しました
    • s/Static Generation/Static Exports/ *1

結論

Next.js の i18n 機能は Static Exports 未サポートなので自前で頑張れ!

Next.js App Router の Static Exports で i18n したい!

今回、以下のような要件を決めて、実現できるかチャレンジしました:

  • Next.js の App Router を用いる
  • 言語は日本語(ja)と英語(en)の 2種類
  • サブパスで言語を分けるが、デフォルト言語のサブパスは切りたくない
    • 日本語ページのパスが /hogehoge/ なら、英語ページは /en/hogehoge/ という感じにしたい
  • output: 'export' モードにし、Static Exports する
  • 生成された各ページの HTML に、localize された後のテキストがそのまま埋め込まれている

ゴリ押しは全てを解決する

試行錯誤した結果、以下のコードのようになりました。

github.com

なぜゴリ押ししたかというと、Next.js の i18n 機能は Static Exports をサポートしていないからです。

コード解説

Server Component での i18n の実現

以下の記事を読んで大体同じようにコードを書きました。ここに書かれている通りにカスタムフックを作ると、App Router を用いた Server Componnent を i18n することができます。

locize.com

import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'

export const defaultLanguage = 'ja'
export const nonDefaultLanguages = ['en'] as const
export const languages = [defaultLanguage, ...nonDefaultLanguages] as const
export type Language = (typeof languages)[number]

type useTranslationProps = Readonly<{
  lang: string
  translationDef: Readonly<{
    [L in Language]: Readonly<{}>
  }>
}>

export const useTranslation = async ({ lang, translationDef }: useTranslationProps) => {
  const i18n = createInstance()
  await i18n
    .use(initReactI18next)
    .use(resourcesToBackend(translationDef))
    .init({
      debug: process.env.NODE_ENV === 'development',
      supportedLngs: languages,
      fallbackLng: defaultLanguage,
      lng: lang,
    })

  return {
    t: i18n.getFixedT(lang),
    i18n,
  }
}

デフォルト言語だけサブパスを切らないルーティングの実現

app ディレクトリ以下を以下のような構成にしました

残念ながらこうするしかないようです。[lang] は動的ルーディングで、ここに en が入るというイメージ。(root)/ 以下の方が日本語のページに対応します。

ポイントは (root)/ というディレクトリ。こいつを app/ 直下に配置すると、[lang]/ 以下のページを閲覧したときに両方の layout が使われマトリョーシカになってしまいます。App Router には route group という仕組みがあり、() で囲まれたディレクトリを用いると、URL のパスに影響を与えることなく component を分類することができます。

(root)/ と [lang]/ 以下に同じようなコードを書くのは馬鹿らしいので、(root)/ 以下のファイルは [lang]/ 以下で定義された component を import し default の言語を props で渡してあげるようにしています。

app/(root)/page.tsx

import IndexPage from '../[lang]/page'
import { defaultLanguage } from '../_hooks/i18n'

export default async function DefaultIndexPage() {
  return await IndexPage({
    params: {
      lang: defaultLanguage,
    },
  })
}

app/[lang]/page.tsx

import { nonDefaultLanguages, useTranslation } from '../_hooks/i18n'
import translationDef from './translationDef'

export const generateStaticParams = async () => nonDefaultLanguages.map(lang => ({ lang }))

type IndexPageProps = Readonly<{
  params: {
    lang: string
  }
}>

export default async function IndexPage({ params }: IndexPageProps) {
  const { lang } = params
  const { t } = await useTranslation({ lang, translationDef })
  return (
    <main>
      <h1>{t('greeting')}</h1>
    </main>
  )
}

(root) 以下のファイルをコード生成するツールを自作すると捗りそうですね。

言語の切り替えリンク

以下のようなコードで、言語だけ切り替えられるようなリンクを作りました。hogehoge/ ページにいる場合は en/hogehoge/ に、 en/hogehoge/ ページにいる場合は hogehoge/ にリンクする、といった挙動です。

'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

import { nonDefaultLanguages } from '@/app/_hooks/i18n'

type ToggleLanguageLinkProps = Readonly<{
  children: React.ReactNode
}>

export default function ToggleLanguageLink({ children }: ToggleLanguageLinkProps) {
  const pathname = usePathname()
  const enPrefix = `/${nonDefaultLanguages[0]}`
  let newPathname: string
  if (pathname.startsWith(enPrefix)) {
    newPathname = pathname.substring(enPrefix.length)
  } else {
    newPathname = `${enPrefix}${pathname}`
  }
  return <Link href={newPathname}>{children}</Link>
}

ページ内容を維持するために現在のパスを取得する必要があり、そのためのフック usePathname() が Next.js で用意されています。しかし、これは Server Component では利用できません。'use client' と書くことにより Client Component にしています。

Client Component だと Static Exports したときに HTML として書き出せないのではないか?という心配は無用です。

With Static Rendering, both Server and Client Components can be prerendered on the server at build time.

https://nextjs.org/docs/app/building-your-application/rendering#static-rendering

感想

いかがでしたか?

Gatsby なら gatsby-plugin-react-i18next plugin を入れて以下のように config を書くだけで実現できるのですが、Next.js の Static Exports ではこううまくはいかないのが現実だなあと感じさせられました。

    {
      resolve: `gatsby-plugin-react-i18next`,
      options: {
        localeJsonSourceName: `locale`,
        languages: [`ja`, `en`],
        defaultLanguage: `ja`,
        siteUrl,
      }
    },

まあ Vercel 使ってねってことなんでしょうけど。i18n 要件のある静的サイトを作るのに Next.js を選択するのはまだまだ厳しいなと思いました。良いやり方知っている人いたらぜひ教えてください。

そして全く同じような境遇の人のエントリを発見しました……

zenn.dev

Go のテーブル駆動テストは map を使って書きたい

Go 言語のプログラムのテストでは、テーブル駆動テストと呼ばれる書き方をすることが多いです。シンプルに例を挙げると以下のような形。

func TestAdd(t *testing.T) {
    cases := []struct{
        title string
        inLhs int64
        inRhs int64
        want int64
    }{
        {
            name: "1 + 1 = 2",
            inLhs: 1,
            inRhs 1,
            want: 2,
        },
    }
    for _, tt := range cases {
        t.Run(tt.title, func(t *testing.T) {
            if got := Add(tt.inLhs, tt.inRhs); got != tt.want {
                t.Errorf("want=%d, got=%d", tt.want, got)
            }
        })
    }
}

色々流派はあるのですが、僕は上のような slice ではなく、下のように map を使ってテストケースを作る方が好みです。

func TestAdd(t *testing.T) {
    cases := map[string]struct{
        inLhs int64
        inRhs int64
        want int64
    }{
        "1 + 1 = 2": {
            inLhs: 1,
            inRhs 1,
            want: 2,
        },
    }
    for title, tt := range cases {
        t.Run(title, func(t *testing.T) {
            if got := Add(tt.inLhs, tt.inRhs); got != tt.want {
                t.Errorf("want=%d, got=%d", tt.want, got)
            }
        })
    }
}

理由は2つ:

  • テストケース名が独立している方が読みやすいから
  • go の map を for した時の順序はランダムなので、順序に依存しないテストであることを保証できるから

明日はいよいよ Go Conference 2023 ですよ〜

gocon.jp

developer.hatenastaff.com

docker build 時に private リポジトリから go get する

要件

Go 言語で作られた、GitHub の private repository の go module に依存しているアプリケーションについて考えましょう。例えばオープンソースではない社内ライブラリに依存している、という状態です。

手元の開発マシン上では GOPRIVATE 環境変数を利用して、go mod download できている状態です。このアプリケーションはコンテナ上で動かすことを想定しており、Dockerfile 内で RUN go build することでバイナリを生成し container image に格納しようとしています。

しかし、public な module だけに依存しているときと同じように Dockerfile を書いても docker build に失敗してしまいます。go mod download 時に private repository から fetch できないのです。

この問題、あなたなら数ある選択肢からどう取捨選択してこの問題を解決しますか?自分は以下の要件を立てて解決策を模索しました:

  • (must) 開発マシン上で docker build した時に、private repository から go get できる
  • (must) GitHub Actions 上で docker build した時に、private repository から go getできる
  • (should) docker build 時にコンテナ環境内で go mod download -> go build できる
  • (should) 生成された docker image 内に不用意に認証情報が埋め込まれていない
  • (may) GitHub の Private Access Token の運用はしたくない
  • (may) 開発マシン上と GitHub Actions 上で同じ Dockerfile を使いまわせる

GitHub Actions 環境で private repostitory から go get する

zenn.dev

概ねこの記事通りにやれば良いです。GitHub App を作る→GitHub Actions上で一時トークンを生成する→そのトークンを使ってgo get する という流れです。

少し古めの記事を見ると、Private Access Token を用いた方法が紹介されています。PAT に依存する運用では組織ではなく個人に紐づいてしまう、アクセス権限の制御をきめ細やかにできない、シークレットのローテーションを定期的に行う必要があるなどのデメリットがあります。

multi stage build を利用することで解決できる問題ではあるのですが、認証情報を ARG で受け渡しているところは少し気になります。multi stage build を用いない場合、RUN --mount=type=secret を用いて一時トークンを受け渡すとより安全になるでしょう。

docs.docker.jp

開発マシン上で docker build した時に、private repository から go get する

こちらの説明も他の記事に譲ります。

qiita.com

手元の秘密鍵を COPY するパワフルなやり方もありますがまあ怖いですよね。今時のBuildKit には --mount=type=ssh と言うオプションがあり、これを用いて ssh-agent に登録している鍵を docker build 時に一時利用することができます。

合わせ技

上の2つを両立するために以下のような Dockerfile を用意しました:

# syntax=docker/dockerfile:experimental

# ** build stage **
FROM golang:1.20-bullseye AS build

COPY ./ /go/src
WORKDIR /go/src

ENV GOPRIVATE=github.com/Arthur1/something-private-repo

# ここがすごい!
RUN --mount=type=secret,id=github_secret_token --mount=type=ssh \
    if [ -z "$(cat /run/secrets/github_secret_token)" ]; then \
    mkdir -p ~/.ssh && chmod 0600 ~/.ssh && ssh-keyscan github.com >> ~/.ssh/known_hosts; \
    git config --global url."git@github.com:".insteadOf https://github.com/; \
    else \
    git config --global url."https://x-access-token:$(cat /run/secrets/github_secret_token)@github.com/".insteadOf https://github.com/; \
    fi

RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=ssh \
    go build -o hoge ./cmd/hoge/

# ** main stage **
FROM debian:bullseye-slim

COPY --from=build /go/src/hoge  /hoge

ポイントはシェルコマンドの中で if 文を使っている箇所で、

  • github_secret_token secret がセットされているなら、その secret を用いて https で go get するための git config を用意
  • github_secret_token secret がセットされていないなら、mount した ssh 鍵を使って go get するための git config を用意

と言う挙動を実現しています。

つまり、GitHub Actions 上では DOCKER_BUILDKIT=1 docker build --secret id=github_secret_token,env=GH_SECRET_TOKEN .、手元では DOCKER_BUILDKIT=1 docker build --ssh default . すればコンテナイメージの build 時に private repository から go get してコンテナ上でバイナリビルドができるようになります。

めでたしめでたし。


 
 
 
 
 

END ... ?

これでいいのか?

この Dockerfile を見てなにか腑に落ちない人もいるかなと思います。高々 private repository から go get したいだけなのに、何でこんな大層な workaround を用意しているのだろう、と言う気持ちになりました。

要件を疑え

そもそも、要件にある「 docker build 時にコンテナ環境内で go mod download -> go build できる」という条件は本当に必要でしょうか?

例えば、go module の download は GOMODCACHE 変数を指定した上で Docker の外でやり、mod cache ディレクトリを Dockerfile で COPY してコンテナ内で build はやる、という解決策もあるでしょう。

他にも、クロスコンパイルが得意な Go で docker container 内で build する必要もなくて、docker の外で go build して生成した実行ファイルをコンテナに格納すると言う手法もあります。docker を使ってホスト環境依存をなくしたつもりでも、結局 Linux カーネル大親友である glibc への依存は避けられないですからね。もちろん、Go のバージョンを各自で固定しづらいといったデメリットは生じます。

まとめ

docker build 時に private リポジトリから go get する手法については、要件を定義して、各自でベストと思える解決策を採用しましょう。また、そもそも docker build 時にやる必要ありますか?ということについてもぜひ考え直してみてください。