RareJob Tech Blog

レアジョブテクノロジーズのエンジニア・デザイナーによる技術ブログです

docker版 redash を最新版 v9 (ベータ版) へ upgrade する

redash の version 9 のベータ版が公開されました。

変更点がかなりあるので Changelog を見るだけでも面白いです。

V9 changelog (in master) by arikfr · Pull Request #4967 · getredash/redash · GitHub

個人的に興味深かった点が以下の 4つ です。

  • frontend は 100% React へ。( Angular、いままでありがとう!)
  • backend の job実行は Celery から RQ へ。
  • backend が Python 2 から 完全に Python 3へ。
  • data source に Amazon CloudWatch, Amazon CloudWatch Logs Insights が追加された。

と、今回の redash は frontend も backend も完全に刷新されており、今まであったメジャーアップデートの中でも一番のターニングポイントだと感じてます。

なにはともあれ version up

レアジョブでは redash が version 3.0 の時から使っており、定期的に version up をしていたのですが、1年半ほど前にversion 6 に上げてからは滞っておりました。

我々としては Amazon CloudWatch の連携が魅力に感じました。まずはベータ版というステータスなので社内の検証環境の redash を version upすることにしました。

redash は メジャー version up をすると redash 自体のデータを格納している postgres のスキーマ構造が変わることがよくあります。

version up するには redash で用意している migration tool を実行する必要があります。 ただし注意する必要があるのが

If you are currently running an instance of Redash prior to V7, do not upgrade directly to V8. Upgrade semantically to V7 first.

とあるように、 version が 7より下の場合は、まず先に version 7 へするようにと明記されています。 公式が提供している docker image を使いながら docker-compose.yml で定義する利用する docker image の version を一つづつあげていきます。

version 毎の docker image は以下公式ドキュメントをご覧ください。 https://redash.io/help/open-source/admin-guide/how-to-upgrade

例えば、version 6 から、version 7にあげる際は docker-compose.yml を以下のようにします。

before

services:
  server:
    image: redash/redash:6.0.0.b8537

after

services:
  server:
    image: redash/redash:7.0.0.b18042

その後

docker-compose run --rm server manage db upgrade

を行うと、server コンテナで migration tool が稼働し、redash の postgres に対しスキーマを変更しに行きます。 エラーが出なかったら

docker-compose up -d

で redash を起動してください。 login すると redash の version をブラウザ越しに確認することができます。 期待通りの version になっていることと、動作確認を行い問題なければ、先ほど行ったように docker-compose.yml で指定するイメージの version を上げて同様の作業を繰り返していきます。

version 9の起動

migration は成功しましたが、起動に失敗する事象に陥りました。

なぜ起動に失敗するのか原因を書く前に、 redash の構成をお話しする必要があると思います。 redash は 1つのコンテナイメージを使い複数コンテナを立ち上げ、それを相互に連携しあう事で動くようになっております。 https://github.com/getredash/redash/blob/master/bin/docker-entrypoint に定義してある server、scheduler、worker は最低限必要であり、 以下のように利用するコンテナイメージと、起動コマンドを定義して各コンテナを立ち上げる必要があります。

services:
  server:
    image: redash/redash:9.0.0-beta.b42121
    command: server
   (略)

  scheduler:
    image: redash/redash:9.0.0-beta.b42121
    command: scheduler
   (略)

  worker:
    image: redash/redash:9.0.0-beta.b42121
    command: worker
   (略)

この仕組み自体は以前と変わらないのですが今回、backend の仕組みが変わったことで、コンテナ を動かす上で必須となる環境変数が変わったことが原因のようです。

各コンテナに対して環境変数を書いていくと多くの場合冗長になってしまうので、公式の docker-compose.yml にある x-redash-environment のように環境変数の設定は1箇所にまとめ、各コンテナはそれを読み込むような作りにすると環境変数の漏れを防ぐことができるかもしれせん。

https://github.com/getredash/redash/blob/master/docker-compose.yml#L11

必要な環境変数が受け渡れば問題なく起動できます。

v9 を使うにあたってベンチマークスコアなどは取っておりませんが frontend も backend も生まれ変わったことで、レスポンスは体感で速くなったと感じています。

新しく追加された機能などについてはまた別の機会でお話しできればと思いますが、今回は docker版 redash を最新版へ upgrade する方法について記載させていただきました。

もちろん、今回はベータ版ですがGA になった場合は GA に update する予定です。

以上です。

Nuxt.jsプロジェクトにStorybookを導入する

こんにちは。APP・UXチームの大谷です。約1年ぶりくらいの投稿です。

最近は家にいることが多いので、ちょっと時間がかかる料理でもしてみよう。。
と餃子を皮から作ったりしています。餃子の皮を作る時は、お湯より水を使って練った方がモチモチ感がでて美味しいです。自分の好みにあった配合と焼き方を研究していきたい。

さて、今回は表題の通りNuxt.jsプロジェクトにStorybookを導入する方法について書いていきたいと思います。

Storybookとは?

StorybookとはUIコンポーネントをカタログのように一覧表示できたり、振る舞いをテストできるオープンソースのツールです。

storybook.js.org

なぜやるか?

新しく始まったプロジェクトをきっかけに、コンポーネントの管理方法やデザイナーとの連携について改めて整理してみようということでStorybookの導入が検討されました。

デザインチーム/フロントエンドチームで事前に用途についても協議してみました。

  • デザインチーム
    • コンポーネントのチェック(どんな種類/どんな挙動か)
    • スタイルガイドの管理
  • フロントエンドチーム
    • コンポーネントのチェック(どんな種類/どんな挙動か)
    • デザインチームへの共有

両チームとも基本的にコンポーネントの管理/共有に使いたい(ボタンやフォーム単位のパーツについて)という意見だったので、ページ全体など大きい単位での表示テストの用途には使わず、まずは小さい単位でのコンポーネントの管理/共有・スタイルガイドとして活用していこうという内容で認識を合わせました。

さっそくStorybookを導入してみる

まずはNuxtのサンプルプロジェクトを作成します。

% yarn create nuxt-app sample-project

作成したプロジェクト内でstorybookを導入していきます。

yarn add -D @storybook/cli
yarn sb init --type vue

yarn storybook

f:id:kinokonotani5656:20200611100741p:plain
storybook initial

storybook/cliは現在のlatest版(5.3.19)を使っています。
無事Storybookが立ち上がりましたが、Nuxtアプリケーションの本体を立ち上げようとしたらエラーが発生してしまいました。

yarn run dev
Type checking in progress...
 ERROR  Failed to compile with 39 errors
* core-js/modules/es6.array.from in ./.nuxt/client.js, ./.nuxt/components/nuxt-link.client.js
 .............
To install them, you can run: npm install --save core-js/modules/es6.array.find core-js/modules/es6.array.from core-js/modules/es6.array.iterator core-js/modules/es6.date.to-string core-js/modules/es6.function.name core-js/modules/es6.object.assign  .............

core-jsでめちゃめちゃ怒られます。 Storybookを導入した時にbabelとcore-jsのバージョンで依存関係の問題が発生してしまうようです。解消するためにここでは明示的にcore-jsの2系を指定してインストールし直しました。

yarn add core-js@2

あとはstoriesファイルと設定を追加していきます。 今回はpropsやdescriptionを作るためのaddonも追加してみました。

yarn add -D @storybook/addon-knobs
yarn add -D storybook-addon-vue-info

addon有効にする設定を追加します。

// .storybook/addons.js
import 'storybook-addon-vue-info/lib/register';
import '@storybook/addon-knobs/register';

configファイルを追加します。

// .storybook/config.js
import { configure, addDecorator } from '@storybook/vue';
import { withKnobs } from "@storybook/addon-knobs";
import { withInfo } from 'storybook-addon-vue-info'

// xxx.stories.jsに対してaddonを反映
addDecorator(withInfo)
addDecorator(withKnobs);

// どのファイルを見るか設定する。今回は../components下のstoriesファイルを対象にする
configure(require.context('../components', true, /\.stories\.(js|ts)$/), module);

storiesファイルを作成。

import { storiesOf } from '@storybook/vue'
import { text, select } from '@storybook/addon-knobs'
// サンプルで作成したコンポーネント
import Button from '@/components/atoms/button/index.vue'

const stories = storiesOf('Buttons', module)

stories.add(
  'button sample story',
  () => {
    return {
      // 対象のコンポーネント設定
      components: { Button },
      props: {
        colorStyle: {
          type: String,
          default: select('colorType', ['green', 'orange'])
        },
        value: { type: String, default: text('default', 'sample') }
      },
    // 描画する内容を設定
      template: `<Button :value=value :colorStyle=colorStyle />`
    }
  },
  { info: true }
)

Buttonコンポーネントの表示を確認できるようになります。

f:id:kinokonotani5656:20200611100837p:plain

GitLab Pagesで公開

毎回環境を立ち上げなくてもいいようにGitLab Pagesで公開するようにしました。 masterにマージされたタイミングでGitLab Pagesを更新するようにしたgitlab-ci.ymlファイルの設定がこちらです。

image: node:latest

cache:
  paths:
  - node_modules/

pages:
  stage: deploy
  tags:
    - sample_runner
  only:
    - master
  before_script:
    - 'yarn install'
  script:
    - 'yarn build-storybook'
    - 'cp -pr storybook-static ./public'
  artifacts:
    paths:
      - public

最後に

今回は記載していませんが、Jestと連携してstoriesファイルのスナップショットテストをすることもできるようです。コンポーネントの管理/共有をするために便利なStorybookですが、更新せずに古くなってしまっては意味がないのでスナップショットテストと連携するなど必ず更新する仕組みをつくっていくことも課題になります。

実運用はまだはじまっていませんが、デザイナーとエンジニアお互いにとって幸せな開発環境にできれば良いなと思います!

デザインセンスとは?


どうも!お久しぶりです!デザイナーの渡辺です!
リモート勤務になって早2ヶ月になりました。
毎日ストレッチをしていたら、だいぶ体が柔らかくなってきました(*´∀`)

さて、今回は【デザインセンス】についてお話していこうと思います。
特に自分のことを【デザインセンスがない】と思っている方に見ていってほしいです!

最初に感じた疑問

私はデザイナーになってから、デザインセンスって一体なんだろう?と
考えるタイミングが何度かありました。

最初は新卒の時です。

Aさん:この企画のバナー作って!俺はデザインセンスないから見た目は任せる!
私:(私にデザインセンスってあるのか?)

単純にデザイナーだからデザインセンスを持っていると
思われていることに疑問を感じました。

私:私ってデザインセンスあるんですか?

先輩:ないだろ
私:わーバッサリー(棒)

そりゃもうバッサリでした笑

その後も事あるごとに、デザイナーではない方から
「自分にはデザインセンスがないのであとよろしく!」と言われることが多々ありました。
これはデザイナーのあるあるなんじゃないですかね?笑

デザインに必要なもの

そもそもデザインするのに何が必要なのか?
正直、なにもいらないと思ってます。
デザインしたければ、好きなようにデザインすればいいのです!

ただ、そのデザインを
【多くの人が良いと感じる】ものにするには

【知識】と【経験】
この2つが必要だと思ってます。

【知識】

  • 配色:色によって与える影響はなにか?
  • 配置:コンテンツの置く場所によって与える影響はなにか?
  • ボタンは押しやすいか?欲しい情報は得られるか?などを考えられるUIの概念
  • 与えたものによってどのような体験をするか、してほしいかを考えられるUXの概念

【経験】

  • 数多くの物を作ったり、目にしたことがあるか
  • 作ったものが成功したか
  • 作ったものが失敗したか
  • 失敗から成功に繋げられたか

など、挙げたらまだまだありますが
このような【知識】と【経験】を蓄積することにより
【多くの人が良いと感じる】ものを
作り出すことが、出来るのではないかと思います。

デザインセンスとは

では、デザインセンスとはなんなのか?

それは【知識】と【経験】を蓄積し、磨き上げた
自身のデザインスキルのことを言うのではないでしょうか?

蓄積することなく始めから持っているとすれば
それは才能です。

センスがあると才能があるは別物です。


【デザインセンスがない】というのは
デザインの【知識】と【経験】がないと言う意味なので、当たり前ですよね。
むしろ、初めからデザインセンスがある人のほうが希少です。
【知識】と【経験】がなくできてしまうので、才能があります。

新卒だった頃の先輩も
まだ新卒で【知識】と【経験】がないんだから
デザインセンスがあるわけない。
と言う意味だったようです。(いや、教えてよ)

そうと分かれば簡単です。

知識と経験は誰にでも蓄積すればいいのです笑

デザインセンスは磨くものです。

特にデザインに関しては、
始めからデザインセンスを備えていないとできないものと
思われがちですが、そんなことはないです。

デザイナーの私にだって、デザインセンスはなかったのですから笑
勉強したか、それを数多くこなしたかの違いなだけなんです。
なにも特別ではありません!誰でもできます!


なので、デザイナーではない方も試しにデザインしてみてください。
何でもいいです。
以前書いた記事を参考に、資料を作ってみるのはどうでしょうか?

rarejob-tech-dept.hatenablog.com

少しデザインの知識があるだけで
受け手の印象が変わってきます。

その先の成功に繋がるかもしれません٩(๑`^´๑)۶

まとめ

デザインセンスについて話してきましたが
今回のお話は【デザインセンス】だけでなく
【センス】という言葉のつくもの全てに言えることだと思います。

自分が○○○のセンスが無いから、、、と諦めてしまっていることがないか
改めて見直してみても良いかもしれません。

それはきっと磨けるスキルなので
【知識】と【経験】を蓄積することで、 今までは諦めていた新しい趣味が見つかるかもしれません!

もちろん、今回のお話は人によって捉え方が違うと思います。
自分なりに解釈し、デザインに触れていただけると嬉しいです(^^♪

それではよい週末を ノシ

身近なデータ分析 〜クラスの継承関係を題材に〜

@hayata-yamamotoです。この記事を書いていたら、小学校の自由研究を思い出しました。当時私は、アリジゴクの採集にハマっていて、研究テーマにしたことがありました。成虫であるウスバカゲロウになるとあっという間に生涯を終えてしまう儚さはなんとも言いがたいものがありますね。

さて、私はEdTech Labという研究開発チームに所属しています。(前回の記事に登場した齋藤と同じです)普段は、Python機械学習のモデル開発やデータ分析をしています。以前、分析チームについては書きましたので、よければそちらもみてください。

rarejob-tech-dept.hatenablog.com rarejob-tech-dept.hatenablog.com

目次

はじめに 

多くのプログラミング言語では、別のクラスをあるクラスに継承させることができます。これにより、共通部分の実装を省きながらユースケースに合わせた追加実装ができるようになります。また、共通部分も上書きすれば、必要な実装に書き換えたりできます。例えば、Djangoでは、以下のようにクラスを継承し必要な実装を加えて使ったりします。

# 引用元: https://docs.djangoproject.com/en/3.0/topics/class-based-views/

from django.http import HttpResponse
from django.views.generic import ListView
from books.models import Book

class BookListView(ListView):
    model = Book

    def head(self, *args, **kwargs):
        last_book = self.get_queryset().latest('publication_date')
        response = HttpResponse()
        # RFC 1123 date format
        response['Last-Modified'] = last_book.publication_date.strftime('%a, %d %b %Y %H:%M:%S GMT')
        return response

本記事では、この継承に着目します。具体的には、継承を「あるクラス」→「別のクラス」という関係性で捉え、ネットワーク分析の枠組みで分析します。最初にUMLで描いたクラス図を定性的に分析します。次にネットワーク分析の指標を用いて定量的に分析しました。普段何気なく実装しているクラスの継承関係も数学的に捉えなおせば、より客観的で俯瞰的な理解ができると伝われば嬉しいです。

なお、ソースはGitHubに掲載してあります。

github.com

使ったデータ

collections.abc1というPythonの標準モジュールを対象に分析しました。データの選定理由は大きく2つあります。念のためですが、このモジュールを知らなくても問題ないですので安心してください。

  • よく使われるデータセットはもうすでに良い記事がたくさんあるから
  • このモジュールを深く理解したかったから

データ作成に当たっては、公式のドキュメントとcpython2を参考にしています。コレクション抽象基底クラスの継承関係と抽象メソッド、及びMixinについて記載したテーブルが公式には記載されています。このモジュールは、typing3の説明にもしばしば登場します。mypy4などを使用するに当たっては、知っておいて損はありません。

また、分析にあたってCSVデータを別途作成しました。(気を利かせて?)Gremlin形式5になっています。もし興味があれば、Gremlin形式でロードできるグラフDBと合わせて使ってみてください。例えば、AWS Neptuneとかでしょうかね。

$ head -n 5 data/vetices.csv
~id,name:String
v0,"Container"
v1,"Hashable"
v2,"Iterable"
v3,"Iterator"

$ head -n 5 data/edges.csv
~id,~from,~to,~label
e0,v3,v2,extends
e1,v4,v2,extends
e2,v5,v3,extends
e3,v8,v6,extends

ネットワーク、そしてグラフ

普段、ネットワークという言葉をよく耳にします。SNSやインターネット、VPN、サブネットとかネットワークACLなどなど、身近なものからテクニカルなものまで様々あります。ビジネスの文脈ですと、ネットワークというのは人脈のような人同士の繋がりを意味することもあります。要するに、あるものと、別のあるものがつながっている時に、「ネットワーク」という言葉を使いたくなります。このような構造を数学的に扱うのに、グラフが有効です。

グラフと言っても、棒グラフなどの図を意味するわけではありません。宮崎氏によると、『いくつかの点と、二つの点を繋ぐ線からなるもの』6と説明されています。この点を頂点(Vertex)や節点(Node)と呼び、繋ぐ線を辺(Edge)や弧(Arc)と呼びます。また、この辺に向きがあるものを有向グラフ(Directed Graph)、向きがないものを無向グラフ(Undirected Graph)と呼びます。実務でよく目にするグラフといえば、木(Tree)や有向非巡回グラフ(DAG)あたりでしょうか。隣接や(最短)経路なども基礎的で重要ですが、今回は省略します。

以下のリンクは、レ・ミゼラブルに登場するキャラクター相関図をグラフで表現しています。目まぐるしくすぎていくストーリーも、一度立ち止まって、それぞれの登場人物を点と線で結んでみるとより深く理解できるというわけです。まさに、"Connecting the dots"ですね。エモい。

observablehq.com

まずはUML

とはいえ最初からグラフを使おうとすると、難しすぎて心が折れてしまいそうです。ですので、まずはこのモジュールをUMLでクラス図にしてみるところから始めます。ちなみに、UMLは以下の記事でも紹介されています。

rarejob-tech-dept.hatenablog.com

この分析では、大きく二点を確認しました。

  • モジュール全体を把握し、クラスを覚える
  • どのクラスがたくさん継承していそうか、継承されていそうかを直感的に理解する

以下の図を確認すると、直感的には

  • Collection, Set, MappingViewに向いている矢印が多い
  • Collectionは出ていく矢印も多い
  • いくつかエッジを持たないクラスが存在する

とわかるのではないでしょうか。

f:id:hayata-yamamoto:20200525220140p:plain
collections.abc Diagram

いよいよ分析

次に定量的な分析をしてみます。今回の分析には、NetworkX7というライブラリを用いました。分析には以下の3つを用います。

  • 次数中心性 (degree centrality)
  • 媒介中心性 (betweenness centrality)
  • PageRank

中心性とは、「そのノードがネットワークの中でどれくらい重要か」を表す指標と思ってくれれば大丈夫です。人で例えると、次数中心性は「たくさんの人とつながりを持っていること」を評価し、媒介中心性は「その人経由で別の人と繋がることが多いこと」を評価しています。PageRankは、Webページの重要度をはかる指標で被リンクの数を重視します。(一部の例外を除けば)良い記事は、被リンク数が多くなることを期待できます。PageRankはその性質を数値的に表現したものくらいの理解で大丈夫です。色々なページで丁寧に説明されていますので、検索してみると理解が深まるかもしれません。勇気がある人はぜひ、論文8も読んでみてください。

さて、CSVを読み出して有向グラフを作成し、そのグラフに対して必要な計算を実行します。JupyterNotebookの処理を抜粋してみます。

import networkx as nx
import pandas as pd
from typing import Tuple

def pairing(row: pd.Series) -> Tuple[str, str]:
    return (row['~from'], row['~to'])

vertices = pd.read_csv('./data/vertices.csv', index_col=None)
edges_df = pd.read_csv("./data/edges.csv", index_col=None)
edges_df['pair'] = edges_df.apply(pairing, axis=1)

G = nx.DiGraph()
G.add_nodes_from(vertices['~id'])
G.add_edges_from(edges_df.pair)

degree = nx.degree_centrality(G)
between = nx.betweenness_centrality(G)
page_rank = nx.algorithms.pagerank_numpy(G)
df = pd.DataFrame.from_records([degree, between, page_rank], index=['degree', 'betweenness', 'pagerank']).T
df = pd.merge(left=df, right=vertices, left_index=True, right_on='~id').drop('~id', axis=1).set_index('name:String')

このDataFrameをheadすると以下のようなテーブルが取得できます。

name:String degree betweenness pagerank
Container 0.0416667 0 0.0536147
Hashable 0 0 0.019103
Iterable 0.125 0 0.118524
Iterator 0.0833333 0.00181159 0.0353406
Reversible 0.0833333 0.00271739 0.0410238

(よしなに)可視化すると以下のようになります。ノードの大きさは、各指標の値の大きさと比例しています。それぞれの指標によって評価のされ方が異なることがみて取れますね。 f:id:hayata-yamamoto:20200528233423j:plain

最後に、各指標毎にランキングをとって、その合計順位の高い順上位5つを並べると以下のようになります。UMLでの定性分析通り、CollectionやSet, MappingViewが上位にあると確認できました。めでたしめでたし

name:String Sum of rank value
Collection 3
Set 8
Sequence 12.5
MappingView 16.5
Mapping 23.5

おわりに

この記事では、継承をテーマに簡単なネットワーク分析を行いました。ところどころ難しい言葉や解釈に苦労する部分もあったかもしれませんが、普段の何気ないテーマも数学的に捉えなおせば、よりよく理解できることが少しわかっていただけたら嬉しいです。実はそんなに頑張ってテーマを探さなくても、意外と身近に分析のテーマは落ちていて、そこまで難しい手法や実装をしなくても解決できたり調査できたりします。

ネットワーク分析は適用範囲が広く、今回取り上げたテーマの他にもSNSや書籍引用、Webページなど手頃なテーマはたくさんあります。ぜひ手頃なテーマを見つけて勉強してみてください。

引用・参考

時系列予測ライブラリProphet触ってみた

こんにちはEdTech Labの齋藤です。初めての投稿です。 緊急事態宣言が解除されつつありますが、生活リズムが在宅勤務に最適化されてしまったため、出勤が再開された際、寝坊に苦しむこととなりそうで心配な今日この頃です。

さて、今回は最近少し触れる機会のあったProphetというライブラリがとてもよかったので紹介しようと思います。

Prophetとは

facebook社が提供している時系列予測ライブラリで、RおよびpythonAPIが提供されています。 簡単な実装で時系列予測ができるので、さっと時系列予測をしたいときとてもうれしいライブラリです。

実装

実際どれくらい簡単なのか、ProphetのQuiq Startを参考にpythonで実装してみます。 Prophetはfbprophet==0.6を使用しています。

では、データを読み込んで準備をしましょう。

import pandas as pd
from fbprophet import Prophet

df = pd.read_csv('examples.csv')

使用するデータセットはQuiq Startと同じものを使用します。 自前のデータセットを利用する際に注意すべき点として、Quiq Startのようなナイーブな実装では、非連続なデータや1日を超える単位のものは使用しないようにするという点です。これを守らないと、予測がうまくいかないという状況が発生します。 また、それらの対応策はNon-Daily Dataの項目で言及されていますが、今回は割愛します。

では、モデルを作ってみましょう。

m = Prophet() # インスタンス化して
m.fit(df) # データをfittingさせる

これで、先ほどのデータをモデルに入れることができました。 では、予測結果を返してみましょう。

future = m.make_future_dataframe(periods=365) # periodsでどれくらいの期間予測するか決める

forecast = m.predict(future)
forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].tail()

これで完了です。 実質7行のコードで時系列予測ができる、とてもうれしい...。

最後に

Quick Startではデフォルトの設定でとてもナイーブな実装となりました。 そのほかにも周期性やトレンドの追加、各種パラメータの設定などリッチな仕様となっていて、ここまで簡単かつ自由に時系列モデリングができるということに感動しました。 自分だけの最強の時系列予測モデルを作っていきましょう。

参考: facebook.github.io

CloudFront + S3 でオリジンが更新されたら自動でキャッシュ削除する仕組みを作る

在宅勤務で引きこもりの才能に目覚めた DevOps チームの Shino です。

美味しいラーメンを食べたい衝動が時々訪れるのが悩みです。 おすすめの取り寄せラーメンがありましたら教えて頂きたく。(ジャンル問わず)

今回は CloudFront + S3 の構成で S3 上のコンテンツが更新されたら自動で CloudFront 上のキャッシュを削除する仕組みを作ったので簡単にまとめます。

概要

状況

CloudFront + S3 の構成で静的 Web ページなどの配信を行っている。

CloudFront が S3 上のコンテンツをキャッシュしてくれるので配信自体のパフォーマンスは悪くない。

課題

S3 上のコンテンツを更新するたびに、手動で CloudFront 上のキャッシュを削除しているため手間がかかる。

∵ キャッシュされたままの古いコンテンツが配信されてしまうのを防ぐため

解決策

Lambda を用いて S3 上のコンテンツが更新されたら自動で CloudFront 上のキャッシュを削除する。

f:id:shino8383:20200507001849p:plain

やったこと

全体の流れ

  1. S3 のオブジェクトが更新・作成されると Lambda にバケット名・オブジェクトのパスの情報を含んだイベントが渡される
  2. Lambda 上で、渡された S3 バケットをオリジンとする CloudFront ディストリビューションを取得する
  3. 取得した CloudFront ディストリビューションに対して、渡されたオブジェクトのパスを元にキャッシュ削除を実行する

実装内容

■ S3 - Lambda 間連携

1.S3 のオブジェクトが更新・作成されると Lambda にバケット名・オブジェクトのパスの情報を含んだイベントが渡される

これは Lambda のトリガーに S3 を設定すれば自動でイベント通知してくれます。 すべての更新イベントに対して設定しておいて問題ないかと思います。

f:id:shino8383:20200507024817p:plain

■ Lambda の実装

叩く API は実装する言語を問わず共通のはずなので、API リファレンスを記載しながら説明します。

2.Lambda 上で、渡された S3 バケットをオリジンとする CloudFront ディストリビューションを取得する

これは CloudFront API

ListDistributions - Amazon CloudFront

を使ってディストリビューションの一覧を取得します。

ただ、ListDistributions にはフィルターの機能はないので目的のオリジンを持つディストリビューションかどうかの判定は自前で条件分岐を作るしかないかと思います。

次に、

3.取得した CloudFront ディストリビューションに対して、渡されたオブジェクトのパスを元にキャッシュ削除を実行する

これは CloudFront API

CreateInvalidation - Amazon CloudFront

を用いてキャッシュ削除を実行します。 引数でディストリビューションの指定とオブジェクトのパスの指定を行います。

実装上の注意点

今回、実装するにあたって注意した点が3つあります。

  • Lambda 関数の冪等性
  • API のリクエスト数制限
  • Lambda の利用料

■Lambda 関数の冪等性

Lambda 関数の冪等性については調べれば色々情報が出てくると思いますので詳しくは触れません。 簡単に説明しますと

"実行した回数に関わらず同じ結果が得られる性質"

のことを冪等性といいます。

そして、

Lambda は複数回実行される可能性がある

という仕様があるので冪等性を考慮しています。 これは今回のケースで言えば S3 上のオブジェクトを 1 回更新しても、Lambda は 1 回以上実行される可能性があるということです。

今回実装したキャッシュ自動削除では、

オブジェクトの更新 1 回に対して、キャッシュ削除が 1 回だけ実行される

という結果が欲しかったので、キャッシュ削除の API を叩く前に 実行中のキャッシュ削除がないかを判定するロジックを設けました。 この判定がなければ、同じオブジェクトに対する複数のキャッシュ削除が重複することになります。

キャッシュ削除の一覧は

ListInvalidations - Amazon CloudFront

で取得しました。

f:id:shino8383:20200507041107p:plain

API のリクエスト数制限

AWSAPI には一定時間当たりのリクエスト上限があります。 一定時間内にリクエスト上限を超えるとエラーが返却されます。

例えば SNSAPI の一つ、 Subscribe は 1 秒あたり 100 回の制限があります。

Subscribe - Amazon Simple Notification Service

This action is throttled at 100 transactions per second (TPS).

CloudFront の API リファレンスには回数の記載がないですが、制限自体はあるはずです。

実装の際にリクエスト回数が増えすぎないかテストしていましたが、ThrottlingException が何度か返ってきていた記憶があります。

Common Errors - Amazon CloudFront

つまり今回のケースでは、オブジェクト更新が一度に大量に行われる場合などを考慮して必要以上に API をコールしないような実装が必要です。

冪等性の件でキャッシュ削除を行わない分岐を設けたのは、影響は軽微ですがリクエスト数の観点からも良いと言えるのではないかと思います。

■Lambda の利用料

最後にお金の話です。

最低でも更新されたオブジェクトの数だけ Lambda が実行されるので Lambda の利用料にも気をつかうことにします。

Lambda の利用料は

利用料 = 実行回数 * 割り当てたメモリの量

で決まります。

Lambda 上のロジックでは実行回数(=更新するオブジェクトの数)のコントロールはできないので、割り当てるメモリの量が小さくなるように配慮します。

具体的に言えば、一度に大量にデータを保持する処理は避けることにします。

今回の実装の中で、データを大量に取得しやすいのが

ListInvalidations - Amazon CloudFront

だと思ったのでこちらの処理に対して手を加えることにします。引数で何も指定しなければ指定したディストリビューションについて過去のキャッシュ削除履歴を相当な件数引っ張ってくることになるかと思います。 (15000 件近く返却されるところまでは確認しました)

今回は、弊社の CloudFront + S3 の利用状況も考慮した上で

  • 取得するキャッシュ削除履歴の数を引数で指定
  • キャッシュ削除の履歴を新しいものから 10 件取得する。
  • 実行中の履歴があればもう 10 件取得する。
  • これを最大で直近 30 分以内の履歴について行う。

という実装をしました。

この実装はリクエスト数を増やしている側面もあるので、リクエスト数の増加とデータ取得量最適化の天秤になるかと思います。

最後に

CloudFront + S3 の構成はよくある構成だと思いますので、実装の際に考えていたことを改めて言語化してみました。

この Lambda (とその中で叩かれる API )は大量に実行される可能性があるので、 キャッシュ自動削除の対象にする S3 バケットやオブジェクトのパスはちゃんと考察するべきかと思います。

ちなみに今回は Slack にキャッシュ削除の開始通知を飛ばすところまで実装しました。

f:id:shino8383:20200514192553p:plain

これからもすきあらば自動化等で日々の業務のコストを下げていけたらいいなと思っています。

では今回はこのへんで。

DevOps チームは仲間を募集中です👏

👏採用/求人情報 | アピール | 未来の教育を作る人のマガジン インフラエンジニア(AWS)

UIWebViewからWKWebViewへのリプレイス作業が完了したのでまとめてみた

APP / UX チームで iOS アプリを担当しています玉置(@tamappe)です。

今回はレアジョブアプリでリリース当初から使われていた UIWebView を全て WKWebView にリプレイスできましたので、その時に取り組んだ事を知見としてまとめることにしました。 iOS アプリ開発において UIWebView の撲滅作業は一つの鬼門ですね。

リプレイスの背景

UIWebView とは Apple が提供するアプリ内ウェブブラウザの機能を持つ UIKit です。 簡単にいえば、アプリ内ウェブビューを実現するものになります。

ですが Apple が去年リリースした iOS13 の誕生とともに、正式に2020年4月からほぼ使えなくなることになります。 UIWebView が使えなく代わりに iOS8 から追加された WKWebView を使うようにと強制されるようになりました。新規アプリに至っては2020年4月以降から UIWebView を使ったアプリの申請はリジェクトが確定しています。既存アプリに関しても1度警告が入るだけですが、2020年12月末以降からはアップデートの申請が出せなくなります。

Updating Apps that Use Web Views

レアジョブアプリも例に漏れず UIWebView を使っている画面がありますので、今回を機に WKWebView へのリプレイスを実施することにしました。

注意すべきこと

では、 WKWebView へのリプレイス作業に関してやるべきことと注意すべきことをを箇条書きにします。

  1. UIWebView のインスタンスを WKWebView のインスタンスに置き換える
  2. WKWebView でのページのローディングで WKNavigationDelegate を使うようにする
  3. アプリ側で JavaScript 実行(同期)をしている箇所は、 WKWebView の場合は非同期(コールバック)になる
  4. ネイティブ画面でログインAPIを叩いている場合、 Cookie の共有の扱いに注意する
  5. UIWebView で UserAgent の操作をしている場合には、取得の仕方を考え直す

以上、5点になりました。

それでは一つずつ見ていきます。

UIWebView のインスタンスを WKWebView のインスタンスに置き換える

ほとんどの場合は UIWebView は storyboard から使って操作していたと思います。それに対して、 WKWebView は Xcode 10 まで Apple のバグにより storyboard から参照できませんでした。そのため、Xcode 10 まではコード上で WKWebView のインスタンスを生成するしかありませんでした。それが Xcode 11 からは Apple がこのバグを直したためか storyboard 上で使ってもビルドエラーが起こらなくなりました。

そのため、「だいたいの場合」は storyboard から WKWebView を使えば置き換え可能になりました。

UIWebView

f:id:qed805:20200223190550p:plain
UIWebViewの場合

WKWebView

f:id:qed805:20200223190654p:plain
WKWebViewの場合

WKWebView の背景色のデフォルトはグレーがかかっています。これだけで WKWebView への置き換えが可能ですが、後述する Cookie の共有ではこのロジックが使えなくなるのでコードでの生成も記載します。

var webView: WKWebView! = {
       let configuration = WKWebViewConfiguration()
        let webview = WKWebView(frame: .zero, configuration: configuration)
        return webview
    }()

computed property を使ってクロージャーの中で初期化宣言すればそのままプロパティとして利用できます。ただし、これだけでは画面上の view には乗らないのでどこかで乗せないといけません。

    override func viewDidLoad() {
        super.viewDidLoad()
        setupWKWebViewLayout()
    }

    private func setupWKWebViewLayout() {
        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.topAnchor.constraint(equalTo: view.bottomAnchor, constant: 0.0).isActive = true
        webView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: 0.0).isActive = true
        webView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0.0).isActive = true
        webView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0.0).isActive = true
    }

オートレイアウトを使いたいので translatesAutoresizingMaskIntoConstraints を false にしています。コードから生成する場合はこれで事足ります。

WKWebView でのページのローディングで WKNavigationDelegate を使うようにする

次にウェブブラウザのローディングで使うデリゲートメソッドが変わりますので、それぞれ置き換える必要が出てきます。ものすごく省略していますが、UIWebView と WKWebView とでこのように変わります。

/// UIWebView
extension ViewController: UIWebViewDelegate {
    func webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest, navigationType: UIWebView.NavigationType) -> Bool {
        guard let host = request.url?.host else {
            // ページのローディングを許可しない
            return false
        }
        // ページのローディングを許可する
        return true
    }
}

/// WKWebView
extension ViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let host = navigationAction.request.url?.host else {
            // ページのローディングを許可しない
            decisionHandler(.cancel)
            return
        }
        // ページのローディングを許可する
        decisionHandler(.allow)
        return
    }
}

大きく異なるのが UIWebView ではページの読み込みを許可するかどうかは Bool で判断するところが、 WKWebView では decisionHandler で処理するように変わります。 またロードしたページの url は request からではなく navigationAction から取得するようになりました。こちらは気をつければいいだけでした。

アプリ側で JavaScript 実行(同期)をしている箇所は、 WKWebView の場合は非同期(コールバック)になる

UIWebView での JavaScript 実行は同期的でしたが、 WKWebView では非同期的に変わります。

/// UIWebView
let string = webView.stringByEvaluatingJavaScript(from: "JavaScriptのスクリプト")

/// WKWebView
webView.evaluateJavaScript("JavaScriptのスクリプト", completionHandler: { result, error in
    if let result = result as? String {
        let string = result
    }
})

そのため、戻り値を String で返す関数がある場合はロジックが破綻してしまうので completionHandler などを用意して関数にコールバック引数を用意してあげる必要が出てきます。ちなみに非同期を同期的に無理やり変換しようとするとデットロックしてしまうので注意です。

ネイティブ画面でログインAPIを叩いている場合、 Cookie の共有の扱いに注意する

これが一番大変でした。 アプリ自体にネイティブ実装のログイン画面が存在しログインAPIを使ってログインしています。そして、レアジョブアプリはレッスンルームと教材の部分でアプリ内WebViewを使っています。このアプリ内 WebView はクッキー共有が前提の設計になっていて、ログインAPIで取得した sessionId をアプリ内 WebView に渡して表示させています。ですので、アプリがログイン中であればアプリ内 WebView はログインを維持していないといけないです。 (途中でセッションが切れてしまった場合はログインページを表示させています)

UIWebView でもこの Cookie 共有が実装されていましたので、 WKWebView でも Cookie 共有が実装されていなければなりませんでした。結論からいえば、これは解決しました。 UIWebView の時に Cookie のセットができていました。 HTTPCookieStorage に保存すれば共有できます。

    class func setCookie(_ cookie: [HTTPCookiePropertyKey: Any]) {
        let newCookie = HTTPCookie(properties: cookie)
        // HTTPCookieStorageにクッキーを保存する
        HTTPCookieStorage.shared.setCookie(newCookie!)
    }

このあとに WKWebView で保存した Cookie をセットしなければ行けなかったのですが、どうやってセットすればいいのかが情報が少なすぎてわかりにくかったです。

答えは WKProcessPool に存在していました。この WKProcessPool を WKWebView の WKWebViewConfigurationインスタンスにセットして WKWebView を生成すれば Cookie を設定できます。

extension WKProcessPool {
    static let shared = WKProcessPool()
}

class ViewController: UIViewController {
    var webView: WKWebView! = {
       let configuration = WKWebViewConfiguration()
        configuration.processPool = WKProcessPool.shared
        let webview = WKWebView(frame: .zero, configuration: configuration)
        return webview
    }()

    /*
     中略
     */
}

ちなみに WKWebView には configuration プロパティが存在するからコードではなく storyboard から @IBOutlet で接続してセットしなおせばいいのでは?と思われるかも知れませんが、 WKWebView のインスタンスを生成した後に WKProcessPool.shared を設定しても共有されません。

var webView: WKWebView! = {
       let configuration = WKWebViewConfiguration()
        /// これは共有される
        configuration.processPool = WKProcessPool.shared
        let webview = WKWebView(frame: .zero, configuration: configuration)
        /// これは共有されない
        webview.configuration.processPool = WKProcessPool.shared
        webview.backgroundColor = UIColor.white
        return webview
    }()

つまり、 Cookie を維持したい場合には storyboard にある WKWebView では実現できなく、コード生成でのみ実現可能です。これは謎の仕様ですが、ハマリポイントでもありますので注意が必要です。

最後に

このような手順を踏むことで WKWebView へのリプレイス作業が完了しました。 UIWebView に未練はありませんが、もうちょっと WKWebView の使い勝手を改善してほしいですね。まだ UIWebView を使っているアプリがあればこちらの記事が役に立てば幸いです。

補足

レアジョブアプリは RxSwift という外部ライブラリを使っていて、この RxSwift のライブラリの中でも UIWebView が使われていました。 正確には RxSwift の RxCocoa で UIWebView が使われています。

github.com

もちろん今回の Appleガイドラインにおいて、外部ライブラリに含まれる UIWebView も例外ではありません。 ガイドラインの対象になりますのでこの問題をどうしようかと悩んでいました。 ですが先月3月に、RxSwiftのライブラリがこのガイドラインに対応したバージョンをリリースしたそうです。

https://github.com/ReactiveX/RxSwift/releases/tag/5.1.0

RxSwift と RxCocoa のバージョンを 5.1.0 にアップグレードすると UIWebView がなくなっていることが分かります。 これで正式に UIWebView の撲滅が達成したことになりますね!

では、引き続きレアジョブアプリの改善を続けていこうと思います。いつもながら長文になりましたが最後まで読んでいただきありがとうございます。