読者です 読者をやめる 読者になる 読者になる

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

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

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://wiki.apache.org/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の下にある、websocket-api.jarや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の書き方を直せば問題なく圧縮できるので、しばらくしたら導入しようと思っています。

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

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

既に稼働しているWebサービスに対してgulp+webpackでReactをビルドする

こんにちは。エンジニアの大久保です。年の瀬ギリギリにブログを書いてみました。

シンクロ・フードでは、半年くらい前からReactの導入を少しずつやっているのですが、今回はgulpが動作している非SPAサイトに対するReactビルド環境を紹介したいと思います。

「Reactを使ってみたいけれど、ゼロからプロジェクトを作る機会は少ない。だから稼働中のWebサイトに対してReactを使いたい。でもどのような環境にしているのだろう」という方向けに、一つの事例として参考にしていただければと思います。
結論から言うと、gulp+webpack+babelの組み合わせでReactのJSXをビルドし、JavaScriptは複数のエンドポイントに出力する、という方法を取っています。
尚、RailsでReactを使う場合は違う方法をとっているため、今回はあくまでもgulpでのビルドプロセスがある環境、且つSPAではないサイトにReactを導入する場合の事例です。

gulp+webpackでのビルド

弊社では、すでにgulpでのSassビルドを行なっていたため、gulp経由でReactをビルドしたいと思っていました。
gulp経由でReactをビルドする構成としては、以下の2つが候補に挙がるのではないでしょうか。

  • gulp+browserify+babelify
  • gulp+webpack+babel

弊社では両方とも設定してみた結果、設定としてスッキリする、という理由からwebpackを使ったほうに決めました。当初は、gulp→webpack、という、両方とも似たような役割のツールを重ねることが気持ち悪かったのですが、結果としては気にならなくなりました。
ただし、これは既にgulpを使った環境があるからこのような構成にしたのであって、gulpなどのタスクランナーを使っていないならば、webpackを直接使ってビルドすることをおすすめします。

具体的な導入手順

ディレクトリ構成

ディレクトリ構成は以下のような感じ。わかりやすいように単純化しています。

project  
├── src  
│   └── main  
│       └── webapp  
│           ├── es2015-src  
│           ├── es2015-dest  
│           └── 省略...  
└── gulpfile.js  

なんとなくJavaでのWebサイトを作った場合によくあるディレクトリ構成にしてみました。
上記例では、es2015-srcディレクトリにおいたjsファイルが、es2015-destに出力されることになります。
では、手順を紹介してみます。

パッケージのインストール

まずgulp-webpackのインストール。

npm install --save-dev gulp-webpack  

引き続き、babel関連のインストール。

npm install --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react  

更に、vinyl-namedを入れます。これは後ほど説明する、複数エンドポイントにjsを出力するために必要なパッケージです。

npm install --save-dev vinyl-named  

これでビルドに必要なパッケージのインストールは完了です。

gulpfile.js

続いてgulpfile.jsを記載します。

var gulp = require('gulp');  
var webpack = require('gulp-webpack');  
var named = require('vinyl-named');  
  
gulp.task('webpack', function () {  
    return gulp.src('src/main/webapp/es2015-src/**/index.js')  
        .pipe(named(function(file) {  
            return file.relative.replace(/\.[^\.]+$/, '');  
        }))  
        .pipe(webpack({  
            module: {  
                loaders: [  
                    {  
                        test: /\.js?$/,  
                        exclude: /(node_modules)/,  
                        loader: 'babel-loader',  
                        query: {  
                            presets: ['es2015', 'react']  
                        }  
                    }  
                ]  
            }  
        }))  
        .pipe(gulp.dest('src/main/webapp/es2015-dest/'));  
});  

// watchタスク  
gulp.task('watch', function () {  
    gulp.watch('src/main/webapp/es2015-src/**/*.js', ['webpack']);  
});
  
gulp.task('default', ['webpack', 'watch']);  

webpackという関数の中で渡しているオブジェクトがwebpackのオプションです。ここでbabelによるビルドと、presetsを指定することで、JSXをビルドすることができます。
次の項目で説明しますが、上記設定はindex.js、というファイル名のjsファイルだけをビルドするようにしています。それと、複数jsを書き出すための設定が加わっています。

以上で、Reactをビルドするための環境構築は完了です。

gulpfileに関する捕捉

複数jsの書き出しについて

SPAサイトでない場合、ページ毎に読み込むJSが違う、ということはよくあると思います。
弊社もそういったWebサイトを運営しており、webpackでもsrcに入れたjs毎に別々にビルドしたい、というニーズがありました。
jsファイルを追加するたびに、webpackのオプションを修正していけば個別出力は可能なのですが、毎回gulpfileをいじくるのも面倒だったので、src側においたjsの構造をそのままdest側に出力させる、ということをやっています。
そのために、vinyl-namedというパッケージを使って、gulpfileを都度修正することなく、複数jsを出力するようにしています。

ここについては、以下の方のエントリーが非常に参考になりました。
http://2no.hatenablog.com/entry/2015/06/08/170511

なぜindex.jsのみをビルドするか

Reactではimport/exportsを使ったJSを書くことが普通だと思うのですが、*.jsでビルド対象のjsファイルを指定すると、本来ビルド不要な、importされる側のjsも個別にビルドしてしまうため、src側でindex.jsのみをビルドするようにしています。
index.jsの場合はビルド対象、それ以外のファイル名はビルド除外、というルールでやっています。
これは運用でカバーという状態なので、改善が必要だと思っています。

まとめ

以上が弊社のgulpビルドをしている非SPAサイト向けReactビルド環境のご紹介でした。

シンクロ・フードではこのようなフロントエンドに対して興味を持つエンジニアを募集しています(フロントだけではありませんが!)。
興味を持った方がいらっしゃれば、お気軽にご応募ください!それでは!
http://www.synchro-food.co.jp/recruit/

レガシーなフレームワークでcss/javascriptのキャッシュクリアを自動化する

シンクロ・フードでフロントエンドの開発を担当している四之宮です。
今回は、「cssとjavascriptのキャッシュクリアの自動化に対応していないフレームワークでのキャッシュクリア自動化」についてについてお話したいと思います。
ここでいうキャッシュクリアとは、ハッシュ値をファイルに付与することで、強制的にブラウザキャッシュを無効化するキャッシュクリアのことです。

お話する前に、なぜこのような記事を書こうと思ったのかを弊社のシステム構成を交え、ご説明したいと思います。

システム構成

弊社のシステム構成に関しては以前にご紹介した通り、新サービスの開発ではRuby on Rails、既存サービスの開発はSeasar2となっています。

シンクロ・フードのサービスとシステム構成

もちろん既存サービスに関しても、少しずつRuby on Railsへの載せ替えを行っていますが、まだSeasar2で開発しているサービスも多々ある状態です。

キャッシュクリアの対応について

Ruby on Railsで開発していればcssとjavascriptのキャッシュクリアは自動で行ってくれます。しかし、Seasar2で開発したものに関しては、キャッシュクリアを自動では行ってくれません。 そのため、Seasar2で開発しているサービスに関しては、以前までは手動で対応していました。よくあるファイル名の後ろにキャッシュクリア用のパラメータを付与するという手法です。
しかし、この対応を忘れ、キャッシュが効いてしまい変更後のcssが反映されないことがありました。

このように、「本当は新しいフレームワークに載せ替えたいけどできていない。そのため手動でキャッシュクリアの対応をしている。」という方もまだいるのではないでしょうか?
本記事はそんな方向けの内容となっています。

キャッシュクリア自動化の方法

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

gulp-cachebust

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

npm install --save-dev gulp-cachebust  

そして、テスト~本番用のTaskにgulp-cachebustを組み込む形となります。
開発用に組み込まなかったのは、ファイル数が多くなってくると時間がかかるためです。

gulp-cachebustについて

gulp-cachebustには大きく分けてresourcesとreferencesという2つのメソッドがあります。
resourcesは、対象ファイルにハッシュ値を付与する処理を、referencesは対象ファイルを参照しているファイル(cssやjsを読み込んでいるhtmlが記述されたファイル)の中身を変える処理を行います。
この2つのメソッドを使用することで、簡単にキャッシュクリアをしてくれます。

使用例

実際のコードを元に説明したいと思います。

var CacheBuster = require('gulp-cachebust');  
  
var cachebust = new CacheBuster();  
  
gulp.task('cachebustResources-css', ['事前Taskがあれば指定(scssのコンパイルTaskなど)'], function () {  
    return gulp.src('src/main/webapp/**/*.css')  
        .pipe(cachebust.resources())  
        .pipe(gulp.dest('src/main/webapp/'));  
});  
  
gulp.task('cachebustReferences-jsp', ['cachebustResources-css'], function () {  
    return gulp.src('src/main/webapp/WEB-INF/view/**/*.jsp')  
        .pipe(cachebust.references())  
        .pipe(gulp.dest('src/main/webapp/WEB-INF/view/'));  
});  

cachebustResources-css Task

まず、srcにcssが格納されたディレクトリをします。
そこにpipeでつなげてcachebust.references()します。

これだけでファイルの中身から作成された文字列が付与されたファイルが生成されます。
test.cssとtest.de4e3ce6.cssのような状態です。
このファイルの中身は全く同じ内容ですので、二重に出来上がってしまいます。
これが嫌な場合は、destで指定するディレクトリを別にするなどして元ファイルを削除する必要があります。

cachebustReferences-jsp Task

cachebust.resources()の後で実行する必要があります。
上記の例では、cssに対するresources実行後に指定しています。
こちらでは、cssを読み込んでいるファイルをsrcに指定し、pipeでつなげてcachebust.references()します。
これで、例えばtest.cssを読み込んでいる箇所が、test.de4e3ce6.cssと書き換わった状態になります。

使用例ではcssのみですが、JavaScriptファイルについても同様の処理を行うことでキャッシュクリアできます。

注意点

srcに指定したディレクトリ直下(上記の場合src/main/webapp/)に.cssファイルがないようにする必要があります。
これは、直下に存在するcssファイルの名前と同名のcssファイルが別ディレクトリにあると問題が発生するためです。
具体的には、jsp内のcss読み込みの書き換えの際に追加されるハッシュ値が、指定したディレクトリ直下にあるものになってしまうというものです。
その結果、存在しないcssの読み込みをすることになり、cssが効かない状態になってしまいます。

例えば、以下のような状態の場合にこの問題が発生します。

ディレクトリ構成

webapp/  
 ├ stylesheet/  
 │ └ test.css  
 └ test.css  

キャッシュクリア前

  • html1
<link href="/test.css" rel="stylesheet" type="text/css">  
  • html2
<link href="/stylesheet/test.css" rel="stylesheet" type="text/css">  

キャッシュクリア後

  • html1
<link href="/test.de4e3ce6.css" rel="stylesheet" type="text/css">  
  • html2
<!-- 本当ならこちらになって欲しい -->  
<link href="/stylesheet/test.se5teg42.css" rel="stylesheet" type="text/css">  
  
<!-- でもこちらになってしまう。html1で読み込んでいるtest.cssのハッシュ値が付与。 -->  
<link href="/stylesheet/test.de4e3ce6.css" rel="stylesheet" type="text/css">  

最後に

以上が、cssとjavascriptのキャッシュクリアの自動化に対応していないフレームワークでのキャッシュクリア自動化になります。
これだけの実装で、cssとjsのキャッシュクリア漏れによるバグがなくなったので、とてもよかったと思います。
この記事によって、キャッシュクリア機能がないフレームワークを使用していて同じ悩みを持っていた方のお役に立てればと思います。

尚、シンクロ・フードではエンジニアを大募集しています!
最近では、下記の記事ようにフロントエンド側にも力を入れており、ReactやAngular2によるサービス開発もスタートしています。

IonicとAngularJSを使ってSPAでサービスを作った話

少しでもご興味があれば、気軽にお話しする場を設けますので、以下よりご連絡ください!

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

IonicとAngularJSを使ってSPAでサービスを作った話

こんにちは、最近CourseraのMachine Learningコースで機械学習を勉強中の安藤です。
今回は食材発注ツール「PlaceOrders」 の開発についてお話したいと思います。
PlaceOrdersの開発ではIonicを使って初めてSingle Page Application(SPA)を構築したので、その感想などを書いていこうと思います。尚、バックエンドはRubyOnRailsです。

f:id:synchro-food:20160908101609p:plain:w300

Ionicとは?

IonicはAngularJSをベースにしたモバイル向けのCSS/JavaScriptフレームワークです。
Apache Cordova(PhoneGap)を利用してネイティブアプリへビルドすることが基本的な使い方ですが、今回はネイティブアプリへのビルドは考えずにスマートフォン/タブレット向けのWEBサイトの開発に使用しました。
http://ionicframework.com/

なぜIonicを採用したか?

当初はPlaceOrdersをネイティブアプリとして開発することも検討していましたが、SPAであればネイティブアプリのようなユーザー体験を提供しながら、Web開発の延長として効率的に開発を進めることができます。
SPAを開発するにあたって検討したフレームワークはIonicとOnsenUIでしたが、Ionicは公式ドキュメントがしっかりしていて、公式フォーラムなどのコミュニティも活発なので情報を探しやすいため初めてSPAを作成する上で安心感がありました。
パフォーマンスでは少し見劣りするといった情報もありましたが、PlaceOrdersでは特別パフォーマンスを重視する必要はなかったため、その点は問題ないと判断しました。

使ってみて良かったところ

ネイティブアプリのような動作が簡単に実現できた

Ionicのコンポーネントを利用するだけでネイティブアプリのように動作を簡単に実現できたことが、今回Ionicを使ってみて一番良かったところです。
画面遷移のアニメーションやサイドメニュー、モーダル、ポップアップなどのコンポーネントを様々な端末、ブラウザに対応して作ろうとするとかなりの労力になりますが、Ionicのコンポーネントはほとんど問題が発生することもなく非常に使いやすかったです。
例えば、ポップアップのコンポーネントでは以下のようにタイトルやテキスト、ボタン名、各ボタンに紐付ける処理などを決まった形で書くだけで利用できます。

var myPopup = $ionicPopup.confirm({
  title: 'myPopup',
  templateUrl: 'myPopup.html',
  buttons: [
    {
      text: 'キャンセル',
      type: 'button-stable',
      onTap: function(e) {
        // キャンセル時の処理
      }
    },
    {
      text: 'OK',
      type: 'button-positive',
      onTap: function(e) {
        // OK時の処理
      }
    }
  ]
});
// ポップアップの表示
myPopup.show();

f:id:synchro-food:20160908101433p:plain:w300

AngularJSベースなので開発しやすかった

IonicはAnglarJSをベースにしているので、通常のAngularJSアプリと同様に開発を進めることができました。
ルーティングには定番モジュールのUI Routerが使用されているため、Nested Viewsでコントローラーやビューの共通化したり、resolveで画面遷移を制御するといった柔軟なルーティングが可能でした。

苦労したところ

大きくハマってしまうことはなかったですが、細かいところで気になるポイントがいくつかありました。

コンテンツサイズ変更時のリサイズ

リストの伸び縮みなどでコンテンツサイズが変化した時に、画面の端までスクロールできなくなることがありました。
サイズが変わるような処理の度に$ionicScrollDelegate.resize()が呼び出されるようにして、強制的にサイズを再計算させることで解決しました。

日本語入力時の挙動

日本語入力時に入力を確定するまでモデルに値が反映されなかったり、確定前に他のテキストボックスにフォーカスを移すと、入力中の文字がフォーカスしたテキストボックスにコピーされてしまうといった現象が一部端末、ブラウザで発生しました。
現在もまだ一部問題が残っていますが、compositionstartというイベントでモデルの更新を止めてしまっている処理を無効化するようなディレクティブを作成したり、data-tap-disabledを付けることで対応しました。
前者の問題については以下の記事が詳しいです。
http://pirosikick.hateblo.jp/entry/2014/10/16/232409

touchstart/touchendイベント

Ionic、AngularJSのどちらにもtouchstart/touchendイベントに対応したディレクティブが実装されていなかっため、独自にディレクティブを作成する必要がありました。
特に難しいものではないですが、なぜ用意されていないのか不思議でした。

最後に

以上、IonicによるSPA開発の所感でした。
初めてのSPA開発でも順調に開発を進めることができたので結果的にはIonicを採用して良かったと思います。

最近ではIonicはAngular2をベースにしたIonic2に移行しつつあるので、機会があればIonic2にも挑戦してみたいと思っています。

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

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

Tomcatのクラスタリング環境でのセッションレプリケーションについて

シンクロ・フードの越森です。
今回は、Tomcatのクラスタリング環境でのセッションレプリケーションについてお話したいと思います。

弊社ではAWS移行したことをきっかけにTomcatのセッションレプリケーションを見直すことになったのですが、中々これといったやり方を決めることができず試行錯誤したため、皆さんの参考になればと思い公開します。
※現段階でも、運用でカバーしている問題は残っていますが...。

何故セッションレプリケーションが必要なのか?

セッションレプリケーションとはTomcatなどのアプリケーションサーバーを複数台並列で稼働させるクラスタリング環境において、各アプリケーションサーバー上のセッションを共有する方法です。

セッションを共有することで、アプリケーションサーバーが1台落ちた場合でもユーザーには影響を与えず、別のアプリケーションサーバーでサイトを運用することができます。

Tomcatのセッションレプリケーションの種類

Tomcatは3種類のセッションレプリケーションの手段を提供しています。

  • DeltaManager
  • BackupManager
  • PersistenceManager

DeltaManager

基本的にマルチキャストで構築されたクラスタリング環境において、セッションレプリケーションするために利用するSessionManagerです。
Tomcatの公式ドキュメントのはじめに説明されている代表的なSessionManagerです。
クラスタリング環境内の全てのサーバーでセッション情報をほぼリアルタイムで共有します。
クラスタリング環境内の全サーバーでセッション情報を共有するため、セッション情報の変更を反映させるための負荷が高いこと、1台で全台数分のセッション情報を持つことによりメモリ使用量が大きくなることから、小規模なクラスタリング環境向けのセッションレプリケーション方法です。

BackupManager

DeletaManagerと同様、マルチキャストで構築されたクラスタリング環境において、セッションレプリケーションするために利用するSessionManagerです。
DeltaManagerとは異なり、リクエストを受け付けているサーバー(Primary)と、クラスタリング環境内にある別の1台のサーバー(Backup)との2台でしかセッション情報を格納しないため、大規模なクラスタリング環境でも利用することができるセッションレプリケーション方法です。

PersistenceManager

Tomcatが持つセッション情報をファイルやデータベースに格納して利用するSessionManagerです。
各Tomcatのサーバーにセッション情報の格納先を設定することでクラスタリング環境を構築することができます。(マルチキャストでグルーピングする必要はありません)
PersistenceManagerではTomcatのメモリ上にセッション情報があれば優先的に利用し、メモリ上にない場合にセッション情報の格納先から取得します。
そのため、Tomcatの前に配置するロードバランサーではスティッキーセッションを付加する必要があります。

注意する点としては、セッション情報の格納先へ反映するタイミングと反映するデータ量です。
反映するタイミングについては、設定で変更はできますが一番短いタイミングでも格納先に反映するのは1秒ごとになるという点です。
反映するデータ量については、反映するタイミングでTomcatが持っている全てのセッション情報を格納先に反映することになりますので、セッション情報に大きな情報を入れているとネットワーク負荷、CPU負荷、I/O負荷が大きくなってしまいます。

オンプレミス時代のセッションレプリケーション

ここからは、シンクロ・フードで利用しているセッションレプリケーションについてお話しします。
初めてセッションレプリケーションを導入したのはオンプレミス時代で、この時はTomcatの公式ドキュメントで紹介されている最初の方法を採用しました。
クラスタリングはマルチキャストで構成し、セッションレプリケーションについては、DeltaManagerによりすべてのノードのセッションをリアルタイムに共有するように設定しました。
設定方法についても公式ドキュメントの設定通りに行うことでセッションレプリケーションを実現できました。

[Apache Tomcat 6.0 Clustering/Session Replication HOW-TO]
https://tomcat.apache.org/tomcat-6.0-doc/cluster-howto.html

AWS移行直後のセッションレプリケーション

2014年からAWSへの移行を検討し始めましたが、検討の過程でAWS環境ではマルチキャストが使えないということが分かり、クラスタリング/セッションレプリケーションの方法についても見直しを行いました。
このタイミングで他の方法に変更することも検討しましたが、簡単には実現することができず調査に時間がかかりそうなことから、他作業との優先度を考えて、マルチキャスト設定部分をユニキャストで1台ずつ設定することで、オンプレミス時代と同じセッションレプリケーション方法で対応しました。

具体的にはマルチキャスト設定を行っているMembershipの記述を削除して、以下の設定を追加しました。

<Interceptor className="org.apache.catalina.tribes.group.interceptors.StaticMembershipInterceptor">  
<Member className="org.apache.catalina.tribes.membership.StaticMember"  
port="5000"  
securePort="-1"  
host="172.16.1.100"  
domain="product-cluster"  
uniqueId="{0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15}"  
/>  
</Interceptor>  

[Apache Tomcat 7.0 Clustering/Session Replication HOW-TO]
https://tomcat.apache.org/tomcat-7.0-doc/config/cluster-interceptor.html

自動デプロイ対応後のセッションレプリケーション

AWS移行が終わりインフラの次に取り組むテーマを検討した結果、AWSに移行したことで自動化周りの施策が色々できるようになったので、自動デプロイに取り組むことになりました。
自動化という方向性で進めるとした場合に現在のユニキャストによるサーバー1台ごとの設定は、今後障害になるということでセッションレプリケーションの方法についてもあわせて変更することにしました。
実はそれ以外にもデプロイで1台ずつサーバーを落とすタイミングでセッションが維持できない場合が発生していたというのも理由の1つであったりします。

色々調査したところ、Amazonが公開しているライブラリを使ったDynamoDBによるセッションレプリケーションやRedisを使ったセッションレプリケーションなどがありましたが、やはりTomcatに含まれている点を重視してPersistenceManagerを採用することにしました。

シンクロ・フードのPersistenceManagerによるセッションレプリケーション

具体的にはEC2インスタンス上のMySQLにセッション格納用のテーブルを作成し、Tomcatからそのテーブルに対して読み書きするように設定しています。
セッション格納用のテーブルについては読み書きの頻度が高くI/Oの負荷を考慮して、MEMORYストレージエンジンを利用して、メモリ上にセッション情報を格納しています。
以下はMySQLとTomcatの設定になります。

  • MySQLのテーブル定義
CREATE DATABASE tomcat_session DEFAULT CHARACTER SET utf8;  
  
USE tomcat_session;  
  
CREATE TABLE sessions (  
  session_id varchar(100) NOT NULL,  
  valid_session char(1) NOT NULL,  
  max_inactive int(11) NOT NULL,  
  last_access bigint(20) NOT NULL,  
  app_name varchar(255) DEFAULT NULL,  
  session_data varbinary(42000),  
  PRIMARY KEY (session_id),  
  KEY kapp_name (app_name)  
) ENGINE=MEMORY DEFAULT CHARSET=utf8;  
  • Tomcatの設定(context.xml)
<Manager className="org.apache.catalina.session.PersistentManager"  
     saveOnRestart="true"  
     processExpiresFrequency="1"  
     maxIdleBackup="0">  
     <Store className="org.apache.catalina.session.JDBCStore"  
         connectionURL="jdbc:mysql://[IPアドレス]:3306/tomcat_session?useUnicode=true&amp;characterEncoding=UTF-8&amp;user=[ユーザー名]&amp;password=[パスワード]"  
         driverName="com.mysql.jdbc.Driver"  
         sessionAppCol="app_name"  
         sessionDataCol="session_data"  
         sessionIdCol="session_id"  
         sessionLastAccessedCol="last_access"  
         sessionMaxInactiveCol="max_inactive"  
         sessionTable="sessions"  
         sessionValidCol="valid_session" />  
</Manager>  

現在はPersistenceManagerを利用した方法で運用はできていますが、1点だけ問題が発生するケースがあるため、その問題について説明します。

PersistenceMangerの運用上の問題

Tomcatのフェイルオーバーやロードバランサーの振り分け変更などにより、同じユーザーのセッションが複数台のTomcatのメモリ上に存在する場合に、Tomcatの再起動やセッションのタイムアウトなどでメモリのセッション情報がクリアされずに、フェイルオーバーなどでもう1度別のTomcatのサーバーにアクセスすることになった場合に、セッション情報が過去の情報に戻ってしまうという問題が発生します。

本問題については、谷本さんの以下の記事が詳しいのでご確認ください。
[DynamoDBでTomcatのセッション共有をするとハマるかも]
http://d.hatena.ne.jp/cero-t/20151019/1445262219

実際に問題が発生する具体的なオペレーションのケースとしては、以下の2つが考えられます。
ここでは、Tomcat1, Tomcat2の2台構成で運用していて、その前にロードバランサー(LB)があることを前提とします。

  1. LBでTomcat1へのアクセス遮断、復旧、遮断
  2. Tomcat2ダウンによるフェイルオーバーが2回連続で発生

弊社ではどちらのケースにも対応できるように別サーバーへのアクセスが発生するような状況になった場合には、全てのサーバーのTomcatを再起動してメモリ上のセッション情報をクリアするようにしています。

最後に

以上で簡単な説明になりますが、弊社でのTomcatのクラスタリング環境でのセッションレプリケーションの設定をご紹介しました。
Tomcatのセッションレプリケーションについて少しでも参考になれば幸いです。

尚、シンクロ・フードではエンジニアを大募集しています!
こうしたインフラ周りの改善以外にも色々なことをやっていますので、少しでもご興味があれば、気軽にお話しする場を設けますので、以下よりご連絡ください!

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