サービス開発を加速させる技術選定 in Kibela

2017/07/18 Service Dev Meetup #1 の資料です。

会場は Speee さんに提供していただきました。ありがとうございました。

自己紹介

f:id:gfx:20170720100419p:plain

今日の話

  • Kibelaにおける技術選定とは
  • やらないこと
  • やったこと
  • これからやること

Kibelaにおける技術選定とは

  • 自分(@gfx)にとっては技術的挑戦は精神衛生上必要なこと
    • 興味のある分野に限るが…
  • スタートアップにとってはサービスの成長が最も重要
  • 技術的挑戦によって機能を磨いて差別化にも繋げられればそれがベスト

しかし、コスパの悪い挑戦はしない

  • リスクが未知数
  • 新しすぎて時期尚早
  • 作業量が膨大

挑戦しない例

❌ ウェブエディタのフルスクラッチ開発

  • エディタで気持ちよく書けるのはサービスとして非常に重要
  • では、 :star2: 最高のエディタ:star2: をフルスクラッチ開発するか?
    • ❌ No!
    • 自由度はあがる
    • しかしKibelaにおける「理想のエディタ」がどんなものかもまだ見えていない
      • WYSIWIG editor vs plain text editor?
    • 現時点だとリスクもコストもベネフィットも未知数すぎる
    • いまはまだやるべきじゃない
  • フルスクラッチでのエディタ開発をやっているところはある
  • KibelaではFacebookのDraftJSを採用
    • しかしDraftJSはいろいろ問題があった
      • カーソルが飛んで行方不明になることがある
      • 入力できなくなることがある
      • スマホでの日本語を入力できないことがある
    • DraftJSは開発も停滞しておりFacebookも本番導入予定はなさそう
    • つまりフルスクラッチ開発に踏み切るは「Facebookにできなかったことが我々ならできる」という確信が必要
  • KibelaのエディタはいまCodeMirrorに置き換え作業中
    • 設計は古いが実装は枯れている
    • GitHubが採用しているのもCodeMirrorなので安心

❌ SPA: Single Page Application

  • UX向上の見込みはあるが、今はまだ開発コストに見合わないという判断
  • 「UX向上 > コスト」となる画面遷移はありそうなのでそこはいずれSPAにする
    • たとえばホーム画面からエントリ画面への遷移はSPAにすると高速になると期待できる

挑戦したこと

TypeScript

  • Microsoftが開発している静的型をもったaltJS
  • 構文はほぼJavaScriptと同じ

TypeScriptのメリット

  • TS導入により 生産性があがり、バグが少なくなる ことを期待できる
  • 静的型は「制約によりコードの整合性を保つためのLint」として振る舞う
  • TypeScriptコンパイラが提供する language-service が優秀で、メジャーなエディタだと補完やリファクタが効く

f:id:gfx:20170720100454p:plain

  • RubyMineでブラウザの File objectのメソッドやプロパティをを補完している様子

TypeScriptに挑戦した理由

  • TypeScriptはリリース当初(2012年)から目を付けていて、情報を追っていたのでリスクについてはある程度予想できた
    • 初期はハマりどころがものたくさんあってオススメできる感じではなかった
  • ES 2015の策定とそれを踏まえた TypeScript 2.0 (2016年)によって不満点がほぼなくなったので、導入のきっかけ探していた
  • KibelaにReact Reduxの導入するタイミングで静的型がほしくなり、導入を決意した

TypeScriptとの付き合い方

  • TypeScriptについては過度に期待しないのがよい
    • ランタイムとしては良くも悪くもただのJavaScript
    • 実行時に影響をおよぼす特別な機能はない
  • 型付けは がんばらない方針。よくわからなければanyでいい
  • 型定義ファイル (@types/*) にもこだわらない。別に使わなくていいし、使っていてもおかしければすぐ捨ててよい
  • TSとRailsとの統合でいくつか足りない機能があったのでそのへんは作った

Railsのルーティングヘルパーと仲良くする

  • Railsのルーティングヘルパー: wiki_path(blog)"/wikis/42"
    • 簡単なクエリビルダつき: wikis_path(page: 1, per: 20)"/wikis?page=10&per=20"
  • 当然ながらJavaScriptからは利用できない
  • 作った: https://github.com/bitjourney/ts_routes-rails
import { wikisPath, wikiPath } as Routes from './generated/routes';

console.log(wikiPath(42)); // "/wikis/42"
console.log(wikisPath({ page: 1, per: 20 })); // "/wikis?page=1&per=20"
  • ある程度静的型が付いて、補完も効きやすいので最高

Railsの画像アセットと仲良くする

import * as React from 'react';
import { ImageRubyIcon } from './generated/assets';

class MyComponent extends React.Component<any, any> {
  render() {
    return (
      <ImageRubyIcon // for app/assets/images/ruby-icon.svg
        className='rubyLogo'
      />
    );
  }
}

これから挑戦したいこと

  • GraphQL
    • これから話す
  • React Native
    • 今日は話しません

なぜGraphQLに注目したか

  • モバイルアプリを開発するにあたってWeb APIがほしい
  • 将来的にWeb APIを公開したい
  • なるべくコスパよく!

GraphQL

  • RESTの次の地位を狙う Web APIのリクエストフォーマット を定めるもの
  • インターフェイスの定義はエンジニアが記述する
    • DBアクセスはActiveRecordを通じて(Railsの場合)
    • 検索はElasticsearchへのアクセスを実装したモデルを叩き
    • モデルの関連(association)も表現できる
  • 実サービスの例としては GitHub API v4 のAPI consoleを触るとよい
  • まだKibelaには導入していないものの、仕様にグッときたので導入を決意
    • RESTの延長にある雰囲気が気に入った
    • GitHubやFacebookという大手が使っているのも導入の決め手

例: GitHub API v4

request:

query {  # トップレベルは必ず "query" または "mutation"
  viewer {  # ログインユーザから見れるデータで…

    # ログインユーザが管理するリポジトリの最初の3個をudpated_atでソートして
    repositories(first: 3, orderBy: { field: UPDATED_AT, direction: DESC }) {
      nodes { # data nodeのリスト
        name # リポジトリの名前をリクエストする
        description # リポジトリの説明をリクエストする
      }
    }
  }
}

response:

{
  "data": {
    "viewer": {
      "repositories": {
        "nodes": [
          {
            "name": "ts_routes-rails",
            "description": "Exports Rails URL helpers to TypeScript"
          },
          {
            "name": "rouge",
            "description": "A pure-ruby code highlighter"
          },
          {
            "name": "pygments.rb",
            "description": "pygments syntax highlighting in ruby"
          }
        ]
      }
    }
  }
}
  • クエリの基本はシンプル
  • コレクションの操作が独特なので習熟が必要
  • API consoleの実装であるGraphiQL (グラフィクル)が優秀

説明付きでフィールドを補完してくれたり:

f:id:gfx:20170720100517p:plain

f:id:gfx:20170720100532p:plain

内蔵されているドキュメントビューアも優秀で、クエリをいじりながらすぐに検索できる。とはいえ、慣れると補完だけでほぼいけるようになる。

GraphQLに関するよくある誤解

  • レイヤ的にはあくまでも「アプリケーションのインターフェイス」を定めるもの
    • RDBMSやNoSQLなどのストレージのレイヤではない
    • というのは「 RESTでアクセスできるRDBMS」というのと同じ水準の話
    • MongoDBはHTTP interfaceがあるのでありえない話ではないが… :innocent:
  • Graph DB とは関係ない
  • 「 N+1が心配」⇢ RESTに置き換えて考える
    • 1 resource : 1 endpoint厳守のRESTだと簡単にクライアントサイドで N+1 になる
    • GraphQLの場合、サーバーサイドで完結する部分が多く工夫の余地があるぶんマシ
  • 「複雑なクエリで過負荷にならないか心配」⇢ RESTに置き換えて考える
    • RESTだとRate-Limitで制限するが、GraphQLは別の方法で制限する必要がある
  • gRPCなどのRPC frameworkと何が違う?
    • GraphQLは「必要最小限なデータを1リクエストで取得する」ことに集中している
      • 取得するフィールドを最低限に
      • 幾つかの型のリソースをまとめて
    • GraphQLの比較対象はRESTで、RPC frameworkではなさそう

コード例

KibelaのGraphQL pull-request(開発中)より抜粋:

f:id:gfx:20170720100600p:plain

たとえば単一のリソースの取得:

# トップレベルの "query" 型 (トップレベルはquery or mutation)
Types::QueryType = GraphQL::ObjectType.define do
  name "Query"

  # GraphQLは仕様としてdescriptionを書ける
  # descriptionはドキュメントビューアで使われる
  description "The query root of the schema"

  # 「blog」というリソースを宣言する
  field :blog do
    type Types::BlogType

    # blog リソースを取得するにはただひとつの必須引数 id が必要
    argument :id, !types.ID

    resolve ->(_object, args, context) {
      # current_userがアクセス可能なblogをidで検索する
      # 戻り値はtypeで宣言したTypes::BlogTypeで宣言されたフィールドを持つ
      Blog.accessible_for(context[:current_user]).find_by!(id: args["id"])
    }
  end
end
  • 結局、パラメータがあってそれを元にActiveRecord経由でデータを検索するという点においてRESTと違いはない
  • 全てのパラメータとレスポンスのフィールドを明示的に宣言しなければならないので、作業量としてはそれなりにある
    • とはいえ新規開発するなら作業内容が変わるだけといえる
  • 最終的には routes.rb からJSON APIを一掃できるのでスッキリ
  • 内部API / モバイルアプリ用API / 外部APIを実装上区別する必要がないのは大きい
    • 認証方法(それに伴うrate-limit的な制限)の違いはあるのでエンドポイントは変えるかも

懇親会で受けた質問

Q: GraphQLはRESTより制限しにくく想定外のクエリによる過負荷が心配。やはり外部公開APIには向かないのでは?

「GraphQLがどうか」だけを考えるとたしかにその通りだと思います。これは結局RESTと比較しないと答えが見えてこないテーマです。

たとえばRESTが単位時間あたりのリクエスト数を制限する(= Rate Limit)ように、GraphQLの場合は、リクエストをパースしたあとの構造を解析してリソース取得のネスト数やリソースの参照数などをRate Limitのような形で制限することで、RESTと同水準の制約は掛けられるのではないかと考えています。

N+1 問題も同じで、たとえばREST APIを大量に発行するN+1リクエストは、REST APIでは簡単に起きてしまいます。そしてRESTの場合、そういう大量にリクエストを送るパターンを、サーバーサイドの工夫で改善するのは難しいと思います。GraphQLの場合、リクエスト自体は1つなので、サーバーサイドで工夫できる余地があります。

総じてみると、GraphQLは「REST同様に問題が起きる可能性はあるが、RESTよりサーバーサイドで対応できる余地があるだけマシ」と言えるのではないかと思います。

Roppongi.rb #3 で「RailsエンジニアがReactを始めてSSRとReduxを導入するまで」という発表を行いました

id:gfx です。Roppongi.rb #3 に登壇する機会をいただいたので、最近のKibelaのフロントエンドについて発表をいたしました。

RailsエンジニアがReactを始めてSSRとReduxとTypeScriptを導入するまで | bitjourney Kibela

イベントページ: Roppongi.rb #3 “Rails x Frontend-Tech” - connpass

(当日は開発中のKibelaのプレゼンテーションモードで行ったため色々不都合があり、ご迷惑をおかけしました。)

書きたいことはほぼ書ききったので特に補足することもないのですが、最近はReactにはだいぶ慣れてきて、Hypernova SSRも安定して運用できています。最初は開発が難しいのではないかという懸念もありましたが、現状はほぼ定形どおりの対応でReact componentの開発ができています。

もっとも、ブラウザ用のJSライブラリについてはNodeJS環境でimportするだけでクラッシュするものが少なくありません。これは windowdocument をトップレベルで参照していて、NodeJS環境ではそこでシンボルが未定義なため落ちてしまうからです。

このため次のように「とりあえずNodeJS環境でロードできるようにする(≒コンパイル時universalにする)」だけのpull-requestもいくつか送りました*1

私を含めてこういう地道な活動を行っているSSR勢がある程度いるみたいなので、だんだんSSRしやすい環境になってきているものの、一部未対応のライブラリについてはimportは使わず、SSRで通過しない componentDidMount() でreuqireによる動的にロードに変更するなどして対応しています。

SPAにするかどうかはサービスの性質によると思いますが、一部のコンポーネントについてSSRをしていくのは、これからの「普通のウェブアプリ開発」の一部になっていくのかなという気がしています。

たとえば

などでもそのように主張されていますね。

去年のISUCON6でもコンポーネントの一部にReact SSRがあったのも記憶に新しいところです。そしてisucon6-finalは同時に、Reactの提供するAPIがあればフルスクラッチでSSRサーバを書いても100行程度ということを知らしめました。

isucon6-final/server.jsx at master · isucon/isucon6-final · GitHub

サーバーサイドとクライアントサイドで同じReact componentを描画するというのは一見すると無駄が多く複雑すぎるシステムに見えますが、やってみるとUIの描画が安定するし意外とメンテコストも高くないしそんなに悪くないなという話でした。

ReduxとTypeScriptは導入したばかりでそこまで知見はありませんが、特に不都合もなく動いてはいます。特にTypeScriptはいいですね、もう静的型付けのない素のES201xで大規模なアプリを書ける気がしません。はやく型アノテーションが標準に取り込まれてほしいなと思います。

*1:これに対して、本当にnodejsでも使えるものは「実行時universalである」といえます。UniversalJSにも分類があるというのはちょっとややこしいですね。

CircleCIでElasticsearchのログが吐かれていない問題を修正する(続き)

井原(@ihara2525)です。

(引き続きめちゃ小ネタです)

CircleCIでElasticsearchのログが吐かれていない問題を修正する」でタイトル通りCircleCIでElasticsearchのログが吐かれるようになったのですが、その対応だと/var/log/elasticsearchディレクトリの権限を変更してあげたりする必要がありました。

/var/log/elasticsearchにログを吐く設定がこのコミットで入っていて、同時にpath_logsというオプションで場所を指定できるようになっています。

/tmp以下には書き込めるだろうということで、以下のようにクラスタを立ち上げてあげると良さそうです。

Elasticsearch::Extensions::Test::Cluster.start(path_logs: '/tmp/log/elasticsearch') unless Elasticsearch::Extensions::Test::Cluster.running?

これでcircle.ymlから設定を削除できて簡単になりました。

- - >
-   if [ -d /var/log/elasticsearch ] ; then
-     sudo chmod 777 /var/log/elasticsearch
-   fi

これも漢方デスク@michi_omochiが見つけてくれました。感謝です!

CircleCIでElasticsearchのログが吐かれていない問題を修正する

井原(@ihara2525)です。

(めちゃ小ネタです)

CircleCI上でelasticsearch-extensionsを使ってテストを動かすのように引き続きテストを行っているのですが、elasticsearch-railsが0.1.8になった頃?から、テストは通るものの以下のようなエラーが出るようになっていました。

...
Starting 1 Elasticsearch nodes...log4j:ERROR setFile(null,true) call failed.
java.io.FileNotFoundException: /var/log/elasticsearch/elasticsearch-test-box500.log (Permission denied)
...
java.io.FileNotFoundException: /var/log/elasticsearch/elasticsearch-test-box500_index_indexing_slowlog.log (Permission denied)
...
java.io.FileNotFoundException: /var/log/elasticsearch/elasticsearch-test-box500_index_search_slowlog.log (Permission denied)
...

気持ちは悪いしログも書かれていない、ということで修正しようと試行錯誤してみました。

どうもインスタンスが立ち上がった時点で以下のディレクトリができている模様。

ubuntu@box844:~$ ls -lF /var/log
drwxr-xr-x 1 elasticsearch elasticsearch     190 Oct 16 05:52 elasticsearch/

多分これがelasticsearchグループ/ユーザ、755なので、テストを動かしているubuntuユーザが書き込めず上記のエラーになっているぽいです。

というわけで、circle.ymlに以下を追加、/var/log/elasticsearchディレクトリがある場合は777にしてあげます。

dependencies:
  pre:
    - >
      if [ -d /var/log/elasticsearch ] ; then
        sudo chmod 777 /var/log/elasticsearch
      fi

これで無事ログが吐かれるようになりました!

ubuntu@box844:~$ ls -lF /var/log/elasticsearch
-rw-r--r-- 1 elasticsearch elasticsearch     0 Oct 16 05:52 elasticsearch_index_indexing_slowlog.log
-rw-r--r-- 1 elasticsearch elasticsearch     0 Oct 16 05:52 elasticsearch_index_search_slowlog.log
-rw-r--r-- 1 elasticsearch elasticsearch   215 Oct 16 05:52 elasticsearch.log
-rw-rw-r-- 1 ubuntu        ubuntu            0 Oct 28 10:41 elasticsearch-test-box844_index_indexing_slowlog.log
-rw-rw-r-- 1 ubuntu        ubuntu            0 Oct 28 10:41 elasticsearch-test-box844_index_search_slowlog.log
-rw-rw-r-- 1 ubuntu        ubuntu        83443 Oct 28 10:41 elasticsearch-test-box844.log

といってもこのログを参照することがほぼないのですが(泣)、気持ち悪さは解消しました!

CircleCI上でnpmパッケージを定期アップデート

出口 (@dex1t) です。

現在開発中のサービスでは、Railsを使いつつ、React.jsを導入しています。
React.jsを使うと必然的にBrowserifyなどのnpmのエコシステムを、Railsプロジェクトに取り入れることになります。 そうすると、Railsプロジェクトであっても、npmで管理するライブラリが増えていきます。今回はそのアップデートを自動化してみました。

CircleCIで Gemfile.lock を定期更新する

本題に入る前に、bundle updateの自動化について触れます。
ググると、やり方はいろいろ出てくるのですが、

  1. 開発用のサーバからcronで定期的にCircleCIのAPIを叩く
  2. circle.ymlのdeploymentセクションで、スクリプトを実行する
  3. そのスクリプト内で、bundle updateの実行と差分をプルリクエストする

というのが、基本的な流れです。詳しくは、 CircleCIを使ってbundle updateを定期実行する - Qiita をごらんください。

CircleCIで package.json を定期更新する

npmのpackage.jsonを定期更新する場合も、上記の流れと同様です。 今回は、3を行うスクリプトをgulpで組んでみました。

npm-check-updatesでpackage.jsonを更新する

package.jsonの更新には、npm-check-updates を利用します。

$ npm install -g npm-check-updates
$ ncu --upgradeAll

これだけで、npmパッケージの更新確認とpackage.jsonの書き換えを行ってくれます。便利!!
今回はこれをgulpから呼び出します。

package.jsonを更新してプルリクエストする

gulpからgitとgithubが扱えれば何でもいいのですが、今回は主に以下を使いました。

run-sequenceは、各gulpタスクを直列に実行するために使います。 また、gulpプラグインの読み込みを簡単にするため、gulp-load-pluginsも使っています。

これら組み合わせると、ちょっと長いですがこんな感じで、package.jsonの更新~プルリクができます。

var gulp = require('gulp');
var gulpLoadPlugins = require('gulp-load-plugins');
var $ = gulpLoadPlugins();
var ncu = require('npm-check-updates');
var github = require('octonode');
var branch = 'npm-update';
var runSequence = require('run-sequence');

gulp.task('ncu', function(cb) {
  ncu.run({
    packageFile: 'package.json',
    jsonUpgraded: false,
    upgradeAll: true
  }).then(function(){
    cb();
  });
});

gulp.task('createBranch', function(cb) {
  $.git.checkout(branch, {args:'-b'}, function(){
    cb();
  });
});

gulp.task('addAndCommit', function() {
  return gulp.src('package.json')
             .pipe($.git.add())
             .pipe($.git.commit('Upgrade package.json'));
});

gulp.task('pushBranch', function(cb) {
  $.git.push('origin', branch, function(){
    cb();
  });
});

gulp.task('sendPullRequest', function(cb) {
  var token = process.env.GITHUB_ACCESS_TOKEN;
  github.client(token).repo('bitjourney/journey').pr({
    'title': 'Update npm packages',
    'body' : '@bitjourney/developers Please :eyes:',
    'head' : 'bitjourney:' + branch,
    'base': 'master'
  }, function() {
    cb();
  });
});

gulp.task('autoNpmUpdate', function(){
  runSequence(['ncu', 'createBranch'],
              'addAndCommit',
              'pushBranch',
              'sendPullRequest');
});

注意点としては、プルリクエストを送るユーザの、GithubのPersonal access tokenをCircleCIに登録しておく必要があります。 CircleCI側のEnvironment variablesにて、GITHUB_ACCESS_TOKENという名前で登録しておけばOKです。

circle.ymlのdeploymentセクションで実行する

あとは、circle.ymlのdeploymentセクションで、gulp autoNpmUpdateを実行するようにすればOKです。 参考までに、以下のようにしました。

deployment:
  maintain-libraries:
   branch: master
   commands:
      - >
        if [ -n "${NPM_UPDATE}" ] ; then
          git config --global user.email user@example.com
          git config --global user.name 'username'
          gulp autoNpmUpdate
        fi

git configもgulp内でやってしまってもいいのですが、今回は外出ししています。 実際にCircle CIをAPI経由で叩く方法については、こちらを参考にしてください!

おわり

要点だけかいつまみましたが、これで弊社のbotがプルリクをくれるようになりました 🎉

f:id:dex1t:20150915163102p:plain

Elasticsearchを使ったテストを書くときにsleep 1するのはやめましょう

井原(@ihara2525)です。

以前RSpecでElasticsearchを使ったテストを書くというエントリの中で、

before do
  Post.__elasticsearch__.create_index! force: true
  Post.__elasticsearch__.refresh_index!
  page
  Post.import
  # Wait test cluster to index the created objects
  sleep 1
end

みたいなコードを書いていたのですが、いろいろと間違っていました。 まずimportがインデックスをつくってくれるので、

before
  Post.import
  # Wait test cluster to index the created objects
  sleep 1
do

でOK。 そしてsleep 1しているのはここにもあるように、Elasticsearchがインデキシングを1秒待つので、それが終わるまで、ということでした。

ただ、これだとタイミングによってインデックスが更新されておらず、テストが通ったり通らなかったりという辛い現象が発生してしまいます。

困っていたら漢方デスクのエンジニア@michi_omochiが対応策を調べてくれて、

before do
  Post.import
  Post.__elasticsearch__.refresh_index!
end

と、import後に即インデックスの更新を実行してあげれば解決しますと。素晴らしい。 さらに

before do
  Post.import(refresh: true)
end

refreshオプションを渡せばimportのみで同様のことが可能に。(この辺りの実装)

というわけで、sleep 1するのはやめて、refresh_idnex!を呼び出すか、import(refresh: true)として、明示的にインデックスを即更新してあげましょう。

これでインデキシングのタイミングでたまにテストがこける問題、無事解決しました。ありがとう、@michi_omochi

GulpでSVGスプライトとアイコン一覧を一発生成

出口 (@dex1t) です。 ベクター素材をSVGスプライト化し、インラインSVGとして利用する仕組みを作ったので紹介します。

アイコンフォントかSVG

アイコンフォントとSVGの違いは、ググるとたくさん出てくると思うのですが、

  • 現在開発中のサービスはモダンブラウザのみ対象にしている
  • SVGのまま利用すれば、フォント化する手間がなく管理もしやすい
  • 多色にも対応できる

という点から、今回はSVGを利用することにしました。 SVGを利用する方法もいくつかありますが、今回はSVGをスプライト化し、Inline SVGとしてHTMLに埋め込む形を取ることにしました。

デザイナーにSVGを追加更新してもらいたい

いまはサービスをガンガン開発してる段階なので、素材の追加や変更も多く発生します。 ベクター素材は、デザイナーがイラレ上で作っているので、書き出しからSVGスプライト化まで一貫してお願いできると、作業効率が上がりそうです。

いま一緒に作業しているデザイナーさんは、黒い画面がある程度使える方なので、 規定のディレクトリにSVG素材を入れてもらい、コマンドを一発叩くとSVGスプライトが生成されるような環境を作ります。

GulpでSVGスプライトを生成する

スクランナーとして、この手のプラグインが充実しているGulpを利用します。 今回は以下のGulpプラグインを組み合わせました。

こんな感じのgulpタスクを書くと、一発で生成できます。 なお、プラグインを毎度requireするのは大変なので、gulp-load-pluginsも使っています。

var gulp = require('gulp'),
    gulpLoadPlugins = require('gulp-load-plugins'),
    pl = gulpLoadPlugins();

gulp.task('build', function() {
  return gulp.src('src/*.svg')
    .pipe(pl.svgmin())
    .pipe(pl.svgstore({ inlineSvg: true }))
    .pipe(pl.cheerio(function ($) {
      $('svg').attr({
        style:  'position: absolute; width: 0; height: 0;',
        width: 0,
        height: 0
      });
    }))
    .pipe(gulp.dest('app/assets/'));
});

後述しますが、生成したSVGスプライトをGem化して管理しているため、生成先をapp/assets/配下にしています。

ついでにアイコン一覧用の静的HTMLも生成する

SVGスプライトを生成するだけでなく、SVGアイコンの一覧 (例: Material Icons) を生成すると開発時に便利です。 前述した、gulp-cheerioと、gulp-templateを組み合わせて、これも自動生成します。

このようなテンプレートを用意した上で、

<html>
  <head>
    <title>Bit Journey Symbols</title>
  </head>
  <body>
    <%= inlineSvg %>
    <h1 class='title'>Bit Journey Symbols</h1>
    <ul class='symbols'>
      <% _.each(symbols, function(symbol) { %>
        <li class='symbol'>
          <svg><use xlink:href="#<%= symbol.id %>"></use></svg>
          <div class='symbol-id'><%= symbol.id %></div>
        </li>
      <% }); %>
    </ul>
  </body>
</html>

以下の様なgulpタスクで、テンプレートにSVGメタデータを流し込み、SVGスプライトとアイコン一覧ページを同時生成します。

var gulp = require('gulp'),
    gulpLoadPlugins = require('gulp-load-plugins'),
    pl = gulpLoadPlugins(),
    templateFile = 'template.html';

gulp.task('build', function() {
  return gulp.src('src/*.svg')
    .pipe(pl.svgmin())
    .pipe(pl.svgstore({ inlineSvg: true }))
    .pipe(pl.cheerio(function ($) {
      $('svg').attr({
        style:  'position: absolute; width: 0; height: 0;',
        width: 0,
        height: 0
      });

      var symbols = $('svg > symbol').map(function (){
        return {
          id: $(this).attr('id'),
        };
      }).get();

      gulp.src(templateFile)
        .pipe(pl.template({
          inlineSvg: $('svg'),
          symbols: symbols
        }))
        .pipe(gulp.dest('public'));
    }))
    .pipe(gulp.dest('app/assets/'));
});

実際に作った一覧ページはこんな感じです。今回は適当にCSSを書きましたが、symbols-for-sketchのような既存のテンプレートを流用してもよさそうです。

f:id:dex1t:20150804180108p:plain

便利ヘルパーと合わせてGem化する

今回はRailsアプリで利用するので、これらを社内Gem (Rails Engine) 化しました。 Gem化することで、アプリ側からはバージョン管理ができて便利です。 Inline SVGは、アイコンフォントに比べて若干使う際のマークアップが面倒なので、Rails向けの簡単なヘルパーも用意しています。

まとめ

このような仕組みを作ることで、以下のフローをデザイナーにお願いできるようになりました。

  1. イラレからSVGを書き出す
  2. gulpコマンドを実行
  3. SVGスプライトと一緒に生成されるアイコン一覧ページで表示を確認
  4. プルリクエストを送ってもらう

エンジニアは、Railsアプリ側でGemをアップデートするだけで、最新版の素材が利用できます。 素材周りは全てデザイナーにお任せできるので開発スピードがあがりそうです 🎉