blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2021.12.12 インターンレポート

Hugoでビルド時にデータ処理した話 -カード型リンク編-

by Takuya Sakuma

#hugo

こちらはDeNA 21新卒×22新卒内定者 Advent Calendar 2021の12日目の記事です。

はじめに

こんにちは。22新卒の佐久間です。現在DeNAではCTO室で内定者インターンをさせていただいており、本サイトの作成をお手伝いさせていただいています。

本稿ではポータルサイト作成に使用したフレームワークである Hugo について書いていこうと思います。なお、ポータルサイトへのHugo採用の経緯などは本稿の趣旨から逸れるため他所に委ね、Hugoを利用する上で直面したある問題とその解決策を紹介しようと思います。


HugoはGo言語性の非常に高速な静的サイトジェネレーターです。詳細は 公式サイト にご覧いただけれな良いと思いますが、“The world’s fastest"と明言されているほどのビルド速度が特徴です。ホットリロードにも対応しており、記事を更新しても数ms〜数百msでビルドが完了するのでストレスフリーです。

その一方でHugoはhtmlのテンプレートエンジンであるため、ビルド時に複雑な処理ができない欠点を抱えています。外部データの取得もほとんど許されておらず、JSONもしくはCSV形式でないとURLから情報を取得できません。

とはいえ、最近は AWS Lambda などが非常に安価、どころかこの程度ならば無料で提供されているため、JSONが受け取れるなら、これらを利用してデータを処理し、HugoにJSONデータを与えれるエンドポイントを作成すれば良いのでは、と思われるかもしれません。

概ねそのとおりです。

しかし、今回はインフラ側で利用するサービスや構成も同時検討していた都合上、外部のクラウドサービスに依存しない形で提供する方法を検討しました。

以降ではカード型リンクを例としてクラウドサービス無しでHugoのビルド時にデータ処理を行う方法を紹介します。カード型リンクは該当するリンクのページからmetaタグを解釈し、画像・タイトル・説明を読み出す必要があります。リンク先は当然htmlであり、Hugoでは利用できないため本稿のターゲットと言えます。

対象読者

  • Hugoビルド時にデータの処理を行いたい人
  • AWS Lambdaをはじめとするクラウドコンピューティングサービスをできるだけ避けたい人

ゴール

以下のようなカード型リンクを実装できるようにします。

コンテンツ側では以下のようにテキスト部分にカード型リンクを利用することを伝えるシンボル:ogpを設定し、Hugoはこのシンボルを検知するとカード型リンクとして上記のように描画します。また、ホットリロードにも対応し執筆中にリアルタイムで確認できるようにします。

[:ogp](https://example.com)

上記を達成するため本稿では Data Templates を活用します。

Data Templates

Data TemplatesはHugoのビルドイン変数に加えてテンプレート内で独自のカスタムデータを利用したい場合に活用します。Data Templatesはビルド時に静的でなければならないため、複雑なことはできません。 data/下にYAML、JSON、TOMLのいずれかのファイルを設置することでビルド時に利用できるようになります。例えば、テンプレートからmyData.ymlに収められているData Templatesにアクセスする場合は次のように記述します。

{{ .Page.Site.Data.myData }}

全体像

contents/下のファイルと同様に、Data Templatesもホットリロードに対応しており、data/下のファイルが更新されると自動的にビルドが走りレンダリングの更新がなされます。 そこでdata/下にogp.ymlというData Templateを用意し、このファイルをハブとしてOGPデータをHugoに与えます。ogp.yml自体は外部スクリプトで継続的に更新します。なお、HugoはJSON、YAML、TOMLのデータ形式に対応していますが、追記の容易性及びモジュールの充実さからYAML形式を採用しました。

全体の構造は次のようになります。

全体像

システムの全体像

今回は外部スクリプトとしてJavascript1を採用し、contents/下のMarkdownファイルを監視します。ファイルの更新を検知すると指定したシンボルを探し出します。シンボルからURLを抜き出し、サイトにアクセスしOGPデータを取得し、ogp.ymlに追記していきます。Hugoはogp.ymlの更新を検知するとビルドが始まり、同様に指定したシンボルを見つけるとogp.ymlからデータを取得し、カード型リンクとしてレンダリングします。

実装

前章を踏まえて実装していきます。本稿のシステムは外部スクリプト部とHugo部に分かれるため、実行時には例えば以下のように、実装するスクリプト(watcher.js)とhugo serverを同時に起動することになります。

node watcher.js & hugo server &

以降ではまず、Data Templateのフォーマットを定義し、この形式に沿って外部スクリプト及びHugo側の実装を行います。

Data Template (ogp.yml)

まずはData Templateの形式です。以下のように取得した情報を以下のようにURLをキーとしてその子要素にOGPデータを持たせます。

https://example.com:
    title: some title
    image: https://example.com/cover.png
    description: some description

このようにURLをキーとするのはHugo側で取得できるユニークな値はURLしかないためです。レンダリングにはタイトル、画像、説明の項目しか利用しないので、それ以外の情報は無視します。

外部スクリプト (watcher.js)

上記を踏まえた上で外部スクリプト側の実装は次のようになります。なお、ogpデータの取得部分は open-graph-scraper をはじめとするパッケージが存在するので、ここでは詳細を控えます。

import * as chokidar from 'chokidar';
import * as fs from 'fs';
import { promises as fsPromise } from 'fs';
import YAML from 'yaml';
import { getOgpData } from "./ogp";

const contentsPath = './content/';
const dataPath = './data/ogp.yml';

// (1)
function analyzeOgpUrls(content) {
    const ogpRegex = /(?<!\!)\[\:ogp\]\((?<url>https?://[\w/:%#\$&\?\(\)~\.=\+\-]+)\)/g;
    const result = [];
    for (const match of content.matchAll(ogpRegex)) {
        result.push(match.groups.url);
    }
    return result;
}

// (2)
async function updateRecord(path, writeStream, record) {
    const content = await fsPromise.readFile(path, { encoding: "utf8" });
    const ogpUrls = analyzeOgpUrls(content);
    for (const url of ogpUrls) {
        if (url in record) {
            continue;
        }

        const data = await getOgpData(url);
        const yml = YAML.stringify({ [url]: data });
        record[url] = data;
        writeStream.write(yml);
    }
}

// (3)
const watcher = chokidar.watch(contentsPath, {
    ignored: /[\/\\]\./,
    persistent: true
});

// (4)
watcher.on('ready', function () {
    const content = fs.readFileSync(dataPath, { encoding: "utf8" });
    const record = YAML.parse(content) || {};
    const writeStream = fs.createWriteStream(dataPath, { flags: 'a' });

    watcher.on('change', function (path) {
        updateRecord(path, writeStream, record);
    });
});

上記(1)~(4)のブロックに分けて説明します。

function analyzeOgpUrls(content) {
    const ogpRegex = /(?<!\!)\[\:ogp\]\((?<url>https?://[\w/:%#\$&\?\(\)~\.=\+\-]+)\)/g;
    const result = [];
    for (const match of content.matchAll(ogpRegex)) {
        result.push(match.groups.url);
    }
    return result;
}

この関数は与えられた文字列から、正規表現を利用して上述した[:ogp](https://example.com)のように表される文字列を抜き出し、URLを抽出します。この時、(?<!\!)のように前方に「!」が存在しないことを確認することで誤って画像に対してOGP取得しないようにしています。

async function updateRecord(path, writeStream, record) {
    const content = await fsPromise.readFile(path, { encoding: "utf8" });
    const ogpUrls = analyzeOgpUrls(content);
    for (const url of ogpUrls) {
        if (url in record) {
            continue;
        }

        const data = await getOgpData(url);
        const yml = YAML.stringify({ [url]: data });
        record[url] = data;
        writeStream.write(yml);
    }
}

updateRecordは処理全体の核となる関数です。更新されたファイルからOGP取得用のURLを抜き出し、レコードを更新します。writeStreamは書き込み先のファイル(ogp.yml)であり、recordはその中身をオブジェクト化したものです。

5行目のように、recordにすでにキーが登録されている場合、データを再取得する必要がないため、処理をスキップします。 10行目のように{ [url]: data }とすることでURLをキーとしてOGP情報を記録します。dataには前章で指定したtitleなどが含まれている想定です。

const watcher = chokidar.watch(contentsPath, {
    ignored: /[\/\\]\./,
    persistent: true
});

今回はディレクトリの監視に chokidar を利用しました。chokidar.watch./content/を監視します。

watcher.on('ready', function () {
    const content = fs.readFileSync(dataPath, { encoding: "utf8" });
    const record = YAML.parse(content) || {};
    const writeStream = fs.createWriteStream(dataPath, { flags: 'a' });

    watcher.on('change', function (path) {
        updateRecord(path, writeStream, record);
    });
});

監視の準備が完了すると、過去のデータを読み込みます。データはYAML形式のため、YAML.parse(content)のようにパースしてオブジェクトに変換しておきます。オブジェクトに変換しておくことで、過去に取得したことがあるか判定しやすくなります。

データの準備が完了すると、ファイルの変更イベントを購読します。変更を検知すると前述したupdateRecordを呼び出しogp.ymlを更新します。

Hugoテンプレート(レンダリング側)

ogp.ymlが更新されると自動的に再ビルドが走るため、Hugo側ではそのレンダリング部分を実装します。

Hugoは唯一Markdownのレンダリング部分に関しては拡張機能が用意されています。詳細は こちら をご覧ください。layouts/_default/_markupディレクトリにrender-link.htmlというファイルを用意することでMarkdown内部のリンクがこちらのテンプレートからレンダリングされます。

以下にrender-link.htmlの例を示します。なお、見やすさのために実際のプロジェクトで使用しているものとは異なる点はご注意ください。

{{ if (eq .Text ":ogp")}}
{{ $destination := .Destination }}
{{ with .Page.Site.Data.ogp }}
{{ $data := index . $destination }}
{{ with $data }}
<div class="card-link">
  <a href="{{ $destination | safeURL }}" target="_blank" rel="noopener noreferrer">
    <div>
      <img src="{{ $data.image }}">
    </div>
    <div>
      <h2>{{ $data.title }}</h2>
      <p>
        {{ $data.description }}
      </p>
    </div>
  </a>
</div>
{{ end }}
{{ end }}
{{ else }}
<a href="{{ .Destination | safeURL }}">
  {{ .Text | markdownify }}
</a>
{{ end }}

render-link.htmlではHugoから.Text及び.Destinationの変数が与えられます。それぞれ表示テキスト及びURLに対応しているのでこれらを利用してカード型の判定を行います。 前述したように表示テキストが:ogpで表される場合はカード型リンクとして描画します。 1行目の判定でカード型リンクとして描画することがわかった場合には3行目のようにData Templatesからogpのデータを取り出します。ogp.ymlは前述のようにURLをキーとしてデータを保存しているため、4行目のように.Destinationを利用して対応する情報を抜き出します。あとはお好みで描画するだけとなります。

また、:ogpシンボルが存在しない場合は通常の文字列リンクとなりますので、24行目のようにelseブロックも実装しておきます。

まとめ

以上で記事を編集しながらOGPデータを収集し、カード型リンクをレンダリングできるようになりました。このようにHugo側ではData tempalteでデータのフォーマットのみを指定し、外部スクリプトによってデータ処理任せるという方式は、最終的な他の用途でも応用できそうです。

なお、実際のプロジェクトではJavascriptもHugoもdockerコンテナとして提供し、docker-composeを利用して同時に起動するため、執筆者はHugo以外に動作しているアプリの存在を意識する必要がないようにしています。

最後になりますが、DeNA 21新卒×22新卒内定者 Advent Calendar 2021 と DeNA Advent Calendar 2021 はそれぞれ新しい記事がどんどん投稿されていきます。新着記事の情報はDeNA公式Twitterアカウント @DeNAxTech でキャッチできるので、ぜひフォローをお願いします!

それでは、明日以降の記事もお楽しみに!

おまけ(Short Codeを利用しない理由)

さて、今回Hugoでのカード型リンクのレンダリングは独自のシンボルを起点にrender hooksを通して実装しました。 しかし、Hugoは本来こうした機能を Shortcodes で実装することを期待しています。 それではなぜ、こちらを利用しなかったのでしょうか。

一番の要因はShortcodesの構文です。Shortcodesは以下のように独自の構文を持っています。

{{<  myshortcode some_params="something" >}}

そのため、Hugoを利用する分には問題ありませんが、もしも今後記事の移転が決まった時、Markdownの記法として正式に採用されていないものが使われていると、それぞれ個別に変換する作業が発生してしまいます。一方、今回のようにあくまでMarkdownの構文に則ることで、別のフレームワークを利用したとしても最低限の見た目は保証できるようになるのです。


  1. 実際にはTypescriptを利用していますが、型定義などが冗長になってしまうためJavascript説明します。また、以降のスクリプトは記事用に要約したものを記載しているため、エラー処理などが不十分な点はご留意ください。 ↩︎

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。