Avoid a void

id:slashnephy の雑記です。

はてなインターン 2020 に参加してきました!

こんにちは id:slashnephy です。今週, はてなインターン 2020 に参加してきました。今年は COVID-19 の影響をダイレクトに受け, どのインターンも開催期間の縮小やオンライン開催といった対応を採らざるを得なくなっている中で, はてなインターン 2020 に参加した記録を書いていきたいと思います。

はてなさんもコロナ渦で苦慮されており, 5日間という短い期間でしたがとても濃密な時間を過ごすことができました。ただ, 1つ残念なことといえば, オフィスのランチに行けなかったことですね...。

来年度は形式が変わるかもしれませんが, 次回の参加を迷っている方々のはてなインターン参加の一助になればと思います。

応募~面接

私が大学院修士1年ということもあり, 前々からこの夏はインターンに行くぞ!と決めていました。周りのエンジニアがはてなインターンを推しまくってるのを見ていたので, かなり意欲がありました。研究室ゼミを抱えていたのもあって, 第一期締切の3日前のぎりぎりになんとか応募が完了しました。

バックエンドなのかフロントエンドなのか方向性を迷っていて, 今回はてなインターンでバックエンドが何たるか知るために応募しました。

今年の応募課題は Docker コマンドを実行して, 出てきたトークン文字列を貼り付けるという斬新なものでした。といってもコマンドを叩いて名前を入力するものでした。

事前に提出した ES や, GitHub アカウント, ポートフォリオサイトを隅から隅まで見て頂いたらしく, 面接中はエンジニアらしい質問が多く学びもありました。面接は1時間程度だったのですが, その後わずか1時間もしないうちに合格の連絡が来てびっくりしました。この日は面接のあと研究室の学生飲み会だったのですが, 飲み会中にメール通知を見て嬉しかったのを覚えています。初めてのインターンはてなになりました。

使用ツール

インターン期間中は全体でコミュニケーションツールとして Discord, Scrapbox, (Zoom) を活用していました。

課題を進める日は, メンターの id:hogashi さんや同じ部屋の id:namachan10777 さんと Discord の VC に繋いでおいて進捗確認とか質問とかする感じでした。 Zoom で常にカメラ ON にしているよりは VC で必要なときだけマイクを On にする感じだったので非常にやりやすかったです。 id:hogashi さんも Discord の画面共有を駆使しつつ丁寧に教えてくださって助かりました!

Scrapboxインターン参加者とか社員さんのプロフィールや, 各部屋の作業ログが書き込まれていってていい感じでした。特に, 自分が何をしていたかのログを残すってことが新鮮でした。

日誌

参加期間が 8/24 ~ 8/28 の 5日間だったので, 時系列で書いていこうと思います。

実際にインターンが始まったのは 8/24 でしたが, その1週間前に事前交流会がありました。
今年はオンライン開催ということで, 参加者-社員間のコミュニケーションが取りづらいということを配慮してくださったのだと思います。

1日目 (2020/8/24・月)

1日目は初日とあって, オリエンテーション等 → 講義 → 歓迎会の流れでした。

オリエンテーションの中で「オープンネス」という言葉が特徴的でした。OSS はやっていたけど, 技術的な知見を外に出すということをあまりやっていなかったので, 今後アウトプットしていこうと決意しました。

講義パートは, Web API, コンテナ, Kubernetes, マイクロサービスの4つから構成されていました。コンテナの講義の最後に Docker CTF みたいな問題が出題されました。ENTRYPOINT にあるプログラムのソースコードを復元するという問題は, Docker コマンドに疎かったので直接 Image の中身を見ながら挑戦していました。

ところで Docker は初心者でしたが, WSL 2 が激アツだったので Docker + WSL 2 でやってみました!

2日目 (2020/8/25・火), 3日目 (2020/8/26・水)

基本的に課題を進める日でした。課題は,

  • ブログサービスのひな形が用意されていて, 見出し/リスト/リンク記法を追加する。さらに独自に記法を実装する。
  • タイトルの省略されたリンク ([](https://github.com) ←こういうやつ) のタイトルを自動で取得できるようにする。

でした。

ユーザが書いた記法は renderer-go (Go 実装) または renderer-ts (TypeScript 実装) のレンダラにより HTML に変換されます。どちらのレンダラをいじるかは自由で, 私は TypeScript のほうを選びました。あとから気付いたけどほかの参加者は皆, Go のほうを選んでいたのかも?

実装を決めるとなれば, ライブラリ選定です。今回は remark.js を採用しました。理由は, プラグインシステムがあり記法の追加が容易であることでした。見出し/リスト/リンク記法の追加はすぐ終わりました (Markdown 準拠だった) が, 自分で syntax を作って記法を導入するというのが難しく, 時間がなかったです。そういうことなので, プラグインで簡単に入る :emoji: 記法などを追加して次の課題に進みました。

f:id:SlashNephy:20200828191057p:plain

レンダラと「URL のタイトルを取得するサービス」は別のサービスとして実装する方針でした。この「URL のタイトルを取得するサービス」を fetcher と名付けました。fetcher の実装は自由だったので, Kotlin で実装しました。

gRPC は Google が推進しており, Kotlin もまた推進しているものの1つです。公式で How-To が説明されており

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:3.12.2"
    }
    plugins {
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:1.30.0"
        }
        id("grpckt") {
            artifact = "io.grpc:protoc-gen-grpc-kotlin:0.1.5"
        }
    }
    generateProtoTasks {
        ofSourceSet("main").forEach {
            it.plugins {
                id("grpc")
                id("grpckt")
            }
        }
    }
}

こんな感じで Gradle のビルドスクリプトを書くと, src/main/proto 以下に *.proto をおいてしまえば, 勝手にコードを生成してくれるのだった。結局, 全体の流れとしては

  1. gRPC プロトコルを作りました。fetcher.proto としました。
  2. Gradle の protobuf プラグイン.proto.kt のコード生成をしてもらいました。
  3. 生成コードを元に URL リクエスト (PageTitleRequest) からそのページタイトル (PageTitleResponse) を返す gRPC サービス FetcherService を実装しました。

という感じでした。URL からタイトルを取得するのにあたっては Ktor と Jsoup を使いました。Ktor で URL に GET リクエストをして, Jsoup で HTML をパースして <title> を抜き出すという実装にしました。User-Agent 設定するの忘れなくてよかった!

URL 先が Content-Type: text/html ではないときや <title> タグがないときも考えられます。その場合は PageTitleResponseStatusCode 列挙体を使って, クライアントに通知することにしました。

service FetcherService {
  rpc GetPageTitle(PageTitleRequest) returns (PageTitleResponse);
}

message PageTitleRequest {
  string url = 1;
}

message PageTitleResponse {
  enum StatusCode {
    OK = 0;
    UNDEFINED_TITLE = 1;
    UNAVAILABLE = 2;
  }

  StatusCode code = 1;
  string title = 2;
}

実装できた!よし動かすぞ!ってときに GRPC Health Checking Protocol を実装してなくて, ヘルスチェックが確定でコケるという事故がありました。health.proto を追加してよしなに HelathCheckService を実装して解決しました。

Kotlin のビルドはアプデを重ねるたびに高速化していっていますが, それでも遅いので Dockerfile で multi-stage な build をするように工夫しました。また, 実際の実行環境に不要な依存が入り込まないよう ShadowJar プラグインを使って .jar の依存をまとめてしまい, できた Fat Jar を JRE だけある環境で実行するだけにしました。

# syntax = docker/dockerfile:experimental
FROM gradle:6.6.0-jdk8 AS cache
RUN mkdir -p /home/gradle/cache_home
ENV GRADLE_USER_HOME /home/gradle/cache_home
RUN mkdir -p /home/gradle/build_tmp
COPY build.gradle.kts settings.gradle.kts /home/gradle/build_tmp
WORKDIR /home/gradle/build_tmp
RUN gradle clean build -i --stacktrace

FROM gradle:6.6.0-jdk8 AS build
COPY --from=cache /home/gradle/cache_home /home/gradle/.gradle
COPY --chown=gradle:gradle . /home/gradle/build_home
WORKDIR /home/gradle/build_home
RUN gradle shadowJar -i --stacktrace

FROM openjdk:8-jre
RUN mkdir /services
COPY --from=build /home/gradle/build_home/build/libs/fetcher-all.jar /services/fetcher.jar

RUN GRPC_HEALTH_PROBE_VERSION=v0.3.2 && \
    wget -qO/bin/grpc_health_probe https://github.com/grpc-ecosystem/grpc-health-probe/releases/download/${GRPC_HEALTH_PROBE_VERSION}/grpc_health_probe-linux-amd64 && \
    chmod +x /bin/grpc_health_probe

USER 1000
ENTRYPOINT ["java",  "-jar", "/services/fetcher.jar"]

なお, 最後に Fat Jar を起動するときのイメージは openjdk:8-jre-alpine だとダメでした。詳しく調べられてないけど, 依存するネイティブライブラリが足りないみたい?

ここまでくれば, あとは renderer-ts をいじってレンダラから実際にリクエストするようにするだけです。いろいろ調べながら remark のプラグインを書き, リンクにタイトルが未設定なときに fetcher にリクエストして, タイトルをはめ込むという実装が無事できたのでした。 Link ノードを visit していき, タイトルがなければ取得・更新を試みるロジックにしました。もし, StatusCode != OK なら (タイトル取得に失敗すれば) もとのリンクの URL にフォールバックするようにしました。

function autoTitle(): Transformer {
    return function transformer(tree): Promise<void> {
        const updates: Link[] = [];
        visit(tree, "link", (node: Link) => {
            const text = node.children.find(e => e.type === "text");
            if (text === undefined) {
                 updates.push(node);
            }
        });
        const promises = updates.map(node => new Promise(async (resolve) => {
            let title: string;
            try {
                title = await getPageTitle(node.url) ?? node.url;
            } catch {
                title = node.url;
            }
            node.children.push({
                type: "text",
                value: title
            });
            resolve();
        }));
        return Promise.all(promises);
    };
}

一応まだ余力があって, fetcher で同一 URL は同一タイトルを返すようにキャッシュを実装しました。ただし, クエリ文字列を含むときはキャッシュをしないような実装にしました。(Cloudflare 的な?)

4日目 (2020/8/27・木)

引き続き作業をする日でした。それと, メンターの id:hogashi さんからのコードレビューがありました。普段, 人にコードを見てもらう機会が少ないので客観的に自分のコードが評価されることがいい体験でした。

PR にも細かくコメントを付けていただいて, 細部まで見てくださっていたのがよく伝わりました。PR のコメント機能を駆使したのはじめてかもしれないです。

それと Mock テストの書き方について学ぶことが多く, とても参考になりました。

5日目 (2020/8/28・金)

各々の成果発表会がありました。皆さん, 思いつかないような独創的な記法とかよい実装とかしていて刺激的だった。もっと時間があれば, 発展課題まで深堀りできたかな? スポイラーとか Wikipedia へのリンクはかなり実用的に思えたし, (ry とか gaming 記法とかみなさん発想力も実装力もすごいって思いました!

私は Kotlin の布教をしつつ, なんとかいい感じにプレゼン完了しました。

19時から表彰式 & 送別会がありました。全く予想していませんでしたが, なんと id:motemen さんの方から成果発表の技術賞を頂いてしまいました。 本当に嬉しくて, 受賞のコメントになんて言ったらいいか分かりませんでした…

本当にありがとうございました!

最後に

前にも書いた通り, 今年はほとんどの会社がオンライン開催 & 短期間というインターンになっていました。自分にとってはこの2つの要素は問題じゃなかったです。参加して得られるものを重視していました。それに id:onishi さんが送別会で仰っていたように, 今回のはてなインターンは「オンライン開催としての新しいインターンの形」だと思いました。その初回に参加できて嬉しく思います。

実際, 新しい技術に触れられてとてもよいインターンになり, 全く触ったことのなかった Docker とか gRPC といった技術にがっつり触れられた, 濃い1週間が過ごせました。インターンを終えて, やっぱりバックエンドたのしいな, と感じました。自分はバックエンドに関わりたいんだと気づけた, いい経験でした!

世間が大変な時期なのにこんなハイクオリティなインターンを, 家から無料で受講できていいの!?って思いました。(しかも参加賞と技術賞まで頂いてしまいました...)

最後に, はてなの皆さん, そして参加者の皆さん, 1週間ありがとうございました!