シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

AWS Lambda+API Gateway + S3で格安リアルタイム画像リサイズAPIを作成する

こんにちは、シンクロ・フードの大久保です。

今回は実際に弊社で運用しているAWS Lambdaを使ったリアルタイム画像変換APIについてご紹介したいと思います。 リアルタイム画像変換APIについては、あまり詳しく説明しませんが、画像のサイズ変換等をURLパラメータで指定してリアルタイムに変換することです。 弊社の場合はリサイズしか行わないため、画像変換APIというよりは画像リサイズAPI、というほうが適切かもしれません。

リアルタイム画像変換の方法について

弊社の方法をご紹介する前に、リアルタイム画像変換の一般的な実現手法を挙げてみます。

  1. Nginx, ApacheなどのWebサーバのプラグインを用いる
  2. CDNとして提供されている機能を用いる

1は、クックパッド社のmod_tofuが有名で、色々企業で実現されている手法です。サーバを用意しなければならない点や、冗長化を考えると弊社ではコスト的に取り組めませんでした。 2は、Akamaiやfastlyなど、高機能なCDNサービスを使う方法です。このあたりのサービスは以前は高額なものが多かったのですが、fastlyなどはとてもお得だと思います。

APIドキュメント
https://docs.fastly.com/api/imageopto/

料金
https://www.fastly.com/pricing/

こんなブログポストを書いておいてなんですが、リアルタイム画像変換は自作するよりも、fastlyの利用を検討することをオススメします。弊社も、このリアルタイム変換のシステムを作る前にfastlyで実現できることを知っていたら、fastlyを利用していたのではないかと思います。

ということで、以下はそれでもLambdaを使って自分たちで構築したい、という方向けの記事です。

設計

ベースとなる設計は、以下のAWSブログの記事をベースとしています。

https://aws.amazon.com/blogs/compute/resize-images-on-the-fly-with-amazon-s3-aws-lambda-and-amazon-api-gateway/

弊社はこの設計に、さらにCloudFrontをかませており、以下のようなリクエストの流れとなります。元記事よりリクエストの流れを丁寧に説明していきます。

f:id:synchro-food:20180114145809p:plain

前提として、S3のバケットは2つ用意する必要があります。1つはリサイズ元画像を格納するOriginalBucket、もう1つはリサイズ後の画像を格納するResizedBucketです。

  1. ユーザーがCloudFrontにリクエストを送ります。初回リクエストはCloudFront上にキャッシュが無いため、CloudFrontはオリジンとして設定しているS3のResizedBucketにリクエストを流します。
  2. CloudFrontから流れてきたリクエストを受け、S3はBucket内を探しますが、Resizeされた画像が存在しません
  3. 画像が存在しない場合、内部用の画像変換APIへ307レスポンスが投げられます
  4. CloudFrontはS3からの307のレスポンスをそのままユーザーへ戻します
  5. ユーザーは307レスポンスを受け、次は内部用画像変換APIのURLを待ち受ける、APIGatewayにリクエストを投げます
  6. API GatewayよりLambdaの画像変換関数がキックされます
  7. LambdaがS3のOriginalBucketより、画像リサイズ元となる画像を取得します
  8. 7で取得した画像をパラメータに従ってリサイズし、ResizedBucketに保存します
  9. 8で保存した画像へのURLへの301レスポンスをLambdaにて返します
  10. 301レスポンスをそのままユーザーに返します
  11. ユーザーは301レスポンスに従い、もう一度リサイズ画像のリクエストを送ります
  12. CloudFrontからResizedBucketにリクエストが流れ、変換後画像をレスポンスとしてキャッシュしつつ、結果をキャッシュする

大きな流れは以上です。最初に画像を閲覧したユーザーは3回リクエストが飛ぶことになりますが、2回目以降はCloudFrontがキャッシュしているため、高速に戻すことができます。 リクエストが3回も飛ぶ動きなどが一見気持ち悪い手法ですが、この方法で半年以上運用し、特に大きな問題は起きていません。

以降、少し細かい設定方法を説明していきます。

Lambda関数を用意する

流れの図にある、7(S3からリサイズ元画像を取得する)と8(画像をリサイズしResizedBucketに入れる)、という2つの処理を行うLambda関数を作ります。 コードは、元記事も紹介している、以下のコードを改造していくのが良いと思います。

https://github.com/awslabs/serverless-image-resizing

弊社はこのコードを元に、以下の修正を加えて運用しています。

  • 不正なリサイズリクエストを除外するためのハッシュチェック
  • 細かいバリデーション、ログ埋め込みなど
  • serverlessフレームワーク化(まったく必須ではありません)

ハッシュチェックについては、誰かれかまわずリサイズできると困るため、確実に自分たちのサービスからのリクエストであることをチェックするために、アプリケーション側で作成したハッシュ値をLambda側でチェックしています。

API Gatewayの設定

こちらは特に特殊なことをやっているわけではないのですが、API Gatewayは分かりにくいため、記載しておきます。

リソースの作成

まず、API Gatewayにある「APIを作成」というボタンから新しいAPIを作成したあと、「アクション」→「リソースの作成」を選択します。

f:id:synchro-food:20180114161809p:plain

その後、上記画面のような入力画面が表示されるのですが、ここで「プロキシリソースとして設定」にチェックを入れて、リソースを作成してください。進むと以下のような画面が出ます。

f:id:synchro-food:20180118155859p:plain

こちらでは統合タイプを「Lambda関数プロキシ」、Lambdaリージョンは、後述するLambda関数のリージョン、Lambda関数は、作成したLambda関数を指定してください。

デプロイ

「アクション」→「APIのデプロイ」を選択し、ステージを選択します。 最初はステージがないため、「新しいステージ」を選択し、ステージ名を入力してください。 とりあえず、prod、という名称にすることが多いように思います。

デプロイが完了すると、APIGatewayのURLが生成されます。

https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod

これでAPIGatewayの準備は完了です。

S3の設定

上記設計図におけるResizedBucketは、StaticWebHostingを有効にしておく必要があります。それ自体は簡単なのですが、同時にリダイレクトルールを以下のように設定しておく必要があります。

<RoutingRules>
  <RoutingRule>
    <Condition>
      <HttpErrorCodeReturnedEquals>404</HttpErrorCodeReturnedEquals>
    </Condition>
    <Redirect>
      <Protocol>https</Protocol>
      <HostName>${API Gatewayのホスト名}</HostName>
      <ReplaceKeyPrefixWith>${API Gatewayのステージ名}/resize?key=</ReplaceKeyPrefixWith>
      <HttpRedirectCode>307</HttpRedirectCode>
    </Redirect>
  </RoutingRule>
</RoutingRules>

もう一つ、Lambdaが作成した画像ファイルを都度閲覧可能にする必要があるため、バケットポリシーの設定も必要です。この設定がないと、Lambdaが作成したリサイズ後画像がPrivate権限となっていて、画像参照ができません。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::${S3のバケット名}/*"
        }
    ]
}

CloudFrontの設定

こちらは通常通り作成し、オリジンにはResizedBucketを指定します。

キャッシュ問題について

ただし、このままですと、CloudFrontは1回目の307リクエストの結果をキャッシュしてしまい、変換後の画像を表示してくれません。

この問題は、CloudFrontがキャッシュするかどうかは、S3のオブジェクトが持つCacheControlヘッダを使うようにすれば、解決します。

具体的な操作は以下の通りです。

  • CloudFrontの「Behaviors」→「Edit」→「Object Caching」を、「Use Origin Cache Headers」から「Customize」にする。
  • Lambda側のコードで、リサイズ後画像をS3にPutする際に、CacheControlヘッダを追加する。
      .then(buffer => S3.putObject({
          Body: buffer,
          Bucket: BUCKET,
          ContentType: 'image/png',
          CacheControl: 'max-age=2592000, s-maxage=259200',
          Key: key,
        }).promise()
      )

これでCloudFrontが適切にCacheしてくれるようになります。

基本的な設定の流れは以上です。 この設定を行うことで、以下のようなURLを渡すと、そのサイズにリサイズされた画像が戻ってくるようになります。 呼び出しイメージは以下のような雰囲気です。

https://xxxxxxxxxxx.cloudfront.net/hash/300x100/xxxx.jpg

実際に運用した感想

実際に上記のような設計でリアルタイム画像変換APIを作成し、半年ほど運用をしましたので、所感を列挙します。

  • 安定しています。CloudFront,S3はもちろんですが、lambdaでのトラブルは一切ありません。
  • 画像以外のリクエストが多い。faviconや画像以外のファイルリクエストが沢山きます。リリース後、余計なリクエストを除外する仕組みを入れました。
  • エラー検知は、とりあえずCloudWatchにて、Lambdaの実行エラーをアラームにしています。変換に失敗したときにメールが飛んできますが、エラー内容がメールに記載されていないので、少し面倒です。
  • lambdaのメモリ設定やtimeout設定などは、最初大きめに取り、ログを見ながら最適なメモリサイズやtimeoutに落としていく、という方法が良さそうです。
  • (これはリクエスト数次第ですが)費用は安いです。一度変換した画像は二度と変換が実行されないため、lambdaの呼び出し回数も少ないです。

まとめ

特に手法自体は新規性がないと思いますが、実際にプロダクション環境で運用している事を公開することで、誰かの参考になるかなと思い、ご紹介しました。 リアルタイム変換を今から導入しようと思うのであれば、まずはFastlyを第一に検討すべきかと思いますが、なんらかの問題があった場合などにご検討ください。

シンクロ・フードでは常にエンジニアを募集しています。 ご興味のある方は以下よりエントリーしてみてください!応募ではなく、話を聞いてみたい的なものでも結構ですので、お気軽にお問い合わせください。

Filelint を作って社内プログラムのコーディングスタイルを矯正した話

はじめまして。今年新卒で入社した基盤チームの川井 (@fohte) です。

最近までは、新卒企画研修として開発した wenu という飲食店向け Web サービスの開発基盤を整えたり、フロントエンド (React) のロジック部分を担当していました。

社内の既存プログラムの問題点

新卒研修終了後に配属され、既存プロジェクトの開発に携わったのですが、古くからあるコードが多く残されており、またコーディングスタイルも統一されていませんでした。
具体的には、以下のような問題がありました。

  • 行末にスペースやタブが残されている
  • ファイルの最後に空行が存在したりしなかったりする
  • インデントがソフトタブだったりハードタブだったりと混在している

これらの問題は構文エラーではなく、コンパイルも正常に通るために放置されていました。
しかし、各開発者が編集した際に無駄にスペースが挿入されたり削除されたりと、意味のない変更が差分として上がってしまう問題がありました。
意味のない差分はレビュワーにとっても負担になりますし、これらを強制することでこのような負担を軽減できないかと考えました。

解決手段

上記の問題は ESLintRuboCop といった言語特化型の lint ツールでは対応していないファイルでも見られるため、単純に様々な lint ツールを導入するといった方法では完全に解決することが困難です。
そのため、今回はこれらの lint ツールを使いつつ、また全てのファイルに対してコーディングスタイルを統一できるような手段について検討しました。

EditorConfig

まず、前述の問題を解決するような、言語を問わずコーディングスタイルを統一するツールとして、EditorConfig というツールが存在します。

EditorConfig は、開発者ごとのエディタや IDE の設定によってコーディングスタイルに差異が生まれてしまう問題を解決しようとしているツールの 1 つです。
.editorconfig という ini 形式のファイルをプロジェクトに配置し、それを各エディタや IDE が参照することで、設定内容に従って自動的にコーディングスタイルが統一されるようになります。

各自が自分の利用しているエディタや IDE に EditorConfig プラグインを入れる必要があるため少し抵抗になりますが、それを上回るメリットが存在すると感じたため、今回は開発チーム全体にプラグインを導入してもらうことにしました。

.editorconfig は以下のように設定し、各プロジェクトのルートディレクトリに配置しました。

root = true

[*]
end_of_line = lf
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

Filelint

EditorConfig を用いることで新規プログラムではコーディングスタイルが統一されますが、既存プログラムに対して適用することはできません。
そこで、EditorConfig のように言語を問わずコーディングスタイルを統一できる Golang 製の lint ツールを作成しました。

github.com

https://user-images.githubusercontent.com/11088009/27952943-16962632-6345-11e7-896f-f6d43aff084b.gif

Filelint では、以下の一般的なコーディングルールを提供しています。

ルール名 内容
indent (実験的なため不安定) ハードタブ (タブ文字) でのインデントあるいはソフトタブ (半角スペース) でのインデントを強制する
linebreak 改行コードを LF または CRLF に強制する
first-newline ファイルの最初に改行を必須にする / しない
final-newline ファイルの末尾に改行を必須にする / しない
no-bom UTF-8 BOM の挿入を禁止する
no-eol-space 各行の末尾が半角スペースあるいはタブ文字で終わることを禁止する

インデントの強制に関しては、Filelint は構文を解析していないため、うまくインデントが解析できないことがあります。 そのため、ESLint や RuboCop といった構文を解析する lint ツールを併用することを推奨しています。

使い方

ESLint 風のコマンドで、特定のディレクトリやファイルに対してコーディングスタイルに関する lint が実行できます。

# 現在のディレクトリ下のテキストファイルに再帰的に lint をかける
filelint
# README.md に lint をかける
filelint README.md
# 現在のディレクトリ下のテキストファイルに再帰的に lint をかけて自動修正する
filelint --fix
# some/dir 下のテキストファイルに再帰的に lint をかけて自動修正する
filelint some/dir --fix

以下のような YAML 形式の設定ファイルを .filelint.yml というファイル名でプロジェクトに配置することで、各 lint ルールの設定の他、特定のファイルを除外したり、特定のファイルには別のルールを適用することができます。

files:
  exclude:
    - '**/*.min.css'
    - '**/*.min.js'
targets:
  - patterns: ['**/*']
    rules:
      indent:
        enforce: false
      linebreak:
        enforce: true
        style: lf
      first-newline:
        enforce: true
        num: 0
      final-newline:
        enforce: true
        num: 1
      no-bom:
        enforce: true
      no-eol-space:
        enforce: true
  - patterns: ['**/*.md']
    rules:
      no-eol-space:
        enforce: false

インストール方法

GitHub Releases で各 OS 向けのバイナリを含んだパッケージを公開しています。 ここからダウンロードして解凍し、 $PATH に通されているディレクトリ下にバイナリファイルを配置してください。

Golang の環境がある場合は以下のコマンドでインストールできます。

go get github.com/synchro-food/filelint

実際の運用

前述の .filelint.yml はあるプロジェクトで利用しているもので、これを基本として各プロジェクトに配置し、ベースブランチにて filelint --fix コマンドで自動修正しました。

また、すでにベースブランチから切られている開発中ブランチに関しても、コンフリクトを避けるために以下の手順で各開発者の手元で修正して頂きました。

# 適用するブランチ (開発中のブランチ) に移動する
git checkout <branch name>

# .filelint.yml が追加されたコミットを現在のブランチに適用する
git cherry-pick $(git log --reverse origin/master --format="%H" -- .filelint.yml | head -1)

# Filelint を実行する
# (一時的に --rule オプションを用いてインデント修正をしています)
filelint --rule 'indent: {enforce: true, size: 2, style: soft}' --fix $(git diff $(git merge-base origin/master HEAD) --diff-filter=d --name-only)

今後継続的にコーディングスタイルを統一させるため、CI を用いた自動コードレビューシステムを現在構築しています。

最後に

Filelint は社内初の OSS プロダクトです。 今後 OSS 活動を推進していきたいと思っていますので、バグ報告や機能追加要望、PR などお待ちしております。

シンクロ・フードでは OSS 活動に興味のあるエンジニアも募集しています。 ご興味のある方は以下よりご連絡ください。

徳丸浩先生をお招きしてセキュリティ研修を行いました!

こんにちは、シンクロ・フードの大久保です。
弊社では今年の4月に入社した新卒エンジニアを対象に、セキュアスカイ・テクノロジー社(以下SST社)の「eラーニング研修」+「1日オンサイト研修」を行いました。

安全なWebサイト構築のための教育[eラーニング] | Webセキュリティ教育サービス | SST 株式会社セキュアスカイ・テクノロジー

「オンサイト研修」では、あの徳丸本でお馴染み、徳丸浩先生が来ていただけるとのこと!eラーニング研修を受けていない既存エンジニアもオンサイト研修には参加OK、ということだったので、エンジニア全員、徳丸浩先生のオンサイト研修に参加しました。

f:id:synchro-food:20170720170537j:plain SST社の乗口社長もお越しいただきました

f:id:synchro-food:20170720170801j:plain 前半はWebサイトをめぐる状況についてのお話し

f:id:synchro-food:20170720182317j:plain 後半はチームに分かれてディスカッション&発表

私物の徳丸本を持参してサインをもらうエンジニアもちらほら…。

参加者は若いメンバーが多いため、セキュリティを学ぶ動機付けを目的としたオンサイト研修でした。
これからエンジニアとしてキャリアをスタートする新卒メンバーに、セキュリティの大切さや興味を持つきっかけになれば良いと思っています。

徳丸浩先生、SST社乗口社長、ありがとうございました!

尚、シンクロ・フードでは、エンジニアを募集しています。 こういった研修も積極的に実施していく予定ですので、興味があれば是非採用ページをご覧ください!

キャリア採用 | シンクロ・フード採用サイト

ReactでLINE風チャット画面を実装してみた

シンクロ・フードでフロントエンドの開発を担当している四之宮です。
今回は、前回のブログで宣言した通り、ReactでLINE風チャット画面を実装してみたことについてお話ししたいと思います。

ReactとCSSがある程度わかることが前提になります。

作成した機能について

この機能は、弊社が運営している店舗デザイン.COMのサイト内で提供されるサービスになります。

www.tenpodesign.com

店舗デザイン.COMでは、「店舗の出店や改装を考える方と店舗のデザインや施工をおこなうデザイン会社とを結びつける」ということを行っています。
機能の実装をお話しする前に、簡単にではありますが店舗デザイン.COMの説明をしたいと思います。

まず、店舗の出店や改装を考える方(以後施主と表記)は、店舗のイメージや情報を登録します。
その後、この情報をデザイン会社に配信し、興味を持ったデザイン会社がエントリーをします。
施主は、エントリーがあったデザイン会社の過去の作品や情報を見て、もっと詳細な情報を知りたいということになると、実際のやりとりが開始します。

ここで、今回作成したやり取りの機能が使用されます。

実際の画面

まず最初に作成した画面をご紹介したいと思います。
こちらの画面は、施主側のスマホ版のページになります。

f:id:synchro-food:20170427190641p:plain

一覧表示について

簡単な概要は下記の通りです。

  • 画面を開くと、最新の10件が表示される
  • 最下部が最新メッセージで、上にいくほど古い
  • 続きの読み込みはボタンのタップ
  • 前後のメッセージで日付けが変わる場合は、日付を挿入する
  • 送信メッセージ
    • 表示エリアは右寄せで表示
    • 背景は白
    • 相手が読んでいれば「既読」と表示
  • 受信メッセージ
    • 表示エリアは左寄せで表示
    • 相手の名前を表示
    • 背景はグレー

ここからは、ちょっと大変だったポイントを掻い摘んで説明したいと思います。

並び順と日付け挿入について

配列には新しい順でデータを持ちますが、表示では逆順で表示する必要があります。
これは、reverse()をすれば解決です。
当たり前ですが、Immutable.jsなどを使って、propsのmessagesを直接reverse()しないように注意してください。
日付け挿入に関しては、直前のメッセージと比較して、日付が違っていたら日付けを表示するようにします。

下記は、MessageListというコンポーネントのrender部分を一部抜粋したものになります。

<div className="messageList js-messageList">
    {
        MessageList.getReverseMessages(this.props.messages).map((message, index, messages) => {
            let prevMessage = {}
            if (index !== 0) {
                prevMessage = messages[index - 1]
            }
            if (MessageList.isChangeDate(message, prevMessage)) {
                return (
                    <div className="messageList__itemWrap" key={message.keyId}>
                        <div className="messageList__item messageList__item--date">
                            {MessageList.showDate(message.createDate)}
                        </div>
                        <div className="messageList__item">
                            <Message message={message} />
                        </div>
                    </div>
                )
            }
            return (
                <div className="messageList__itemWrap" key={message.keyId}>
                    <div className="messageList__item">
                        <Message message={message} />
                    </div>
                </div>
            )
        })
    }
</div>

日付単位でulで実装したかったのですが、綺麗に日付毎表示されるわけでもないので、渋々div実装になっています。

補足

  • getReverseMessagesは引数のthis.props.messagesをreverseするメソッド
  • isChangeDateは直前のメッセージと日付けが同じか判定するメソッド
  • Messageはこの後に説明します

続きを読むなどの時にスクロール位置を保持する

続きを読みこむと、一番上に続きのメッセージが積まれていきます。
そして、読み込むためのボタンは一番上にあります。
そのため、続きを読みこむとスクロールの位置が一番上になってしまいます。
これを制御するための処理が下記になります。

下記は、MessageListというコンポーネントを一部抜粋したものになります。

constructor(props) {
    super(props)
    this.state = {
        prevHeight: 0,
    }
}

componentDidMount() {
    const height = $('.js-messageList').height()
    const heightDiff = height - this.state.prevHeight
    $('body').scrollTop(heightDiff === 0 ? $('body')[0].scrollHeight : heightDiff)
}

componentWillReceiveProps(nextProps) {
    if (nextProps.messages !== this.props.messages) {
        const prevHeight = $('.js-messageList').height()
        this.setState({ prevHeight })
    }
}

componentDidUpdate(prevProps) {
    if (prevProps.messages !== this.props.messages) {
        const height = $('.js-messageList').height()
        const heightDiff = height - this.state.prevHeight
       $('body').scrollTop(heightDiff === 0 ? $('body')[0].scrollHeight : heightDiff)
    }
}

componentWillReceivePropsを使用しているのは、メッセージの配列を管理しているが、このファイル内ではないためです。
そして、jQueryも使用しています。
賛否両論あるかと思いますが、個人的にそんなに嫌いではないので、Reactの中でも使用することがあります…。
もちろんDOM操作はしませんが。

また、新着メッセージの受信については、setTimeoutなどで受信を監視する必要があります。

受信メッセージの表示について

送受信にはそれぞれに決まったcssをあてる、日付けが変わる場合は日付を挿入するという2点を実現できればいいということです。
そして、先ほど出てきていた、Messageコンポーネントがここにあたります。
下記は、Messageというコンポーネントを一部抜粋したものになります。

const getMessageClass = (message) => {
    if (message.isSend) {
        return 'send'
    }
    return 'receive'
}

const replaceBr = text => (
    text.split('\n').map((line, index) => <p key={index}>{line}</p>)
)

return (
    <div className={`messageBox messageBox--${getMessageClass(props.message)}`}>
        <div className="messageBox__inner">
            {!props.message.isSend && <p className="messageBox__name">{props.message.name}</p>}
            {props.message.message && <p className="messageBox__message">{replaceBr(props.message.message)}</p>}
            {(() => {
                if (props.message.file) {
                    return (
                        <div className="messageBox__file">
                            添付ファイルのアイコンなどを表示する処理
                        </div>
                    )
                }
                return ''
            })()}
        </div>
        {(props.message.isAlreadyRead && props.message.isSend) && <p className="messageBox__alreadyRead">既読</p>}
    </div>
)

ここからはCSS(scss)です。
今更ではありますが、弊社ではMindBEMdingによる命名規約で、クラス名をつけています。
こちらも、必要な箇所を一部抜粋したものになります。

.messageList {
    &__itemWrap {
        &:not(:first-child) {
            margin-top: 20px;
        }
    }

    &__item {
        overflow: hidden;

        &:not(:first-child) {
            margin-top: 20px;
        }

        &--date {
            font-size: 14px;
            color: #666666;
            text-align: center;
            display: flex;
            align-items: center;
        }
    }

    &__item--date {
        // 日付けは中央で左右に線を引く
        &:before, &:after {
            border-top: 2px solid #E6E6E6;
            content: "";
            display: inline;
            flex-grow: 1;
        }

        &:before {
            margin-right: 0.5em;
        }

        &:after {
            margin-left: 0.5em;
        }
    }
}

.messageBox {
    &--send {
        float: right;
    }

    &--receive {
        float: left;
    }

    &__inner {
        border-radius: 16px;
        padding: 10px;
        box-sizing: border-box;
        display: inline-block;

        .messageBox--send & {
            background-color: #fff;
            border: solid 1px #ccc;
        }

        .messageBox--receive & {
            background-color: #f2f2f2;
        }
    }

    &__alreadyRead {
        margin-top: 5px;
        text-align: right;
    }
}

あまり関係ないですが、jsxからだとeCSStractorがそのまま使えないのが個人的にとても悲しいです。
HTMLから自動でCSSのセレクタを生成してくれるので、とても便利です。
しかもBEM記法にも対応しています。
jsxで使うときは、一旦classNameをclassに置換して生成して戻すみたいなことしています…。

https://packagecontrol.io/packages/eCSStractorpackagecontrol.io

まとめ

以上が、「ReactでLINE風チャット画面」の解説になります。
それぞれの分野でお詳しい方からすれば、ReactもCSSも大したことない内容ではあると思いますが、ReactとCSSの知識がそれぞれある程度必要という意味で面白そうでしたので記事にしてみました。

またコード量の問題で、一部抜粋する形での記載になっているので、解り辛い点もあるかと思います。
その際はお気軽にお問合わせいただければと思います。

最後に、シンクロ・フードではエンジニアを募集しています。
少しでもご興味があれば、気軽にお話しする場を設けますので、以下よりご連絡ください!

www.synchro-food.co.jp

Compassのsprite-mapによるスプライト画像生成を、spritesmithに移行する

シンクロ・フードでフロントエンドの開発を担当している四之宮です。
最近は、ReactでLine風のやりとり機能を実装しました。
近々こちらについても、ご紹介できたらと思います。

では、本題に入っていきたいと思います。
タイトルには入っていないですが、レガシーシリーズもこれが最後だと思います。

Sass+Compassについて

弊社ではSass+Compassでcssのコーディングを行っています。
今では、「Compassは終焉を迎えた」などと言われていますが当時はこれがテッパンだっと思います。
ベンダープレフィックス対応はCompassが提供する各mixin、スプライト画像の対応もCompassが提供するsprite-map、これらを使用してコーディングしました。

その後、ベンダープレフィックス対応はautoprefixerが主流となりました。

www.npmjs.com

スプライト画像に関しては、アイコンフォントを使用することでスプライト画像自体の使用をやめることも増えたのではないでしょうか?

本記事で説明すること

スプライト画像対応に絞って説明していきます。

弊社ではこのベンダープレフィックス対応とスプライト画像の対応が、Compassに依存していました。
autoprefixerへの移行に関しては、記事も多いので割愛させていただきます。

そして、スプライト画像対応に関しては、既存の画像をそのまま使用する方向で対応します。

脱Compassを行った理由

世の中の流れというものありますが、やはり一番の理由はコンパイルの速度向上を目指したためです。
scssファイルの数を増えてからコンパイルに時間がかかり、開発効率が悪いという課題がありました。

そこで、コンパイルの高速化プロジェクトが開始しました。
まず、最初に行ったことですが、@import "compass";の記述をやめました。
必要に応じて、関数名まで指定するという対応を取りました。

例えば、@include border-radius();を使用したいとします。
このとき、ファイルのトップで、@import "compass";を書いてはいけません。
@import "compass/css3/border-radius";と書きます。
この対応だけでも早くなったと体感できるレベルで改善しました。

更に、libsassを使用したgulp-sassにしたいと思ったのですが、こちらはCompass対応していないので、対応見送りとなりました。

しばらくの間は、これで満足していたのですが、慣れというのは怖いですね。この速度では満足できなくなってきました。
コンパイルの時間を見てみると、一番時間がかかっているのはスプライト画像周りの処理でした。
これを解決しない限りはどうしようもないということになり、別の方法でスプライト画像を対応しようとなりました。

スプライト画像対応の移行

gulp.spritesmithを使用します。

www.npmjs.com

npm install --save gulp.spritesmith  

これは、指定したディレクトリに格納された画像を、1つの画像にしてこれに対応するscssファイルを書き出すというものです。
詳しい使用方法はこのあと記述していきます。

gulpfile.jsの対応

本来の使い方から少し拡張して使用しています。
1サイトで1スプライト画像というのが世の中の流れなのかもしれませんが、弊社ではページ単位などで管理していたためこの対応が必要となりました。
元々、gulp.spritesmithはディレクトリ単位でスプライト画像を生成する機能を持たないため、これに対応するための書き方をしています。
これを実現するために、更にglobbyというnpmを使用します。

www.npmjs.com

更に、複数のストリームがある状態で非同期化したいので、merge-streamというnpmを使用します。

www.npmjs.com

1サイトで1スプライト画像の構成になっていれば、この対応は不要になります。

var spritesmith = require("gulp.spritesmith");  
var globby = require('globby');  
var Merged = require('merge-stream');  
  
var root = "src/main/webapp",  
config = {  
   "path" : {  
      "htdocs"    : root,  
      "spriteSass"      : root+"/spriteSass",  
      "spriteImage"    : root+"/spriteImage",  
      "image"     : root+"/image/sprite"  
   }  
};  
  
gulp.task('sprites', ['cleanやcopyなど'], function() {  
    var merged = new Merged();  
  
    globby('src/main/webapp/image/sprite/**/*.png').then(paths => {  
        paths.forEach(function(path) {  
            var filePath = path.match(/^(.+\/)(.+?)(\/.+?\..+?)$/);  
            var spritePath = (filePath[1] + filePath[2]).replace("src/main/webapp/image/sprite", '');  
            var spriteData = gulp.src(filePath[1] + filePath[2] + '/*.png')  
                .pipe(plumber())  
                .pipe(spritesmith({  
                    imgName: filePath[2] + '.png', //生成される画像名を指定します(ここでは各ディレクトリ名になります)  
                    imgPath: '/spriteImage' + spritePath + '/' + filePath[2] + '.png', //生成される画像のパスを指定します  
                    cssName: filePath[2] + '.scss', //生成されるscss名を指定します(ここでは各ディレクトリ名になります)  
                    algorithm: 'top-down', //スプライト画像になるときの画像の並びを指定します  
                    padding: 100 //スプライト画像になるときの、各画像の間隔を指定します  
                }));  
            var imgStram = spriteData.img.pipe(gulp.dest(spriteImage + spritePath));  
            var SassStram = spriteData.css.pipe(gulp.dest("src/main/webapp/spriteSass/" + spritePath));  
  
            merged.add(imgStram);  
            merged.add(SassStram);  
        });  
    });  
    return merged;  
});  

algorithmについては以下が指定できます。
※sprite-mapにあった$position: 100%のような指定はできないようです

https://github.com/twolfson/gulp.spritesmith#algorithms

ここでは使用していないオプションも他にも用意されています。

https://github.com/twolfson/gulp.spritesmith#documentation

scssファイルの対応

  1. sprite-mapでgrep
  2. 対象のscssファイルのsprite-mapの記述を、gulp.spritesmithで生成された対象のscssをimportするように修正
  3. スプライト画像設置用mixinの呼び出しを消す
    • mixin化していなかったらスプライト画像呼び出しの記述を全て消す
  4. 画像を表示するセレクターに@include sprite(○○);のような記述をしていく
    • spriteという関数はgulp.spritesmithで生成されたscss内に宣言されたmixinです
    • ○○にはimportしたスプライト用scss内にある変数を記述します
    • 恐らく$画像名となっているはずです

アイコンなどの設置を空要素で行っていればこの手順でOKです。
もし、空要素ではなくテキストなどが書かれたタグに対して、アイコンを設置していた場合は、アイコン用の要素を設置する必要があります。
弊社ではこの形になっていたので、かなり厳しい対応となりました。
これについては、後ほど実際のコードがでてくるところで説明します。

scssのコンパイル処理の書き換え

Compass依存がなくなったので、gulp-sassを使用してコンパイルするようにします。
gulp-ruby-sassというものもありますが、libsassを使用しているgulp-sassの方が処理が早いので、こちらを選択しました。

www.npmjs.com

npm install --save gulp-sass  

gulpfile.jsの対応

var sass = require('gulp-sass');  
  
gulp.task('sass', ['clean'], function () {  
    return gulp.src('src/main/webapp/sass/**/*scss')  
        .pipe(sass({includePaths: ['src/main/webapp/sass/', 'src/main/webapp/spriteSass/']}).on('error', sass.logError))  
        .pipe(gulp.dest('src/main/webapp/stylesheets/'));  
});  

src/main/webapp/spriteSass/はspritesmithで生成されたscssファイルがあるディレクトリを指定しています。

全体像

gulp.task('sprites', ['cleanやcopyなど'], function() {  
    var merged = new Merged();  
  
    globby('src/main/webapp/image/sprite/**/*.png').then(paths => {  
        paths.forEach(function(path) {  
            var filePath = path.match(/^(.+\/)(.+?)(\/.+?\..+?)$/);  
            var spritePath = (filePath[1] + filePath[2]).replace("src/main/webapp/image/sprite", '');  
            var spriteData = gulp.src(filePath[1] + filePath[2] + '/*.png')  
                .pipe(plumber())  
                .pipe(spritesmith({  
                    imgName: filePath[2] + '.png',  
                    imgPath: '/spriteImage' + spritePath + '/' + filePath[2] + '.png',  
                    cssName: filePath[2] + '.scss',  
                    algorithm: 'top-down',  
                    padding: 100  
                }));  
            var imgStram = spriteData.img.pipe(gulp.dest(spriteImage + spritePath));  
            var SassStram = spriteData.css.pipe(gulp.dest("src/main/webapp/spriteSass/" + spritePath));  
  
            merged.add(imgStram);  
            merged.add(SassStram);  
        });  
    });  
    return merged;  
});  
  
gulp.task('spritesSass', ['sprites'], function () {  
    return gulp.src('src/main/webapp/sass/**/*scss')  
        .pipe(sass({includePaths: ['src/main/webapp/sass/', 'src/main/webapp/spriteSass/']}).on('error', sass.logError))  
        .pipe(gulp.dest(cssPath));  
});  
  
gulp.task('sass', function () {  
    return gulp.src('src/main/webapp/sass/**/*scss')  
        .pipe(sass({includePaths: ['src/main/webapp/sass/', 'src/main/webapp/spriteSass/']}).on('error', sass.logError))  
        .pipe(gulp.dest(cssPath));  
});  
  
gulp.task("spritesAutoprefixer", ['spritesSass'], function() {  
    return gulp.src(cssPathRegexp)  
        .pipe(autoprefixer({  
            browsers: ['last 2 version', 'IE >= 9', 'iOS >= 8.1', 'Android >= 4.4'],  
            cascade: false  
        }))  
        .pipe(gulp.dest(cssPath));  
});  
  
// watchタスク  
gulp.task('watch', ['spritesAutoprefixer'], function () {  
    gulp.watch("src/main/webapp/sass/**/*scss", ['sass']);  
    gulp.watch('src/main/webapp/image/sprite/**/*.png', ['spritesSass']);  
});  

実際のコーディング

  1. スプライト用画像をディレクトリへ入れる
    • 自動でスプライト画像の生成が行われ、これに対応するscssファイルも生成される
    • このscssファイルの中に、スプライト画像を読み込むためのmixinや変数が宣言されている
  2. このscssファイルをスプライト画像でimportする
    • こんな感じで呼び出す @include sprite($next-button);
  3. このmixinによって、画像の縦横幅を指定されるので、空要素を置いて対応する
    • inline要素の場合、blockやinline-blockにするのを忘れないように

画像ディレクトリから以下のようなファイルが出来上がったとします。
対象のディレクトリにはexample_iconがあったということです。

  • sprite.scss
$example-icon-name: 'example_icon';  
$example-icon-x: 0px;  
$example-icon-y: 1591px;  
$example-icon-offset-x: 0px;  
$example-icon-offset-y: -1591px;  
$example-icon-width: 50px;  
$example-icon-height: 50px;  
$example-icon-total-width: 50px;  
$example-icon-total-height: 1641px;  
$example-icon-image: '/spriteImage/top/sprite/sprite.png';  
$example-icon: (0px, 1591px, 0px, -1591px, 50px, 50px, 50px, 1641px, '/spriteImage/top/sprite/sprite.png', 'example_icon', );  
  
@mixin sprite-width($sprite) {  
  width: nth($sprite, 5);  
}  
  
@mixin sprite-height($sprite) {  
  height: nth($sprite, 6);  
}  
  
@mixin sprite-position($sprite) {  
  $sprite-offset-x: nth($sprite, 3);  
  $sprite-offset-y: nth($sprite, 4);  
  background-position: $sprite-offset-x  $sprite-offset-y;  
}  
  
@mixin sprite-image($sprite) {  
  $sprite-image: nth($sprite, 9);  
  background-image: url(#{$sprite-image});  
}  
  
@mixin sprite($sprite) {  
  @include sprite-image($sprite);  
  @include sprite-position($sprite);  
  @include sprite-width($sprite);  
  @include sprite-height($sprite);  
}  
  
@mixin sprites($sprites) {  
  @each $sprite in $sprites {  
    $sprite-name: nth($sprite, 10);  
    .#{$sprite-name} {  
      @include sprite($sprite);  
    }  
  }  
}  

スプライト画像の呼び出しは以下の通り。

@import "sprite";  
  
.exampleIcon {  
    @include sprite($example-icon);  
  
    display: inline-block;  
}  

ここで使用しているspritesという関数ですが、見ての通りですがwidthheightが固定化されます。
これが、スプライト画像を配置する要素を別で用意しなければならない理由です。

また、sprite.scssには変数としていくつか宣言されているので、こちらも使用することができます。
たとえば、$example-icon-widthはスプライト画像として連結される前の画像の横幅です。

retina対応画像の場合

少し対応が変わります。
spritesmithにはretina対応するためのオプションが用意されているのですが、弊社ではこれを使用しませんでした。
使用しないというよりも、弊社では使用できませんでした。
このオプションが使用できるには条件があります。

  • 2x用の画像とそれに対応する1xの画像を用意する
  • この2xと1xの画像は正確に2倍の関係である

弊社では、2x画像のみで1xがないという場合がありました。
1xと2xの両方を用意して対応していたんですが、世の中のスマホが2xが主流となり、1x対応をやめたという背景があります。
更に、2x画像のwidthが奇数値というものもあり、1xの画像を新たに用意することもできませんでした。

そのため、retina対応に関しては、独自の対応をとることにしました。

retina画像用のmixinの作成

自作といっても上記のsprite.scssをベースに作成した形になります。
下記の通り、sprite.scssにあった各種mixinを○○-2xとし、サイズ関係を全て2で割っただけです。
retina画像の場合、以下のscssファイルをimportして、使用するmixinを-2xシリーズにすればOKです。

@charset "UTF-8";  
  
@mixin sprite-width-2x($sprite) {  
    width: nth($sprite, 5) / 2;  
}  
  
@mixin sprite-height-2x($sprite) {  
    height: nth($sprite, 6) / 2;  
}  
  
@mixin sprite-position-2x($sprite) {  
    $sprite-offset-x: nth($sprite, 3) / 2;  
    $sprite-offset-y: nth($sprite, 4) / 2;  
  
    background-position: $sprite-offset-x $sprite-offset-y;  
}  
  
@mixin sprite-image-2x($sprite) {  
    $sprite-image: nth($sprite, 9);  
  
    background-image: url(#{$sprite-image});  
    background-size: (nth($sprite, 7) / 2) (nth($sprite, 8) / 2);  
}  
  
@mixin sprite-2x($sprite) {  
    @include sprite-image-2x($sprite);  
  
    @include sprite-position-2x($sprite);  
  
    @include sprite-width-2x($sprite);  
  
    @include sprite-height-2x($sprite);  
}  

まとめ

以上が脱Compassまでの手順になります。
正直かなり面倒な作業です。

多少時間がかかりますが、無心で対応すればやりきれます。
更に大変なのがテストですが、弊社ではこのタイミングでBackstopJSを導入して、リグレッションテストを自動化しました。
こちらに関しても、詳しく説明された記事も多いので、興味がある方は調べてみてください。簡単に導入できると思います。
もちろんリクエストがあればご説明いたします。

さて、最後に衝撃的な告白です。
ここまで全てやりきったような口ぶりで書いてきましたが、この対応による修正は日の目を見ることはありませんでした。
というのも、この対応をする際、弊社が運営しているサイトの中でも比較的小規模なサイトを選んだのですが、工数がかかり過ぎました。
この工数のほとんどは、スプライト画像を配置するために要素を新たに設置するというものです。
今でこそアイコン関連は空要素に対して配置していますが、記事内でも紹介した通り以前まではテキストなどと同じ要素に対してアイコンを設置していました。
比較的小規模なサイトでも大変だったのに、より大規模なサイトで対応するのは現実的ではなく、サイト間でコーディング方法がずれるのはよくないということで、見送りとなりました。

ただ、一通りテストは行っているので、この対応に関しては問題ないと思います。

といったように既存コードの改修は一旦諦めて、新しいコードは当たり前ですがアイコンフォントを使用する方針で実装しています。
デザイナーさんにもアイコンは可能な限り単色で用意していただくようにしています。

最後に、シンクロ・フードではエンジニアを募集しています。
少しでもご興味があれば、気軽にお話しする場を設けますので、以下よりご連絡ください!

www.synchro-food.co.jp

Tomcatを高速に起動する

こんにちは、シンクロ・フードの大久保です。最近はAWSのLambdaを触っています。近々、そのネタもブログにしようと思っています。

さて、今回はTomcatの起動高速化のお話しをしようかと思います。Railsもやっていますが、なんだかんだ言って弊社はTomcatとの付き合いが長いので…。

まず、Tomcatの起動高速化する方法を紹介する記事としては、以下のWikiが有名です。
https://cwiki.apache.org/confluence/display/TOMCAT/HowTo+FasterStartUp

今回はこの記事を軽く紹介していく、という、新しいことが特にあるわけでもない記事です…。
とはいえ、元記事を読んだことが無い方には参考になるかもしれませんので、もしこの記事を読んで興味が湧いたら、元記事を読んでみてください。

尚、弊社の本番環境で運用されているTomcatは、サーバを切り替えながら再起動をする仕組みができているため、起動速度を上げたいというニーズがなく、本番環境で適用している設定はありません。弊社では開発環境や、テスト環境などで設定をしています。

高速化手法 その1:web.xmlにmetadata-complete=trueとabsolute-orderingを設定する

Wikiによると、WEB-INF/web.xmlに以下の設定を加えることで、高速化される、とのこと。
1. web-app要素に、metadata-completeという属性を設定し、値をtrueにする
2. absolute-orderingという空要素を追加する

そもそも、Servlet3.0にて追加された、各種便利機能(Servlet開発を楽にするアノテーションなど)のために、Tomcatは起動時にJavaのclassファイルをscanするのですが、これが起動速度に影響します。弊社のようにSeasar2を使っている場合、Servlet3.0で追加された機能が無くても開発に支障はでないため、scanが不要だったりします。
この設定は自身のプロジェクトにあるclassファイルやライブラリをscanしないようにさせる設定です。

適用例はこんな感じ

<web-app xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd"  
version="2.4" metadata-complete="true">  
  
    <absolute-ordering />  

実際に手元のPC(Intel Corei7-4750HQ、メモリ16GB)にある弊社サイト「飲食店.COM」の起動時間を適用前/後で比較したところ、起動時に大体6600msくらいかかっていたのが、2300msになりました(10回起動した平均値)。
ただし、クラスや依存ライブラリの少ないWebアプリケーションで比較すると、ほとんど効果を感じられませんでした。
当然ながら、クラスやライブラリの数が多ければ多いほど効果があるようです。

高速化手法1~4は、この「jarスキャンを減らすことで速度を上げる」という方向性での高速化を行なっています。

高速化手法 その2:不必要なjarファイルを消す

当たり前ですが、不要なjarファイルが無い方がスキャン対象が減って起動速度が早くなります。

高速化手法 その3:jarファイルをスキャンから除外する

Tomcat7の場合、catalina.propertiesに除外するjarを記載することでスキャンするjarファイルを指定できます。

tomcat.util.scan.DefaultJarScanner.jarsToSkip=\  
bootstrap.jar,commons-daemon.jar,tomcat-juli.jar,\  
...(省略)  
除外したいjarファイル.jar  

Tomcat8ではcatalina.propertiesの他に、context.xmlにある要素の下に、要素を追加して除外もできますが、弊社はcatalina.propertiesに設定をしてしまっています。
この手法は個別にスキャンをスキップするjarファイルを指定することができるので、1の手法よりも少し安心感があります。
当然ですが、除外するjarファイルが多ければ多いほど、起動が早くなります。

高速化手法 その4:WebSocketサポートを切る

TomcatでWebSocketを使わない、という場合は、WebSocketを無効化しましょう、という内容です。
実際には、tomcat/libの下にある、tomcat7-websocket.jar、tomcat-websocket.jarなどを消せば良いです。
こちら、ここに言及した高速化手法をまったくしていない環境に対して実施すると、20%ほど起動が高速化します(6600msが4400msくらい)。
ただし、他の手法でjarファイルのスキャンを無効化していると、WebSocket周りのjarスキャンも無効化されているため、jarファイルを消す意味はないです。
つまり、jarのスキャンは無効化したくないけれども、WebSocketは使わない、という場合に有効な手法ですね。

高速化手法 その5:Entropy Sourceをnon-blockingな/dev/urandamを指定する

Tomcatは起動時にSecureRandamを初期化するために、/dev/randamからホストサーバのノイズを集めて乱数生成をするのですが、/dev/randamは十分なノイズが集まるまではブロックをします。こちらを、ブロックしない/dev/urandamを使うことで高速化する、というのがこちらの内容です。
この2つの違いはTomcat起動とは関係なく、様々なブログなどで言及されているので、あまり詳細には書きません。
具体的な指定としては、以下を起動オプションに加えます。

-Djava.security.egd=file:/dev/./urandom  

高速化手法 その6:プロジェクトを並列起動する

一つのTomcatで複数プロジェクトを起動している場合、デフォルトでは一つ一つ直列に起動するのですが、これを並列起動することで速度を上げることができます。
弊社は一つのTomcatで複数サイトを運営しているため、この設定でかなり起動速度が上がっています。
具体的には、Host要素にstartStopThreads属性を追記し、ここに並列起動数を記載します。0を指定すると、CPUのコア数分起動するので、Tomcat以外のアプリケーションがCPUを常時使っていないのであれば、0で良いと思います。
https://tomcat.apache.org/tomcat-8.0-doc/config/host.html

まとめ

Wikiに記載されているのは以上です。地味ーな内容ですが、Tomcat起動が遅くてイライラ…という方がいらっしゃればお試しください。
少しでも興味があれば、元記事を読んでいただくのが良いと思います。
https://wiki.apache.org/tomcat/HowTo/FasterStartUp

シンクロ・フードではエンジニアを募集しています。Tomcat、好きだなあ…という方、是非ご応募ください! もちろんRubyでもWebアプリケーションを書いているので、Java以外でもWebアプリケーション書きたい、という方も募集中です。

www.synchro-food.co.jp

レガシーなフレームワークでcssの圧縮を自動化する

シンクロ・フードでフロントエンドの開発を担当している四之宮です。
前回投稿した「レガシーなフレームワークでcss/javascriptのキャッシュクリアを自動化する」が思っていた以上にアクセスがありましたので、今回はその続編となります。
本記事からでも理解できるようにしたつもりですが、上記の記事から読んでいただくとより理解していただけるかなと思います。

ちなみに、前回の記事を投稿してから約2か月ですが、この間にReactを使ったサービスの開発を行いリリースしています。
弊社のReact導入の手順に関しては、下記の通りになります。
ご興味があれば是非ご確認ください。
既に稼働しているWebサービスに対してgulp+webpackでReactをビルドする

さて、いつもレガシーばかりではないこともアピールできたので、本題に入りたいと思います。

CSSの圧縮を行う方法

弊社ではgulpによるscssのコンパイルを行っているため、gulp-cssminというnpmを使用しました。

gulp-cssmin

下記コマンドでインストールを行います。

npm install --save-dev gulp-cssmin  

実際の使用例は下記の通りです。

var cssmin = require('gulp-cssmin');  
  
gulp.task('cssmin', ['sassのbuildなど'], function () {  
    return gulp.src('dest/webapp/**/*.css')  
        .pipe(cssmin(  
            {  
                'processImport': false,  
                'advanced': false  
            }  
        ))  
        .pipe(gulp.dest('dest/webapp/'));  
});  

圧縮する際のオプションは以下のものが設定できます。
https://github.com/jakubpawlowicz/clean-css

今回使用しているオプションは2つあります。
どちらのオプションも圧縮前のcssの記述方法によっては、致命的なバグを生んでしまうので注意が必要です。
また、圧縮前のcssは可能な限り綺麗な方が圧縮による思わぬバグを防げるため、lintを通してから行うのもいいかと思います。

※弊社では、この2つのオプションで運用できていますが、導入する環境に応じて適切なオプションを指定してください

processImport

css内の@importを圧縮の際に残すためのオプションです。
@importを使うことはないかと思いますが、弊社のcssは本当に古いものがありこれの対応をする必要がありました。
@importを使っている際は注意が必要ですが、使っていないのであればこの指定は不要になります。

advanced

font-sizefontborder-colorborderといったような、親子関係のようなプロパティが指定されているとき、これらをまとめあげるかを決めるオプションになります。
'advanced': falseを指定しなかった場合の例は下記の通りです。

圧縮前

.test {  
    border-color: #aaa;  
    border: solid 1px;  
}  

圧縮後

.test {  
    border: solid 1px;  
}  

'advanced': falseの指定が無いと、border-colorの指定が無くなってしまいます。


以上でcssの圧縮処理は完了です。
しかし、弊社では圧縮前のファイルと、圧縮後のファイルを別名で保存する対応を行っています。
ここからはそれについて説明していきたいと思います。

圧縮後のCSSの名前を変更する

gulp-renameというnpmを使用します。

gulp-rename

下記コマンドでインストールを行います。

npm install --save-dev gulp-rename  

使用例は下記の通りです。
suffixでリネイム後のファイル名を指定します。
ここでは、○○.min.cssにするためsuffix: '.min'を指定しています。
これで、元のファイル名.min.cssというファイル名でファイルがコピーされます。

var rename = require('gulp-rename');  
  
gulp.task('rename', function () {  
    return gulp.src('dest/webapp/**/*.css')  
        .pipe(rename({suffix: '.min'}))  
        .pipe(gulp.dest('dest/webapp/'));  
});  

これを先ほどの圧縮と合わせます。

var cssmin = require('gulp-cssmin');  
var rename = require('gulp-rename');  
  
gulp.task('cssmin', function () {  
    return gulp.src('dest/webapp/**/*.css')  
        .pipe(cssmin(  
            {  
                'processImport': false,  
                'advanced': false  
            }  
        ))  
        .pipe(rename({suffix: '.min'}))  
        .pipe(gulp.dest('dest/webapp/'));  
});  

これで、圧縮後のファイルが、○○.min.cssという名前で生成することができます。

HTMLからの読み込むcssを圧縮後のものに変更する

圧縮後のファイル名を変更しましたが、hrefで指定されているcssは圧縮されていないので、読み込むcssを変更する必要があります。

そこで、gulp-replaceというnpmを使用します。

gulp-replace

下記コマンドでインストールを行います。

npm install --save-dev gulp-replace  

使用例は下記の通りです。
書き換えの対象のcssの取得と、書き換え後のcssのファイル名を正規表現で指定する必要があります。
replaceメソッドの第1引数で対象を取得して、第2引数で書き換え指定しています。

var replace = require('gulp-replace');  
  
gulp.task('htmlreplace', function() {  
    return gulp.src('dest/webapp/WEB-INF/view/**/*.jsp')  
        .pipe(replace(/<link(.*)href=(["`]\/[^/].*)\.css/g, '<link$1href=$2.min.css'))  
        .pipe(replace(/<link(.*)href=(["`]\/[^/].*)\.min\.min\.css/g, '<link$1href=$2.min.css'))  
        .pipe(gulp.dest('dest/webapp/WEB-INF/view/'));  
});  

2つ目のreplace処理についてですが、ライブラリなどの圧縮済のmin.cssの対応のための記述になります。
元々、○○.min.cssとなっている箇所を、1つ目のreplace処理によって、○○.min.min.cssにしてしまうため、これを元に戻しています。
1つ目のreplace処理の正規表現でmin.cssを除外できれば、この処理は不要になります。
ファイル名のminと圧縮を表すminを区別することができなったので、このような形になっています。

圧縮からhtmlの書き換えまでをまとめる

ここまで行ってきた処理を全てまとめたものが下記になります。
※ライブラリ用などの圧縮済のmin.cssを除外する処理を追加しています

var cssmin = require('gulp-cssmin');  
var rename = require('gulp-rename');  
var replace = require('gulp-replace');  
  
// 各cssファイルの圧縮  
gulp.task('cssmin',  function () {  
    return gulp.src(['dest/webapp/**/*.css', '!dest/webapp/**/*.min.css'])  
        .pipe(cssmin(  
            {  
                'processImport': false,  
                'advanced': false  
            }  
        ))  
        .pipe(rename({suffix: '.min'}))  
        .pipe(gulp.dest('dest/webapp/'));  
});  
  
// cssの読み込みを圧縮したものに書き換える  
gulp.task('htmlreplace', ['cssmin'], function() {  
    return gulp.src('dest/webapp/WEB-INF/view/**/*.jsp')  
        .pipe(replace(/<link(.*)href=(["`]\/[^/].*)\.css/g, '<link$1href=$2.min.css'))  
        .pipe(replace(/<link(.*)href=(["`]\/[^/].*)\.min\.min\.css/g, '<link$1href=$2.min.css'))  
        .pipe(gulp.dest('dest/webapp/WEB-INF/view/'));  
});  

前回のキャッシュクリア自動化対応にを組み込む

更に、前回のキャッシュクリア自動化対応を組み込むと下記のようになります。
圧縮したファイルを元に、キャッシュクリアを行っています。

var CacheBuster = require('gulp-cachebust');  
var cssmin = require('gulp-cssmin');  
var rename = require('gulp-rename');  
var replace = require('gulp-replace');  
  
// css圧縮タスク  
gulp.task('cssmin', ['sassのbuildなど'], function () {  
    return gulp.src(['dest/webapp/**/*.css', '!dest/webapp/**/*.min.css'])  
        .pipe(cssmin(  
            {  
                'processImport': false,  
                'advanced': false  
            }  
        ))  
        .pipe(rename({suffix: '.min'}))  
        .pipe(gulp.dest('dest/webapp/'));  
});  
  
// キャッシュクリア準備  
var cachebust = new CacheBuster();  
  
gulp.task('cachebustResources-css', ['cssmin'], function () {  
    return gulp.src('dest/webapp/**/*.css')  
        .pipe(cachebust.resources())  
        .pipe(gulp.dest('dest/webapp/'));  
});  
  
gulp.task('cachebustResources-javascript', function () {  
    return gulp.src('dest/webapp/**/*.js')  
        .pipe(cachebust.resources())  
        .pipe(gulp.dest('dest/webapp/'));  
});  
  
// 圧縮したcssを読み込むようにして、キャッシュクリア対応  
gulp.task('convert-jsp', ['cachebustResources-css', 'cachebustResources-javascript'], function () {  
    return gulp.src('dest/webapp/WEB-INF/view/**/*.jsp')  
        .pipe(replace(/<link(.*)href=(["`]\/[^/].*)\.css/g, '<link$1href=$2.min.css'))  
        .pipe(replace(/<link(.*)href=(["`]\/[^/].*)\.min\.min\.css/g, '<link$1href=$2.min.css'))  
        .pipe(cachebust.references())  
        .pipe(gulp.dest('dest/webapp/WEB-INF/view/'));  
});  

これで、圧縮からキャッシュクリアまでを自動に行うことが可能となります。

また、弊社では、元のcss、圧縮後のcss、圧縮後のキャッシュクリア用のcssの3つが同時に存在した状態になっています。
特に問題もないですし、何かあった際の調査も全てが揃っていた方がやりやすいということからそうしています。
最終形のみでよいということであれば、不要ファイルを削除してしまってもいいと思います。

最後に

以上が、レガシーなフレームワークでcssの圧縮を自動化する方法になります。

javascriptに関しても、専用のnpmを使用し同じように組み込むことはできると思いますが、AngularでDIしている部分のコードが一部動かなくなることがわかっているため、今はストップしています。
こちらはAngularでのDIの書き方を直せば問題なく圧縮できるので、しばらくしたら導入しようと思っています。

尚、シンクロ・フードではエンジニアを大募集しています!
少しでもご興味があれば、気軽にお話しする場を設けますので、以下よりご連絡ください!

シンクロ・フード採用サイト