LCL Engineers' Blog

夜行バス比較なび(高速バス比較)・格安移動・バスとりっぷを運営する LCLの開発者ブログ

LCLはITエンジニアが「成長できる」環境か

モバイルアプリエンジニアの山下です。
去年の8月に入社してから早くも半年が経とうとしています。

techblog.lclco.com

先日、開発チームで ITエンジニア採用に欠かせない原則とは の記事が話題に上がりました。
そこで、LCLはITエンジニアが「成長できる」環境なのかこの半年間と共に振り返ってみました。

(下記は記事中の『ITエンジニアが「成長できる」環境と「成長できない」環境』の項目を挙げています。)

触るシステムの種類

LCLでは現時点で大きく分けて3つのサービスを運営しており、各メンバーがメイン分野を軸にサービスを横断して着手しています。そのため、触るシステムの種類は絞られていません。

私の場合はモバイルアプリエンジニアとしてiOS, Androidの開発している傍ら、APIやPUSH通知などを扱う管理画面などの開発も進めています。

テーマの変化

カスタマー向けの自社サービスを運営している会社なので同じ課題が発生することは少なく、次々と新しい機能追加・改善が求められる環境です。

どのサービスも成長段階のため、サービスの成長に合わせてアーキテクチャ見直し、インフラ強化、パフォーマンス・安定性の向上などが必要です。

また、チーム全体で開発手順の効率化や業務改善に取り組んでいるため、目先のタスクのみを消化するのではなく、先の運用コストをみて改善タスクを優先したりと優劣も相談しあって進めています。

扱う技術の種類

各メンバーは自分がメインとする分野を軸に関連する技術に対して制限なく扱っています

私の場合は、前述の通りモバイルアプリとバックエンドの開発を日々取り組んでいるため、それらに関連する技術とCI周りや業務改善に伴うHubotやChrome拡張機能開発を扱っています。

これらは個人のモチベーション次第でメイン分野を超えて誰でも着手できる環境にあります。

扱う技術の新しさ

新しく運用を始めるシステムは暫定の新しいバージョンで始め、現行のサービスは段階を経て追いつくようにしています。また、言語に限らずOSSや新しいサービスも良いものはまず試して採用を検討できる環境です。

モバイルアプリでは、iOSはSwiftの最新のバージョンを追っており、AndroidもKotlinへ移行を始めています。

扱う技術の汎用性

基本的に技術や設計はその分野のデファクトスタンダードを採用しています。

仕事の範囲・大きさ

LCLでは各サービスにエンジニアとは別にPOが就いており、POとエンジニアが相談して仕様やスケジュールを決定する仕組みができています。そのため、エンジニアは裁量を持ちつつ開発に専念できます。

アーキテクチャの選定・設計などはエンジニアチーム全員で話し合い、立場関係なく質疑を出しベストな選択ができるようにしています。

エンジニア全員がリリースまで担当でき、常に全体が見れる環境で業務が行なえます。

仕事の難易度

タスクによっては見積もりよりも工数が掛かることがありますが、どうしようもなく難しいということはありません。
行き詰った際はエンジニアチームのチャットで相談したり、別の解決方法を試したり柔軟に進めることができます。

仲間

プルリクのレビュー、設計の議論など、チームとして技術力向上の機会が多くあります。 チャット文化なこともあり業務中に口頭で喋る機会は少ないですが、チャット上でも技術のことから世間話まで日々楽しくコミュニケーションがとれています。

社外活動

勉強会の参加を推奨しており、業務時間として参加することも可能です。
参加した際は内容を共有して業務に取り込んだりしています。

最近の例ではこれにより週1でモブプロを行う文化が生まれました。

インターネット

個人ブログやSNSなどの制限はありません。業務で使うアカウント(例えば GitHub)についても個人アカウントを利用することができます。

技術的な業務の割合

社内に事務や営業部署などの仕組みができているため、エンジニアがメールを作成したりExcel業務をすることは一切ありません。エンジニアは開発に専念することができます。

私自身この半年で1通もメールを作成していません。

おわりに

LCLは分野や裁量に縛られず業務したいエンジニアにとっては成長ができる環境だと思っています。
とはいえ、色々と手を付けすぎてメイン分野の工数が減ってしまっているという点も気をつけなければいけないです。

エンジニア募集しています

LCLではエンジニア積極的に募集中です。
興味のある方はお気軽にご連絡よろしくお願いいたします。
インターンも募集しています。

https://www.lclco.com/recruit/

AWS WAFの概要まとめ

年末年始にかけて、AWS関連の最新情報を整理していたところ、AWS WAFがかなり使いやすいレベルにアップデートされていました。 セキュリティ関連のサービスは、日頃はそれほど最新の情報を追えていないので、これを機会に簡単にまとめたいと思います。

AWS WAFとは

AWS WAF は、CloudFrontまたは、ALBへのHTTP/HTTPSリクエストをモニタリングできるWeb Application Firewallです。 詳細については、各所で記事がありますので割愛しますが、「従量課金で低コスト」「専用のインスタンス不要」「マネージドルール」という点が、他のWAF製品との違いになると思います。

概念

まず、AWS WAFの概念について整理します。基本的には、一般的なACLの考え方と同じです。

Condition

Conditionは、AWS WAFがモニタリングするリクエストの条件を定義します。

例として以下のような条件を定義できます。

  • IPアドレス
  • リクエスト元の地域
  • User-Agent等のHTTP Header
  • URI

Conditionでは、あくまでもモニタリングする条件のみを定義し、この条件に対する振る舞いは定義しません。

Rule

Ruleは、Conditionを組み合わせて、アクションを発動するための条件を定義します。(アクションについては、後述します)

例えば、下記を一つのRuleとして定義し、このRuleに対しての振る舞いを設定できます。

  • リクエスト元のIPアドレスが 192.xx.xx.xxでない
  • リクエスト元の User-Agent ヘッダーに XxxBot が含まれる
  • 特定の地域からのリクエストである

また、レートベースのRuleも定義可能です。レートベースのRuleでは通常のRuleに加え、リクエスト数がレートリミット(最小値2,000 / 5分)を超えた場合にアクションを発動します。

Web ACL

Web ACLは、各Ruleのアクションとデフォルトアクションを定義します。

アクションの種類

アクションには、3種類あります。

  • Allow
    • 条件に一致するリクエストを許可します。
  • Block
    • 条件に一致するリクエストを拒否します。( ステータスコード403を返します)
  • Count
    • 条件に一致するリクエストをカウントします。主にACLのテストに利用するようです。

各Ruleのアクション

各Ruleに対してアクションを定義します。Ruleに定義したすべての条件に一致した場合の振る舞いを定義します。

デフォルトアクション

デフォルトアクションは、Web ACLのすべてのRuleの条件に一致しないリクエストの振る舞いを定義します。

例えば、「特定のIPアドレスのリクエストのみ許可」という場合には、

  1. 特定のIPアドレスのRuleを定義し、アクションはAllow
  2. デフォルトアクションはBlock

という定義をします。

以上で、AWS WAFの基本的な概念について説明しました。

マネージドルール

マネージドルールは、AWS re:Invent 2017 で発表された新機能です。

AWS WAFではルールを柔軟に定義できるものの、適切なルールをメンテナンスし続けるには運用負荷が高いことがネックでした。

マネージドルールでは、セキュリティベンダーが事前設定済みのルールを利用することができます。 さらに、ルールはセキュリティベンダーによって自動的に更新されるため、ルールの運用から開放されます。

マネージドルールの料金は、利用するルール毎に異なりますが、現在見る限りでは、月額 数ドルから数十ドルと比較的低コストで導入できます。

ALBへ設定例

AWS ConsoleからALBに対しての設定も紹介します。例として、「特定のIPからのリクエストは拒否する」というルールを定義します。

Web ACLの基本情報を入力

ACLの名前と、対象とするALBを設定します。対象とするALBは、ACL作成後も変更できます。

f:id:lcl-engineer:20180103012730p:plain

Conditionの作成

拒否対象のIPアドレスを定義します。

f:id:lcl-engineer:20180103012740p:plain

Ruleの作成

作成したConditionを元に、Ruleを定義します。ここでは、「IPアドレスが一致」を条件としています。

f:id:lcl-engineer:20180103013327p:plain

ACLにRuleを追加

ACLにRuleを追加します。上記で作成したRuleに対しては、ActionをBlockと設定します。その他、デフォルトのActionはAllowを設定します。

f:id:lcl-engineer:20180103013546p:plain

以上で完了です。このように簡単な条件なら、すぐに設定できます。

Sampled requestsの確認

設定がうまくいっているかどうかは、Web ACLの画面の「Sampled requests」から確認ができます。

Block条件に該当するリクエストが発生していた場合は、リクエストの情報・該当するRuleが表示されます。

f:id:lcl-engineer:20180103013814p:plain

まとめ

AWS WAFの最初のリリース時は、ALBには対応しておらずCloud Frontのみの利用可能でした。その後、ALB対応・レートベースのRule・マネージドルールなど、ユーザが本当に必要な機能を着実に追加しているのは、さすがAWSです。

特に、マネージドルールの登場で、セキュリティ専任者が不在のチームにもAWS WAFの導入の敷居が大幅に下がったと思います。今後もマネージドルールの拡充などが予定されているので、アップデート情報は随時追っていきたいと思います。

Redashで1つのカラムに保存されたJSONデータを取り扱う

Redashでデータを加工する際に、1つのカラムに含まれるJSONの中身を取り扱う必要がありました。しかし、単純にクエリを叩くだけではJSONの中身を参照できません。

そこで、Redashの機能の一つであるPython Data Sourceを利用してJSONを解析し、各データを1つのカラムで取得・加工できるようにしてみました。

準備

Python Scriptを有効化するために/opt/redash/.envを修正

export REDASH_ADDITIONAL_QUERY_RUNNERS=redash.query_runner.python

設定を反映

$ sudo supervisorctl restart all

Data Sourceを追加

f:id:lcl-engineer:20171231005613p:plain

以上でPython Data Sourceを選択できるようになります。

使い方

Python Data Sourceでは以下の記述でデータの取得・表示を行います。

データの取得

Redashで既に定義済みのクエリを利用する場合

例: https://localhost/queries/123

get_query_result(123)

直接クエリを叩く場合

query='select * from queries'
execute_query('<DATA_SOURCE_NAME>', query)

行の定義

add_result_row(result, {
  '<COLUMN_NAME_1>': '値', 
  '<COLUMN_NAME_2>': '値'
})

列の定義

add_result_column(result, 'COLUMN_NAME_1', '', 'string')
add_result_column(result, 'COLUMN_NAME_2', '', 'integer')

JSONの解析

それでは、本題のJSONの解析をします。

今回は例として予約時の日付とブラウザの種類を取得します。

元データ

ID ACTION DETAIL
1 book {"date": "20180124", "browser": "safari"}
2 book {"date": "20180201", "browser": "webview"}

クエリ

Data Sourceに「Python」を選択します。

f:id:lcl-engineer:20171231005601p:plain

下記のように記述にして実行します。

import json

# 既に定義済みのクエリの結果を参照
q_res = get_query_result(123)

result = {}
for row in q_res["rows"]:
    detail = json.loads(row["detail"])
    
    add_result_row(result, {
        "id": row["id"],
        "action": row["action"],
        "date": detail["date"],
        "browser": detail["browser"]
    })

add_result_column(result, 'id', '', 'integer')
add_result_column(result, 'action', '', 'string')
add_result_column(result, 'date', '', 'string')
add_result_column(result, 'browser', '', 'string')

結果

ID ACTION DATE BROWSER
1 book 20180124 safari
2 book 20180201 webview

(おまけ)割合を計算

上記ではJSONを解析しましたが、Pythonを書いてデータの加工も可能です。

from collections import Counter

q_res = get_query_result(123)

result = {}
browserArr = []

for row in q_res["rows"]:
    browserArr.append(row["browser"])

counter = Counter(browserArr)
for key, value in counter.most_common():
    add_result_row(result, {
        "browser": key,
        "total": value,
        "割合": str(float(value) / len(browserArr) * 100) + "%"
    })

add_result_column(result, 'browser', '', 'string')
add_result_column(result, 'total', '', 'integer')
add_result_column(result, '割合', '', 'string')

結果

BROWSER TOTAL 割合
webview 1 50.0%
safari 1 50.0%

最後に

本来であれば事前に加工し別のDBやテーブルに保存しておくのが理想ですが、急ぎの対応であったり基盤を作る前であればこの方法で補えると思います。

エンジニアを募集しています

LCLではエンジニア積極的に募集中です。
興味のある方はお気軽にご連絡よろしくお願いいたします。
インターンも募集しています。

https://www.lclco.com/recruit/

コードレビューの機械的な指摘はDangerに任せる

先日のコードレビューの機械的な指摘はSideCIに任せるに続き、 今回は同様のことが可能であるOSSのDangerについて紹介します。

Danger とは

Dangerとは、Pull Requestのレビュー時に発生しやすい、 ”You Forgot To...(...するのを忘れてませんか?)"という指摘を自動化するツールです。

danger.systems

事前に指摘する内容をコードで記述することで、CI上でPull Requestを解析して自動でコメントしてくれます。

課題

Pull Requestをレビューする中で、稀に以下の指摘が発生することがありました。

  • PRの本文に説明が無い、又は不足している
  • UIの変更があるのにスクリーンショットが貼られていない
  • 変更するべきではないファイルが変更されている
  • 1つのPRに対して変更が多い
  • (WIP中のPRに)WIPラベルが貼ってない

さらに弊社ではIssue管理をYouTrackで行っており、PRのタイトルとコミットメッセージの先頭には該当するIssue番号を付けるというルールがあります。

これらの指摘事項が発生した際にレビュアーが都度コメントするのは労力が掛かりますし、コードとは関係ない手戻り作業が発生してしまう可能性もあります。

そこでこれらのチェックを自動で機械的に即時に行うため、Dangerを導入しました。

効果

事前に記述したチェック項目に該当する場合は、下記のようなコメントが表示されます。

f:id:lcl-engineer:20171229211046p:plain

これはPRの作成、又はPUSHした際にCI上でDangerが実行され即時に反映されます。

そのため、PR作成時やPUSHした時点で誤りを把握できるので、レビュアーがチェックしてから修正が発生するという手戻りが減りました。また機会的に指摘されることで、小言を言う/言われる心理的負担も減りました。

それでは、導入手順をローカル環境とCI環境に分けて紹介します。

ローカル環境

導入方法

CI上でも同じ環境を作るためにBundlerでGemの管理をします。

Gemfileを生成

$ bundle init

Gemfileを編集

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "danger" // ← 追加

Dangerfileを生成

$ bundle install
$ bundle exec danger init

Dangerfileを更新

warn "変更箇所が200行を超えています。可能であれば分割しましょう。" if git.lines_of_code > 200

上記のコードで、変更箇所が200行を超えていた場合に警告を出すことができます。

次に動作確認を行います。

事前準備

Dangerを動作させるには、PRにアクセスするためのAPI トークンが必要です。

今回はGitHubを例に進めます。

まず、リポジトリにアクセス可能なアカウントのAPI Tokenを取得します。

https://github.com/settings/tokens

個人のアカウントで十分ですが、bot専用アカウントを作ると機械的に指摘されたコメントということが区別できてわかりやすいです。

Webhockの設定でPull Requsetにもチェックが入っているか確認

f:id:lcl-engineer:20171229211106p:plain

動作確認

以下のコマンドでチェックしたいPRを指定して実行します。 これらのコマンドで実行した内容はPRへ反映されません。

$ DANGER_GITHUB_API_TOKEN={API_TOKEN} bundle exec danger pr {PR_URL} 

既にマージ済のブランチに対してはdanger localコマンドを使うことで動作が確認できます。

$ DANGER_GITHUB_API_TOKEN={API_TOKEN} bundle exec danger local --use-merged-pr=[#id]

Dangerfileのコードを変更した際にキャッシュが効いて反映されない場合があります。 その際は--clear-http-cache オプションでキャッシュをクリアすることが可能です。

CI環境

弊社ではモバイルアプリはBitriseを、その他のリポジトリではCircle CIを使用しています。

Bitriseの設定

  1. API Tokenを登録

f:id:lcl-engineer:20171229211125p:plain

  1. Workflowを追加

f:id:lcl-engineer:20171229211150p:plain

  1. Triggersを追加

f:id:lcl-engineer:20171229211159p:plain

以上でBitriseの設定は完了です。 指定したブランチに対してPRが作成された際にDangerが実行されます。

CircleCIの設定

GitHubと連携することで、Github側の設定は不要になります。

デフォルトでは全てのコミットでビルドが走ってしまうため、プロジェクトの設定でプルリクのみ走るように変更します。

f:id:lcl-engineer:20171229211601p:plain

プロジェクトのリポジトリにcircle.ymlを配置します。

下記のサンプルはCircle CI v1.0の書き方です。

machine:
  timezone:
    Asia/Tokyo
dependencies:
  cache_directories:
    - "vendor/bundle"
  override:
    - bundle -j4 --path=vendor/bundle
test:
  override:
    - bundle exec danger

実装例

弊社で利用している実装例を一部紹介します。

プロジェクト共通のチェック項目

# 対象コード以外についてのメッセージは除く
github.dismiss_out_of_range_messages

# ===== Title =====

# PRのタイトルにYouTrackのIssue番号が含まれているか(文字列-数字の塊)
match = github.pr_title.match /\w+-\d+/
if match
  # YouTrackへのリンクを貼る
  markdown "## YouTrack Ticket", "<a href='https://XXX.myjetbrains.com/youtrack/issue/#{match[0]}'>#{github.pr_title}</a>"
elsif !github.branch_for_head.match(/release\/\d.+/)
  warn "PRのタイトルはYouTrackのIssue番号から始めてください。"
end

if github.pr_title.split(' ').size < 2
  fail "PRのタイトルは、「Issue番号 Issueタイトル」形式にしてください。"
end

# ===== Description =====

# 本文が1行以上書かれているか(10行以下の軽微な変更は考慮)
warn "作業内容について本文に1行以上の説明を記載してください。" if github.pr_body.length < 1 && git.lines_of_code > 10


# ===== Commit message =====


# コミットメッセージにYouTrackのIssue番号が含まれているか
has_youtrack_issue_commit = git.commits.any? { |c| c.message =~ /\w+-\d+/ }
warn "YouTrackのIssue番号から始まっていないコミットメッセージがあります。" unless has_youtrack_issue_commit


# ===== Label =====

labels = github.pr_labels

# ラベルが付いていなかったら「wip」ラベルを付ける
add_label "wip", 'fbca04' if labels.empty?

Webのチェック項目

# ===== Code =====

warn "変更箇所が200行を超えています。可能であれば分割しましょう。" if git.lines_of_code > 200

if git.modified_files.include? "lib/XXX-module"
    warn 'sub moduleが変更されています。意図した更新か確認してください。'
end

 if git.modified_files.include? "app/views/pc/*"
    warn 'PC ERBが変更されています。タブレットに影響がないか確認してください。'
end

iOSのチェック項目

# SwiftLintのチェック
swiftlint.lint_files inline_mode: true
swiftlint.config_file = '.swiftlint.yml'

protected_files = ["Podfile", "Cartfile", "Dangerfile", "Gemfile", ".gitignore", ".swiftlint.yml"]
protected_files.each do |file|
  next if git.modified_files.grep(/#{file}/).empty?
  message("#{file}に変更が加えられています。")
end


if github.branch_for_head.start_with?("release")
# Deliverfileの変更されているか
warn "リリースノートを変更してください <a href='https://github.com/XXX/blob/develop/fastlane/Deliverfile'>Deliverfile</a>", sticky: true unless git.modified_files.include?("fastlane/Deliverfile")

  
view_extensions = [".xib", ".storyboard", "View.swift"]
has_view_changes = git.modified_files.any? { |file| view_extensions.any? { |ext| file.end_with? ext }}
pr_has_screenshot = github.pr_body =~ /https?:\/\/\S*\.(png|jpg|jpeg|gif){1}/
warn("見た目に変更がある場合は、スクリーンショットを添付してください。") if has_view_changes and !pr_has_screenshot

Swiftlintを使う場合は、Gemfileにdanger-swiftlintを追加します

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "danger"
gem "danger-swiftlint" // ← 追加

※ 一部、以下で紹介されているコードを参照してます

WEB+DB PRESS Vol.99:|gihyo.jp … 技術評論社

(おまけ)別のリポジトリにあるDangerfileを参照

各リポジトリでDangerfileを用意してチェック項目を記述していますが、組織独自のルールなど共通する処理は専用リポジトリで管理して参照するようにしています。

例えば、danger-lclというリポジトリを用意して共通のチェック項目を記述したDangerfileを管理します。 そして別のリポジトリのDangerfileで下記を追加することで、そのリポジトリのDangerfileの内容を読み込むことができます。

# danger-lclを読み込み
danger.import_dangerfile(github: "XXX/danger-lcl")

エンジニアを募集しています

LCLでは業務改善に意欲的なエンジニア積極的に募集中です。
興味のある方はお気軽にご連絡よろしくお願いいたします。
インターンも募集しています。

https://www.lclco.com/recruit/

AMPページにsticky広告(固定追尾広告)を表示する

フロントエンドエンジニアの岡田です。 先日、AMPページにsticky広告を追加しました。 その際にいくつかつまづいた点があったのでまとめます。

弊社が運営するバスとりっぷでは、AMPページを用意しています。 f:id:lcl-engineer:20171229094824p:plain

すでに通常の広告は表示していたのですが、今回は追加で、sticky広告(固定追尾広告)を追加しました。
f:id:lcl-engineer:20171229094841p:plain

AMPでのsticky広告表示方法

結論から書きますと、sticky広告を表示するには専用のタグ<amp-sticky-ad>を使います。 www.ampproject.org

headerでjsを読み込みます。

<script async custom-element="amp-sticky-ad" src="https://cdn.ampproject.org/v0/amp-sticky-ad-1.0.js"></script>

body内に以下のようなタグを記載します。

<amp-sticky-ad layout="nodisplay">
  <amp-ad width="320"
      height="50"
      type="doubleclick"
      data-slot="/35096353/amptesting/formats/sticky">
  </amp-ad>
</amp-sticky-ad>

layout="nodisplay" は必須です。

AMPでの通常の広告表示方法

stickyでない広告の場合は、<amp-ad>タグを使います。 www.ampproject.org

headerでjsを読み込みます。

<script async custom-element="amp-ad" src="https://cdn.ampproject.org/v0/amp-ad-0.1.js"></script>

body内に以下のようなタグを記載します。

  <amp-ad width="320" height="50"
          type="doubleclick"
          data-slot="/4119129/mobile_ad_banner">
  </amp-ad>

※ doubleclickの場合の例です。詳細はこちら

つまづいた点

最初は、sticky広告を<amp-ad>タグを使い、sticky部分はCSSのposition: fixed;で実装してリリースしてみました。しかし、これでは広告が表示されませんでした。 AMPのCSSのルールでも特に規定はなかったのですが、ダメなようです。 www.ampproject.org

validatorを使ってもエラーが出なかったので注意が必要です。

また、<amp-sticky-ad>はおかしな動きをすることがありました。 広告がヘッダーに表示されてしまうことがあります。
f:id:lcl-engineer:20171229094917p:plain

こちらは、google経由のURLでアクセスすると再現しなかったので、特に対応はしていません。 https://www.google.co.jp/amp/s/www.bushikaku.net/article/amp/41024/

以上です。

iOSアプリ開発で動作確認をする際にあると捗るTips

この記事はiOS2 Advent Calendar 2017の9日目の記事です。

開発中やリリース前に、手動での動作確認を行うことは多いと思います。

弊社では、リリースの前にテスト配布を行い、他のエンジニアメンバーや 担当のデザイナー、プロジェクトマネージャーに動作確認をしてもらう体制を取っています。

そしてその際に、”特定の条件で発生する状態”の確認が必要なことは稀に存在します。

例えば、以下のような場面です。

  • インストールから○回起動でダイアログを表示をチェックしたい
  • とある設定データの値が存在しない状態をチェックしたい

これらのパターンはアプリを再インストールしてもらうことで実現が可能ですが、 再インストールに多少の時間がかかってしまうのが勿体無いです。

また、KeyChainなどで特定のデータを取り扱っている場合は アプリをアンインストールしても、データがDB側に存在し DBから個別に削除する必要があるといったパターンもあります。

その際に、バージョンで削除用の処理を埋め込んだり、 複数台ある端末の特定のIDを個別に、手動で対応するのは現実的ではないですし 時間やコミュニケーションコストが掛かってしまいます。

例え開発者だけだったとしても、都度データを削除するのは面倒ですよね

そこで弊社ではテスト配布版のみ「秘密のアクション」という社内向けの機能を実装しています。

今回は実際のコードと共に機能の紹介をしたいと思います。

テスト版でのみ表示する画面を用意

まず、追加する機能へアクセスするための機能一覧画面が必要です。
Other Swift Flagsを設定し、テスト環境でのみ表示するようにすると良いです。

弊社ではDEBUGとTEST環境でのみ、タイトルロゴを長押しで表示するようにしています。

class HomeViewController: UIViewController {
    override func viewDidLoad() {
        #if DEBUG || TEST
            let titleViewGesture = UILongPressGestureRecognizer(target: self, action: #selector(HomeViewController.longTapTitleView))
            titleViewGesture.minimumPressDuration = 0.5
            logoImageview.addGestureRecognizer(titleViewGesture)
            logoImageview.isUserInteractionEnabled = true
        #endif
    }
...

    @objc private func longTapTitleView() {
        let nextViewController = InhouseViewController()
        let nextNavigationController = navigationController(rootViewController: nextViewController)
        present(nextNavigationController, animated: true, completion: nil)
    }
}

以下のようなシンプルなテーブルビューを用意しています。

f:id:lcl-engineer:20171209234554p:plain:w300

データ削除/追加 機能

前述の通り、特定の状態でのみ動作する機能を確認するために 関連するデータを削除する必要があります。

上のスクリーンショットにもありますが、4つの項目にリセット機能を設けています。

これらの項目はEnumで管理しています。

import Foundation

enum InhouseActionType {
    case none
    case reset(ResetActionType)
    case .log

    enum ResetActionType {
        case activityLog, readNotification
        case resentSearchCondition, savedSearchCondition
    }

    var cases: [InhouseActionType] {
        return [.reset(.activityLog), .reset(.readNotification), .reset(.resentSearchCondition), .reset(.savedSearchCondition), .log]
    }

    var displayName: String {
        switch self {
        case .reset(.activityLog): return "アクティビティログ"
        case .reset(.readNotification): return "検索履歴"
        case .reset(.resentSearchCondition): return "お知らせの開封履歴"
        case .reset(.savedSearchCondition): return "条件保存"
        case .log:  return "ログ"
        default: return ""
        }
    }

    var cellText: String {
        switch self {
        case .reset: return "\(self.displayName)をリセット"
        case .log: return "(self.displayName)の一覧を表示"
        default: return ""
        }
    }

    func run() {
        switch self {
        case .reset(.activityLog): /* resetFunction() */
        case .reset(.readNotification): /* resetFunction() */
        case .reset(.resentSearchCondition): /* resetFunction() */
        case .reset(.savedSearchCondition): /* resetFunction() */
        default: break
        }
    }
}

社内向けの機能であり、UIに装飾は必要ないので
機能一覧画面ではInhouseActionType取り扱うだけです。

import UIKit

final class InhouseViewController: UIViewController {

    private var inhouseActionType: InhouseActionType = .none

    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "InhouseTableViewCell")
        tableView.alwaysBounceVertical = false
        tableView.rowHeight = 44
        return tableView
    }()

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


// MARK: - Private Methods
extension InhouseViewController {
    private func setupUI() {
        navigationItem.title = "秘密のアクション"
        navigationItem.backBarButtonItem = UIBarButtonItem(title: "戻る", style: .plain, target: self, action: nil)

        tableView.dataSource = self
        tableView.delegate = self
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: bottomLayoutGuide.topAnchor).isActive = true
    }

    private func displayAlertView(inhouseAction: InhouseActionType) {
        let alertController = UIAlertController(title: "秘密のアクション", message: "\(inhouseAction.cellText)しますか?", preferredStyle: .alert)
        let cancelAction = UIAlertAction(title: "いいえ", style: .default, handler: nil)
        let successAction = UIAlertAction(title: "はい", style: .default) { _ in
            inhouseAction.run()
        }
        alertController.addAction(cancelAction)
        alertController.addAction(successAction)
        present(alertController, animated: true, completion: nil)
    }
}

// MARK: - UITableViewDataSource
extension InhouseViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return inhouseActionType.cases.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "InhouseTableViewCell") else {
            fatalError("InhouseTableViewCell is not found.")
        }
        let inhouseAction = inhouseActionType.cases[indexPath.row]
        cell.textLabel?.text = inhouseAction.cellText
        return cell
    }
}

// MARK: - UITableViewDelegate
extension InhouseViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let inhouseAction = inhouseActionType.cases[indexPath.row]
        switch inhouseAction {
        case .log: displayLogListTableView()
        default: displayAlertView(inhouseAction: inhouseAction)
        }
    }
}

ここで一つポイントなのは、アラートビューを挟んであげることです。
誤タップ防止にも繋がりますし、社内向けの機能とはいえ削除機能なので ワンクッション設けることをおすすめします。

気をつけたいこと

API経由での削除機能に限り、気をつけるべき点が一つあります。
それは、万が一本番APIへ接続した場合に、削除処理を行わないようにすることです。
稀に、テスト版で本番APIに接続するということもあるかと思います。
やはり、本番データを操作してしまうと思わぬ事故が発生しかねないので しっかりとAPI側でフィルタリングしてあげましょう。

ログの可視化

社内向けの動作確認の中でもう一つあるとよい機能があります。
それはログを画面に表示してあげることです。

例えば、以下のような場面があると思います。

  • 行動ログを埋め込むタイミングが正しいか不安
  • エラーが起きた際に具体的な内容を確認したい

行動ログを埋め込む際に、別の機能のライフサイクルの関係で 意図したタイミングで送信できていなかったということがあるかもしれません。

また、行動ログを埋め込みを開発者のみしか確認できないのは誤設置のリスクもあります。

そのようなことを防ぐためにログを送信するタイミングでToastを表示しています。

f:id:lcl-engineer:20171209234633p:plain:w300

このように、操作のタイミングで送信する行動ログを3秒間だけ表示します。

実際のコードは以下になります。

import UIKit

final class ToastView: UIView {

    static let shared = ToastView()

    private(set) var logs = [(body: String, createdAt: String)]()

    /// 表示秒数
    private let displayDurationInSeconds: Double = 3.0
    /// 表示/非表示時のアニメーションの秒数
    private let fadeInOutDurationInSeconds: Double = 0.4
    /// 表示/非表示時のアニメーションのタイプ
    private let transition: UIViewAnimationOptions = .transitionCrossDissolve
    /// 非表示用ののタイマー
    private var hideTimer = Timer()

    private func startTimer() {
        hideTimer = Timer(timeInterval: displayDurationInSeconds, target: self, selector: #selector(hideView(_:)), userInfo: nil, repeats: false)
        let runLoop: RunLoop = .current
        runLoop.add(hideTimer, forMode: .defaultRunLoopMode)
    }

    /// Toastを非表示にする
    @objc internal func hideView(_ timer: Timer) {
        if timer.isValid {
            timer.invalidate()
        }

        UIView.transition(with: self, duration: fadeInOutDurationInSeconds, options: transition, animations: nil) {
            if $0 {
                self.removeFromSuperview()
            }
        }
        isHidden = true
    }

    /// Toastを表示する
    func show(text: String, backgroundColor: UIColor = UIColor.black.withAlphaComponent(0.8)) {
        guard let keyWindow = UIApplication.shared.keyWindow, let targetView = keyWindow.rootViewController?.view else { return }

        let offset = CGPoint(x: 8, y: 8)
        let frame = CGRect(x: keyWindow.frame.origin.x + offset.x, y: keyWindow.frame.origin.y + offset.y, width: keyWindow.frame.size.width - offset.x, height: keyWindow.frame.size.height - offset.y)
        let toast = ToastView(frame: frame)
        toast.translatesAutoresizingMaskIntoConstraints = false

        let backgroundView = UIView(frame: CGRect(x: 0, y: 0, width: toast.frame.size.width, height: toast.frame.size.height))
        backgroundView.translatesAutoresizingMaskIntoConstraints = false
        backgroundView.backgroundColor = backgroundColor
        backgroundView.layer.cornerRadius = 4

        let textLabel = UILabel(frame: CGRect(x: 0, y: 0, width: toast.frame.size.width, height: toast.frame.size.height))
        textLabel.translatesAutoresizingMaskIntoConstraints = false
        textLabel.font = UIFont.systemFont(ofSize: 14)
        textLabel.text = text
        textLabel.numberOfLines = 0
        textLabel.sizeToFit()
        textLabel.textColor = UIColor.white
        textLabel.textAlignment = .left

        backgroundView.addSubview(textLabel)
        toast.addSubview(backgroundView)

        targetView.addSubview(toast)

        let constraints = [
            NSLayoutConstraint(item: toast, attribute: .bottom, relatedBy: .equal, toItem: backgroundView, attribute: .bottom, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: toast, attribute: .top, relatedBy: .equal, toItem: backgroundView, attribute: .top, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: toast, attribute: .leading, relatedBy: .equal, toItem: backgroundView, attribute: .leading, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: toast, attribute: .trailing, relatedBy: .equal, toItem: backgroundView, attribute: .trailing, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: backgroundView, attribute: .bottom, relatedBy: .equal, toItem: textLabel, attribute: .bottom, multiplier: 1.0, constant: 10.0),
            NSLayoutConstraint(item: backgroundView, attribute: .top, relatedBy: .equal, toItem: textLabel, attribute: .top, multiplier: 1.0, constant: -10.0),
            NSLayoutConstraint(item: backgroundView, attribute: .leading, relatedBy: .equal, toItem: textLabel, attribute: .leading, multiplier: 1.0, constant: -10.0),
            NSLayoutConstraint(item: backgroundView, attribute: .trailing, relatedBy: .equal, toItem: textLabel, attribute: .trailing, multiplier: 1.0, constant: 10.0),
            NSLayoutConstraint(item: toast, attribute: .bottom, relatedBy: .equal, toItem: targetView, attribute: .bottomMargin, multiplier: 1.0, constant: -12.0),
            NSLayoutConstraint(item: toast, attribute: .leading, relatedBy: .equal, toItem: targetView, attribute: .leadingMargin, multiplier: 1.0, constant: 0.0),
            NSLayoutConstraint(item: toast, attribute: .trailing, relatedBy: .equal, toItem: targetView, attribute: .trailingMargin, multiplier: 1.0, constant: 0.0),
        ]
        NSLayoutConstraint.activate(constraints)

        targetView.layoutIfNeeded()
        toast.alpha = 0

        logs.append((body: text, createdAt: now()))

        UIView.animate(withDuration: fadeInOutDurationInSeconds, delay: 0, options: transition, animations: {
            toast.alpha = 1
        }, completion: { _ in
            toast.startTimer()
        })
    }

    private func now() -> String {
        let now = Date()
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
        return formatter.string(from: now)
    }
}

ここでポイントなのはToastを表示する際に、背景色を設定できるようにしておくところです。

上のスクリーンショットでは行動ログのJSONを表示していますが、 弊社ではその他にもエラー等を表示させています。

f:id:lcl-engineer:20171209234650p:plain:w300

背景色を変更することで、表示したログのカテゴリがわかりやすくなります。

画面上に表示することで、エンジニア以外の方がエラーなどに遭遇した場合でも 実際のログを共有してもらいやすくなり、修正等の対応がしやすくなります。

コードをみてお気づきの方もいるかと思いますが、ログのデータを日時とともに保持しており、これらのログは一覧画面から確認できるようにしています。

f:id:lcl-engineer:20171209234659p:plain:w300

import UIKit

final class ToastViewController: UIViewController {

    private let tableView: UITableView = {
        let tableView = UITableView()
        tableView.register(ToastTableViewCell.self, forCellReuseIdentifier: ToastTableViewCell.className)
        tableView.alwaysBounceVertical = false
        tableView.rowHeight = UITableViewAutomaticDimension
        tableView.estimatedRowHeight = 44
        tableView.separatorInset = .zero
        if #available(iOS 11.0, *) {
            tableView.contentInsetAdjustmentBehavior = .never
        }
        return tableView
    }()

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

// MARK: - Private Methods
extension ToastViewController {
    private func setupUI() {
        navigationItem.title = "ログリスト"

        tableView.dataSource = self
        tableView.delegate = self
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.topAnchor.constraint(equalTo: topLayoutGuide.bottomAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: bottomLayoutGuide.topAnchor).isActive = true
    }
}

// MARK: - UITableViewDataSource
extension ToastViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return ToastView.shared.logs.reversed().count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: ToastTableViewCell.className, for: indexPath) as? ToastTableViewCell else {
            fatalError("ToastTableViewCell is not found.")
        }
        let log: (body: String, createdAt: String) = ToastView.shared.logs.reversed()[indexPath.row]
        cell.dateLabel.text = log.createdAt
        cell.bodyLabel.text = log.body
        return cell
    }
}

// MARK: - UITableViewDelegate
extension ToastViewController: UITableViewDelegate {}
final class ToastTableViewCell: UITableViewCell {
    internal let dateLabel: PaddingLabel = {
        let label = PaddingLabel()
        label.backgroundColor = UIColor.gray
        label.font = UIFont.systemFont(ofSize: 14)
        label.textColor = UIColor.white
        label.numberOfLines = 1
        label.textAlignment = .left
        return label
    }()

    internal let bodyLabel: PaddingLabel = {
        let label = PaddingLabel()
        label.backgroundColor = UIColor.white
        label.font = UIFont.systemFont(ofSize: 14)
        label.textColor = UIColor.black
        label.numberOfLines = 0
        label.textAlignment = .left
        return label
    }()

    override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        selectionStyle = .none

        addSubview(dateLabel)
        dateLabel.translatesAutoresizingMaskIntoConstraints = false
        dateLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
        dateLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        dateLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        dateLabel.heightAnchor.constraint(equalToConstant: 25).isActive = true

        addSubview(bodyLabel)
        bodyLabel.translatesAutoresizingMaskIntoConstraints = false
        bodyLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor).isActive = true
        bodyLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
        bodyLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        bodyLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

ここでポイントなのはreversed()で配列を逆順にして表示してあげることです。
最新のものが上に表示されるようにすると確認がしやすくなります。

最後に

削除/追加 機能があれば、削除用にコードを埋め込んでビルドをし直したり データを追加するために同じ操作を繰り返すようなこともなくなるので スムーズに開発を行うことができます。

ログ表示に関しても、具体的なエラー内容が知れると対応がとてもしやすいです。
行動ログもこのタイミングでこのデータがほしいということがより具体的にわかるようになりました。

このような補助系の実装は後回しになりがちですが、 紹介した機能を活用することで、開発速度があがることは間違いなしです。

今回は実際のコードも紹介していますので、是非活用してください。

エンジニアを募集しています

LCLではエンジニア積極的に募集中です。
興味のある方はお気軽にご連絡よろしくお願いいたします。
インターンも募集しています。

https://www.lclco.com/recruit/

コードレビューの機械的な指摘はSideCIに任せる

コードレビューを自動化してくれるSideCIを導入しました。GitHubのプルリクエストを自動で解析して指摘してくれます。 主にRubyを使用しているのでRuboCopを筆頭に解析ツールが豊富に揃っているのは助かっています。

導入の経緯

もともとRuboCop, JSHint, ESLintは使用しており主にローカルで実行して個人個人で修正対応していました。 RuboCopはJenkinsのJOBを走らせていましたが、毎回リポジトリ全体に対して実行すると時間がかかっていたり、わざわざJenkinsの結果を確認する必要がありプルリクエストベースの開発プロセスから外れているためあまり効率が良いものではありませんでした。 そこで自前でやるよりサービスを利用したほうが開発にリソースを投入できるということもあり以前から気になっていたSideCIを導入してみることにしました。

導入してみて良かったところ

SideCIを数ヶ月運用してみて全体的に大きなメリットを感じています。

既存の開発プロセスに簡単に組み込めた

プルリクエストと連携しているので検出した問題やサマリへのリンクをコメントしてくれたりステータスが表示されるので効率よく確認と対応ができるようになりました。

f:id:lcl-engineer:20171124114558p:plain
指摘がない場合
f:id:lcl-engineer:20171124114604p:plain
指摘がある場合

無駄な指摘をする必要がなくなった

静的コード解析ツールによって検出されたコーディングスタイルに関する問題点がSideCIによってプルリクエスト上で可視化されるのでわざわざレビュアーがコメントする必要がなくなりました。 また、複雑度を数値で確認することができるので「このコードってちょっとわかりにくいよね。」ではなく「ここはこうした方がもっとわかりやすくなるよね。」のように複雑なのは数値で判明している前提で一歩進んだところから議論を始められるようになりました。

ナレッジが共有しやすくなった

これまでは実装者個人のみが解析結果を確認することが多かったのですが、レビュアーや他のメンバーの目にも触れやすくなり自分が実装する際にも意識してコードを書くことができるようになりました。対応に迷ったときにもコメントのURLをチャットで共有できるので議論の効率も上がりました。

メンテが楽になった

JenkinsのJOBの面倒を見たりGitHubとの連携を考える必要もなくなりました。チームの実情に合わせて解析ツールの設定ファイルを修正するだけで良く、メインの仕事であるサービスの開発に力を注ぐことができるようになりました。

ちょっと残念なところ

2017年10月31日にクラシックモードからアビシニアンモードへ完全移行となりました。これによりソースコードの自動修正機能が廃止されました。

SideCIでのクラシックモード廃止のお知らせ - SideCI Blog

自動修正機能とはRuboCopのauto-correct機能を利用して、自動修正可能な問題点を解消したプルリクエストをSideCIが作成してくれるというものです。

RuboCopによる自動修正の方法 | SideCI Documentation

自動修正されるのはコーディングスタイルに関する指摘がメインです。この機能のお陰でコーディングスタイルに関しては多少雑に実装してもPushして自動修正機能で対応するということができ、効率アップにつながっていました。 廃止の理由としてはツールの誤検出により誤った修正をしてしまうことを防ぐためかと想像できますが、プルリクエストとして確認できるのでオプションとして残っていてくれたら嬉しかったかな。という感想です。

アビシニアンモードでLINTツールの誤検出と闘う - SideCI Blog

まとめ

自前で仕組みを作るより断然効率的でコードレビューでも無駄な時間を使う必要がなくなったのでSideCIには満足しています。 サポートする言語やツールも増えていますのでこれからも期待大です。