LINE Front-end Framework(LIFF) v2でQRコードを読み取るよ

こんにちは!
冬が苦手なディレクターの神吉です。

この記事はEnigmo Advent Calendar 201920日目の記事です。

LINEの開発者情報をチェックしていて、ちょっと前にLIFF v2がリリースされていました。
https://developers.line.biz/ja/docs/liff/release-notes/#spy-releasedate_20191016

f:id:enigmo7:20191218130815p:plain:w300

LIFFとは

LIFFとはLINE Front-end Frameworkのことで、LINEが提供するウェブアプリのプラットフォームです。
JavaScriptを書いて開発する感じです。

LIFFはけっこう前から提供されていましたが
LIFF v1 → LIFF v2になって主に下記ができるようになりました。

  • 外部ブラウザでLIFFアプリが動作する。
    • v1はLINE内ブラウザでのみ動作していました。
  • ユーザーのプロフィール情報とメールアドレスを取得できる。
    • LINEログインでできる仕組みに近い仕組みなのかなと思います。
  • QRコードを読み取れる。
    • 新機能ですね!
  • LIFFアプリの動作環境を細かく取得できる。
    • 外部ブラウザで動作するようになって細かい情報を取得する必要がでてきたのかと思います。

LIFF v1もちゃんと触っていなかったのですがこの機会にv2を触ってみたいと思います。
とりあえずLIFFでQRコードを読み取りをやってみます。

事前準備

こちらを事前に準備してください。

LIFFアプリの追加

LINE Developersコンソールよりの 「LIFFアプリを追加」より必要事項を入力してLIFFアプリの追加をしてください。
※今回はQRコードを読み取りをするのでScan QRの設定はONにしてください。

f:id:enigmo7:20191219115402p:plain:w500

またLIFFサーバーAPIでもLIFFアプリを追加することができます。
https://developers.line.biz/ja/reference/liff-v1/#add-liff-app

追加後LIFF URLが発行され、LIFFを開くことができるようになります。

f:id:enigmo7:20191219105854p:plain:w300

LIFFアプリの開発

ここからは実際の開発手順です。

LIFF SDKを読み込み

LIFF SDK https://static.line-scdn.net/liff/edge/2.1/sdk.js を読み込んでください。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <title>sample</title>
  </head>
  <body>
    <script src="https://static.line-scdn.net/liff/edge/2.1/sdk.js"></script>
  </body>
</html>

LIFFアプリを初期化する

sdk.js 読み込み後に追加してください。

liff
  .init({
    liffId: "XXXXX" // liffIdを指定。line://app/XXXXXの部分
  })
  .then(() => {
    // 処理はここから
  })
  .catch((err) => {
    // エラー処理
    console.log(err.code, err.message);
  });

QRコードを読み取り処理

document.getElementById('qr_button').addEventListener('click', function() {
    if (liff.isInClient()) {
        liff.scanCode().then(result => {
            document.getElementById('qr_text').textContent = result.value;
        }).catch(err => {
            document.getElementById('qr_text').textContent = err.message;
        });
    }
});

liff.scanCode()はLINEのQRコードリーダーを起動し、読み取った文字列を取得するメソッドです。
#qr_buttonにクリックするとLINEのQRコードリーダーが立ち上がり、 QRコードをスキャンすると#qr_textQRコードで読み取ったテキストが挿入されます。

f:id:enigmo7:20191219105829p:plain:w300

注意点

  • iOS版LINEバージョン9.19.0以降では、liff.scanCode()の提供を一時停止しています。(2019/12/18時点)
  • liff.scanCode()は、外部ブラウザでは利用できません。

どう活用する?

手軽にQRコードリーダーを使えるのは良いです。QRの読み取りも速いです。
またLIFFはLINEのuserIdと紐づけてイベントログを取得でき、データ活用できます。
そういったところ活かしオンラインとオフラインをつなぐような施策使えればと思います。
(今のところBUYMAでの具体的な活用案が浮かばずすみません。。)

感想

  • かなり簡単に試すことができました!
  • LIFFを使用する上で実行環境による挙動の違いはしっかり把握しないといけないなと感じました。
  • 今後もLINEの開発者情報はこまめにチェックして、新機能は早めに試してみたいと思います。

参考

株式会社エニグモ 正社員の求人一覧

hrmos.co

ゼロ年代後半ゆるふわ情報系学生がSQLのクラスタリングをやってみた

インフラチームの山口です。 ゼロ年代後半ゆるふわ情報系学生でしたが紆余曲折の末にインフラエンジニア1年目となりました。 今回は編集距離を使用してSQLのクエリをクラスタリングしてみたので記事にまとめてみます。 奇しくも、伊藤直也さんのブログで編集距離の記事が公開されたのが2009年だったのですが、時の流れの速さを感じてしまいます。

1.背景

DBのCPU負荷のスパイク時に、DBのクエリのログを取得・人手で集計して、CPU負荷が高いクエリを改善するという運用を実施することがあります。 ログ(クエリ)の量が少ない場合は良いのですが、大きくなるにつれ、人手での集計に伴い以下のような問題が発生しています。

  • 人手での集計には時間を要する
  • 作業者が変わると結果が一意に決定できない場合があり、集計作業の再現性がない

スクリプトに起こして作業をしようとしても、 単純な文字列一致の方法で集計を試みると、WHERE の条件にしている id が異なっているだけといったクエリをうまく拾えないことが予想されます。 今回は、2つの文字列に対して定義される編集距離(Levenshtein Distance)を使用して、スクリプトでの結果の集計を試みます。 (「TF-IDFやNグラムを使用してやるのもちょっと……」という個人的な気持ちがあったので)

2.関連する事例

「編集距離を使用してクエリ同士の距離を測ればなんかうまくいきそうな気がする」というワンアイディアで調べもせずスクリプトの作成をしてしまったのですが既存の参考文献を調べてみます。 sql query clustering filetype:pdf などで検索してみるといくつか事例が見つかります。 詳しい書誌情報は割愛でタイトルとざっと読んでどんな事やっているかだけ記載します。 クエリのクラスタリングというタスクの論文は書かれているようです。 論文書かれているということは、おそらく他のエンジニアも同じ問題にあたった人もいるはず。 (手でクエリのログ分類して大変な思いしてるのは私だけではなさそう……。)

3.方法

編集距離とは(Wikipedia参照)

編集距離については既存で参考になる記事が複数存在するため、本記事では特に説明せず、Wikipediaからガッツリ引用します。 端的には2つの文字列の間の距離の尺度です。

レーベンシュタイン距離(レーベンシュタインきょり、英: Levenshtein distance)は、二つの文字列がどの程度異なっているかを示す距離の一種である。 編集距離(へんしゅうきょり、英: edit distance)とも呼ばれる。 具体的には、1文字の挿入・削除・置換によって、一方の文字列をもう一方の文字列に変形するのに必要な手順の最小回数として定義される編集距離(Levenshtein distance)は2つの文字列がどの程度異なっているかを示す距離の一種。

kittensitting に変更する場合は、最低でも3回の手順が必要とされるので2単語間の編集距離は3となる。

  1. kitten
  2. sitten(kをsに置換)
  3. sittin(eをiに置換)
  4. sitting(gを挿入して終了)

今回の方法

以下の要件を満たす、なんちゃってクラスタリングを実行するスクリプトを作成しました。

  • クラスタリング結果が一意に決まらない方法は避けたい
  • 手元のマシンですぐに結果がみたい(時間がかかる方法は避けたい)

今回の方法を説明します。 クラスタの代表と以下で記載していますが、実際はレコード中で一番はじめに出てきたものをそのクラスタの代表にしているだけで、特に重心などをとっているわけではないです。 また、今回は、クエリの先頭からN単語分を抜き出して使用します。 これは、「人間が比較する際もクエリの先頭から末尾まで見ておらず、先頭N文字で判断しているだろうという仮定」と、「試しにクエリ全体で計算してみると思いの外時間がかかった」ためです。

今回の方法の概要

- クラスタリング対象のクエリすべてに対して以下を実行する
- クラスタリング対象の1つめのクエリの場合はそのクエリをクラスタ0の代表としクラスタ0に追加する
- クエリと各クラスタの代表のクエリの先頭からN単語の部分文字列との編集距離を計算
  - 編集距離が閾値以下かつ、最も近いクラスタに割り当てる
  - 閾値以下の距離のクラスタがない場合は新しいクラスタを作成する

また、編集距離を取得する際に、一旦クエリをスペースで分割して、単語単位での編集距離を取得します。 日本語だと形態素解析が必要ですが、雑に半角スペースで単語に分割します。

>>> import editdistance
>>> q1 = 'SELECT * FROM tbl_1 WHERE id = 1;'
>>> q2 = 'SELECT * FROM tbl_1 WHERE id = 50;'
>>> q3 = 'UPDATE tbl_1 SET id = 40 WHERE id = 1;'
>>> editdistance.eval(q1.split(' '),q2.split(' '))
1
>>> editdistance.eval(q1.split(' '),q3.split(' '))
6

>>> import Levenshtein
>>> Levenshtein.distance(q1,q2)
2
>>> Levenshtein.distance(q1,q3)
22

パラメータと使用したライブラリおよびスクリプト

  • 使用したライブラリ

    • editdistance (0.5.3)
  • パラメータ(パラメータはエイヤで経験的に決定した)

    • 同じクラスタと判断するための閾値: 10
    • 比較に使用する単語数: 先頭から100単語
      • SQLの先頭100単語を切り出し編集距離を計算する
  • スクリプト

#!/usr/bin/env python3
# coding: utf8
import csv
import editdistance
from collections import defaultdict


# ファイルを読み込む
def load_file(in_fname, skip_header=False):
    queries = []
    with open(in_fname, newline="", encoding="utf8", errors='ignore') as f:
        queries = list(csv.reader(f, delimiter="\t", quotechar='"'))
    if skip_header:
        del queries[0]
    return queries


# 結果をファイルに書き込む
def write_file(out_fname, result):
    with open(out_fname, 'w',  newline="", encoding="utf8") as f:
        writer = csv.writer(f, delimiter="\t")
        writer.writerows(result)


# 半角スペースでクエリを分割して、先頭word_len単語のlistを作る
def get_substr(query, word_len):
    return query.split(' ')[0:word_len]


# クエリの編集距離を計算する
def calc_distance(query_1, query_2):
    return editdistance.eval(query_1, query_2)


# 編集距離を使ってクエリをグループ化する
def cluster(queries, query_pos=1, word_len=100, threshold=10):
    clusters = []
    result = []

    for row in queries:
        current_query = row[query_pos]
        current_query_substr = get_substr(current_query, word_len)

        # 1レコード目の場合は新規のクラスタに入れる
        if len(clusters) == 0:
            clusters.append(current_query_substr)
            row.insert(0, 0)
            result.append(row)
            continue

        # 2レコード目以降
        current_cluster = ""
        # 初期値
        min_dist = float('inf')
        for cluster_id, cluster_query_substr in enumerate(clusters):
            current_dist = calc_distance(cluster_query_substr, current_query_substr)
            # 閾値以下
            if current_dist <= threshold:
                # クラスタとの距離が近ければ所属するクラスタと編集距離を更新する
                if current_dist <= min_dist:
                    min_dist = current_dist
                    current_cluster = cluster_id

        # 閾値以下のクラスタがなければ新規のクラスタにする
        if current_cluster == "":
            clusters.append(current_query_substr)
            current_cluster = clusters.index(current_query_substr)

        row.insert(0, current_cluster)
        result.append(row)

    return clusters, result


# 各クラスタのメンバー数などのサマリを出力
def cluster_summary(clustering_result, cluster_pos=0):
    cluster_members = defaultdict(int)
    for row in clustering_result:
        cluster_id = row[cluster_pos]
        cluster_members[cluster_id] += 1

    # サマリ
    print("### Clustering Summary ###")
    # レコード数
    print("#of records {}".format(len(clustering_result)))

    # クラスタ数
    print("#of clusters {}".format(len(cluster_members.keys())))

    # 要素数上位10のクラスタ
    print("### top 10 clusters ###")
    print("rank\tcluster_id\t#of member")
    for indx, v in enumerate(sorted(cluster_members.items(), key=lambda x: x[1], reverse=True)):
        print("{}\t{}\t{}".format(indx+1, v[0], v[1]))
        if indx >= 9:
            break

    # 各クラスタのメンバー数
    print("### #of member in clusters ###")
    for c_id in cluster_members.keys():
        print("{}\t{}".format(c_id, cluster_members[c_id]))


if __name__ == '__main__':
    # inputfile
    base_fname = 'bombay_queries.tsv'
    base_fname_without_ext = base_fname.split('.tsv')[0]
    in_fname = "data/{}.tsv".format(base_fname_without_ext)

    # parameter
    word_len = 100
    threshold = 10

    # outputfile
    out_fname_result = "result/{}_result_wordlen{}_threshold{}.tsv".format(base_fname_without_ext, word_len, threshold)

    queries = load_file(in_fname, skip_header=True)

    clusters, clustering_result = cluster(queries, query_pos=2, word_len=word_len, threshold=threshold)
    write_file(out_fname_result, clustering_result)

    cluster_summary(clustering_result)

4.結果

データセット

"Similarity Metrics for SQL Query Clustering", 2018. で使用されているのと同じデータセットの中の、bombay_queries.csv を用いました。 データセットにはアノテーションされたラベルが付与されています。

id  label   query
0_1_1   1   select course.course_id,course.title from course
0_1_2   1   select course_id from course
0_1_3   1   select distinct course_id,title from course
0_1_4   1   select course_id,title from course
0_2_1   2   select course_id,title from course where dept_name='comp. sci.'
0_2_2   2   select distinct course.course_id,course.title from course where course.dept_name='comp. sci.'
0_2_3   2   select course.course_id,course.title from course where dept_name='comp. sci.'
0_2_4   2   select course_id,title from course where course.dept_name = 'comp. sci.'
0_2_5   2   select course_id,title from course where course.dept_name='comp. sci.'

クラスタリングの結果と所感

クラスタリング結果を以下に示します。 結果の評価には、こちらの説明を参照し、PurityとInverse PurityとF値を使用します。 purity,inverse purity, F値は0から1の間で1に近づくほど良い値です。 purityが0.75とやや大きいですが、これはクラスタのメンバー数1のクラスタが多数あるためです。 参照にした資料でも説明がありますが、1クラスタ1レコードにクラスタリングされた場合purityは1になります。 今回の結果では、218クラスタ中でクラスタのメンバー数が1のクラスタは156個ありました。

結果を定性的に確認してうまくできていそうな箇所を無理やり見つけます。 クラスタ108の結果を見ると意図したとおりにクラスタリングできているように見えます。 108の結果をよく見ると、同じクエリですがスペースの差異があるもの( count(*)>1count(*) > 1 )を同じクラスタにまとめられています。ただ、完全文字列一致でやっても、事前に空白除去しておけば拾える箇所なので、編集距離を使った良さとは強く言えなそうです。

また、今回使用したデータセットはとりあえず、クエリにアノテーションされているものを見つけたので、きちんとした理由もなく使っているのですが、そもそも私がクラスタリングしたい目的とデータセット作成時の目的があっているのかなども確認する必要がありそうです。クエリのクラスタリングがうまくできると、嬉しいものの、社内でアノテーションするメンバーを複数人集めて、ラベル付きデータ作って評価するほどのものでも無いような気もするしなという気持ちもあります。

レコード数とクラスタ

#of records|629
#of clusters|218

クラスタのメンバー数降順10クラスタ

rank cluster_id #of member
1 0 127
2 1 33
3 14 31
4 12 22
5 134 16
6 3 14
7 145 14
8 16 13
9 48 10
10 2 8

purity,inverse purity, F値

purity inverse purity F measure
0.75 0.27 0.40

クラスタ108の結果

cluster_id label query
108 9 select student.id,count(id) from (section join takes using (course_id)) natural join student group by id,name having count(id) = 2
108 9 select id,name from section natural join takes natural join student group by id,semester,year,time_slot_id,name having count(*)>1
108 9 select id,name from section natural join takes natural join student group by id,semester,year,time_slot_id,name having count(*) > 1

5. まとめ

編集距離を使用しクエリのクラスタリングを試みました。 クラスタリング方法や、パラメータ、テストに使用したデータセットなど、全体的にエイヤな部分が多いですが、定性的に結果をみると類似したクエリをなんとなくまとめることができたようにも思います。実業務でクエリのログのクラスタリングに使えるかはかなり怪しいのと、DatadogなどのSaaSでもログのクラスタリングはできるようなので、ローカルであえてスクリプト準備してクラスタリングする機会はないのかなとも感じます。

かなり散漫な記事となってしまったので無理やりまとめますが、新しい仕組みがどんどん出てくるなかで、昔からのやり方と新しいやり方適材適所でうまく扱えるようになっていきたいなと思います。 あと、もっと楽に精度良くやれる方法を教えてくれる人がいれば教えてほしいです(かなり切実)。

6. 参考

株式会社エニグモ 正社員の求人一覧

hrmos.co

RSpec を 6 倍速くしてカオスな CI を正常化した話

サービスエンジニアリング本部の山本です。

この記事は Enigmo Advent Calendar 2019 の 18 日目の記事です。

普段はフロントエンド中心の開発をしていますが、たまに DX(Developer Experience) 的なことにも手を出しています。 今回はそんな DX のお話です。

やばい CI

f:id:hi_yamamoto:20191217182336p:plain

エニグモが運営している BUYMARuby on Rails アプリケーションとして動いており、自動テストフレームワークとして RSpec を採用しています。
CIツールとしては Jenkins を採用していましたが、1 年以上の期間、常に Fail しているというエニグモのようなイケてるウェブ企業としてはあるまじき状態が続いていました。
Jenkins は素晴らしいソフトウェアですが、当時動いていた Jenkins のバージョンは 1 系かつオンプレミスサーバーで動いていたこともあり色々と問題を抱えていました。
また、エニグモでは Git ホスティングサービスとして Gitlab を採用していて、ちょうど社内では Gitlab CI を使っていこうという流れもあったため RSpec も Gitlab CI に移行することにしました。

Jenkins 時代の課題

  • ローカルと CI で実行結果が違う
    • ローカルだと通ってたのに CI だと通らない
  • 実行するだけで 2 時間くらいかかっていた
    • 並列実行したいがサーバーのリソースの問題で難しい
  • ブランチごとに実行出来ないため、マージしてみないと CI 上の結果がわからない
    • ブランチごとに実行しようとするとサーバーのリソース不足になる
    • ローカルですべてのスペックを実行するのは非現実的なため関係ありそうな箇所だけローカルでテストしたらマージしてしまう

# jenkins という slack チャンネルがありましたが、失敗しているのが当たり前になってしまっていたため誰も気にしておらず、失敗通知が虚しく響き渡っていました。(割れ窓理論)

どうやって解決していったか

ローカルと CI で実行結果を同じにしたい!

まず、ローカルと CI での実行結果が違う問題について。
これは単純に Docker を使うことにしました。 BUYMA の開発環境はもともと Vagrant を使っていて Docker 化はされていませんでした。 今回はローカルの開発環境全部を Docker 化するのではなく、一旦テスト実行環境だけを Docker 化して CI 上でも同じイメージを利用する方針としました。

Gitlab CI にはジョブを実行する Gitlab Runner というツールを利用します。 Gitlab Runner の実行方式である Executor は Shell 、 Docker などの複数のなかから選択することができますが、今回は後述の実行時間短縮を実現するために並列で実行することを考えて Docker を選択しました。
Docker 上で Docker を動かす Docker in Docker という方式があり、それ専用のイメージも用意されているのでそちらを採用しました。 詳しくはこちら

各ブランチのMR ( Merge Request )ごとに実行したい!

Jenkins 時代は開発用メインブランチである develop ブランチにマージされた時だけテストが走るようになっていたため、 MR をマージして初めてテスト結果がわかるという状態でした。
この場合マージした後に失敗したことに気づき修正する、という流れになりますが、テスト時間が長いこともあり失敗していることに気づかず放置されてしまいます。
マージする前にテストを実行する、という当たり前のことが出来ていない状態だったのです。

これを解決するための Gitlab CI の設定はかんたんで、 .gitlab-ci.yml にこのように記述するだけです。

only: 
 - merge_requests

問題はリソースです。
Gitlab CI への移行当初、 Gitlab Runner サーバー はオンプレミスのサーバーだったため、複数の MR が同時に作成され RSpec のジョブがトリガーされた場合リソースが足りなくなってしまう懸念がありました。 そこで Gitlab Runner サーバー を AWS へ移行して Gitlab Runner の AutoScaling 機能を利用しました。
AutoScaling については こちらの記事が参考になります。

f:id:hi_yamamoto:20191217153040p:plain
移行前の構成


f:id:hi_yamamoto:20191217153141p:plain
移行後の構成

この構成の設計や移行作業は全て弊社インフラエンジニアである夏目さんによるものです。ありがとうございました!!

実行速度を速くしたい!

さて、ブランチごとに実行する環境は整いました。
しかし全てのテストが完了するまで2時間ほどかかってしまうという最大の問題が解決されていません。
MR を作成してマージするぞ!っと思ってからテストが終わるまで 2 時間は流石に待ってられません。
RSpec を速くする方法としては、テストコード内で無駄にレコードを作らないなど色々プラクティスがあると思いますが、数百ファイルのテストファイルを全て見て修正するというのは現実的ではありませんでした。

そこで RSpec の並列化という方法で解決することを考えました。
元々 CI 上でのテストは他のテストファイルの影響を無くすため、通常の rspec コマンドではなくそれぞれのテストファイルを独立して実行するスクリプトを利用していました。

#!/bin/bash

# 全てのスペックファイルを単独で実行するためのスクリプト。

fail_count=0

spec_array=()
while IFS=  read -r -d $'\0' spec; do
    spec_array+=("$spec")
done < <(find spec/ -name '*_spec.rb' -print0)

spec_count=${#spec_array[@]}
echo "Running $spec_count spec files"

export SKIP_CODE_COVERAGE=1

for (( i=0; i<$spec_count; i++ )); do
  echo -e "\n#########################################################################"
  echo -e "\nRunning spec $((i+1)) of $spec_count"
  if ! bin/rspec "${spec_array[$i]}"; then
    ((fail_count++))
  fi
  export SKIP_DATABASE_SEEDING=1
done

echo -e "\n$spec_count test files, failures in $fail_count files\n"

[[ $fail_count -eq 0 ]]

このスクリプトと Gitlab CI の parallel オプションを利用して並列化を実現しました。

まず、 .gitlab-ci.yml のジョブに parallel フィールドを追加します。 今回は 10 個のジョブを並列で実行することにしました。

parallel: 10

この parallel フィールドを追加すると、 ジョブの環境変数として CI_NODE_TOTALCI_NODE_INDEX という環境変数がセットされます。 CI_NODE_TOTAL には parallel で指定した値、 CI_NODE_INDEX にはそのジョブのインデックスがセットされるので、この 2 つの環境変数から実行するファイルを算出します。 並列化後のスクリプトはこちらです。

#!/bin/bash

# 全てのスペックファイルを単独で実行するためのスクリプト。

fail_count=0

spec_array=()
while IFS=  read -r -d $'\0' spec; do
    spec_array+=("$spec")
done < <(find spec/ -name '*_spec.rb' -print0)

spec_count=${#spec_array[@]}
echo "CI_NODE_TOTAL is $CI_NODE_TOTAL"
echo "CI_NODE_INDEX is $CI_NODE_INDEX"
count=$((spec_count / CI_NODE_TOTAL))
start_index=$((count * (CI_NODE_INDEX - 1)))

if [[ $CI_NODE_INDEX -eq $CI_NODE_TOTAL ]]; then
  count=$((spec_count - start_index))
fi

echo "Running $count spec files"
echo "Start at $start_index"

export SKIP_CODE_COVERAGE=1

for (( i=$start_index; i<$start_index+$count; i++ )); do
  echo -e "\n#########################################################################"
  echo -e "\nRunning spec $((i+1 - start_index))(total: $(($i+1))) of $count(total: $spec_count)"
  if ! bin/rspec  "${spec_array[$i]}"; then
    ((fail_count++))
  fi

 
  export SKIP_DATABASE_SEEDING=1
done

echo -e "\n$count test files, failures in $fail_count files\n"

[[ $fail_count -eq 0 ]]

テストを 10 分割することにより 2 時間かかっていたテストが約 20 分で完了するようになりました!

残念ながらジョブ実行用のスポットインスタンスの起動にある程度時間がかかるため、 10 分割で 10 倍速くなるというわけにはいきませんでした。
また、これ以上並列数を増やしても速くなることもありませんでした。

結果

  • ローカル環境と CI 環境での差異がなくなったため、ローカルで成功したのに CI では落ちることがなくなった
  • MR ごとに実行できるようになったためテストが成功した状態でマージでき、メインブランチは常にクリアな状態が保てるようになった (たまに落ちてることもある)
  • テストが 20 分で終わるようになった

所感

長らく不満に思っていた、常に Fail している CI から脱却することができました。
人によるとは思いますが、個人的には CI が落ちて放置されているというような状態に精神が乱されるタイプなので心の平穏を取り戻すことができました。

上記の作業をする前に落ちまくって放置されているテストを全て直すという作業があったのですが正直そこが一番きつかったです。


エニグモではカオスな開発環境を一緒に正常化していきたいエンジニアを募集中です!

hrmos.co

k9sで快適なk8sライフを送ろう!

はじめに

みなさん、こんにちは。 主にBUYMAの検索周り、時々機械学習なエンジニアの伊藤です。

今年もあっという間の1年でしたね。
振り返ってみると多くのことを学ばせてもらい、また成長させてもらった1年でした。
その中でもk8sがやはり自分の中では中心だった気がします。
なので今回はk8sネタで今年を締めくくりたいと思います。

k9sとは?

k9sとはk8s上のリソースを監視、管理するためのCLIツールです。
k8sCLIといえばkubectlデファクトですが、そのI/Fをよりリッチに使いやすくしたのがこのツールです。

f:id:pma1013:20191210142021p:plain
公式のページはこちら

読み方はけぃないんずだと思います(たぶん)
個人的になぜ犬のロゴなんだろうというのがまず引っかかったので軽く調べたところ、 canine=犬のヌメロニム表記であるk9と、kubernetesk8sが表記的に近いから犬なんだなと勝手に納得することにしました。

使ってみる

では実際にk9sをインストールして使ってみましょう。
LinuxOSXWindows上で動作するようですが、今回はMac上でインストールして動かしてみたいと思います。

  • インストール

    brew install derailed/k9s/k9s

  • 起動

    k9s

    表示される情報については大きく2つの領域に分かれており、上部に現在使用しているkubectlのcontext、クラスタ名や、コマンドヘルプ等が固定表示され、
    枠内にpodの一覧など、指定した情報が表示されるようになっています。
    デフォルトではkubectl get podで取得できる項目相当の一覧が見れますが、CPU、メモリ使用率なども表示されます。

  • 基本操作について
    基本的なところさえ抑えておけば、あとは直感的に操作が可能です。

    • コマンドモード
      :でコマンドモードになります。
      続けて、k8sのリソース名(alias名でも可)を指定することでその一覧が表示されます。
      指定するリソース名を忘れた場合はCtrl-aで確認できます。
      例えば、deploymentの一覧を確認したい場合は:dpと入力してEnterでdeploymentの一覧が表示できます。
      Escでコマンドモードを抜けます。
      f:id:pma1013:20191213105637g:plain

    • フィルター
      /のあとにフィルターを指定することで絞り込みが可能です。
      kubectlと同じうように-l app=XXXXで任意のラベルで絞りこみが可能ですし、
      任意の文字列だけ入力してもその文字列がpod名やnode名に含まれれば絞り込みが可能です。
      Escでフィルターを解除します。
      f:id:pma1013:20191213121414g:plain

    • ソート
      Shiftと特定のキーで項目毎のソートが可能です
      各項目とキーのマッピング?で確認できます。
      昇順/降順の切り替えはShift-iで行います。

実践的な使いどころ

基本操作が分かったところで、よりk9sの便利さが伝わるように下記のような場面を想定した操作をしてみます。

  • あるdeploymentリソースに異常があり、該当のdeployment及びその配下のpodのdescribeを確認して、ログを確認する
    f:id:pma1013:20191213121938g:plain

  • podへのport-forward f:id:pma1013:20191213122419g:plain

所感

kubectlに慣れている人や、aliasやpeco等でコマンド操作自体をゴリゴリにチューニングしている場合は、 k9sの使い始めは慣れない分、「そこまで便利か?」っていう感じを受けるかもしれません。 自分は最終的にどちらかに移行するというよりは、下記のような使い分けをすることで落ちついています。

  • 単発の確認はkubectl
    例えば今のpod一覧を知りたい時など、特定の情報だけ知りたい場合はわざわざk9sを起動する方が手間なので、kubectlだけで済ませています。

  • 解析作業や複数のコンテキストを跨る作業
    こういう場合は圧倒的にk9sが便利です。
    上でも記載していますが、クラスタ側に何か異常があった場合には、様々なリソースの情報を見ていくことになると思います。 そういった場合にはk9s上であれば、 わざわざコマンドを打つことなく少ないキー操作で確認が可能です。
    また、複数のkubectlコンテキスト内のリソースを同時にみるのはkubectlではできませんが、
    k9sでは別ターミナルで起動してコンテキストを切り替えることで、
    ターミナルの切り替えだけで複数コンテキストの操作や参照が可能になります。

最後に

k8sとその周辺エコシステムは進化が激しく、日々新しい機能やツールが出てきている印象です。 キャッチアップが大変ですが、今後も日々の運用やシステムの安定性に繋がるようなものは時間を見つけて試していきたいと思います。

株式会社エニグモ 正社員の求人一覧

hrmos.co

入社して1年目の振り返り

こんにちは、サービスエンジニアリング本部の穴澤です。

今年の1月に入社して丁度1年になります。良いタイミングなので1年を振り返ってみたいと思います。 これから転職をしてみようかなと考えている人、新しい事に挑戦したいと思っている人に読んでもらえると幸いです。

1-3月

年始に入社。前日は眠れませんでした。6年働いた前職から転職し、人間関係はゼロから。使っている技術、サービスも違う。大丈夫かなー3ヶ月後生きてるかな?と思っていました。入社後は環境構築しつつ、その後運用業務を上司から少しずつ分けてもらい日々の業務全体をみるようになりました。エンジニアとしての経験を買ってもらって入社したものの、プログラミング言語での経験は主にPHPだったのでエニグモでのRailsを使った開発に戸惑うところも未だにあります。このタイミングでチームに新しく入ってきた方も居たので受け入れ時に必要な情報やドキュメントを徐々にまとめて自分がサービスの説明や開発フローを説明できるものを準備しました。

この頃はまだ前職の人たちとの飲み会が多く、ホームシック的?な気持ちになったのを覚えています。ああ、転職したんだなあ、という気持ちでした。 EM見習いとして入社したので、入社したその月からメンバーの方と一人ひとり話をする時間を持ち始めました。ただし全然みんながなにやってるのかわからん。という感じでした。

4-6月

「社内での情報共有サービスを移行しよう」という動きがありチームを横断した形でエンジニア数名、関係者含めて小さいチームが発足しました。 私自身が刷新に前向きだったのでやってみようと思い手をあげることになりました。実際には部署をまたいで新ツールの使い方のレクチャや 既存のデータの移行スケジュールの共有、他の部署の人たちへのヒアリングや、もっている課題などを集約しデータ移設などは山本さん におんぶにだっこでした。 このチームのおかげで普段接点のない部署の方々とお話する機会があったかなと思います。名前もだいぶ覚えてきました。

夏場に大きな施策が控えており、メンテナンスが必要になったため自分が担当になりました。過去メンテのドキュメントを探しマニュアルや手順書の整備をはじめました。 メンテにはインフラエンジニアや他のサーバサイドエンジニアの方もいましたが「入社して半年でメンテ要員わたしで大丈夫かな?」と思いながらテスト環境で手順書の確認やわからないことを少しずつ潰していきました。

7-9月

サーバーメンテナンスは深夜に実行する事となり、夜に関係者が出社しメンテナンスを終えたのは朝でした。そのまま朦朧としたまま会社を出、ファストフード店でおいしい朝ビールをいただきました!色々と学びが多い月でした。メンテをしたことでちょっとシステムの全体構成やアーキテクチャ全般が「わかった気」になったのを覚えています。

開発業務で機能をゴリゴリ書いている身ではないので案件の調整や小さい運用、リリースの調整やドキュメントの整備、問い合わせやMTG、ちょっとした相談・・・。こういう事に忙殺されているとたまに「わたしエンジニアって言っていいんだっけ?」と悲観的になることもあるのです。なんだか何もしてないな、EMで入ってきてるけどわたし大丈夫かな。と不安になったこともありました。チームの成果を伸ばしたい、組織に貢献したい。なので技術力そのものだけじゃないところで活躍したい。自分の達成チケット数=成果ではないという事を上司に確認したこともありました。その際、「そういう前提であなたを迎えているのですから(大丈夫ですよ)」と言ってくれたのはいまでも覚えています。メンテも経験したことで、この辺りからはっきりと自分の役割を理解してきました。技術的な学習や実際の開発から完全に手をひいたわけではないけれどそこに自分の価値があるわけではない。というのが1年たった気持ちです。

10-12月

リリースに関わる改善をしたいと日頃思っていたので、夏過ぎからまた有志のチームで細々と情報の整理と改善の策を練るところからはじめています。ちょっと不安ですが自分にできることを探していこうと思います。

また春先から頑張ってきた採用活動が実を結び、新しい人が入社してくれました!人材育成も過去の経験をそこそこに活かしながら、今わたしの目の前にいる人の顔をみて、自分ができる「お手伝いをしていこう」と思った今日このごろです。

最後に

異なる環境に飛び込むことは色々考える事も多いし悩みも多いです。それでも今年楽しかったな、ここはやりきったな。という達成感はあります。 それと、自分がどんどん自社のサービス・支える人を好きになってると感じます。携われる領域やできることが些細なことでも一つふえると嬉しいものです。 環境や周りの人を強制的に変えることで視野が広まったり自分が少しずつ変わる変化を、来年も楽しんでいこうと思います。

株式会社エニグモ 正社員の求人一覧

hrmos.co

第4回 Google Cloud INSIDE Digital に登壇しました。「ショッピングサイトにおける商品画像への Could Vision API の活用」

少しさかのぼりますが、11/1に「ここでしか聞けない AI 、機械学習サービスの活用例」をテーマに開催された第4回 Google Cloud INSIDE Digital に 弊社エンジニアの木村が登壇しました。

f:id:enigmo7:20191212163632j:plain

弊社で運営しておりますショッピングサイトバイマへ出品された商品画像に規約違反となるような不適切なものが含まれる場合があるため、その検出に Cloud Vision API を活用した事例についてお話ししました。

発表資料を公開します。

Vision APIの他、エニグモでは DWHとしての BigQuery をはじめ、それに伴ったデータ収集・分析・活用のインフラとしてGCPAWSを活用しています。それらを活用してデータの収集、整備、機械学習モデルの運用、サービスへの組み込みなど大規模データを扱ってみたいエンジニアを募集しています。

↓↓↓

株式会社エニグモ 正社員の求人一覧

hrmos.co

dry-validation (1.3) で Form Object を実装する

dry-validation (1.3) で Form Object を実装する

こんにちは、エンジニアの齊藤です。 この記事は Enigmo Advent Calendar 2019 の12日目の記事です。

本日は、バリデーションロジックの開発で Form Object の設計を支える dry-validation について書きたいと思います。

Form Object について

ユーザー向けのウェブアプリケーションの実装で必ずといって発生するのが、インプット値のバリデーション処理です。 ウェブフォームだけでなく API のリクエストパラメーターや CSV ファイルのコンテンツ等そのケースは様々です。

Enigmo で開発しているウェブアプリケーションの多くは Ruby on Rails で実装されています。Rails アプリケーションでは、Model にバリデーションを実装するのが一般的ですが、モデルのアトリビュートを逸脱したこのようなケースではそれぞれ専用のクラスを実装する必要があります。

Form Object 自体は有名な Rails の Fat モデルを改善する 7 Patterns to Refactor Fat ActiveRecord Models という記事のなかで紹介された設計テクニックの一つです。

Before dry-validation

今回紹介する dry-validation 以外の実装では先程の 7 Patterns... にも登場する virtusActiveModel::Validations を使って実装しています。 virtus でアトリビュートを定義して、通常のモデル同様にバリデーションルールを実装します。
実行方法も通常の ActiveModel のインスタンスと差異がないので、バリデーション専用のモデルを実装しているのと同じです。

app/forms/product_form.rb

class ProductForm
  include Virtus.model
  include ActiveModel::Model

  attribute :product_name, String
  attribute :reference_number, Integer
  attribute :images, Array[Hash]

  validates :product_name, presence: true
  validates :reference_number, presence: true
end

実行結果

[11] pry(main)> form = ProductForm.new(product_name: '', reference_number: '1000'); form.valid?; form.errors
=> #<ActiveModel::Errors:0x000055839f9bacd0
 @base=
  #<ProductForm:0x000055839f9bb090
   @errors=#<ActiveModel::Errors:0x000055839f9bacd0 ...>,
   @images=[],
   @product_name="",
   @reference_number=1000,
   @validation_context=nil>,
 @details={:product_name=>[{:error=>:blank}]},
 @messages={:product_name=>["を入力してください。"]}>

dry-validation 1.0 released

virtus 自体は少し前に開発が止まっていたので、作者が新たに開発している dry-validation が気になっていたのですが、全く違う設計のライブラリのため導入までにはいたりませんでした。 バージョン 1.0.0 (執筆時 1.3.1) がリリースされたのをきっかけにこちらに乗り換えてみることにしました。

dry-validation の良いところはスキーマを定義する DSL の記述がわかりやすく、ドメインロジックのバリデーションルールと明確に処理を分離できる点だと思います。

ここからは dry-validation を使った Form Object の実装方法について説明していきます。

Configurations

dry-validation 自体の共通の設定はベースクラスを用意して定義します。 エラーメッセージの出力時のロケールの設定、共通ルールのマクロやカスタムデータタイプをここで定義します。
例えば、ここに定義している StrippedString は、ありがちな前後のスペースを取り除くのと、空白であった場合に nil に強制する文字列のハンドリングのために利用するデータタイプです。

class ApplicationContract < Dry::Validation::Contract
  config.messages.default_locale = :ja
  config.messages.backend = :i18n
  config.messages.load_paths = [
    Rails.root.join('config/locales/ja.yml'),
    Rails.root.join('config/locales/en.yml')
  ]

  module Types
    include Dry::Types()

    StrippedString = Types::String.constructor { |str| str.strip.presence }
  end
end

Schemas

Schemas は dry-validation の重要な機能でデータをプリプロセスしてバリデーションするための DSL です。 例として以下のようなパラメーターを Schemas を定義してバリデーションします。
params は HTTP パラメーター向けのスキーマ定義のメソッドです。 スキーマ定義の DSL はほぼ記述そのままなので理解しやすいのではないでしょうか? バリデーションの実行結果からクレンジングされた入力値とエラーがそれぞれ .values.errors で取得できます。

{
  "product_name": " Rustic Paper Gloves",
  "reference_number": "59142",
  "images": [
    { "url": "http://ruelarmstrong.com/howard", "caption": "" },
    { "url": "boyer.name/rhett_wunsch", "caption": "" }
  ],
  "description": "                 "
}

app/contracts/product_contract.rb

class ProductContract < ApplicationContract
  params do
    required(:product_name).filled(Types::StrippedString, max_size?: 200)
    required(:reference_number).filled(:integer)
    optional(:images).value(:array, min_size?: 1).array(:hash) do
      required(:url).filled(:string, format?: URI::DEFAULT_PARSER.make_regexp)
      optional(:caption).maybe(Types::StrippedString, max_size?: 500)
    end
    optional(:description).maybe(Types::StrippedString, max_size?: 1000)
  end
end

実行結果

[7] pry(main)> result = ProductContract.new.call(product_name: "  Rustic Paper Gloves        ", images: [{ url: 'http://ruelarmstrong.com/howard', caption: '' }, { url: 'boyer.name/rhett_wunsch', caption: '' }], reference_number: '59142', description: '          ')

=> #<Dry::Validation::Result{:product_name=>"Rustic Paper Gloves", :reference_number=>59142, :images=>[{:url=>"http://ruelarmstrong.com/howard"}, {:url=>"boyer.name/rhett_wunsch"}], :description=>nil} errors={:images=>{1=>{:url=>["は不 正な値です。"]}}}>
[8] pry(main)> ap result.values.to_h
{
        :product_name => "Rustic Paper Gloves",
    :reference_number => 59142,
              :images => [
        [0] {
                :url => "http://ruelarmstrong.com/howard",
            :caption => nil
        },
        [1] {
                :url => "boyer.name/rhett_wunsch",
            :caption => nil
        }
    ],
         :description => nil
}
=> nil
[9] pry(main)> ap result.errors
{
    :images => {
        1 => {
            :url => [
                [0] "は不正な値です。"
            ]
        }
    }
}
=> nil

Rules

スキーマ定義をクリアしたデータをさらに Rules を使ってドメインロジックのバリデーションを実行できます。ここで扱うデータはスキーマ定義をクリアしているのでロジック自体の実装に集中することが可能です。

ここでは例として単純な reference_number が存在するかをデータベースに問い合わせるドメインロジックと、各 imagesの URL の妥当性を判定するルールを追加しました。

class ProductContract < ApplicationContract
  ...

  rule(:reference_number) do
    next if Product.exists?(reference_number: value)

    key.failure(:invalid)
  end

  rule(:images).each do
    next if ImageChecker.call(value[:url])

    key(key_name << :url).failure(:invalid)
  end
end

実行結果

[88] pry(main)> result = ProductContract.new.call(product_name: "  Rustic Paper Gloves        ", images: [{ url: 'http://ruelarmstrong.com/howard', caption: '' }, { url: 'http://boyer.name/rhett_wunsch', caption: '' }], reference_number: '59143', description: '          '); ap result.errors
  Product Exists? (1.2ms)  SELECT 1 AS one FROM "products" WHERE "products"."reference_number" = $1 LIMIT $2  [["reference_number", "59143"], ["LIMIT", 1]]
{
    :reference_number => [
        [0] "は不正な値です。"
    ],
              :images => {
        0 => {
            :url => [
                [0] "は不正な値です。"
            ]
        },
        1 => {
            :url => [
                [0] "は不正な値です。"
            ]
        }
    }
}
=> nil

Macros

例えば、登録フォームとログインフォーム、ログイン API で ID であるメールアドレスのフォーマットを判定するロジックを共通化したい場合 Macros が利用できます。 マクロのブロックにはパラメーターを使うことも可能です。

app/contracts/application_contract.rb

class ApplicationContract < Dry::Validation::Contract
  ...

  register_macro(:email_format) do
    unless URI::MailTo::EMAIL_REGEXP.match?(value)
      key.failure(:invalid_email_format)
    end
  end
end

app/contracts/login_contract.rb

class LoginContract < ApplicationContract
  params do
    required(:email).filled(Types::StrippedString)
    required(:password).filled(Types::StrippedString)
  end

  rule(:email).validate(:email_format)
end

app/contracts/registration_contract.rb

class RegistrationContract < ApplicationContract
  params do
    required(:email).filled(Types::StrippedString)
    required(:first_name).filled(Types::StrippedString)
    required(:last_name).filled(Types::StrippedString)
  end

  rule(:email).validate(:email_format)
end

実行結果

[15] pry(main)> result = RegistrationContract.new.call(email: 'foo'); ap result.errors
{
    :first_name => [
        [0] "を入力してください。"
    ],
     :last_name => [
        [0] "を入力してください。"
    ],
         :email => [
        [0] "は不正なメールアドレスです。"
    ]
}
=> nil
[16] pry(main)> result = LoginContract.new.call(email: 'bar', passsword: nil); ap result.errors
{
    :password => [
        [0] "を入力してください。"
    ],
       :email => [
        [0] "は不正なメールアドレスです。"
    ]
}
=> nil

Rspec

ユニットテストでは、シンプルにバリデーションメッセージの期待値をテストしています。

spec/contracts/product_contract_spec.rb

describe ProductContract do
  subject { result.errors.to_h }

  let(:contract) { described_class.new }
  let(:result) { contract.call(params) }

  describe 'params' do
    describe 'product_name' do
      subject { super()[:product_name] }

      describe '.filled?' do
        context 'filled with valid name' do
          let(:params) { { product_name: Faker::Commerce.product_name } }
          it { is_expected.to eq(nil) }
        end

        context 'not filled' do
          context 'without key' do
            let(:params) { {} }
            it { is_expected.to eq(['を入力してください。']) }
          end

          context 'nil' do
            let(:params) { { product_name: nil } }
            it { is_expected.to eq(['を入力してください。']) }
          end
        end
      end
    end
  end
end

実行結果

ProductContract
  params
    product_name
      .filled?
        filled with valid name
          is expected to eq nil
        not filled
          without key
            is expected to eq ["を入力してください。"]
          nil
            is expected to eq ["を入力してください。"]

Finished in 0.57222 seconds (files took 9.7 seconds to load)
3 examples, 0 failures

Conclusion

最近の開発で以前 virtus ベースで実装したかなり大規模な入力値のバリデーションを dry-validation を使って実装したのですが、コントラクト自体のテストも実装しやすく柔軟に構成できるため、今後も利用していきたいと思います。 ここでは紹介しきれない機能がまだまだありますし dry-rb 自体興味深いプロジェクトなので、ぜひドキュメンテーションソースを参照していただければと思います。


参考

株式会社エニグモ 正社員の求人一覧

hrmos.co