zephiransasのチラシの裏

とあるJava/Rubyプログラマのメモ代わりブログ

Spring BootのCSSをGulpで管理する

Spring BootのプロジェクトでもSASSが書きたい!という欲望から、Gulpを使ってSASSをコンパイルするようにしました。

基本Gulpでやってるので、出力先だけ変えれば、特にSpring Bootには関係ない気もしますが・・・

npmは既にインストールしてある前提で。

フォルダの構成と基本方針

フォルダ構成は以下のようなイメージ。

1
2
3
4
5
6
7
8
9
10
11
12
13
├── assets
│   └── stylesheets
│       └── application.scss
├── gulpfile.js
├── package.json
└── src
    └── main
        └── resources
            └── static
                └── css
                    ├── maps
                    │   └── application.css.map
                    └── application.css

assets/stylesheets以下にSASSを配置し、これをGulpでコンパイル。

出力先をSpring BootのCSS配備先 src/main/resources/static/css にして、これをThymeleafから参照する、という方針です。

必要なパッケージをインストールする

まずは必要なパッケージをnpmでインストールしていきます。

1
2
3
$ npm init
(以降、全てデフォルトで)
$ npm install --save-dev gulp gulp-sass gulp-sourcemaps gulp-minify-css gulp-plumber

これでpackage.jsonが作成され、node_modulesに依存ライブラリがインストールされます。node_modulesはgitignoreしておくといいでしょう。

gulpfile.jsを作成する

SASSをコンパイルできるようGulpのタスクを定義します。

gulpfile.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var gulp = require('gulp'),
    sass = require('gulp-sass'),
    sourcemaps = require('gulp-sourcemaps'),
    minifyCss = require('gulp-minify-css'),
    plumber = require('gulp-plumber');

gulp.task('sass', function(){
  gulp.src('assets/stylesheets/*.scss')
    .pipe(plumber())
    .pipe(sourcemaps.init())
    .pipe(sass())
    .pipe(minifyCss())
    .pipe(sourcemaps.write('./maps'))
    .pipe(gulp.dest('src/main/resources/static/css'))
});

まずscssファイルの置き場所を gulp.src('assets/stylesheets/*.scss') としてassets/stylesheetsディレクトリに設定します。

.pipe(sass())でSASSのコンパイル、.pipe(minifyCss())でCSSの圧縮をしています。

圧縮したCSSだと、元のSASSでの場所がわからなくなるので .pipe(sourcemaps.write('./maps'))でmapファイルを作成します。

sourcemaps.write()はデフォルトだとコンパイルされたcss内部にインラインでmapを書き込むので、別途mapsディレクトリにmapファイルを書き込むよう設定しておきます。こうしておくことでChromeなどのDeveloper toolで見たときにSASSの場所が分かるようになります。

最後に .pipe(gulp.dest('src/main/resources/static/css'))でCSS出力しています。

設定したタスクを実行します。

1
$ gulp sass

これでCSSが出力されますので、あとはThymeleaf側から

1
<link rel="stylesheet" type="text/css" href="@{/css/application.css}"/>

として参照できます。

SASSを分割したい

よくあるケースとして、画面単位でSASSを分け、最終的なCSSでは1つにまとめてしまいたい、というケースがあります。 例えば、以下のようなイメージ。

1
2
3
4
5
6
assets
└── stylesheets
    ├── modules <- 各画面ごとのSASS
    │   ├── _hoge.scss
    │   └── _fuga.scss
    └── application.scss <- modulesを全てimportしたい

こういった時にはgulp-sass-bulk-importが便利です。

1
$ npm install --save-dev gulp-sass-bulk-import

としてgulp-sass-bulk-importをインストールした後、gulpfile.jsを修正します。

gulpfile.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var sassBulk = require('gulp-sass-bulk-import');

...

gulp.task('sass', function(){
  gulp.src('assets/stylesheets/*.scss')
    .pipe(plumber())
    .pipe(sourcemaps.init())
    .pipe(sassBulk()) # 追加
    .pipe(sass())
    .pipe(minifyCss())
    .pipe(sourcemaps.write('./maps'))
    .pipe(gulp.dest('src/main/resources/static/css'))
});

あとはapplication.scssで

application.scss
1
@import "modules/*";

とすることで、modules内のSASSを含めて、CSSがコンパイルされます。

注意点として、modules以下のSASSファイルは、プレフィックスとしてアンダーバーが付与されています。付与しない場合は、modules以下のSASSも普通にコンパイルの対象になり、そのままdestに出力されてしまうので、これを防止するため、アンダーバーを付与しています。

Bootstrapを使いたい

まずBoostrapをnpmでインストールします。

1
$ npm install --save-dev bootstrap@4.0.0-alpha.4

これでnode_modulesにBootstrapがインストールされたので、これをGulpから参照できるようgulpfile.jsを修正します。

gulpfile.js
1
2
3
4
5
6
7
8
9
10
11
12
gulp.task('sass', function(){
  gulp.src('assets/stylesheets/*.scss')
    .pipe(plumber())
    .pipe(sourcemaps.init())
    .pipe(sassBulk()) # 追加
    .pipe(sass({
      includePaths: ['./node_modules/bootstrap/scss']   # 追加
    }))
    .pipe(minifyCss())
    .pipe(sourcemaps.write('./maps'))
    .pipe(gulp.dest('src/main/resources/static/css'))
});

あとはapplication.scssで

application.scss
1
@import "bootstrap";

とするだけです。SASSのコンパイルを実行すると、application.cssにBootstrapのcssが含まれていることがわかります。

参考など

CircleCIでGradleのテストを並列実行する

現在開発を行っているプロジェクトでは、Spring Bootを使って開発を行っているのですが、そこでのテストをCI環境で実行できるよう設定を行ったので、その手順を書いておきます。

CircleCIで普通にテストできるようにする

最初は並列ではなく、1つのコンテナを使ってCircleCIでテストできるように設定を行います。まずcircle.ymlを以下のように準備。

circle.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
machine:
  java:
    version: openjdk8
  timezone:
    Asia/Tokyo
  environment:
    _JAVA_OPTIONS: "-Xms512m -Xmx1024m"
    GRADLE_OPTS: '-Dorg.gradle.jvmargs="-Xmx1024m -XX:+HeapDumpOnOutOfMemoryError"'
  post:
    - sudo service postgresql stop

dependencies:
  override:
    - ./gradlew testClasses

database:
  post:
    - mysql -e 'create database [データベース名];'
    # flywayなどでのマイグレーション

test:
  override:
    - ./gradlew test
  post:
    - mkdir -p $CIRCLE_TEST_REPORTS/junit/ && find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} $CIRCLE_TEST_REPORTS/junit/ \;:

メモリ割り当てについて

machine.environmentでJAVA_OPTIONSに"-Xms512m -Xmx1024m"を指定しています。これはCircleCIでは1つのコンテナには4Gのメモリが割当られており、その上限をこえると、コンテナがフリーズして、10分経過するとテスト失敗になるという現象に対応するためです。合わせてGRADLE_OPTSにも同様の設定をおこなっています。

このあたりの設定も状況によっては増やせる場合もありますので、テストを実行しながら、調整してみてください。

使わないデータベースを止める

CircleCIではデフォルトでPostgreSQLとMySQLがインストールされたコンテナが準備されます。machine.postで使わないデータベースを止めることで、貴重なメモリの使用量を抑えることができます。

今回テスト対象のデータベースはMySQLですので、PostgreSQLを止めてメモリを節約します。

dependenciesでライブラリをダウンロードしておく

CircleCIではdatabaseサイクルが終わったタイミングで、次回のビルドを高速に実行できるよう、依存ライブラリなどをキャッシュする仕組みがあります。

しかしGradleではテストを実行する直前まで依存ライブラリはダウンロードされず、通常のままだと依存ライブラリをキャッシュに含めることができません。

そこでdependencies.overrideにてtestClassesタスクを実行しておきます。こうすることで、依存ライブラリがダウンロードされ、databaseサイクル終了後にキャッシュが作成されるようになります。

Spring Bootのprofileはciにする

CircleCIで動かす場合はデータベースの接続先が開発環境などとは変わるはずですので、CircleCI専用のapplication.ymlをapplication-ci.ymlとして作成します。

application-ci.yml
1
2
3
4
5
6
7
8
spring:
  profiles:
    active: ci
  datasource:
    url: jdbc:mysql://localhost:3306/{データベース名}
    username: ubuntu
    password:
    driverClassName: com.mysql.jdbc.Driver

CircleCIのMySQLには上記の設定で接続可能です。次にテスト実行時に

1
SPRING_PROFILES_ACTIVE=ci ./gradlew test

とすることで、application-ci.ymlのデータベース接続情報を使用するようになります。

テスト実行結果を集約する

test.postにて、テスト結果のxmlを$CIRCLE_TEST_REPORTSにコピーしておきます。こうすることで、CircleCIの画面からテスト結果を簡単に見ることができます。

1
2
3
test:
  post:
    - mkdir -p $CIRCLE_TEST_REPORTS/junit/ && find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} $CIRCLE_TEST_REPORTS/junit/ \;:

並列テストが実行できるようにする

次にCircleCI+Gradleで並列テストをすることを考えてみます。

一般的に並列テストを行う場合は、テスト対象のクラスを取得し、これをノードそれぞれに均等に割り振ることでテストを分散して実行します。

Gradleにはデフォルトではテスト対象のクラスフィルタリングする昨日はあるのですが、対象クラスを個別に指定する方法はありません。

https://docs.gradle.org/current/userguide/java_plugin.html#test_filtering

ですので今回はGradle実行時に-Pオプションを指定し、以下のようにして対象クラスを一括して渡す方法を採用しています。

1
./gradlew test -PtestFiles=./src/test/java/com/example/HogeTest ./src/test/java/com/example/FugaTest ....以下テスト対象クラスを列挙

まずは、このオプションを組み立てつつ、gradlew実行する専用のシェルスクリプト(circleci.sh)を準備します。

circleci.sh
1
2
3
testFiles=$(find ./src/test -name *Test.java | sort | awk "NR % ${CIRCLE_NODE_TOTAL} == ${CIRCLE_NODE_INDEX}")
echo $testFiles
SPRING_PROFILES_ACTIVE=ci ./gradlew :webapp:test -PtestFiles="$testFiles"

CircleCI上でビルドに使用しているノード数は環境変数CIRCLE_NODE_TOTALから、自身のノード番号は環境変数CIRCLE_NODE_INDEXから取得できますので、これをawkから利用しつつ、テスト対象クラスを分散させます。

次にbuild.gradle内では-Pオプションで渡されたtestFilesのみをテスト対象にするよう、includeTestsMatchingを使って設定を行います。

build.gradle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
test {
  if (project.hasProperty("testFiles")) {
      ArrayList files = project.getProperties().get("testFiles")
              .replaceAll("./src/test/java/", "")
              .replaceAll("/", ".")
              .replaceAll(".java", "")
              .split("\\s+")
      for(String file : files) {
          println file
          filter {
              includeTestsMatching file
          }
      }
  }
}

こうすることで-PtestFilesで指定されたもののみ、テストを行うことができます。

最後に、並列実行できるようcircle.ymlを修正します。

circle.yml
1
2
3
4
5
6
7
test:
  override:
    - ./circleci.sh:
        parallel: true
  post:
    - mkdir -p $CIRCLE_TEST_REPORTS/junit/ && find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} $CIRCLE_TEST_REPORTS/junit/ \;:
        parallel: true

テストは先ほど作成したcircle.shを実行するようにしparallel: trueを付与して並列実行するようにします。parallel: trueはインデント4つであることに注意!

スクフェス・ログサーバをつくった

今日は大晦日ですね。年末ですが今年も例によって、コード書いたりプラモ作ったり、普段の連休と同じくダラダラ過ごしております。

ところで今年のラブライブ!アドベントカレンダーはチェックしましたか? 自分も20日目に劇場版ラブライブとμ’sメンバーのその後というタイトルでエントリしてます。 今年はその他にも、さまざまな視点から見た素晴らしいエントリがたくさん集まってますので、ラブライバーならぜひチェックしてみてください。

で、22日目のエントリには@hideo54さんのスクフェスのライブスコアを取得する”schfeslog”を作った話というのがあります。 これはnodeで建てたプロキシを使って、スクフェスがサーバに送信してる通信内容をみて、ライブのプレイ結果をツイートすることができるツールです。

これをみて「お、ツイートできるんなら、外部サーバにも送信できるんじゃね?」ってことで、早速コードを書いてみました。

まずはschfeslog側に外部サーバへの通信機能を実装しています。該当するPull Requestはこちら。単純にプレイデータをJSON形式にして、設定でされたサーバにPOSTするだけです。

これを受信するサーバはこちら。

送信されたプレイデータを一覧で見ることもできます。ちなみに私のプレイデータがこちら

見た目とかは、もうちょっと改善したいところです・・・

最近、ちょっとJavaの案件をやってるせいもあって、真面目にSpring Bootで書いています。こういったRESTなアプリケーションを作るにはSpring Bootはとても簡単でいいですね。

サーバ側は簡単に自分用に環境を作れるよう、Deploy to Herokuボタンも準備してますので、興味のあるかたはschfeslogと一緒に、ぜひ試してみてください。

合同勉強会2016に参加してきた

このエントリは大都会岡山アドベントカレンダー2016の18日目のエントリです。

昨日のエントリは たがみだいきさんの「RailsGirlsOkayama 2ndを2月25日26日に開催する予定です!」でした。 去年の第1回には、自分もコーチとして参加させてもらいましたが、そこから新たに岡山に女性エンジニアのコミュニティができ、それが主体となってRailsGirlsをやる、というのは素晴らしいことだと思います。きっとイベントも大成功することでしょう。

先日、毎年恒例となった合同勉強会と忘年会議が開催されました。

例年のように県内だけでなく県外からも多くのかたに参加していただき、大変よいイベントになったのではないでしょうか。

例によって写真を撮ってますのでこちらからどうぞ。

合同勉強会&忘年会議2016

個人的に今回のベストトーカー(not ベストストーカー)を上げたいのは、SamunePさんの「ゲーム会社経営ゲーム」。 とにかく高い技術力を武器に、ちょっと尻込みしちゃうような案件をやってるハナシで、とにかく「今の時代にこんな人がいるのか?!」と驚きの連続でした。その凄さをみんなもうまく言語化できずに

などなど、人のボキャブラリーを著しく低下させる作用があったようですw

さて、次は来年のオープンセミナーです。こちらも面白いセッションをたくさん用意できるよう、誠意準備中ですので、ぜひご参加ください!

Maven Wrapperを使ってプロジェクトで使うMavenのバージョンを指定する

Javaでの開発において、ライブラリのバージョン管理にMavenを用いているところはたくさんあると思います。

しかし、pom.xmlを使って各ライブラリのバージョンを管理していても、各開発者が使うMavenのバージョンを固定することはできません。

プロジェクトで使うMavenのバージョンを固定したい!そんな場合に使えるのがMaven Wrapperです。

導入方法

導入方法は至って簡単。

maven wrapperを適用したいプロジェクトに移動して、以下のコマンドを発行するだけ。

1
mvn -N io.takari:maven:wrapper

これだけで、プロジェクトに以下のファイルが追加されます。

  • mvnw - Maven Wrapper経由でmavenを実行するためのファイル
  • mvnw.cmd - mvnwのWindows版。Windowsで使う場合はこっちを使いましょう。
  • .mvnディレクトリ - maven wraperがダウンロードしてきたMavenのバイナリとかが入ってる

上記のコマンドだと実行時の最新のバージョンが使用されるので、バージョンを指定したい場合はオプションで

1
mvn -N io.takari:maven:wrapper -Dmaven=3.3.1

としてやりましょう。以降は今まで

1
2
mvn clean
mvn package

としていたのをmvnwコマンドに置き換えるだけで

1
2
./mvnw clean
./mvnw package

固定されたバージョンをMavenを利用することができます。

.gitignoreの設定

~~ Gitなどのバージョン管理にはmvnwとmvnw.cmdのみコミット対象とし、.mvnディレクトリはコミット対象外にしましょう。 ~~

はい、これウソでしたorz

正しくは「.mvnディレクトリもコミットしましょう」です。

はい、その通りですね(真顔

Past, Present and Future

これは大都会アドベントカレンダー24日目の記事です。

昨日は@yantonaさん君は「玉野音頭」を知っているかでした。

不覚にも自分は知りませんでした・・・瀬戸大橋音頭なら・・・!

実は今年の大都会アドベントカレンダー、12日目のきよくらさんが自分とIT勉強会とのかかわり合いについて書かれていて、これにインスパイアされたので、便乗して自分が岡山のIT勉強会に関わって、岡山Javaユーザ会を開催するようになるまでを、まとめてみようと思います。

岡山でJavaの勉強会をスタートするまで

2010年

2010年、当時自分は岡山にある小さなSIerで仕事をしていました。当時やっていたのは主にJavaを使ってのWeb開発で、Apache,Seasarなどのオープンソースソフトウェアを好んで使っていました。小さな会社だったこともあり、新しい技術の導入には比較的寛容な環境だったかもしれません。

そんなとき、ネットで偶然見かけたのがオープンラボ岡山でした。勉強会についてはほとんど知らなかったし、それほど興味も無かったのですが、偶然Seasarの作者であるひがやすおさんが、岡山に来られるということだけで参加しに行きました。

この時にはSeasarの開発も一時期よりは落ち着いてきて、ひがさん自身も「これからはクラウドだ!GAEだ!Slim3だ!」と、Slim3についての話をされていたことを覚えています。

同時に年末だったこともあり、忘年会議も開催されていますが、これには参加してませんでした・・・

思えば、ここが最初のスタートです。

2011年

去年参加したオープンラボをキッカケに、2011年からは勉強会にも積極的に参加するようになりました。

このころはオープンラボ岡山は月に1度のペースで開催されていましたので、ほぼ毎月参加していたように思います。そしてそのなかで、いろいろな人から、技術に関する話を聞くうちに、勉強会に参加する面白さを感じることができるようになったと思います。

当時は勉強会に登壇する人全てが、雲の上の人のように感じていましたね。

それと同時に「人の話を聞いてるだけじゃなく、自分も話せるようになって、みんなに喜んで欲しい」という気持ちも同時に芽生え始めたように思います。

そして転機は第19回オープンラボ岡山のときです。

この回はJava特集ということで、当時は日本オラクル所属だった寺田佳央さんと、RedHatの木村貴由さんのお二人からJavaについての話を聞きました。そこで寺田さんはGlassFishの話をされたのですが、これが個人的に一番興味をそそられたので、その後も個人的にいろいろ調べ、以下のブログを書きました。

このエントリを書いたことをキッカケに、オープンラボ岡山の常連であり、隣国?である福山で「オープンラボ備後」を主催されていた、Yさんから「GlassFishの話してみない?」という依頼がありました。そして第10回オープンラボ備後で初登壇することになります。

図らずも「いつか自分も発表できるようになりたい」と思っていたことが、たった1つのブログエントリから1年たたずに実現したわけです。しかしこれは運とかではなく、岡山周辺のIT勉強会コミュニティに、いろいろな人を登壇させていこうという風土のようなものが、ちゃんと根付いていたことの現れではないかと思っています。

そしてこの登壇あと、主催のYさんが唐突に「吉田さん、岡山でJavaのコミュニティやってくれない?」という話がありました。

聞けば、以前、岡山にはJavaのコミュニティがあったのですが、主催の方の転勤とともに開店休業状態にあるということ。そこで、自分にコミュニティの主催をやってみないか?ということでした。

今回が初登壇だった自分にいきなりそんなこと・・・とは、正直思いましたが、オープンラボ備後のYさんの勧めや、オープンラボ岡山の主催Hさんからもサポートしていただける、とのことだったので、岡山でJavaのコミュニティを始めることにしました。

幸いにも、自分ひとりでのスタートではなく、いつもScalaや関数型の話でみなをポカンとさせる@razonさんや、Twitterの裏垢で下ネタをつぶやいてばかりの@ryosmsさん、そして唯一?の常識人の@o310yusukeさん、などのこころ強い(?)メンバーの助けを借りつつ、岡山Javaユーザ会をスタートさせることになります。

その他にも助けて頂いた方はたくさんいますが、誰一人欠けても今までコミュニティを継続させることはできなかったと思います。

この場を借りて、関わった全ての皆様に深く感謝したいと思います。

あなたはエンジニアの仕事、好きですか?

ところで、あなたはエンジニアの仕事、好きですか?

エンジニアでない人、今の仕事好きですか?

私はエンジニアの仕事が大好きです。

自分はプログラマを目指して大学に入ったものの挫折し、その後のフリーター生活を経て、24歳の時に故郷に戻って運良くプログラマとして就職することができました。それから約16年。何事にも中途半端だった自分が、こうやって一つの仕事を続けてこられたのは、ひとえに「この仕事が好き」だからです。

だからこそ、このエンジニアという仕事を選んだ人たちが、楽しく仕事ができるようになればいいと思い、いままで岡山のIT勉強会コミュニティに関わってきました。

そしてその想いは、今でもかわることはありません。これからも岡山のIT勉強会コミュニティを影となって支えて参ります。

the Future

そしてもう一つ。来年に新しいことをスタートしようと考えています。

それが子供向けプログラミング教室です。

自分がはじめてプログラミングをしたのは、小学校の時に触れたファミリーベーシックでした。そこで説明書のプログラム通りに、右手の人差し指1本で、おぼつかない手でコードを書き、エラーを直し、実行し、そして画面でマリオが動いた時のあの感動。

この教室を通じて、自分が幼いときに感じた「自分で考え、自分で作って、動かす」そんな感動を子供たちに感じてもらい「プログラミングって楽しい!」と感じてくれる子供が一人でも増えてくれれば、これに勝る喜びはありません。

あくまでボランティアベースの教室になるので、塾のように授業料をもらうこともないです。なので成果を保証することもしません。子供たちがやりたいことを最優先に。やりたいことが見つからなければ、見つける手伝いから。

そして子供たちが「楽しかった、また来たい!」と思えるように。そんな空間を作れたらいいなと思っています。

既存のIT勉強会のコミュニティは、現役のエンジニアたちを支え、子供向けプログラミング教室が、未来のエンジニアを支える。

そしてこの両輪を回すことを、今後の自分の仕事として続けていけたら、と思っています。

合同勉強会でCrystalの話をしてきた

これはCrystalアドベントカレンダー2015の12/9のエントリです。

昨日はpine613さんの「Crystal-JP の活動紹介と、今後の活動について」でした。

12/5に岡山県立大学にて行われた、合同勉強会 in 大都会岡山 -2015 Winter-にて、Crystalの紹介のセッションを行いました。

スライドはこちら。

主に伝えたかった内容としては

  • CrystalはRubyistには簡単に使えます
  • Crystalは型があります
  • コンパイルして高速に実行できます

といった3点を主題としました。

時間配分がいい感じだったので、実際にRubyでの実行速度と、Crystalでの実行速度の違いをデモすることができたのは、良かったのではないでしょうか。

途中うっかり「Rubyには型がないので云々」といったおかげで、現場の型警察のみなさんに

となったのは迂闊でしたw

岡山の大きな勉強会で使う懇親会場に「座・スタジアム」というちょっと珍しい会場があります。

screen

ここでLTしたいがために、東京、大阪からやってくる人もいるくらい、懇親会に最適な会場です。次はオープンセミナー岡山2016という勉強会がありますので、ぜひ県外の皆様にもお越しいただきたいですね。

CrystalをHerokuで動かしてみた

最近ちょっと話題のcrystal。これをHerokuで動かしてみました。

Herokuの準備

まずはHerokuにアプリケーションを作成します。

Herokuでは当然crystalをサポートしていませんので、crystalのコンパイラを自前でインストールする必要があります。 Herokuにはこういったことを実現するために、buildpackという仕組みが用意されています。

crystal用のbuildpackは既にあるので、今回はこれを利用します。

heroku createする際に上記のbuildpackを指定しておきます。

1
heroku create --buildpack https://github.com/zamith/heroku-buildpack-crystal

ちなみに、これはあとから指定することも可能です。

1
heroku

アプリケーションの準備

次にcrystalで簡単なWebサーバを実装します。といってもcrystalの公式ページにあるサンプルに手を加えた簡単なものです。

app.crというファイル名で以下のファイルを準備します。

app.cr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
require "http/server"
require "option_parser"

server_port = 8080
OptionParser.parse! do |opts|
  opts.on("-p PORT", "--port PORT", "define port to run server") do |port|
    server_port = port.to_i
  end
end

server = HTTP::Server.new("0.0.0.0", server_port) do |request|
  HTTP::Response.ok "text/plain", "Hello world, got #{request.path}!"
end

puts "Listening on http://0.0.0.0:#{server_port}"
server.listen

注意するところは2点。

1点目は、option_parserを使って起動時のオプションでWebサーバのポート番号を指定できるようにしています。デフォルトでは8080ポートで起動します。

Herokuの場合、サーバのポートは$PORTの値を使用しなければいけませんので、起動時にその値を渡せるようにするためです。

2点目は、以下のようにServerのインスタンス生成時に"0.0.0.0"を指定することです。

1
server = HTTP::Server.new("0.0.0.0", server_port) do |request|

こうすることで、localhost以外からでもアクセス可能にしてあります。これを指定していない場合は、Herokuでの起動時に

1
Error R10 (Boot timeout) -> Web process failed to bind to $PORT within 60 seconds of launch

というエラーが発生します。

できたら早速、起動してみましょう。

1
crystal run app.cr

ブラウザからlocalhost:8080にアクセスし、以下の用に表示されれば、Webサーバが正しく起動しています。

screen

その他のファイルの準備

次にProcfileを以下のように準備します。

Procfile
1
web: ./app -p $PORT

起動時に$PORTをWebサーバのポートとして、指定しています。

次にProjectfileを準備します。中身は空でOKです。

1
touch Projectfile

これは本来は不要なファイルなのですが、crystalのbuildpack内でProjectfileがない場合は、crystalのアプリケーションとして認識してくれないため、空のファイルを作成しています。

Herokuへデプロイ

app.cr、Procfile、Projectfileの3つが準備できたら、Herokuにデプロイしてみましょう。

1
2
3
4
5
git init
git add .
git commit -m 'First commit'
heroku git:remote --app [APPNAME]
git push heroku master

あとは、Herokuにアクセスして、正しく動作していればOKです。

まとめ

今回作成したコードはこちらにおいてありますので、参考にしてください。

https://github.com/zephiransas/crystal-heroku

Unicorn-worker-killerが便利だった件

自分が現在関わっているプロジェクトでは、nginx + unicornの構成で運用しているのですが、この構成でサーバのメモリが足りなくなるという現象に悩まされていました。

unicornのワーカプロセスは、通常では起動したままユーザからのリクエストを処理し、再起動されることはありません。 その関係で、長時間運用していると、そのワーカプロセスがメモリをあるだけ食いつぶすような挙動になります。

こんな時に便利なのが「unicorn-worker-killer」です。

unicorn-worker-killerを使うことで、ワーカプロセスが以下の条件の場合に、自動的に再起動してくれます。

  • ワーカプロセスが指定回数のリクエストを処理した場合
  • ワーカプロセスが指定量のメモリを使用している場合

いずれの場合でもワーカプロセスの再起動は、現在のリクエストを処理した後に再起動(いわゆるgraceful restart)されます。

設定のしかた

設定はconfig.ruにて行います。

リクエストの回数基準で再起動する

config.ru
1
use Unicorn::WorkerKiller::MaxRequests, 3072, 4096

これはワーカプロセスが、3072回~4096回のいずれかの回数リクエストを処理したら再起動する設定です。

config.ru
1
use Unicorn::WorkerKiller::MaxRequests, 3072, 4096, true

とすることで、unicorn.rbのstderr_pathで指定されたパスに状況を出力することができます。

メモリの使用量を基準に再起動する

config.ru
1
use Unicorn::WorkerKiller::Oom, (192*(1024**2)), (256*(1024**2)), 16

これはワーカプロセスが16回リクエストを処理する度に、自身のメモリ使用量をチェックし、これが192M~256Mのいずれかの使用量をオーバーしていた場合に、再起動する設定です。

設定が2つある理由

リクエスト回数とメモリ使用量の設定両方とも、しきい値を範囲で指定するようになっていますが、これには理由があります。

1つのしきい値だと、各ワーカが再起動するタイミングが、ほぼ同じになるからです。同じタイミングで全てのワーカプロセスが再起動してしまうと、その間リクエストを処理することができなくなってしまうので、これは好ましくありません。

ですので、しきい値を範囲で指定し、その範囲内のいずれかの値を実際のしきい値として採用するという仕組みになっています。

なので、しきい値の範囲は狭いより、広いほうが、ベターです。

unicorn-worker-killerを試してみる

では、unicorn-worker-killerがちゃんとワーカプロセスをKillできているかを確認してみます。

シナリオとしては

  • config/unicorn.rbのworker_processesは1として、ワーカプロセスは1つだけにする
  • unicorn-worker-killerの設定は100回〜120回のリクエストを受けたタイミングで、ワーカプロセスを再起動するようにする

まずはGemfileに

Gemfile
1
gem 'unicorn-worker-killer'

と設定します。config.ruの設定は、以下のようになります。

config.ru
1
use Unicorn::WorkerKiller::MaxRequests, 100, 120, true

unicorn-worker-killerの詳細なログを出力するように設定しておきます。

この設定でunicornを起動します。すると以下ような感じでログが出力されます。

config.ru
1
2
3
4
5
I, [2015-07-29T16:38:31.589102 #29745]  INFO -- : worker=0 spawning...
I, [2015-07-29T16:38:31.591242 #29745]  INFO -- : master process ready
I, [2015-07-29T16:38:31.593001 #29752]  INFO -- : worker=0 spawned pid=29752
I, [2015-07-29T16:38:31.593581 #29752]  INFO -- : Refreshing Gem list
I, [2015-07-29T16:38:47.035570 #29752]  INFO -- : worker=0 ready

pid=29752でワーカプロセスが1つ立ち上がりました。psで確認すると

config.ru
1
zephiransas 29752 30.0  2.8 544708 235984 ?       Sl   16:38   0:15 unicorn worker[0] -c config/unicorn.rb -E production -D

のような感じです。ここでブラウザから何度かアクセスすると、unicornのログに以下のように出力されます。

config.ru
1
2
3
4
5
I, [2015-07-29T16:40:37.156111 #29752]  INFO -- : #<Unicorn::HttpServer:0x0000000343fa00>: worker (pid: 29752) has 119 left before being killed
I, [2015-07-29T16:40:37.349161 #29752]  INFO -- : #<Unicorn::HttpServer:0x0000000343fa00>: worker (pid: 29752) has 118 left before being killed
I, [2015-07-29T16:40:37.559274 #29752]  INFO -- : #<Unicorn::HttpServer:0x0000000343fa00>: worker (pid: 29752) has 117 left before being killed
I, [2015-07-29T16:40:37.649334 #29752]  INFO -- : #<Unicorn::HttpServer:0x0000000343fa00>: worker (pid: 29752) has 116 left before being killed
I, [2015-07-29T16:40:47.690545 #29752]  INFO -- : #<Unicorn::HttpServer:0x0000000343fa00>: worker (pid: 29752) has 115 left before being killed

pid=29752のワーカプロセスがあと何回リクエストを処理できるかが分かります。またリクエストするたびに1つづつ減っています。

では、引き続きブラウザからアクセスし、ワーカプロセスのメモリ使用状況を確認してみましょう。

config.ru
1
zephiransas 29752  7.1  3.2 785940 269420 ?       Sl   16:38   0:26 unicorn worker[0] -c config/unicorn.rb -E production -D

メモリ使用量が少し増えているのがわかります。次にリクエストの残り回数を使い切り、ワーカプロセスが正しく再起動されるか確認します。

ブラウザからリクエストを投げ続けると、unicornのログに以下のように出力されます。

config.ru
1
2
3
4
5
6
7
8
W, [2015-07-29T16:47:00.539055 #29752]  WARN -- : #<Unicorn::HttpServer:0x0000000343fa00>: worker (pid: 29752) exceeds max number of requests (limit: 119)
W, [2015-07-29T16:47:00.539621 #29752]  WARN -- : Unicorn::WorkerKiller send SIGQUIT (pid: 29752) alive: 383 sec (trial 1)
I, [2015-07-29T16:47:03.467363 #29745]  INFO -- : reaped #<Process::Status: pid 29752 exit 0> worker=0
I, [2015-07-29T16:47:03.467928 #29745]  INFO -- : worker=0 spawning...
I, [2015-07-29T16:47:03.472507 #7549]  INFO -- : worker=0 spawned pid=7549
I, [2015-07-29T16:47:03.473377 #7549]  INFO -- : Refreshing Gem list
I, [2015-07-29T16:47:19.137831 #7549]  INFO -- : worker=0 ready
I, [2015-07-29T16:47:20.251309 #7549]  INFO -- : #<Unicorn::HttpServer:0x0000000343fa00>: worker (pid: 7549) has 101 left before being killed

1行目でpid=29752がリクエスト回数上限の119回に達したことがわかります。

2行目ワーカプロセスに対してQUITシグナルを送信しています。

その後、別のワーカプロセスがpid=7549で起動しています。試しにpid=7549のメモリ使用量を見ると

config.ru
1
zephiransas  7549  5.7  3.1 705752 261532 ?       Sl   16:47   0:18 unicorn worker[0] -c config/unicorn.rb -E production -D

となり、以前より減っていることがわかります。

Railsアプリを実運用するときには必須のgemだと思います。

論理削除とeager_loadでN+1問題が発生する件

Railsアプリにて論理削除とeager_loadを合わせて使うとN+1問題が発生することに気づいたのでメモ。

N+1問題を確認する

まずはN+1問題が起きるようなモデルを作成します。よくあるブログアプリのような、ブログのエントリがあり、それにコメントが複数あるパターンです。

1
2
3
4
5
6
7
8
9
10
11
12
class Post < ActiveRecord::Base
  attr_accessible :title,
                  :content
  has_many :comments
end

class Comment < ActiveRecord::Base
  attr_accessible :post_id,
                  :name,
                  :content
  belongs_to :post
end

適当なデータを入れた後、これに対してrails cで以下のようにレコードを取得します。

1
2
3
Post.all.each do |post|
  puts post.comments.first.name
end

すると、以下のようなSQLが発行されます。

1
2
3
4
5
6
7
8
9
Post Load (0.1ms)  SELECT "posts".* FROM "posts"
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 12 LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 13 LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 14 LIMIT 1
ユーザ1
...
(以下続く

この場合は、対象となったPostの件数分、CommentsテーブルへのSQLが発行されることになります。 これがN+1問題です。

N+1問題に対処する

これを解決するには、eager_loadを使うことが一般的です。つまり

1
2
3
Post.eager_load(:comments).each do |post|
  puts post.comments.first.name
end

この場合のSQLは(一部簡略化しています)

1
2
SQL (0.2ms)  SELECT "posts".*, "comments".*
FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"

となります。Postsテーブルと一緒にCommentsテーブルを取得しているので、SQLが1回だけ発行されていることがわかります。

美しい理想の世界です。ハラショー

paranoiaを導入する

さて本題。ここでうっかり論理削除を導入してみましょう。

Railsには論理削除に関するgemは多数ありますが、現在のデファクトスタンダードはparanoiaだと思います。まずはparanoiaをGemfileに記述します。

Gemfile
1
gem 'paranoia', '~> 1.0'  # Rails3系には1.0系を使用

その後、モデルを以下のように変更します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Post < ActiveRecord::Base
  acts_as_paranoid
  attr_accessible :title,
                  :content
  has_many :comments
end

class Comment < ActiveRecord::Base
  acts_as_paranoid
  attr_accessible :post_id,
                  :name,
                  :content
  belongs_to :post
end

その後、eager_loadしてみます。

1
2
3
Post.eager_load(:comments).each do |post|
  puts post.comments.first.name
end

するとSQLは以下の様に発行されます。(一部簡略化しています)

1
2
3
SQL (0.2ms)  SELECT "posts".*, "comments".*
FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"
WHERE ("posts".deleted_at IS NULL)

PostsテーブルのWHERE条件にdeleted_at is nullが付与されているのは期待通りですが、Commentsテーブルには付与されていないので、これでは論理削除されたCommentsテーブルの内容も取得してしまいます・・・

では、以下のようにPostのcommentsにconditionsを付与するのはどうでしょう?

1
2
3
4
5
6
class Post < ActiveRecord::Base
  acts_as_paranoid
  attr_accessible :title,
                  :content
  has_many :comments, conditions: 'comments.deleted_at is null'
end

ここで同様にeager_loadするとSQLは以下の様に発行されます。

1
2
3
SQL (0.2ms)  SELECT "posts".*, "comments".*
FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" AND comments.deleted_at is null
WHERE ("posts".deleted_at IS NULL)

WHERE条件が追加されて、なんだか、いい感じにeager_loadできました。

論理削除したデータも取得したい場合

さて、ここで少々頭がおかしくなって「削除したCommentも取りたい(^q^)」という気分になったとしましょう。

そこでPostクラスにcomments_with_deletedなるアソシエーションを追加します。

1
2
3
4
5
6
7
8
9
class Post < ActiveRecord::Base
  acts_as_paranoid
  attr_accessible :title,
                  :content
  has_many :comments, conditions: 'comments.deleted_at is null'
  has_many :comments_with_deleted,
           class_name: 'Comment',
           foreign_key: :post_id
end

さて、これを使ってeager_loadしてみましょう。

1
2
3
Post.eager_load(:comments_with_deleted).each do |post|
  puts post.comments.first.name
end

すると、以下のようなSQLが発行されます。

1
2
3
4
5
6
7
8
9
10
SQL (0.2ms)  SELECT "posts".*, "comments".* FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."id" = "posts"."id" WHERE ("posts".deleted_at IS NULL)
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 12 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1
ユーザ2
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 13 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 14 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 15 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1
ユーザ1
Comment Load (0.1ms)  SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 16 AND ("comments".deleted_at IS NULL) AND (comments.deleted_at is null) LIMIT 1

最初のSQLでは、条件にcomments.deleted_at is nullが付与されていないので、これは期待通りなのですが、その後、なぜかN+1問題が再発しています。

現在のところ、これを回避できる方法は見つけられていません。

  • 追記

我らのひむひむセンセイから、アドバイスを頂きました。

なるほど!やってみましょう。

1
2
3
Post.eager_load(:comments_with_deleted).each do |post|
  puts post.comments_with_deleted.first.name
end

するとSQLは

1
2
3
4
SELECT "posts".
SQL (0.2ms)  SELECT "posts".*, "comments".* FROM "posts" LEFT OUTER JOIN
ON "comments"."post_id" = "posts"."id"
WHERE ("posts".deleted_at IS NULL)

となって、意図した結果になりましたとさ。

でもこれ、実装時に意識しながら書ける自信ないですわ・・・(´・3・`)

結論

結論を社畜ちゃんにまとめていただきます。

summary

とは言っても論理武装が必要でしょうから、こちらも合わせてどうぞ。