アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

ブラウザ上で自作javascriptを走らせたい!

こんにちは!!こんにちは!! moriyamaです。

早速ですが、特定WEBサイトのアクセス時に自作scriptを走らせたい時ってありませんか?
私はあります。読者の皆さんもありますよね?

いつかは実現したいと常日頃から考えており、今回ついに実装できたので記しておきます。


拡張機能では駄目なのか?

bodyタグ内で走って問題ないなら、ぶっちゃけ駄目じゃないと思います。

ChromeやFireFoxなどのブラウザは拡張機能でjavascriptを組み込めたり出来ますが、
headタグに組み込めるものは見たことがありません。

例えば純粋なjavascriptで書かれたWEBページでDOM操作したい際に、
面倒臭がってjQueryでscriptを書いても、jQuery本体を先に読み込ませる必要がありますよね?
bodyタグで読み込んでも、実行順序で不都合が起きたこともあったため、
headタグにscriptを埋め込めないかと常々考えていました。


どう実現したのか?

ローカルにnginxでプロキシサーバを立てました!

完!!!で締めたいところですが、もう少し詳しく説明します。

nginx の proxy_passを使ってリクエストを流し、Luaのスクリプトを走らせてレスポンスを改変します。
そのためluaモジュールが組み込まれたnginx(OpenResty)を使いました。

フォルダ構成は、シンプルにこんな感じです↓

.
├── docker-compose.yml
└── nginx
    ├── default.conf
    └── src
        ├── body_filter.lua
        └── script.js

ファイルの中身

■ docker-compose.yml

version: '3'

services:
  nginx:
    image: openresty/openresty:centos
    ports:
      - 8880:80
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
      - ./nginx/src:/etc/nginx/src

■ nginx/default.conf

server {
  listen  80;

  location / {
    # プロキシ設定
    proxy_pass 'https://iko-yo.net/';
    proxy_set_header Accept-Encoding "";

    # luaコードのキャッシュ設定
    #lua_code_cache off;

    # コンテンツ長が変動するため、ヘッダ書き換え
    header_filter_by_lua_block {
      ngx.header.content_length = nil
    }

    # コンテンツ内容の改変
    body_filter_by_lua_file '/etc/nginx/src/body_filter.lua';
  }
}

■ nginx/src/body_filter.lua

-- jsファイルを読み込み
function load_script()
  local file = io.open("/etc/nginx/src/script.js", "r")
  local script = file:read("*a")
  file:close()

  return script
end

-- 置換する文字列生成
local replace = "<head><script>" .. load_script() .. "</script>"

-- body書き換え
ngx.arg[1] = ngx.re.sub(ngx.arg[1], "<head>", replace)

■ nginx/src/script.js

if (confirm('動いた?')) {
  console.log('動いたらしい');
}

苦労した箇所

実装時はLua上でレスポンス内容を出力しながら試していましたが、
文字化けが酷く、内容が全く読めませんでした。

そのため置換処理も走らず、吐き出されるHTMLは変化せず四苦八苦。
string.upper等の文字列処理を試しても、ブラウザ上ではHTML認識されず、更には文字化けテキストが表示され悩まされ。
UTF8のライブラリを使い、エンコードしてみても駄目だったのです。

色々調べているうちに、偶然、解決方法を見つけました。
HTTPヘッダのAccept-Encoding が原因でした。
レスポンス本文が圧縮されている場合、
中身を出力したところで、エンコード関係なく文字化けしている様に見えるわけですね!


実際に動かしてみる

こうなりました!
各ページで、javascriptのconfirmが動作している様子が伺えますね!

f:id:setsuna82001:20200717144324g:plain:w350


最後に

思った通りに実装するのはなかなか難しいですが、動いた時は感無量ですね!

S3のフォルダーが日毎にちゃんと作られているかLambdaで監視する

こんにちは!!こんにちは!!
インフラエンジニアのyamamotoです。

最近、しいたけ栽培セット にハマっています。モリモリしいたけが生えてくるのでとっても面白いです!
生のしいたけというのも普段なかなかお目にかかれないので、秋の味覚を楽しめてイイですよ!

しいたk…ログがちゃんと出力されているか?

当社のあるプロダクトではAWSを使用していますが、ログをAthenaで読むために、S3に日毎のフォルダーに分けて出力するようにしています。
ログの出力のしかたについては過去の記事をご参照ください。 tech.actindi.net

ログって使わないときはつい放置しがちになりますよねー。いつの間にか出力が止まっていたりしても気づかなかったり。
そこで、ログが毎日ちゃんと出力されているか監視するために、スクリプトを書いてLambdaで監視するようにしてみました。

コードの説明

ざっくり、コードはこんな感じになりました。

const {
  S3_BUCKET_NAME,
  S3_CHECK_PATTERN1,
  S3_CHECK_PATTERN2,
  S3_CHECK_PATTERN3,
  SNS_TOPICARN,
  SNS_REGION,
  SNS_MESSAGE_TITLE,
  SLACK_URL,
  SLACK_CHANNEL,
} = process.env;

const AWS = require('aws-sdk');

const s3 = new AWS.S3();
const sns = new AWS.SNS({
  apiVersion: '2010-03-31',
  region: SNS_REGION,
});

const checkPattern = [S3_CHECK_PATTERN1, S3_CHECK_PATTERN2, S3_CHECK_PATTERN3];

function formatDate(date, format) {
  let ret = format;
  if (!ret) ret = 'YYYY-MM-DD';
  ret = ret.replace(/YYYY/g, date.getFullYear());
  ret = ret.replace(/MM/g, `0${(date.getMonth() + 1)}`.slice(-2));
  ret = ret.replace(/DD/g, `0${date.getDate()}`.slice(-2));
  return ret;
}

async function checkS3Object(bucket, pat) {
  const params = {
    Prefix: formatDate(new Date(), pat),
    Bucket: bucket,
    MaxKeys: 1,
    StartAfter: formatDate(new Date(), pat),
  };

  console.info(`Checking S3 log directory ${bucket}/${params.Prefix}`);
  const res = await s3.listObjectsV2(params).promise();
  if (res.KeyCount === 0) {
    console.info('Data listobj: ', res);
    return 1;
  }
  return 0;
}

async function checkS3Status(bucket, pattern) {
  let ret = 0;
  let res = 0;
  for (let i = 0; i < pattern.length; i += 1) {
    if (pattern[i]) {
      res = await checkS3Object(bucket, pattern[i]);
      if (res === 1) {
        ret = `${bucket}/${pattern[i]}`;
        break;
      }
    }
  }
  return ret;
}

async function publishSNS(arn, title, message) {
  const params = {
    Message: message,
    Subject: title,
    TopicArn: arn,
  };

  try {
    await sns.publish(params).promise();
  } catch (err) {
    console.error('Error publishing: ', err);
  }
}

async function publishSlack(slackurl, slackchannel, message) {
  const { IncomingWebhook } = require('@slack/webhook');
  const slackwebhook = new IncomingWebhook(slackurl, {
    channel: slackchannel,
  });

  try {
    await slackwebhook.send(message);
  } catch (err) {
    console.error('Error Slack: ', err);
  }
}

exports.handler = async () => {
  const ret = await checkS3Status(S3_BUCKET_NAME, checkPattern);
  if (ret !== 0) {
    const msg = `Error! S3 log directory ${ret} is not known!`;
    await publishSNS(SNS_TOPICARN, SNS_MESSAGE_TITLE, msg);
    if (SLACK_URL !== '') {
      await publishSlack(SLACK_URL, SLACK_CHANNEL, msg);
    }
    console.error(msg);
  }
};

フォルダー名の形式を受け取って、それにマッチしたものが無いとSNS→メールと、Slackで通知するようになっています。
当社の例ではフォルダー名は「/year={年}/month={月}/day={日}/ 」なので、日付をフォーマットに当て込む関数を簡易的に使っています。
また、AWSのオブジェクトは非同期で扱うのでasync・awaitを使って受け渡しています。

意外と迷ったのが、フォルダーの存在有無をどう確認するか、というところです。
S3のフォルダーの有無を確認するには、s3.listObjectV2() メソッドを使用しています。

あと、SlackのWebhookを使った通知については、下記を参照しました。
https://slack.dev/node-slack-sdk/webhook

Lambdaに仕込む

これを、Lambdaに仕込みます。
トリガーとして CloudWatch Event を使い、毎日特定の時間に実行するようにします。日毎の場合、日付が変わってある程度時間が過ぎてからにしています。
リソースについては、監視するS3オブジェクトに対する s3:ListBucket 権限と、SNSの sns:Publish 権限が追加で必要になります。
あと、コードの冒頭にある環境変数を設定する必要があります。

さいごに

アクトインディでは、しいt…Webエンジニアを募集しています! actindi.net

ネストしたリストで Vue.Draggable を使ってみた

morishitaです。

Vue.Draggableをネストさせて利用してみたので紹介します。

Vue.Draggable とは

Vue.Draggable はVueアプリケーションでドラッグドロップ操作を実現するのにとても便利なコンポーネントライブラリです。

単に編集対象のリストを表示して、それをドラッグドロップで並べ替えるだけだととても簡単に実装できます。

複数のリスト間で要素を移動したり、階層的なリストの並べ替えもできたりとちょっと複雑な要件にも対応できる柔軟な作りになっています。

言葉で説明するより動いているものに触っているみのが早いのでライブデモのページを参照してみてください。

sortablejs.github.io

いこレポでは Vue.Draggable をコンテンツ管理機能(CMS)で利用しています。

report.iko-yo.net

いこレポのCMSとその課題

いこレポは独自のCMSを開発しており、それを使って編集チームの皆さんが記事を制作しています。

記事本文は見出しテキスト画像などのブロックと呼んでいる単位で登録して構成します。

本文編集のUIはこんな感じです。

f:id:HeRo:20190724084634p:plain
いこレポのCMS

記事を一通り書き終えてから、より読みやすくわかりやすいようにブロックを並べ替えて並べかえるそうなのですが、効率よく編集するために複数のブロックをまとめて移動したいという改善要望をもらっていました。(ちなみにブロック1つづつなら移動する機能は以前から持っています。)

しかし詳しくヒアリングしてみると単に複数のブロックということではなく一体として意味を持つブロックの塊、つまり段落(セクション)を移動したいということでした。そこで次のように見出しアウトラインとして表示してドラッグドロップで順序を入れ替えることができるアウトライン編集機能を提案して実装することにしました。

f:id:HeRo:20190724084740g:plain
アウトライン編集

いこレポの記事本文データの構造

いこレポではJSONで本文データを保持しています。 見出しや、テキストなどを前述したようにブロックと呼んでおり、そのリストを本文として次のようなJSON形式でデータ保持しています。

[
  {"type":"text", "html":"リード1", "blockId":"bid-0"},
  {"type":"text", "html":"リード2", "blockId":"bid-1"},
  {"type":"headline_2", "html":"大見出し1", "url":"", "blockId":"bid-2"},
  {"type":"text", "html":"テキスト1−1", "blockId":"bid-3"},
  {"type":"headline_3", "html":"小見出し1−1", "blockId":"bid-4"},
  {"type":"image", "url":"/image.png", "blockId":"bid-5"},
  {"type":"text", "html":"テキスト1−1−1", "blockId":"bid-6"},
  {"type":"headline_3", "html":"小見出し1−3", "blockId":"bid-7"},
  {"type":"text", "html":"テキスト1−3−1", "blockId":"bid-8"},
  {"type":"text", "html":"テキスト1−3−2", "blockId":"bid-9"},
  {"type":"headline_3", "html":"小見出し1−2", "blockId":"bid-10"},
  {"type":"text", "html":"テキスト1−2−1", "blockId":"bid-11"},
  {"type":"headline_2", "html":"大見出し2", "url":"", "blockId":"bid-12"},
  {"type":"text", "html":"テキスト2−1", "blockId":"bid-13"},
  {"type":"headline_3", "html":"小見出し2−1", "blockId":"bid-14"},
  {"type":"text", "html":"テキスト2−1−1", "blockId":"bid-15"},
  {"type":"text", "html":"テキスト2−1−2", "blockId":"bid-16"},
  {"type":"headline_3", "html":"小見出し2−2", "blockId":"bid-17"},
  {"type":"text", "html":"テキスト2−2−1", "blockId":"bid-18"},
  {"type":"text", "html":"テキスト2−2−2", "blockId":"bid-19"},
  {"type":"headline_3", "html":"小見出し2−3", "blockId":"bid-20"},
  {"type":"text", "html":"テキスト2−3−1", "blockId":"bid-21"},
  {"type":"text", "html":"テキスト2−3−2", "blockId":"bid-22"},
  {"type":"headline_2", "html":"大見出し3", "url":"", "blockId":"bid-23"},
  {"type":"text", "html":"テキスト3−1", "blockId":"bid-24"},
  {"type":"headline_3", "html":"小見出し3−1", "blockId":"bid-25"},
  {"type":"text", "html":"テキスト3−1−1", "blockId":"bid-26"},
  {"type":"text", "html":"テキスト3−1−2", "blockId":"bid-27"}
]

"type":"headline_2"大見出し"type":"headline_3"小見出して、"type":"text"本文テキスト"type":"image"画像を表します。 各ブロックのblockIdは一意IDとなっています。

見ての通り、データ構造的にはフラットなブロックのリストとなっており、段落という概念はデータ構造上はありません。

文章構造上は次の条件でグルーピングされる一連のブロックがセクション(段落)となります。

  • "type":"headline_2"ブロックから次の"type":"headline_2"まで
  • "type":"headline_3"から次の"type":"headline_2"あるいは"type":"headline_3"まで

図示するとこんな感じです。

f:id:HeRo:20190724090214p:plain
セクション構造

各セクションの見出しとなる"type":"headline_2""type":"headline_3"のブロックを抽出して、次のようなアウトラインデータを作ります。

[
  {"type":"headline_2", "html":"大見出し1", "blockId":"bid-2",
    "h3":[{"type":"headline_3", "html":"小見出し1−1", "blockId":"bid-4"},
          {"type":"headline_3", "html":"小見出し1−2", "blockId":"bid-6"},
          {"type":"headline_3", "html":"小見出し1−3", "blockId":"bid-8"}]},
  {"type":"headline_2", "html":"大見出し2", "blockId":"bid-11",
    "h3":[{"type":"headline_3", "html":"小見出し2−1", "blockId":"bid-13"},
          {"type":"headline_3", "html":"小見出し2−2", "blockId":"bid-16"},
          {"type":"headline_3", "html":"小見出し2−3", "blockId":"bid-19"}]},
  {"type":"headline_2", "html":"大見出し3", "blockId":"bid-22",
    "h3":[{"type":"headline_3", "html":"小見出し3−1", "blockId":"bid-24"}]}
]

このJSONを次のテンプレートでレンダリングして、上のGIFアニメーションで示したアウトラインのUIを作っています。

<template lang="pug">
  div.outline-editor
    draggable(:list="outline" tag="ul" :group="{ name: 'outline' }" :move="onMove" @end="end")
      li(v-for="item in outline" :class="item.type" :key="item.blockId" :value="item", :data-block-id="item.blockId") {{item.html}} {{item.blockId}}
        draggable(:list="item.h3" tag="ul" :group="{ name: 'outline' }" :move="onMove" @end="end")
          li(v-for="h3 in item.h3" :class="h3.type" :value="h3" :data-block-id="h3.blockId") {{h3.html}} {{h3.blockId}}
</template>

見ての通り、draggableをネストさせて使っています。 これにより次の操作を実現しています。

  • "type":"headline_2"をドラッグしたときには子の"type":"headline_3"も一緒に移動する
  • "type":"headline_3"をドラッグすると"type":"headline_3"だけが移動する

単純な Vue.Draggable の利用と異なる部分は次の点です。

  • リスト(アウトライン)がネストしているが、階層を超えてブロックを移動したい
  • 操作するリスト(アウトライン)を並べ替えたいのではなく、本文を並べ替えたい

階層を超えたブロックの移動

上記に示したアウトラインデータでは次の合計4つのリストができます。

  • "type":"headline_2"を要素に持つリスト
  • "type":"headline_2" のそれぞれにネストした "type":"headline_3" を要素とする3つのリスト

Vue.Draggableではリストごとに並べ替えができ、異なるリスト間は移動できません。 しかしgroup属性を使えば、リスト間の移動を実現できます。

上記のテンプレートのコードでは:group="{ name: 'outline' }"をすべてのdraggableで同じ値で指定しています。同じgroupのリストは1つのリストして扱われます。これによりすべてのdraggableが同一リストとなり、ドラッグしたセクションをアウトラインのどこにでもドロップできるようになります。つまり、"type":"headline_3"セクションを別の"type":"headline_2"セクションに移動するという操作が実現できます。

操作するリスト(アウトライン)を並べ替えたいのではなく、本文を並べ替えたい

なんのこっちゃと思われるかもしれませんが、単純な Vue.Draggable の利用では、UIに表示されて操作するリストが並べ替えたい対象です。ドラックして変更すると、コンポーネントに渡したリストデータも変更さるのでそれをVuexストアに反映したり、サーバにPOSTして永続保存するなりすれば良いです。

今回はそうではなく、アウトラインを操作して本文のブロックを並べ替えたいのです。 操作対象のリストと最終的に変更したいリストが異なるということです。 そのために、アウトラインからは次の2つを取得し、これらの値から本文データを並べ替えることを考えました。

  • 移動したブロック
  • 移動先の位置

さて、これらをどうやって取得するかですが、最初はドラッグしたブロックをドロップした時に発生するendイベントで取れると思って渡されるイベントオブジェクトを色々いじってみたのですが、取りづらい。それでいろいろ試行錯誤して最終的にmove属性にコールバック関数を渡して得られるonMoveイベントからのほうが取りやすかったのでそちらから次のように取りました。

  • 移動したブロック:onMove.dragged
  • ドロップした位置にあるブロック:onMove.related

移動先の位置相当のものとして ドロップした位置にあるブロック を取得します。 各ブロックには一意なblockIdつけてあるので、ちょっと泥臭いですが、本文のブロックのリストを走査して移動したブロックを先頭の見出しとするセクションと移動先のインデックスを求めました。

後は、本文ブロックリストから一旦セクションに属するブロック群を抜き取って移動先に挿入する操作を行いセクションの移動が完了です。

アウトライン上で並べ替え操作をすると、Vuexストアに格納している本文データを更新します。するとVue+Vuexの単方向データフローでアウトラインの表示も更新するという仕組みです。

こうして上のGIFアニメのようなアウトライン編集機能を実現しました。

まとめ

  • Vue.Draggable ではネストしたリストの並べ替えも簡単
  • 操作するリストと、変更したいリストが異なる場合にはちょっと面倒
  • ドロップ先要素の上下どちらに挿入するのかがわからない(未解決課題)

実は、移動先はドロップする位置にある要素はすぐ取れるのですが、その要素の上下どちらに挿入するのかを知る方法を見いだせませんでした。 今回は諦めて、ドロップ要素の前に挿入することにしています。そのためアウトラインの末尾に移動できません。末尾の要素にドロップするとその前に移動するので、末尾要素を移動すると目的の移動は実現できますが、ちょっと面倒です。今後の課題です。

最後に

アクトインディではエンジニアを募集しています。 actindi.net

Web Componentsを試してみた

morishitaです。

プログラムを部品化して再利用したい。

コードを書く人間にとってはいつも考えていることですし、永遠のテーマなのではないかと思います。

オブジェクト指向はクラスとしてパッケージしたコードを再利用する仕組みを提供します。関数型言語は関数で。
言語レベルでなくても、コードを分割し再利用する仕組みはフレームワークやビルドシステムなどいろんな形で存在します。

Web Components もそんな再利用の仕組みの1つです。 ちょっと前に、「すでにモバイルではポリフィルなしでも使える状況になってきている」と聞きかじって気になっていました。

で、ちょっと試して見ました。

なお、このエントリではPolymerなどの polyfill は使いません。

Web Componentsとは?

Web ComponentsはざっくりいうとオリジナルのHTMLタグを作るための仕組みです。JSP(JavaServer Pages)や ASP(Active Server Pages)のカスタムタグを思い出すと「あーあれかー」って感じかと思います1

JSPやASPはサーバサイドの技術でしたが、Web Componentsはクライアントサイドの技術で次の要素を組み合わせて実現する仕組みです。

ざっくりいうとカプセル化された(他のコードを汚染しない)環境で実行されるJavaScriptとHTMLの断片で独自のHTMLタグが実装できる技術ということです2

上記の技術要素をブラウザがサポートしていればWeb Components が使えるってことになります。
それぞれの要素のCan I use... のリンクを付けておきましたが、それによると一部の機能制限はあるもののモバイルだけでなくPCでも最新版のChrome、Safari、Firefox、そしてEdgeでは動く様です。

ネックになるのは4.4.4以前のAndroidとIEですかね。

シンプルな例

シンプルな例としてブラウザのユーザエージェントを表示する<user-agent>タグを作ってみました。

今回作成したものは次のファイル群で構成されます。

/
├── components
│   └── user-agent.js
├── index.html
└── main.js

順に上記のファイルについて説明します。

index.html

まずは<user-agent>タグを利用するページであるindex.html

内容は次のとおりです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <script type="module" src="main.js"></script>
  <title>Web Components - Simplest Sample</title>
</head>
<body>
  <h1>Web Components</h1>
  <h2>User Agent Tag</h2>
  <user-agent></user-agent>
</body>
</html>

ここで、main.js<script>で読み込んでいます。 type="module"をつけなくてもコンポーネントは動作しますが、main.jsので定義した変数などがページのコンテキストに漏れるのでつけておいたほうがいいです。

<user-agent></user-agent>がカスタム要素を使用しているところです。

main.js

index.htmlで読み込んでいるmain.jsでは、カスタム要素<user-agent>を使えるように定義しています。

import UserAgentElement from './components/user-agent.js';

customElements.define('user-agent', UserAgentElement);

UserAgentElementクラスをcomponents/user-agent.jsからインポートしています。
importはこのファイルを読み込む<script>タグにtype="module"を指定することにより使えるようになります。

そして、customElements.define()でカスタム要素名user-agentとそれを実装するUserAgentElementクラスを指定してカスタム要素を定義しています。
カスタム要素名にはダッシュ(-)を含める必要があるので注意です。

components/user-agent.js

最後に、カスタム要素の実体components/user-agent.jsです。

export default class UserAgentElement extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    const ua = navigator.userAgent;
    shadowRoot.innerHTML = ua;
  }
}

ポイントは次のとおりです。

  • カスタム要素の実体であるUserAgentElementクラスをHTMLElementを継承して実装する
  • クラスのコンストラクタでは必ずsuper()を実行する
  • 表示要素をShaddow DOMで実装する
    • 上記の例ではthis.attachShadow()shadowRootを作ってそれをRootとしてDOMを構築しています。

表示結果

表示の結果は次の様になります。

f:id:HeRo:20190624232810p:plain
<user-agent>

<user-agent>で、ブラウザのユーザエージェントが表示されています。 Dev Toolで確認すると、Shadow DOMが使われているのがわかります。

次のブラウザで試してみました。いずれも動作します。

  • Chrome(75, macOS, Android)
  • Safari(12.1.1, macOS,iOS)
  • Firefox(67, macOS,Android)

上記のページには次のリンクからアクセスできます。
Web Components - Simplest Sample

HTML Template も使ってみる

上記の例では、Web Components の技術要素のうち、HTML Templateは使っていません。

HTML Templateを使ったカスタム要素を作ってみます。

<popup-elememt>タグ

ボタンをクリックするとポップアップダイアログを表示するカスタム要素<popup-elememt>を作ってみました。

動作の様子は次のとおりです。

f:id:HeRo:20190624233114g:plain
<popup-element>

こちらで試せます => Popup Sample

HTMLのコードは次のとおりです。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <script type="module" src="main.js"></script>
  <title>Web Components - Popup</title>
</head>
<body>
  <h1>Web Components</h1>
  <h2>Popup Tag</h2>
  <template id="popup-tmpl">
    <style>
      #popup {
        display: none;
        position: fixed;
        background-color: rgba(0,0,0,0.3);
        width: 100vw;
        height: 100vh;
        top: 0;
        left: 0;
      }
      .dialog {
        background-color: white;
        width: 80vw;
        margin: 100px auto 0 auto;
        padding: 10px;
        border-radius: 5px;
      }
      .title { font-weight: bold; }
    </style>
    <div id="popup">
      <div class="dialog">
        <div class="title">
          <slot name="title"></slot>
        </div>
        <div class="content">
          <slot name="content"></slot>
        </div>
      </div>
    </div>
  </template>

  <div>
    <p class="title">ポップアップ - 1</p>
    <popup-elememt>
      <span slot="title">タイトル - 1</span>
      <span slot="content">
        本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1 本文1
      </span>
    </popup-elememt>
  </div>

  <div>
    <p class="title">ポップアップ - 2</p>
    <popup-elememt>
      <span slot="title">タイトル - 2</span>
      <span slot="content">
        本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2 本文2
      </span>
    </popup-elememt>
  </div>
</body>
</html>

<template>タグでポップアップダイアログのマークアップを実装しています。
<template>タグで囲われた要素はレンダリングされません。 <style>要素も処理されず、あとでShadow DOM内でレンダリングするときに有効となります。Shadow DOM内でのレンダリングはShadow DOM内に閉じます。したがって<template>タグの外には影響しません。

<script type="module" src="main.js"></script>で読み込んでいるmain.jsは次のとおりです。 前述の<user-agent>と同じくカスタム要素を定義しているだけです。

import PopupElement from './components/popup.js';

customElements.define('popup-elememt', PopupElement);

カスタム要素<popup>を実装しているcomponents/popup.jsは次のとおりです。

export default class PopupElement extends HTMLElement {
  constructor() {
    super();
    // テンプレートの読み込み
    const template = document.querySelector('template#popup-tmpl').content.cloneNode(true)

    // #popup を取り出してクリックしたら閉じる様にイベントを追加
    const popup = template.querySelector('#popup');
    popup.addEventListener('click', function() {
      popup.style.display = 'none';
    })

    // ダイアログを開くボタンはDOMで作る
    const button = document.createElement('button')
    button.innerHTML = 'OPEN'
    button.addEventListener('click', function() {
      popup.style.display = 'block';
    });

    // テンプレートとボタンをShadow DOMに追加する
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.appendChild(template);
    shadowRoot.appendChild(button);
  }
}

カスタム要素のマークアップが多くなると、JS内でDOMのAPIを使って要素を構築するのは大変です。
なので通常のHTMLとして実装し再利用できる<template>タグは便利なのですが、何でしょう? このコレジャナイ感。
カスタム要素の中身のカプセル化がどこかへ行ってしまっています。

だいたい、導入が面倒です。複数のページで利用しようとするとそれぞれでテンプレート部分はコピー&ペーストになります。
必要なスクリプトを読み込むだけで使えるっていうのが理想的です。

テンプレートリテラルでpopupタグを改善してみる

HTML内で実装している<popup>のマークアップをなんとかしましょう。
ES moduleで使えるテンプレートリテラルを利用してマークアップを定義するとJS内で完結できそうです。

components/popup.jstemplateメソッドを追加して、先程の<template>タグの中身相当の文字列を返すようにします。

export default class PopupElement extends HTMLElement {
  constructor() {
    super();

    // テンプレートの読み込み
    const template = document.createElement('div')
    template.innerHTML = this.template(); // innerHTMLを使って一気にDOMオブジェクト化

    // #popup を取り出してクリックしたら閉じる様にイベントを追加
    const popup = template.querySelector('#popup');

    popup.addEventListener('click', function() {
      popup.style.display = 'none';
    })

    // ダイアログを開くボタンはDOMで作る
    const button = document.createElement('button')
    button.innerHTML = 'OPEN'
    button.addEventListener('click', function() {
      popup.style.display = 'block';
    });

    // テンプレートとボタンをShadow DOMに追加する
    const shadowRoot = this.attachShadow({ mode: 'open' });
    shadowRoot.appendChild(template);
    shadowRoot.appendChild(button);
  }

  // ポップアップのマークアップを返す
  template() {
    return `
      <style>
        #popup {
          display: none;
          position: fixed;
          background-color: rgba(0,0,0,0.3);
          width: 100vw;
          height: 100vh;
          top: 0;
          left: 0;
        }
        .dialog {
          background-color: white;
          width: 80vw;
          margin: 100px auto 0 auto;
          padding: 10px;
          border-radius: 5px;
        }
        .title { font-weight: bold; }
      </style>
      <div id="popup">
        <div class="dialog">
          <div class="title">
            <slot name="title"></slot>
          </div>
          <div class="content">
            <slot name="content"></slot>
          </div>
        </div>
      </div>
    `
  }
}

動作はこちらから試せます => Popup sample その2

これでHTMLから<template>を削除でき、<popup-elememt>タグをJSのコード内にカプセル化できます。
HTMLからテンプレートは排除できました...。
が、これはこれでコードが見にくいしエディタのコード補完等の支援を受けられないし…。イマイチ感は残ります。

うーん。

それと、この<popup-element>ではSlotを使ってみましたが、カスタム要素が適用される前に一瞬表示されてしまいます。見苦しいです。 ユーザのアクションがあるまで非表示にしておくようなカスタム要素にSlotを使うのは良くないみたいです3

やってみた感想

以前は動作するブラウザが一部に限られていたのでPolymerの導入とかの話題が多い印象でめんどくさそうだと思っていました。
しかしVanillaなJavaScriptだけで動く様になってきて、思ったより簡単に実装できるのだなとやってみて思いました。
しかもモバイルだけでなくデスクトップのブラウザでもだいたい動くようですし、Polymarを使えばもっと多くのブラウザで動作するコンポーネントライブラリを作ることもできるでしょう。
うまく実装されたWeb ComponentsならJSを読み込めばそれだけで高機能なカスタム要素が利用可能になるのは手軽でいいと思いました。

今回はやりませんでしたが既存のHTMLタグを拡張することも可能です。 Bootstrapのような立ち位置で既存タグの拡張を含む使いやすいコンポーネントライブラリが現れれば一気に利用が広がりそうな可能性は感じました。

一方で、今回試した範囲ではマークアップ部分の実装方法についてはもうちょっといい方法がないものかと感じました。
テンプレートリテラルを使う方法も試しましたが、やはりメンテナンス性が悪そうに思います。
だいたいHTMLテンプレートはデータバインディングの機能は持たないので、何かのデータ取得しそれを表示するカスタム要素を作る場合にはDOMで書き換 動的なリストを表示するようなカスタム要素を作ろうとする場合にはループを回しながら要素を追加しなければなりません。
自前でやるのはしんどいので、mustache.jsなどを使うことになるのかなぁと思います。
結局、何らかのライブラリを使うならVueやReactで実装したいなと思ってしまいました。

最後に

アクトインディではエンジニアを募集しています。


  1. いろいろ違いはあるかと思いますが、目指すところは同じだと思いますし、私は既視感を覚えました。ちょっと古くて伝わらない?

  2. ちょっと乱暴に要約しすぎている?
    より詳しい説明はwebcomponents.orgのSpecificationsを御覧ください。

  3. カスタム要素の属性として値を渡せば一瞬表示されるのは抑えられますが、あまり長い内容をタグの属性として渡すのはコードが見にくくなりますね。Popup sample その3

Web Share APIを試してみた

morishitaです。

いこーよいこレポでは、情報を共有してもらいやすいようにシェアボタンをページに置いています。

f:id:HeRo:20190614084226p:plain
シェアボタン

現状はTwitterやFacebookなど各サービスごとにシェアボタンを用意していますが、 モバイルに於いてはWeb Share APIを使っても良い状況ではないか? と思って調べてみました。

ちなみにCan I use... ではこんな感じです。

Can I use ... Web Share API
Can I use ... Web Share API

スマホに限るとiOS SafariもAndroid Chromeもサポートしているようです。 モバイルでは使えそう?!

コード

とりあえず、試すためのページを用意をしないと、ということで Web Share APIを使ったシェアボタンだけのページを作りました。

コードは次の通りです。

<html lang="ja">
  <head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=360,initial-scale=1">
    <title>Web Share API Sample</title>
    <style type="text/css">
      div {
        text-align:center;
      }
      button {
        font-size: xx-large;
        width: 80vw;
        height: 100px;
        border: solid 1px black;
        border-radius: 5vw;
      }
    </style>
  </head>
  <body>
    <h1>Web Share API Sample</h1>
    <button id="share">シェア</button>
    <script type="text/javascript">
      (function(){
        function share() {
          if (navigator.share) {
            navigator.share({
              title: 'アクトインディ開発者ブログ',
              text: '子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社のエンジニアブログです',
              url: 'https://tech.actindi.net/',
            })
            .then(() => {
              // シェアしたら実行される
              console.log('Successful share');
            })
            .catch((error) => {
              // シェアせず終了した場合もここに入ってくる。
              console.log('Error sharing', error));
            };
          } else {
            alert('Web Share API is not supported!!');
            // Web Share API未対応ブラウザ向けのフォールバックを実装する。
          }
        }
        document.querySelector('#share').addEventListener('click', share);
      })();
    </script>
  </body>
</html>

navigator.shareの有無でWeb Share APIに対応したブラウザかどうかを判定しています。 navigator.share()の戻り値はPromiseになっていて、デバイスのシェアUIを閉じたときにresolveします。
ユーザがキャンセルした場合も含め、シェアされずにUIを閉じたときにはrejectとなるようです。

また、localhost以外ではHTTPSでないとWeb Share APIは動きません。

上記サンプルは次のQRコードかその下のリンクから試せます。

Web Share API

各ブラウザでの動作

次のブラウザで試してみました。

  • Chrome
  • Safari
  • Firefox

Chrome

まずはChromeから。
Androidでは問題なく動作し、次のUIが表示されます。

Android Chrome
Android Chrome

よく連絡する送信先が上部に表示され、選択しやすくなっています。
その下にはインストールされているアプリでWebサイト情報を共有できるものがズラッと出てきて共有できます。

iOSのChromeでも後述するSafari同様に動作します。

で、PCでは、Can I use...の通りですね。
サポートされていません。

PC Chrome
PC Chrome

Safari

続いてSafariです。

iPhone Safari
iPhone Safari

iPhone、iPadとも同様に動きます。
SlackやLINEなど各種メッセージングアプリ、SNSアプリがインストールされていればシェア先として表示されます。Air Dropでも送れるんですね。

前述しましたが、iOSのChromeでも動作し、表示されるシェアUIはSafariと同じです。
まあ、ネイティブの共有メカニズムを呼び出すと言うのがshare()の仕様なんで当然ですね。

そして、唯一SafariはPC版も対応しています。

PC Safari
PC Safari

Firefox

最後にFirefox。

Androidでは動きません。
PC版ももちろん動きません。

しかし、iOSではSafari同様に動きます。
独自ブラウザエンジンの実装が認めてられなくてベースはSafariと同じなので動いちゃうってことですかね。

まとめ

PC向けはダメですね。フォールバックを用意する必要がありそうです。

一方、AndroidとSafariの標準ブラウザで動作するので モバイル向けには使える状況だと思います。
PC向けにフォールバックを用意するのだったら未対応のスマホにもそれを表示すれば良いでしょう。
未対応のブラウザからアクセスが無視できる量なら、 いっそ対応するブラウザでのみシェアボタンを置くという割り切りもいいかなと思います。

今後はWeb Share APIを使ったほうがボタンが少なくなるし、共有先の選択肢も多いので便利だと思います。

と思ってアプリエンジニアに見せたら、「いやー、まだ世の中が追いついてないよー」とのコメント。
ボタンに「シェアする」だと弱くて「LINEで送る」って書いたほうが使ってもらえるとのこと。
あと、たくさん選択肢が表示されるのも難しいと感じる人が多いようです。

うーん、まだなのかなぁ。
導入に際しては一気にリプレースしてしまうのではなくちょっとづつ試して様子を見ようと思います。

最後に

アクトインディではエンジニアを募集しています。

AWS CloudWatch Logs に貯めこんだログをどうにかしようとしてハマった話

こんにちは!!こんにちは!!
インフラエンジニアのyamamotoです。

AWS CloudWatch Logs に貯めこんだログを、Kinesis Data Firehose を使って S3 に保管し、Athenaで検索しよう、と思ったらいろいろつまづいたのでまとめてみました。

きっかけ

当社の新プロジェクトで、ログをどげんかせんといかん、という話に。

ひとまず CloudWatch Logs に保存しておいて後でどうにかしようと思ったのですが、検索するにも保管するにも良くないので、S3に保管してAthenaで読めたらいいよねー、ということになりました。

しかし CloudWatch Logs のログを S3 に出そうとすると、手動での実行か、Lambdaでゴニョゴニョしないといけなさそうです。

もっとスマートに、逐次出力できないものか、と思って調べてみたところ、Kinesis Data Firehose を使ってストリーミングできるということが判明。
すごい。さすがはAWS。

ことの一部始終

Kinesis Data Firehoseのストリームを作成

まず、Kinesis Data Firehoseのストリームを作成します。
ここら辺は参考になるサイトがいっぱいあるので、そちらを見ながら設定。

CloudWatch Logsをサブスクリプション

CloudWatch Logsにはサブスクリプションという、外部出力の機能があります。
しかし、Kinesis Data Firehose に出力するにはコンソールからではできず、AWS APIで操作しなければなりません。
すごくないよAWS……

こちらのサイトを参考に登録しました。ありがとうございます。
https://christina04.hatenablog.com/entry/cloudwatch-logs-to-s3-via-firehose

ここまでは順調に進みましたが、この先でつまづきました。

S3に出力されるも……

ログがS3に出力されるようになりました。ただ、ログの内容がなんか変……

{"messageType": "DATA_MESSAGE","owner": "677754094491","logGroup": "/aws/elasticbeanstalk/hogehoge-rails","logStream": "production/rails/c6c65f81-1c88-4c02-8407-2e1d8bce69fa","subscriptionFilters": ["All"],"logEvents": [{"id": "34657843470841441528834034625987848118641882362345488384","timestamp": 1554111450640,"message": "{\"method\":\"GET\",\"path\":\"/hogehoge/products\",\"format\":\"json\",\"controller\":\"Hogehoge::ProductsController\",\"action\":\"show\",\"status\":200,\"duration\":1.41,\"view\":0.72,\"transaction_id\":\"bcfa33d20729b874d4f8\",\"params\":{},\"type\":\"RAILS\",\"host\":\"foobar.hogehoge.com\",\"remote_ip\":\"192.168.1.11\",\"ua\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36\",\"request_id\":\"d2c5fd8c-a207-4fe1-b030-e58439073ace\",\"session_id\":null,\"timestamp\":\"2019-05-21T08:03:50+00:00\",\"timestamp_jst\":\"2019-05-21T17:03:50+09:00\"}"}]}

見た感じ、生ログが Cloudwatch Logs のログで包まれ、複数のログも全部が1行にまとまっていました。
Athenaで読み込ませるには、1行に一つのログがある形式にしないといけないとのことなので、このログをバラす必要があります。

Kinesis Data Firehose の Transform 機能

そこで、Kinesis Data Firehose にあるTransform機能を使うことにしました。
一言で言うと、流れてきたログをLambdaで加工できる機能です。

これを使ってログをバラそう思い、流れてくるログをキャッチしてみると、なにやらbase64っぽい文字列が……
単純にbase64デコードしても、バイナリになっていて中が読めない。ううむ。

調べてみると、CloudWatch Logがサブスクリプションとして出力されるログは、gzip圧縮されてBase64エンコードされて送られてくるようです。な、なんと……

これを解凍してJSONで読み込み、生ログ部分だけ取り出すスクリプトをLambdaに用意しました。

const zlib = require('zlib');

exports.handler = (event, context, callback) => {
  const output = event.records.map((record) => {
    const buf = zlib.gunzipSync(Buffer.from(record.data, 'base64'));
    const cwlogs = buf.toString('utf-8');
    const cwlogsparsed = JSON.parse(cwlogs);
    let ret = '';

    for (let i = 0; i < cwlogsparsed.logEvents.length; i += 1) {
      ret += `${cwlogsparsed.logEvents[i].message}\n`;
    }

    return {
      recordId: record.recordId,
      result: 'Ok',
      data: Buffer.from(ret, 'utf8').toString('base64'),
    };
  });
  callback(null, { records: output });
};

肝心のログは「logEvents」配列の「message」フィールドに入っており、その他の部分はCloudWatch Logsの情報になっているようです。
「logEvents」配列を for で回して「messages」フィールドの文字列だけを取り出し、改行を付けています。

で、

{"method":"GET","path":"/hogehoge/products","format":"json","controller":"Hogehoge::ProductsController","action":"show","status":200,"duration":1.41,"view":0.72,"transaction_id":"bcfa33d20729b874d4f8","params":{},"type":"RAILS","host":"foobar.hogehoge.com","remote_ip":"192.168.1.11","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36","request_id":"d2c5fd8c-a207-4fe1-b030-e58439073ace","session_id":null,"timestamp":"2019-05-21T08:03:50+00:00","timestamp_jst":"2019-05-21T17:03:50+09:00"}

こんな感じに生ログが一行に一つだけになりました。
この形式であればAthenaで読み込むことができます。

なお、Kinesis Data Firehoseのオプションでgzip圧縮して保存できるので、この段階では無圧縮で渡しています。

Kinesis Data Firehoseでのデータ保存

Athenaではパーティションという機能があるので、これを活用する形で Kinesis Data Firehose でも意識してデータを保存するようにしてみました。

Kinesis Data Firehoseのストリームの設定で、指定したS3のパスの下に /year=年/month=月/day=日/ のディレクトリを掘り、その中にログを出力するようにしています。
※Firehoseの仕様上、UTCで日付が振られます

S3 bucket: hogehoge
Prefix: logs/rails/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/ 
Error Prefix: logs/error/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/!{firehose:error-output-type}

この形式にしていると、Athenaでパーティションを作成するときに便利になります。

また、データはオプション設定でgzip圧縮して保存します。

Athenaの設定

Athenaでは、下記のSQLを使ってテーブルを作成しました。
RDBと違ってデータはS3にあってテーブルはいくらでも作り直せるので、いろいろ試行錯誤してみるのもいいかもしれません。

CREATE EXTERNAL TABLE IF NOT EXISTS hogehoge.rails (
  `type` string,
  `timestamp` string,
  `timestamp_jst` string,
  `method` string,
  `path` string,
  `controller` string,
  `action` string,
  `status` string,
  `duration` float,
  `view` float,
  `transaction_id` string,
  `request_id` string,
  `session_id` string,
  `params` string,
  `host` string,
  `remote_ip` string,
  `ua` string,
  `sql` string
)
PARTITIONED BY(
  year int,
  month int,
  day int
)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
WITH SERDEPROPERTIES (
  'ignore.malformed.json' = 'true'
) LOCATION 's3://hogehoge/logs/rails/'
TBLPROPERTIES ('has_encrypted_data'='false');

そして、パーティションをS3から読み込ませます。
テーブル名左の点メニューから「Load Partition」を選択します。すると、自動的に年月日ディレクトリを読み込みパーティションを作ります。

あとはAthenaでコマンドを実行してデータを取り出せばいい感じです。

ログの流れ

最終的に、下記のようなログの流れになりました。

CloudWatch Logs
 ↓ サブスクリプション
 ↓
 ↓ Logは、JSON形式でCloudWatch Logsの情報が付与されて、
 ↓ gzip圧縮されBase64で文字列化されて送られるので解凍
 ↓
 ↓ Kinesis Data Firehose にある Transform 機能(Lambda)を使い、
 ↓ CloudWatch Logsの情報をはがして純粋なログのみを1行ずつJSONで受け渡す
Kinesis Data Firehose
 ↓ 年月日ディレクトリにデータを保存
 ↓ gzip圧縮オン
 ↓
S3
 ↓
 ↓ 定義したテーブルに読み込まれる
 ↓
Athena

最後に

アクトインディでは、楽しくAWSのソリューションを活用して楽しいサービスを作るエンジニアを募集しています!

Vue+Vuexにvalidatorjsを組み合わせると自在にバリデーションが書ける!

morishitaです。 今回はJavascriptのバリデーションライブラリvalidatorjsを紹介します。

TL;DR

  • いこレポではクライアントサイドバリデーションにvalidatorjsを使っています。
  • validatorjsはとても柔軟に設定でき使いやすいバリデータです。
  • Vue+Vuexなアプリケーションととても相性がいいです。

いこレポの記事編集機能

いこレポは見ての通り、公開しているWebサイトは特に特別な機能を持たないメディアサイトで、実装もシンプルです。 したがって表側よりも、記事を管理する機能のほうが実装的に複雑です1

その管理機能の中でも最もコード量が多く複雑なのは記事編集機能です。

記事編集機能はVue.jsを利用してます。Vue.jsに加えてVuexも利用しています。 本文以外にも記事を分類するための属性が結構たくさんあります。 それらを公開時にはもれなく設定する必要があるので、ミスなく運用するためにバリデーションが必要です。 サーバサイドのバリデーションももちろんやっていますが、インタラクティブに不足を指摘したほうが使いやすいので クライアントサイドのバリデーションも行っています。

JavaScriptのバリデーションライブラリはたくさんありますが、次の理由からValidatorjsを採用しました。

  • 他のライブラリに依存していない
  • カスタムバリデータの追加が簡単
  • i18n対応もしている(いコレポではあまり使っていないですが...)

Validatorjs とは

Validatorjs はJavaScriptのバリデーションライブラリです2

バリデーションライブラリというと値を1つ渡して、それがバリデーションルールを満たすかを判定するというものが多いです。しかしVaridatorjsは A data validation library を謳っています。

どういうことかというと、JavaScriptのオブジェクトを渡すと、指定したプロパティの値がバリデーションルールを満たしているのか一度に判定してくれるのです。渡したオブジェクトのデータ全体が適正な値であるかを判定してくれます。ネストした深い階層の属性のバリデーションも可能です。

用意されているバリデーションルールも多数あり、必須チェックや文字数のチェックはもちろん、 emailやURLの形式チェックのルールもあります。また関連する属性がある値の場合のときに必須になるrequired_ifのような便利なルールもあります。

利用例

次のようなpersonオブジェクトのバリデーションを考えます。

  • name属性が必須
  • accept_mail_magazine属性がtrueのとき、mail属性が必須
  • mail属性は E-mailアドレスの形式でなければならない

上記を Validatorjs で実装すると次のようになります。

const Validator = require('validatorjs');

// バリデーション対象のオブジェクト
const person = {
  name: 'hogehoge',
  accept_mail_magazine: true,
  email: 'hoge@example.com',
}

// バリデーションルールの定義
const rules = {
  name: 'required',
  email: [{required_if: ['accept_mail_magazine',true]}, 'email'],
}

const v = new Validator(person, rules); // バリデータインスタンスの生成

// バリデーション
check: v.check() // => true
v.errors // バリデーションエラーの情報がすべて取れる

更に足りなければ独自のルールを追加することもできます。

例えば、次のバリデーションを先程のpersonオブジェクトに追加します。

  • languages属性は次の選択肢から少なくとも1つは選択する必要がある。複数選択しても良い。
    • ruby
    • javascript
    • python
    • kotlin
    • swift
    • others

このようなバリデーションルールは標準では用意されていません。

でも、次のようにカスタムバリデータを実装して追加することでバリデーション可能になります。

const Validator = require('validatorjs');

// カスタムバリデータの定義
function someOneTrue(value, requirement, attribute) {
  return Object.values(value).some((val) => val);
}
// 定義したカスタムバリデータを登録
Validator.register(
  'some_one_true', 
  someOneTrue, 
  ':attribute は少なくとも1つはチェックする必要があります'
);

const person = {
  name: 'hogehoge',
  accept_mail_magazine: true,
  email: 'hoge@example.com',
  languages: { // 新たに追加した属性。
    ruby: false,
    javascript: false,
    python: false,
    kotlin: false,
    swift: false,
    others: false,
  }
}
const rules = {
  name: 'required',
  email: [{required_if: ['accept_mail_magazine',true]}, 'email'],
  languages: 'some_one_true', // 追加したカスタムバリデータの使用
}
const v = new Validator(person, rules);

check: v.check() // => true
const errors = v.errors // バリデーションエラーの情報がすべて取れる

上記を実行すると errors の中身は次のようになります。

{"languages":["languages は少なくとも1つはチェックする必要があります"]}

簡単ですね。

そして、Vue+Vuexと組み合わせると、それはもうとてもとても便利なのです。

Vue+Vuex

Vue+VuexでVaridatorjsを利用するとどう便利なのか? ということを説明するためにVuexについて少し説明します。

Vuex は Vue.js アプリケーションのための状態管理パターン + ライブラリです。Vue+Vuexなアプリケーションの場合、データを Vuexのstoreで管理するJSのオブジェクトの形で持ちます。 Flux、 Redux に影響を受けており、単方向データフローでデータの状態を管理、更新します。

f:id:HeRo:20190222161334p:plain
Vuexのドキュメントより引用

VueアプリケーションではVuexのmutationを使ってstateを変更します。 stateの変更はそれを参照するVueコンポーネントにすぐに反映されます。

更にVuexにはゲッター(getters)というVueコンポーネントの算出プロパティ(computed)に対応する機構があります。これによりstateの値をそのまま参照するのではなく計算した結果を参照することも可能です。算出プロパティ同様に、stateが更新されればゲッターにも反映され、ゲッターを参照するコンポーネントにも伝播されます。

Vue+Vuex での Validatorjs

Vue+Vuex アプリケーションでValidatorjsを使ったバリデーションをどこに実装するのが良いのでしょうか?

それは VuexのStoreのゲッターです。

次のコードはVuexストアの実装例です。

import Vue from 'vue';
import Vuex from 'vuex';

// 次のモジュールで前述のカスタムバリデータと
// それを使ったルールを定義しているとします。
import {Validator, rules} from './validator';

Vue.use(Vuex);
export default new Vuex.Store({
  state: {
    person: {
      name: '',
      accept_mail_magazine: true,
      email: 'hoge@example.com',
      languages: {
        ruby: false,
        javascript: false,
        python: false,
        kotlin: false,
        swift: false,
        others: false,
      }
    },
  },
  getters: {
    validationErrors: (state) => {
      const v = new Validator(state.person, rules);
      v.check();
      return v.errors;
    },
  }
});

このStoreをVueのモジュールから参照する例が次のコードです。

export default {
    store,
    computed: {
      validationErrors() {
        return this.$store.getters.validationErrors;
      },
    },
  // 〜 略 〜
}

こうしておくと、算出プロパティvalidationErrorsには storeに格納されたpersonオブジェクトの バリデーション結果が格納されます。

personオブジェクトが更新されるたびにバリデーション結果も更新されるので、 算出プロパティvalidationErrorsを参照するUIにもその結果が即時に反映されます。

便利かつ簡単でしょう? 一度お試しあれ。

最後に

アクトインディでは使いやすいUXを一緒に作っていく エンジニアを募集しています。


  1. 複雑と言っても表側がCRUDのほぼREADしかないのに対して、管理機能ではCRUDを一通り実装しているだけですが。

  2. chriso/validator.js: String validationというよく似た名前のライブラリもあるのでお間違えのないように。

Nuxt SPAのPVを @nuxtjs/google-tag-manager を利用して計測する

morishitaです。

Nuxt の SPA を運用する際にページビューを計測しようと、@nuxtjs/google-tag-managerを導入しました。

ページがロードされるランディングは計測されるのですが、SPA 内でページ遷移したページビューが計測されなかったので調べたことを書きます。

利用バージョンは次のとおりです。

  • nuxt@2.2.0
  • @nuxtjs/google-tag-manager@2.1.0

@nuxtjs/google-tag-manager

@nuxtjs/google-tag-managerは Nuxt アプリケーションに Google Tag Manager を簡単に組み込めるプラグインです。

npm や yarn でインストールして、次の設定をnuxt.config.js に追加すると使えるようになります。

  modules: [
    ['@nuxtjs/google-tag-manager', { id: 'GTM-xxxxxx', pageTracking: true }]
  ],

modules/README.mdpageTrackingtrueにするとページビューも計測できると書いているので、設定してみました。

が、冒頭で書いたようにページがロードされるランディングは計測されるのですが、SPA 内でページ遷移したページビューが計測されませんでした。

で、pageTracking: true の時、何をやっているのかを調べました。 @nuxtjs/google-tag-manager/plugin.js の実装は次のようになっています。

// Include Google Tag Manager Script
window['<%= options.layer %>'] = window['<%= options.layer %>'] || [];
window['<%= options.layer %>'].push({
  event: 'gtm.js', 'gtm.start': new Date().getTime()
});

<% if (options.pageTracking) { %>
// Every time the route changes (fired on initialization too)
export default ({ app: { router } }) => {
    router.afterEach((to, from) => {
      window['<%= options.layer %>'].push(to.gtm || { event: 'nuxtRoute', pageType: 'PageView', pageUrl: to.fullPath, routeName: to.name })
    })
  }
<% } %>

見ての通りpageTrackingtrue の場合、router.afterEachで GTM のイベントが push されていますが、nuxtRouteというカスタムイベントを push しています。

なるほど、これを受ける設定が GTM 側に必要ってことですね。
ちゃんと README に書いておいてほしいなぁ。 「実装を見ろ」だけでもいいので。

GTM の設定

ということで、上記のイベントを受けてページビューとして計測するための設定をします。

カスタムトリガー

まずは前述の nuxtRouteを受けるトリガーとを設定します。

送信されるイベント名に合わせてnuxtRouteという名前で次の様に設定しました。

f:id:HeRo:20181029082434p:plain

カスタム変数

次にイベントで送信されてくるデータレイヤー変数を取るためのカスタム変数を作ります。

pageUrlrouteName という変数が送られてくるので、それぞれ次の様に作りました。

f:id:HeRo:20181029082435p:plain

f:id:HeRo:20181029082930p:plain

カスタムタグ

最後に上記トリガーと変数を使ってページビューを記録するタグ pageView を次の様に作りました。

f:id:HeRo:20181029082442p:plain

ランディング時にページビューを計測していた次のタグは無効にしました1

f:id:HeRo:20181029082419p:plain

これらの設定を GTM で公開すると次の様にページビューが計測できるようになりました。

f:id:HeRo:20181029082430p:plain

ページタイトルは Nuxt の VueRouter に渡ってくる値なので、ページごとの具体的な値は取れていません。

title は各ページコンポーネントで書き換えているので、これを取ろうとするとページロード前に実行されるプラグインではできません。 各ページコンポーネントのasyncDataなどで処理すれば取れると思うのですが、今回はそこまで必要なかったのでやりませんでした。

まとめ

  • @nuxtjs/google-tag-managerで Nuxt に GTM を簡単に組み込める
  • ルーティング毎のページビューも取れるが、GTM にそのための設定が必要。

最後に

アクトインディではエンジニアを募集しています。


  1. 削除しても良かったのですが、誰かが設定し忘れていると思って追加するといけないので、あえて一時停止で置いています。

NuxtのSPAを S3+CloudFrontでホストする。デプロイはCodeBuildで自動化


≪2019/06/20追記≫
この記事を公開したのは2018-10-19です。
2019-06-20時点でより簡単さ、運用の容易さを求めるならAWS Amplify Consoleもおすすめです。

tech.actindi.net


morishitaです。

Nuxt で実装した SPA を S3 + CloudFront で配信する機会があったのでそれを書きます。

NuxtのSPA自体については、標準的な作りでTypescript、PugSassを使ってますよってことぐらいしか書くことがないのですっ飛ばして、 S3とCloudFrontでどのようにSPAをホストしたのか、そしてCodeBuildを使ったデプロイの自動化について書きます。

概要は次のとおりです。

  • NuxtのSPAをホストするための S3とCloudFrontを設定する
  • CodeBuildを使ってS3にデプロイする
    • GIthubリポジトリのmasterにpushしたら動く
    • Gulpを利用して、古いファイルの削除やCloudFrontのキャッシュ無効化も実施。

f:id:HeRo:20181018063222j:plain

ちなみに、主なライブラリ等のバージョンは次のとおりです。

以下、nuxt.actindi.net というドメインで配信するとして説明します。
nuxt.actindi.netは例であって実際には存在しません。念のため。

S3 の設定

バケット nuxt.actindi.netを作成します。 バケット内のディレクトリ構成は次のとおりです。

nuxt.actindi.net   # S3バケット
  ├ app/                # Nuxt SPAをデプロイするディレクトリ
  └ data/               # Nuxt で読み込むJSONファイルを置くディレクトリ

app ディレクトリには nuxt build の生成物をデプロイします。
data ディレクトリには JSON ファイルを置きます。このファイルを Nuxt SPA が動的に読み込んで表示します。 このJSON ファイルは別のアプリケーションが任意のタイミングで更新します1

このバケットには公開してよいリソースのみ配置するので Static website hostingを有効にして次の項目も設定します。

設定項目 設定値
インデックスドキュメント index.html
エラードキュメント /app/index.html(※後述)

次のバケットポリシーも設定しておきます。
これでこのバケットに置いたファイルへのパブリックアクセスを設定しています。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::nuxt.actindi.net/*"]
    }
  ]
}

そして、data ディレクトリのJSONファイルはXHRで取得するのでCORSの設定もしておきます。

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
    <AllowedOrigin>*</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <MaxAgeSeconds>3000</MaxAgeSeconds>
    <AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>

CloudFront の設定

HTTPS にするために CloudFront を利用します。

AWSコンソールのタブごとに設定内容を説明します。

General

目的が HTTPS 化なので、SSL Certificate を設定します。
ここでは nuxt.actindi.net で配信したいので Alternate Domain Namesnuxt.actindi.net を設定します。
SSL Certificate には ACM で作った証明書を設定します。

Origin

キャッシュの有効期限を分けたいのでOrigin は 2 つ登録します。

1 つ目は次の設定。

オリジン(1)
Origin Domain Name S3 バケットの公開ドメイン
Origin Path / (表示上は空)

もう 1 つは次の設定。

オリジン(2)
Origin Domain Name S3 バケットの公開ドメイン
Origin Path /app

Behaviors

Behaviors は上記で作ったオリジンそれぞれに作ります。

まずは、オリジン(1)/に対する設定。

設定項目 設定値
Path Pattern /data/*
Origin オリジン(1)/
Whitelist Headers Origin (CORSのため)
Object Caching Customizeを選択し、TTLはすべて0にする

dataディレクトリ以下のファイルの更新を即時反映するため、キャッシュさせないようにTTLをすべて0にするのがポイントです。

続いて、オリジン(2)/appに対する設定。

設定項目 設定値
Path Pattern Default(*)
Origin オリジン(2)/app
Object Caching Use Origin Cache Headers

こちらはSPAを構成するファイル用なのでキャッシュさせます。
後述しますがデプロイ時に古いキャッシュを無効化するので、デプロイしても反映されないということは起こりません。

Error Pages

次の様に404エラーが発生した場合、index.htmlを返すようにします。重要です。

設定項目 設定値
HTTP Error Code 404: Not Found
Customize Error Response Yes
Response Page Path /index.html
HTTP Response Code 200: OK

なぜこの設定が必要かというと、NuxtではPageshoge.vueのような固定ルーティングのページを作るとnuxt buildしたときに実体として/hoge/index.htmlファイルが生成されます。

しかし、_id.vueのような動的ルーティングするページを作った場合、それに対応するファイルは生成されません2

その状態で /some_dir/10の様なパスにアクセスするとCloudFrontはS3にファイル/some_dir/10を探しに行きます。開発者の意図としては/some_dir/_id.vueで処理したいのですが、対応するファイルが無いので404エラーとなっていまいます。
Nuxtではindex.htmlでリクエストを受けないと何も処理できないのでS3上に存在しないパスへのリクエストにはとにかく index.htmlを返すようにします。そのため上記のError Pages設定を行います。(その上でNuxtの動的ルーティング上でも存在しなければNuxt上で404ページを表示します)。

Lambda Edgeで処理する方法もあるのですが、少々大げさな気がしますしLambdaのコストがかかるのでこうしました。

S3の設定でもエラードキュメントを設定しましたが、あの設定は念のためなので上記の様にError Pagesを設定すればS3のエラードキュメントの設定はたぶん不要です。

Nuxtの設定

デプロイする先が用意できたので、デプロイするものを作るための設定です。

ポイントとなるのはnuxt.config.jsの次の箇所です。

let generateDir = {};
if (process.env.DEPLOY_ENV === 'S3'){
  generateDir = {
    generate: { dir: "dist/app" },
  }
}

module.exports = {
  mode: 'spa',
  ...generateDir,
  // 〜中略〜
}

何をやっているのかというと、DEPLOY_ENVという環境変数にS3という値が設定されていれば、dist/appディレクトリにビルド生成物を出力するという設定です(デフォルトでは distディレクトリに出力されます)。
これにより、appディレクトリを丸ごとS3にアップすれば良くなります。

また、modeを 'spa'に設定しています。

CodeBuildでのデプロイ

デプロイ先とデプロイするものが揃ったので、デプロイ処理を作ります。
デプロイにはCodeBuildを使います。

CodeBuildのプロジェクトは次のように設定して作成します。

設定項目 設定値
ソースプロバイダ GithubのNuxtのSPAのリポジトリを設定
Webhook 有効にしてブランチフィルタに ^master$を設定
環境イメージ aws/codebuild/nodejs:10.1.0
インスタンスタイプ 3 GB メモリ、2 vCPU(最低スペックで十分)
Buildspec buildspec.yml

Webhookを有効にすると、GithubのリポジトリにWebhookを作ってくれます。 ただ、この時作られるWebhookには、Pull requestsの操作時もトリガーするように設定されてしまいます。
不要で邪魔なのでGithubのWebhook設定画面で Pull requestsのチェックを外して、Pushesのみにします。
ブランチフィルタを設定しているので masterにpushまたはRPをmasterにマージしたタイミングでCodeBuildが実行されるようになります。
アーティファクトは出力しません。

環境変数には次の4つの値を設定します。

環境変数名 設定値
AWS_BUCKET_NAME nuxt.actindi.net
AWS_CLOUDFRONT 作成したCloudFrontのディストリビューションID
API_URL_BROWSER https://nuxt.actindi.net/data
DEPLOY_ENV S3

上2つは あとで説明するGulpで参照するものです。
API_URL_BROWSERはNuxtに組み込まれているaxios-moduleの設定で、axiosのリクエストパスのプリフィックスを上書きするものです3

DEPLOY_ENVは前述したビルド生成物の出力先を変更するフラグですね。

で、CodeBuildプロジェクトで実行する処理を記述した buildspec.ymlは次のとおりです。

version: 0.2

phases:
  install:
    commands:
      - apt-get update -qq && apt-get install -y apt-transport-https
      - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
      - echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
      - sudo apt-get update && sudo apt-get install yarn
  pre_build:
    commands:
      - echo 'Start pre_build phase'
      - yarn
  build:
    commands:
      - echo 'Start build phase'
      - nuxt build
  post_build:
    commands:
      - echo 'Start post_build phase'
      - ./node_modules/.bin/gulp deploy

特に難しいことはしていません。

install フェーズ

残念ながら、CodeBuild標準で用意されているNode.js環境にはyarnがインストールされていないので インストールしています。

pre_buildフェーズ

yarnを使ってモジュールをインストールしているだけです。

buildフェーズ

nuxt buildを実行してアプリケーションをSPAとしてビルドしています。

post_build フェーズ

生成物をS3にアップロードしているのですが、そのためにGulpを使っています。

Gulpの設定

CodeBuildからS3にビルド生成物をアップロードするために次の gulpfile.js を使いました。

var gulp = require('gulp');
var awspublish = require('gulp-awspublish');
var cloudfront = require('gulp-cloudfront-invalidate-aws-publish');
var parallelize = require('concurrent-transform');

// https://docs.aws.amazon.com/cli/latest/userguide/cli-environment.html

var config = {

  // Required
  params: { Bucket: process.env.AWS_BUCKET_NAME },

  // Optional
  deleteOldVersions: true,
  originPath: '/app',       // ポイント①
  distribution: process.env.AWS_CLOUDFRONT, // Cloudfront distribution ID
  region: process.env.AWS_DEFAULT_REGION,
  headers: { /*'Cache-Control': 'max-age=315360000, no-transform, public',*/ },

  // Sensible Defaults - gitignore these Files and Dirs
  distDir: 'dist',              // ポイント②
  indexRootPath: true,
  cacheFileName: '.awspublish',
  concurrentUploads: 10,
  wait: true,  // wait for Cloudfront invalidation to complete (about 30-60 seconds)
}

gulp.task('deploy', function() {
  // create a new publisher using S3 options
  // http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#constructor-property
  var publisher = awspublish.create(config, config);

  var g = gulp.src('./' + config.distDir + '/**');
    // publisher will add Content-Length, Content-Type and headers specified above
    // If not specified it will set x-amz-acl to public-read by default
  g = g.pipe(parallelize(publisher.publish(config.headers), config.concurrentUploads))

  // Invalidate CDN
  if (config.distribution) {
    console.log('Configured with Cloudfront distribution');
    g = g.pipe(cloudfront(config));
  } else {
    console.log('No Cloudfront distribution configured - skipping CDN invalidation');
  }

  // Delete removed files
  // ポイント③
  if (config.deleteOldVersions) g = g.pipe(publisher.sync(config.originPath));  
  // create a cache file to speed up consecutive uploads
  g = g.pipe(publisher.cache());
  // print upload updates to console
  g = g.pipe(awspublish.reporter());
  return g;
});

このgulpfile.js は実はNuxtの公式ドキュメントの次のページにあるサンプルほぼそのままです。

https://nuxtjs.org/faq/deployment-aws-s3-cloudfrontnuxtjs.org

このgulpfile.jsは次の2つのライブラリを使ってS3へのアップロードとCloudFrontのキャッシュ無効化を行っています。

  • gulp-awspublish:S3にファイル/ディレクトリをアップロードしてくれる。古いファイルの削除も可能で大変便利。
  • gulp-cloudfront-invalidate-aws-publish:更新されて古くなったファイルのCloudFrontキャッシュを無効にしてくれます。大変便利。

ただ、Nuxtの公式ドキュメントの例そのままでは困るところがあり、いくつか手を入れています。

順にポイントを説明します。

ポイント①

deleteOldVersions: trueにすることで、S3上の古いファイルを削除してくれます。 しかし、バケット全体を対象にするので、そのままだと /dataディレクトリまで削除されてしまいます。 それは困るので originPath: '/app' で影響範囲を /appディレクトリ配下のみに限定しています。

ポイント②

distDir: 'dist'ディレクトリ以下をまるごとS3にアップしてくれます。 nuxt buildの生成物は /dist/appディレクトリに出力されるので、S3には/appディレクトリごとアップされます。

ポイント③

publisher.sync()は、古くなったS3上のファイルを削除してくれる関数ですが、 もともとのコードではpublisher.sync()の様に引数無しで使われています。 ポイント①で設定したoriginPathを渡すことにより、/appディレクトリとその中身のみ削除の対象とするようになります。

ポイント④

ポイント④はコードから削除した部分です。 Nuxtの公式ドキュメントにも書いてありますが、もともとのコードはAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYを環境変数で渡すように書かれており、 次のコードが記載されています。

var config = {
  // 〜略〜
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  // 〜略〜
}

CodeBuildを実行するロールに次の権限を与えておけば、これらの値をわざわざ設定しなくても S3とCloudFrontへの操作が可能になります。なので不要となり削除しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObjectAcl",
                "s3:GetObject",
                "s3:AbortMultipartUpload",
                "s3:ListBucket",
                "s3:DeleteObject",
                "s3:PutObjectAcl",
                "s3:ListMultipartUploadParts"
            ],
            "Resource": [
                "arn:aws:s3:::nuxt.actindi.net",
                "arn:aws:s3:::nuxt.actindi.net/*"
            ]
        },
        {
            "Sid": "VisualEditor1",
            "Effect": "Allow",
            "Action": [
                "cloudfront:ListInvalidations",
                "cloudfront:GetInvalidation",
                "cloudfront:CreateInvalidation"
            ],
            "Resource": "*"
        }
    ]
}

他のCIの仕組みを使うと AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY という秘密情報を渡さないといけなくなり その管理どうしようと考えないといけなくなるのですが、CodeBuildではそれが不要です。便利ですね。

これですべての設定の説明は終わりです。
冒頭の図を再掲しますが、次のような流れが実現できます。

f:id:HeRo:20181018063222j:plain

まとめ

  • Nuxtで実装したSAPをS3+CloudFrontでホストしました。
  • 動的ルーティングを持つSPAも運用可能です。
  • CodeBuildで自動デプロイも楽々。

最後に

アクトインディは今のところRailsメインの会社ですが、Railsだけではありません。
あれこれ一緒に試してみたいエンジニアを募集しています。

actindi.net


  1. APIを作っても良かったのですが、今回の案件ではデータ量も多くないし、更新頻度も多くないので簡易に済ませました。

  2. nuxt generate でも_id.vueは無視され何も生成されません。

  3. https://github.com/nuxt-community/axios-module/blob/dev/docs/options.md#browserbaseurl

任意のイベントを受付け、発火できるオブジェクト

komagataです。

canvasでウロウロするシリーズも少しコードが汚くなって来たのでリファクタリングしました。

iPhone シミュレータ

canvasでウロウロする4

コンストラクタの継承が出来なかったのをJavaScript: The Good Partsに載っていた関数型の継承を使って書き直しました。

また、同じく同書に載っている、任意のオブジェクトに任意のイベントを受付け、発火できる機能を追加する、とても便利なeventuality関数を使うように書き換えました。

var eventuality = function(that) {
  var registry = {}

  that.fire = function(event) {
    var array, func, handler,
        type = typeof event === 'string' ? event : event.type

    if (registry.hasOwnProperty(type)) {
      array = registry[type]
      for (var i = 0; i < array.length; i++) {
        handler = array[i]
        func = handler.method
        if (typeof func === 'string') {
          func = this[func]
        }
        func.apply(this, handler.parameters || [event])
      }
    }
    return this
  }

  that.on = function(type, method, parameters) {
    var handler = {method: method, parameters: parameters}
    if (registry,hasOwnProperty(type)) {
      registry[type].push(handler)
    } else {
      registry[type] = [handler]
    }
    return this
  }

  return that
}

eventuality関数はonとfireの二つのメソッドを持ちます。onで任意の名前のイベントを登録し、fireで発火します。

eventuality関数を継承したオブジェクトは全て同機能を持つのでenterframeイベントを持ったspriteオブジェクトなどがとても簡単につくれるようになりました。

var sprite = function(img_src, x, y, width, height) {
  var that = eventuality()
  that.img = new Image
  that.img.src = img_src

  that.on('enterframe', function(){
    that.game.context.drawImage(
      that.img,
      that.x,
      that.y,
      that.width,
      that.height
    )
  })

  return that
}

こんな感じで好き勝手なイベントが作れるのが便利です。

コード:4 at master from komagata's canvas-prowler - GitHub

JavaScript: The Good Parts ―「良いパーツ」によるベストプラクティス