SwiftでiTunes ライブラリファイルを編集するMac OS X アプリを作ってみた
一昨日のMP3ファイルのID3タグを一度に変更できるツールを作ってみた に続き、iTunes ライブラリファイル (iTunes Music Library.xml) を編集するMac OS X アプリをSwiftで作ってみたので、簡単にSwift言語とQuickというBDDフレームワークに付いて書きます。
コードはGitHubに置きました 。Xcode6 beta6で動作確認しています
このアプリの使い方
起動するとTunes ライブラリファイルiTunes Music Library.xmlを読み込み上のような画面が表示されます。Alubm Title名を入力しSearchボタンを押すと、そのアルバムの曲がテーブルに表示されます。ここでタイトルを変更したい場合は、AmazonでCDを検索し下のような曲名一覧をコピーし、テキストビューにペーストしてReplaceボタンを押すと、アプリ内の曲名情報が更新されます。そしてメーニューのSaveを選択するとデスクトップに更新されたiTunes Music Library.xmlファイルと 一昨日ツールで使う曲名情報のファイル MpegTitleList.txt が保存されます。
注意 このアプリにはバグがあるかもしれませんので、実際に使う際には絶対に iTunes Library.itl , iTunes Music Library.xml のバックアップを取ってから使って下さい m(__)m
解説
iTunes Music Library.xml に付いて
iTunesで管理している曲の情報は iTunes Library.itl ファイルで管理されていますが、その内容をXML形式で書き出したファイルが iTunes Music Library.xml です。iTunes Library.itlの全ての情報は含まれていないようですが、ほぼ全てと考えて良いと思います。iTunes ライブラリおよびプレイリストを作成し直す方法 に iTunes Library.itl ファイルが壊れてしまった際に iTunes Music Library.xml から復旧する方法が書かれています。
iTunes Music Library.xmlは NextSTEP由来のオブジェクトをシリアライズしたplistファイルで今風に言えばJSONみたいなものです、Mac OS X, iOSで設定情報を保存するによく使われています。 詳細はWikipedaのプロパティリスト したがって、Mac OS X, iOSのCocoaフレームワークのクラス(例 NSDictonary)から簡単に読み書きできます。
ITunesLibraryクラス
iTunes Music Library.xmlの読み書き、検索、置換を行うクラスです。Swift言語はC, Objectve-C, Java, Rubyなどの言語になれている人ならコードの大部分は理解できると思います。ここでは Swiftらしい部分のみ解説してみます。
まずは内部で使う型を定義しています。 Swiftでは構造体が使えるので簡単なデータ構造は struct で定義するのがお手軽です。また struct にはメソッドが定義できるので、ここではデバッグ等で便利なように descriptionメソッドを定義しています(しかし現在はdescriptionメソッドを明示しないと行けません!?)
import Cocoa struct MpegFileTitle : Printable { let path: String let title: String var description: String { get {return "path: \(self.path) title: \(self.title)"} } // Not work as "Swift Standard Library Reference" } typealias TrackId = Int
定数(class var 〜 { get 〜})の定義とインスタンス変数の定義(var 〜)。Swiftのクラスに対しての定数は書けない(インスタンスから参照出来る定数は let で書けます)ので参照のみのクラスComputed Propertiesを使っています。(もっと良い方法があるかも、ちなみに class let と書くとnot yet supportedエラーになります)
class ITunesLibrary: NSObject { class var LibraryXmlFileName: String { get { return "iTunes Music Library.xml" } } class var MpegTitleFileName: String { get { return "MpegTitleList.txt" } } var libaryDict: NSMutableDictionary = [:] var mpegTitleList: [MpegFileTitle] = []
XmlFilePath()はclass funcから始まるのでクラスメソッドです
class func XmlFilePath() -> String { return NSHomeDirectory() + "/Music/iTunes/iTunes Music Library.xml" }
さて次は、iTunes Music Library.xmlの読み書きするメソッドです。 Swiftの特徴の一つOptionalが使われています。Swiftは強い型付けの静的言語で、通常に宣言した変数は nil (値無し)には出来ません。nilになる可能性のある変数は、型の後ろに ? を付けて宣言します(または !を付ける、少し動作が違います)。また、コードの中で変数に格納されたオブジェクトのメソッドを呼び出す際には、nil でないことを保証するコードにする必要があります。CやObjective-Cに慣れているととても面倒に感じますが、nullポインターエラーにならない安全なコードがかけます。ただし、変数名! と書くと面倒な処理を省略できますが、値がnilの場合は実行時エラーになります。
loadの処理はNSData.dataWithContentsOfFile()メソッドでファイルを読み込み、NSPropertyListSerialization.propertyListWithData()メソッドでplist(XML)をSwiftのオブジェクトに変換しています。saveはその逆の操作をしています。
ちなみに、 NSDictionary(contentsOfFile: path)で直接plistファイルを読み込めますが、エラーがあった場合のエラー情報が取得出来ないのでこのようにしました。 詳しくはこちら
またsaveの中では、曲名情報のファイル(mpegTitlesFile)の作成ではArray#mapやString#joinなどの便利なメソッドを使っています。詳しくはこちら
func load(libraryXmlPath: String) -> NSError? { var error:NSError?; let data = NSData.dataWithContentsOfFile(libraryXmlPath, options: nil, error: &error) if data == nil { return error } let plist = NSPropertyListSerialization.propertyListWithData(data, options: 2, format: nil, error: &error) as NSMutableDictionary! if plist == nil { return error } libaryDict = plist mpegTitleList = [] return nil } func save(saveFolderPath: String) -> NSError? { var error:NSError?; let data = NSPropertyListSerialization.dataWithPropertyList(libaryDict, format: NSPropertyListFormat.XMLFormat_v1_0, options: 0, error: &error) if data == nil { return error } if !data.writeToFile(saveFolderPath.stringByAppendingPathComponent(ITunesLibrary.LibraryXmlFileName), options: NSDataWritingOptions.AtomicWrite, error: &error) { return error } var mpegTitlesFile = "\n".join(mpegTitleList.map({"\($0.path)\t\($0.title)"})) + "\n" if !mpegTitlesFile.writeToFile(saveFolderPath.stringByAppendingPathComponent(ITunesLibrary.MpegTitleFileName), atomically: true, encoding: NSUTF8StringEncoding, error: &error) { return error } return nil }
searchAlubm()は指定されたアルバムタイトルの曲を検索するメソッドです、単純に全ての曲を調べるので遅いかもしれません。前にも書いたようにSwiftは強い型付けの静的言語なので NSDictionaryのように何型のデータ(AnyObject!型)が入っているか判らないと処理が書けなので as 〜 で型をキャストしています。ただし、SwiftのStringとNSString等はある程度は自動的に変換されます。
SwiftのArrayやDictionaryは宣言時に型を指定しますが、NSArrayやNSDictionaryを扱う場合はキャストが必須です。キャストする際にもnil状態(Optiontal)を考えないといけません。ただしコンパイラー(Xcode)が警告やエラーを出すので助かります。
ここでも Array#sortのようにクロージャー(ブロック)を受け取るメソッドを使っています。
func searchAlubm(title: String) -> [TrackId] { var trackIds : Array<TrackId> = [] let tracks = libaryDict["Tracks"] as NSDictionary for (key, dict) in tracks { if (dict["Album"] as String?) == title { trackIds.append((key as String).toInt()!) } } trackIds.sort { $0 < $1 } return trackIds } func songTitle(id: TrackId) -> String { let tracks = libaryDict["Tracks"] as NSDictionary let track = tracks[String(id)] as NSDictionary return track["Name"] as String }
SwiftはObjective-C同様に正規表現の組み込みクラスやリテラルはありません。メソッドも引数がたくさんあり使いにくいです ^^;
class func parse(titlesChunk: String) -> [String] { var titles: [String] = [] let regex = NSRegularExpression.regularExpressionWithPattern("\\d+\\.\\s*(.*?)(\\s*試聴する)?$", options: nil, error: nil) for line in titlesChunk.componentsSeparatedByString("\n") { if var matches = regex?.firstMatchInString(line, options: nil, range: NSMakeRange(0, countElements(line))) { titles.append((line as NSString).substringWithRange(matches.rangeAtIndex(1))) } } return titles } func replaceTiles(ids: [TrackId], _ titles: [String]) -> NSError! { if ids.count != titles.count { return NSError.errorWithDomain("The number of titles is not the same as the number of tracks", code: 10001, userInfo: nil) } let tracks = libaryDict["Tracks"] as NSDictionary for id in ids { if tracks[String(id)] == nil { return NSError.errorWithDomain("TrackId \(id) not found", code: 10002, userInfo: nil) } } for var i = 0; i < ids.count; i++ { var track = tracks[String(ids[i])] as NSMutableDictionary track["Name"] = titles[i] mpegTitleList.append(MpegFileTitle(path: NSURL.URLWithString(track["Location"] as String).path!, title: titles[i])) } return nil } }
ITunesLibraryクラスのテストコード
テストはXcode標準のXCTestではなく、RSpec風に書ける QuickというBDDフレームワークを使いました。ただし8/22日時点で QuickはXcode6 beta6 に対応してないので フォークの bendjones/Quickを使いました。 (/Quick/Quickもbeta6対応されました 8/23)
最近はどの言語にもRSpec風に書けるツールがあり、新しい言語を学ぶにもテスト駆動開発が役に立つと思います。
コードはRSpecになれている方なら、だいたい読めると思います。Swift言語に付いてコメントすると
- 基底クラスのメソッドを置き換えるするには override を書く必要があります
- メソッドの戻り値を無視する事は出来ないので。 var _ = method() のように書きました(もっと良い書き方がるのかな?)
- ヒアドキュメントや、文字列を改行したりは出来ないので文字列を改行したい場合は + で連結して下さい
import Quick import Nimble class ITunesLibrarySpec: QuickSpec { override func spec() { var lib: ITunesLibrary = ITunesLibrary() var loadeErr: NSError! beforeEach { loadeErr = lib.load("./iTunesTitleRepairerTests/iTunesLibrary.xml") } describe("#load") { it("iTunesLibary XMLファイルを読み込める") { expect(loadeErr).to(beNil()) expect(lib.libaryDict.count).to(equal(9)) expect(((lib.libaryDict["Tracks"] as NSDictionary).allKeys as [String]).count).to(equal(4)) } it("iTunesLibary XMLファイルが存在しない場合はErrorを戻す") { let err = lib.load("./iTunesTitleRepairerTests/NOiTunesLibrary.xml") expect(err!.localizedDescription).to(contain("no such file")) } } describe("#save") { let NewLibFolder = "/tmp" let NewLibPath = NewLibFolder.stringByAppendingPathComponent(ITunesLibrary.LibraryXmlFileName) let MpegTitlesPath = NewLibFolder.stringByAppendingPathComponent(ITunesLibrary.MpegTitleFileName) let fileMan = NSFileManager.defaultManager() beforeEach { var _ = fileMan.removeItemAtPath(NewLibPath, error: nil) } it("ファイルに保存出来る") { expect(lib.save(NewLibFolder)).to(beNil()) expect(fileMan.fileExistsAtPath(NewLibPath)).to(beTruthy()) expect(fileMan.fileExistsAtPath(MpegTitlesPath)).to(beTruthy()) } it("変更内容が書き込まれている") { var _ = lib.replaceTiles([6257, 6259], ["名もない恋愛", "足ながおじさんになれずに"]) var _ = lib.save(NewLibFolder) var newLib: ITunesLibrary = ITunesLibrary() newLib.load(NewLibPath) expect(newLib.songTitle(6257)).to(equal("名もない恋愛")) } it("音楽ファイル名変更用shell scriptも書かれる") { var _ = lib.replaceTiles([6257, 6259], ["VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ", "SARAVAH! / サラヴァ!"]) var _ = lib.save(NewLibFolder) expect(NSString(contentsOfFile: MpegTitlesPath, encoding: NSUTF8StringEncoding, error: nil)).to(equal( "/Users/yy/Music/iTunes/iTunes Media/Music/高橋幸宏/Saravah!/01 AudioTrack 01.mp3\tVOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ\n/Users/yy/Music/iTunes/iTunes Media/Music/高橋幸宏/Saravah!/02 AudioTrack 02.mp3\tSARAVAH! / サラヴァ!\n")) } } describe("#searchAlubm") { it("アルバム名で検索し曲名のTrackIDを戻す") { expect(lib.searchAlubm("A Day in the next life")).to(equal([5791, 5793])) } } describe("#songTitle") { it("指定されたTrackIDの曲名を戻す") { expect(lib.songTitle(5791)).to(equal("震える惑星(ほし)")) } } describe(".parse") { it("Amazonの曲名リストから曲名の配列を取得する") { let chunk = "1. VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ\t試聴する\n" + "2. SARAVAH! / サラヴァ!\t試聴する\n" + "1. 名もない恋愛\n" + "2. 足ながおじさんになれずに" expect(ITunesLibrary.parse(chunk)).to(equal( ["VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ", "SARAVAH! / サラヴァ!", "名もない恋愛", "足ながおじさんになれずに"])) } } describe("#replaceTiles") { it("指定された複数のidの曲名を置き換える") { let err = lib.replaceTiles([6257, 6259], ["VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ", "SARAVAH! / サラヴァ!"]) expect(err).to(beNil()) expect(lib.songTitle(6257)).to(equal("VOLARE (NEL BLU DIPINTO DI BLU) / ボラーレ")) expect(lib.songTitle(6259)).to(equal("SARAVAH! / サラヴァ!")) println(lib.mpegTitleList[0].description) } } } }
AppDelegateデリゲート
GUI操作に対応したコードは今回は全てAppDelegateクラスに書いてしまいました。iOSプログラミングをしたことがある方は、だいたいわかると思います。
- iOSに比べるとGUI要素(例 NSTableView, NSTextView)が高機能でいろいろと戸惑いました
- またメニューもiOSにはないで勉強になりました。詳細はここ
- Swiftには performSelectorメソッドがないので、少し後に行いたい処理はGCDを使いました。ヒントはいつものStackOverflow
import Cocoa class AppDelegate: NSObject, NSApplicationDelegate, NSTableViewDataSource, NSTableViewDelegate { @IBOutlet weak var window: NSWindow! @IBOutlet weak var albumTitle: NSTextField! @IBOutlet weak var songTable: NSTableView! @IBOutlet var newTitleText: NSTextView! var trackIds: [Int] = [] var iTunes: ITunesLibrary = ITunesLibrary() let NewFilesFolder = NSHomeDirectory() + "/Desktop" func applicationDidFinishLaunching(aNotification: NSNotification?) { performBlock(0.1) { self.openLibrary(ITunesLibrary.XmlFilePath()) } } func applicationWillTerminate(aNotification: NSNotification?) { } func numberOfRowsInTableView(tableView: NSTableView!) -> Int { return trackIds.count } func tableView(tableView: NSTableView!, objectValueForTableColumn tableColumn: NSTableColumn!, row: Int) -> AnyObject! { if tableColumn.identifier == "SequenceColumn" { return row + 1 } else { return iTunes.songTitle(trackIds[row]) } } @IBAction func openDocument(sender: AnyObject) { let openPanel = NSOpenPanel() openPanel.canChooseFiles = true if openPanel.runModal() == NSOKButton && openPanel.URLs.count == 1 { openLibrary((openPanel.URLs[0] as NSURL).path!) } } @IBAction func saveDocument(sender: AnyObject) { if let err = iTunes.save(NewFilesFolder) { showErrorAlert(err) } } @IBAction func searchPushed(sender: AnyObject) { trackIds = iTunes.searchAlubm(albumTitle.stringValue) songTable.reloadData() } @IBAction func replacePushed(sender: AnyObject) { let titles = ITunesLibrary.parse(newTitleText.string) if titles.count == 0 { return } if let err = iTunes.replaceTiles(trackIds, titles) { showErrorAlert(err) } else { songTable.reloadData() newTitleText.string = "" } } private func openLibrary(path: String) { if let err = iTunes.load(path) { showErrorAlert(err) } else { newTitleText.string = "" albumTitle.stringValue = "" albumTitle.enabled = true trackIds = [] songTable.reloadData() } } private func showErrorAlert(error: NSError!) { var alert = NSAlert(error: error) if alert.runModal() == NSAlertFirstButtonReturn { // NOP } } private func performBlock(deley: Double, _ closure: () -> ()) { dispatch_after( dispatch_time(DISPATCH_TIME_NOW, Int64(deley * Double(NSEC_PER_SEC))), dispatch_get_main_queue(), closure) } }
感想
MP3ファイルのID3タグを一度に変更できるツールを作ってみた
昔にリッピングしたMP3ファイルには下のように iTunesで曲名が 01 AudioTrack 01.mp3 などと表示されています。これはiTunesの「情報を見る」ダイアログで変更できますが、いちいちGUIから変更していくのは大変です。そこで、ファイル名、曲名を書いたファイルから一括で変更できるツールを作ってみました。
iTunes, MP3 などに付いて・・・
iTunesは曲名等の情報は iTunes Library.itl (それを書き出した可読性のある iTunes Music Library.xml)というファイルで管理しています、このファイルを変更するとiTunesに表示される曲名を変更できます(この話しは次回にでも書きます)。
しかし、そこで曲を再生すると、なんと元の 01 AudioTrack 01.mp3 に戻ってしまいます!
実は wikipediaのID3タグ項目 にあるようにMP3,4(.mp3, .m4a)ファイルには アーティスト・作成年・曲名等の情報を書かられていて、iTunesは再生する時に、この情報から曲名を戻して(変更して)しまいます。
ID3タグを編集するツールは有料、オープンソースを含めたくさんあります。中には Amazonを検索し曲名等を一括で設定すてくれそうなツールがあったのですが、なぜか上手く行きませんでした。また、ID3タグのバージョンやエンコード(文字化け)の問題がありMacで動作するオープンソースのツールは良い物が見つけられませんでした。
TagLib, taglib-ruby
ID3タグを扱うライブラリーとしては、C++で書かれた TagLibが良いようで、Rubyから使える taglib-ruby があったので Rubyでツールを書く事にしました。
ちなみに、id3lib というライブラリーもあります。こちらはHome brewでコマンドラインツールもインストール出来るのですが、最近はメンテされてないようで曲名に日本語を入れる事が出来ないので断念しました。
ツール
下のコードのように taglib-rubyを使う事で、MP3ファイルを読み込み、タイトル (曲名)を設定し保存するツールが簡単にできました。MP3ファイルのパス名、曲名をタブ区切りで書いたファイルを用意する事で複数のMP3ファイルを一度に変更できます。
#!/usr/bin/env ruby require 'taglib' MP_FILE_CLASSES = {'.mp3' => TagLib::MPEG::File, '.m4a' => TagLib::MP4::File} def set_mp_audio_file_to_title(path, title) MP_FILE_CLASSES[File.extname(path)].open(path) {|mp| mp.tag.title = title; mp.save} puts " set #{path} title=#{title}" rescue puts " error: #{$!}" exit(-1) end if ARGV.count < 2 puts "Usage: #{$0} MPEG_AUDIO_FILE TITLE or" puts " #{$0} -f FILE_AND_TITLE_LIST" elsif ARGV[0] == '-f' IO.readlines(ARGV[1]).each do |line| path_title = line.split("\t") set_mp_audio_file_to_title(*path_title) if path_title.length == 2 end else set_mp_audio_file_to_title(ARGV[0], ARGV[1]) end exit(0)
Ruby開発者を増やすための教育について (8年間のRuby教育で得た知見)
わけあって昔作ったKeynoteを眺めていたら、かなり良い資料だと思い再掲載してみました (はてなダイアリー|ブログには初めてです)。 RubyWorld Conference 2013 の発表資料です。
Ruby、Ruby on Rails開発者を増やしたいと考えている開発マネージャーやリーダーの方は是非読んでみて下さい。社内で教育いくして行く際に役立つヒントがたくさん書かれていると思います (自画自賛 ^^;)。
Dockerを使いRuby on Railsアプリ、PostgreSQL、Nginxなどのコンテナーをクラウドサービスで動かしてみた
環境構築
普通に Boot2Docker をMacにインストールしました。Boot2Dockerは ここのブログの中ほどの画像のようにVitualBox上でDockerサーバーを動かし、Macの dockerコマンドがDockerサーバーと通信して動作します。
Dockerサーバーの作成・起動などは boot2docker コマンドで行います。
作成・起動は、
% boot2docker init % boot2docker up
これで、dockerコマンドが使えるようになりますが、通信用のDOCKER_HOST環境変数を設定する必要があります。
% export DOCKER_HOST=tcp://192.168.59.103:2375 % docker images REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE nginx latest c43e4a994b02 4 hours ago 231.7 MB app latest 4b465cc33c7c 4 hours ago 602.7 MB pg latest 8f1dd8d424ad 4 hours ago 380.3 MB ....
dockerコマンドの詳細はドキュメント等を参考にして下さい。Dockerでば docker build コマンドで Docefile に従いイメージ(ファイルシステムのアーカイブ?)を作成します。そして、 docker run コマンドでコンテナー(仮想マシン?)を作成しイメージを実行します。
コンテナーの割り振り
Ruby on Railsのアプリケーションは通常、アプリケーションを動かすサーバー(Unicron等)、データベース(PostgreSQL, MySQL等)、画像CSS等の配信を行うWebサーバー(Nginx,Apache等)のサーバーソフトが必要になります。これらを1つのコンテナーの中で動かす事も出来ますが、今回はメンテナンス性等を考え、3つのコンテナーで動かす事にします
- pgコンテナー : RDBサーバー、PostgreSQL
- nginxコンテナー : Webサーバー、Nginxと画像/CSS等のコンテンツ
- appコンテナー : アプリケーション、Ruby, Ruby on Rails, Unicorn
ちなみに、今回参考にした Deploy Rails Applications Using Docker では RDBサーバー、アプリ+Nginxの2つのコンテナーで動いていいます。
Ruby on Rails アプリケーションの作成
シンプルな TODOアプリです。コードは GitHub あります。
作成手順
% rails new rails_app_with_docker % cd rails_app_with_docker % rails g scaffold todo due:date task:string % rake db:migrate % ruby s
その後 unicorn, pgをインストルし、production環境ではRDBがPostgreSQLになるように、またサーバーはUnicornに変更しました。
pgコンテナー
pgコンテナーは DockerのドキュメントにあるDockerizing a PostgreSQL serviceを、ほぼそのまま使っています。
行っているのは、ubuntu14.04にPostgreSQLをインストールし docker というデータベースを作成し、run でPostgreSQLサーバーが起動するようにしています。また、VOLUME 命令でバックアップ等で使うディレクトリーを他のコンテナーからマウント出来るようにしています。
- config/docker/pg/Dockerfile
FROM ubuntu:14.04 RUN apt-get update RUN apt-get install -y -q postgresql-9.3 libpq-dev postgresql-client-9.3 postgresql-contrib-9.3 USER postgres RUN /etc/init.d/postgresql start &&\ psql --command "CREATE USER docker WITH SUPERUSER PASSWORD 'docker';" &&\ psql --command "CREATE DATABASE docker WITH OWNER docker TEMPLATE template0 ENCODING 'UTF8';" RUN echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/9.3/main/pg_hba.conf RUN echo "listen_addresses='*'" >> /etc/postgresql/9.3/main/postgresql.conf EXPOSE 5432 VOLUME ["/etc/postgresql", "/var/log/postgresql", "/var/lib/postgresql"] CMD ["/usr/lib/postgresql/9.3/bin/postgres", "-D", "/var/lib/postgresql/9.3/main", "-c", "config_file=/etc/postgresql/9.3/main/postgresql.conf"]
appコンテナー
appコンテナーは rbdock Gem が生成するDockerfile を参考に作りました。内容は
- ubuntu14.04 + ruby2.1.2のインストール(後ほど説明します)
- Gemfileをコピーしてbundleコマンドで必要な Gem をインストール
- アプリケーションのコピー
- 不要なファイルの削除
やはり、VOLUME 命令でアプリ全体を他のコンテナーからマウント出来るようにしています、この一部は nginxコンテナーで使います。
- Dockerfile
FROM yuumi3/ruby:2.1.2 RUN mkdir /home/app WORKDIR /home/app ADD Gemfile /home/app/Gemfile RUN bundle install ADD . /home/app RUN rake tmp:clear RUN rake log:clear VOLUME ["/home/app"] ENTRYPOINT bin/start_server.sh
run で実行されるスクリプトでは、Rails用の環境変数の設定、データベースのマイグレーション、Unicornサーバーの起動です。
データベースのマイグレーションは、Dockerfileで指定したかったのですが、buildの際にはpgコンテナーと接続出来ないのでここで行っています。
- bin/start_server.sh
#!/bin/bash -x export RAILS_ENV=production export SECRET_KEY_BASE=`rake secret` rake db:migrate unicorn_rails -c config/unicorn.rb
ubuntu14.04 + ruby2.1.2のインストールは既に Docker Hubに登録してあるイメージを使っています。 このDockerfileもrbdock Gem が生成するDockerfile ほぼそのままです。ありがとうございます! 内容は
- Rubyをコンパイルするためのツール・ライブラリーのインストール
- Rubyのコンパイル・インストール
gemのアップデート、bundler gemのインストール
yuumi3/ruby:2.1.2作成時のDockerfile
FROM ubuntu:14.04 # Install basic dev tools RUN apt-get update && apt-get install -y \ build-essential \ wget \ curl \ git # Install package for ruby RUN apt-get install -y \ zlib1g-dev \ libssl-dev \ libreadline-dev \ libyaml-dev \ libxml2-dev \ libxslt-dev # Install package for sqlite3 RUN apt-get install -y \ sqlite3 \ libsqlite3-dev # Install package for postgresql RUN apt-get install -y libpq-dev # Install ruby-build RUN git clone https://github.com/sstephenson/ruby-build.git .ruby-build RUN .ruby-build/install.sh RUN rm -fr .ruby-build # Install ruby-2.1.2 RUN ruby-build 2.1.2 /usr/local # Install bundler RUN gem update --system RUN gem install bundler --no-rdoc --no-ri
nginxコンテナー
nginxコンテナーは Deploy Rails Applications Using Docker に書かれているものを参考にしています。
ただし、sedを使いnginxの設定ファイルを書き換える力業は Server Fault を参考にしました。通常のnginxの設定ファイルに環境変数の値を使う事は出来ないのですね(luaを組み込んだnginxなら出来るようです)。
- config/docker/nginx/Dockerfile
FROM ubuntu:14.04 RUN apt-get update RUN apt-get install -y nginx RUN echo "daemon off;" >> /etc/nginx/nginx.conf ADD default /etc/nginx/sites-available/default EXPOSE 80 VOLUME ["/var/log/nginx"] ENTRYPOINT /bin/sed -i "s/[0-9]\+\.[0-9]\+\.[0-9]\+\.[0-9]\+/${APP_PORT_3000_TCP_ADDR}/" \ /etc/nginx/sites-available/default &&\ /usr/sbin/nginx
nginxの設定ファイルも、ほぼDeploy Rails Applications Using Docker に書かれているものです。
root に指定されている /home/app/public は appコンテナーでexportしている /home/app をマウント(共有)しています。これで画像やcssなどがnginxで配信できます。
- config/docker/nginx/default
cat config/docker/nginx/default server { server_name _; root /home/app/public; add_header X-Frame-Options DENY; add_header X-Content-Type-Options nosniff; location / { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_redirect off; proxy_set_header Host $http_host; if (!-f $request_filename) { proxy_pass http://192.168.111.111:3000; break; } } }
Dockerイメージの作成と実行
各コンテナーは以下のRakeコマンドで作成 docker build 、実行 docker run しています。オプションの指定がたくさんあるので rakeコマンドにしてあります。
例えば、 nginxコンテナーの作成では -t nginx でイメージの名前を付け、次にDockefileのあるディレクトリーのパスを指定しています。 実行は * -d でバックグラウンド実行 * -p 80:80 はコンテナーのポート80を外部から80でアクセス出来るように設定 * --link app:app はappコンテナーの情報をnginxコンテナー内で app(例 APP_PORT_3000_TCP_ADDR 環境変数はappコンテナーのIPアドレス)で参照出来るようにする * --volumes-from app appコンテナーの VOLUME で指定されたディレクトリーをこのコンテナーでマウント(共有) * --name nginx 起動されたコンテナーに nginx という名前を付ける
namespace :docker do desc "Build Postgresql container" task :build_pg do sh "docker build -t pg config/docker/pg" end desc "Run Postgresql container" task :run_pg do sh "docker run -d -p 5432:5432 --name pg pg" sh "docker ps" end desc "Build Rails application container" task :build_app do Rake::Task['assets:precompile'].invoke sh "docker build -t app ." end desc "Run Rails application container" task :run_app do sh "docker run -d -p 3000:3000 --link pg:db --name app app" sh "docker ps" end desc "Build Nginx container" task :build_nginx do sh "docker build -t nginx config/docker/nginx" end desc "Run Nginx container" task :run_nginx do sh "docker run -d -p 80:80 --link app:app --volumes-from app --name nginx nginx" sh "docker ps" end end
DigitalOceanへdeploy
激安なクラウドサービス DigitalOcean に出来た Docker Image をデプロイしてみましょう。
1. DigitalOcean に SignUpし、サーバー(droplet)を作成
SIGN UPで登録し $ 0.015 / Hour のサーバーを選んでみました、OSはUbuntu 14.04 x64 。
メールでサーバー(droplet)のIPやアカウント情報が送られてきます。
2. Dockerのインストールと確認
Ubuntu - Docker Documentation の手順で最新版のDockerのインストールし、確認。
$ sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 36A1D7869245C8950F966E92D8576A8BA88D21E9 $ sudo sh -c "echo deb https://get.docker.io/ubuntu docker main > /etc/apt/sources.list.d/docker.list" $ sudo apt-get update $ sudo apt-get install -y lxc-docker $ sudo docker run -i -t ubuntu /bin/bash /# .... /# exit $
3. 作ったDocker ImageをDocker Hubから取り込み実行
DockerコマンドでDocker HubからImageを取得し実行
$ sudo docker run -d -p 5432:5432 --name pg yuumi3/postgres:docker $ sudo docker run -d -p 3000:3000 --link pg:db --name app yuumi3/rails_app:todo $ sudo docker run -d -p 80:80 --link app:app --volumes-from app --name nginx yuumi3/nginx
ブラウザーでアクセスしTODOアプリの動作確認。
動いた ^^)/
DigitalOceanではサーバー(droplet)を停止しても、存在すれば課金されるのようなので 終わったらDestroyしてしまいましょう :-)
Docker感想
既にChefは使っていますが、今回Dockerを使って思った事は
- Chefはサーバー構築手順をコード化するものですが、Dockerはイメージを構築し利用するものなので、Chefのような考え方は切り換えないと行けない。例えば実行時に決まるIPアドレスなどはイメージ/Dockerfileには書けない。
- Dockerfileはシンプルな記述しか出来ないので、shell script等に頼る事になり、Rubyプログラマーの私には色々な事ができるChefの方が良いな
- イメージが出来てしまえば確かにサーバーへのデプロイは早いが、大きなイメージファイルをやり取りするので思ったほど早くはない。イメージをネットワーク的にデプロイ先に近いところ置けば良いのだろうか?
- 一つのサーバー上で複数のコンテナーが作れるので、今回のようにRDB,Web,アプリを分けておけばメンテナンス等でのダウンタイムが減らせるかも。
- 現在はクラウド+ Chefで満足しているので直ぐにDockerに移ろうとは私は思わないですが、Dockerをベースにしたサービスや新たな技術が生まれてきているので将来が楽しみな技術ですね。
はてなブログに引っ越しました。
今さらながら はてなブログに引っ越しました。
URLは http://yuumi3.hatenablog.com/ ですが、従来の http://d.hatena.ne.jp/yuum3/ をアクセスしても新URLにリダイレクトします。RSSも従来のままでもだいじょぶです。
Herokuへのデプロイのデモで失敗しないための手順
先日の セミナーの中で Heroku を使えば Ruby on Rails アプリの公開(デプロイ)は超簡単! という話しをしデモを行ったのですが、見事に失敗してしまいました ^^;
2度と失敗しないように記事を書きました。
失敗する手順
1. セミナー等でデモを行う場合は事前に練習!!
と言うことで以下の手順で無事にデプロイ出来る事を確認
$ heroku create $ git push heroku master $ heroku run rake db:migrate $ heroku open
3. セミナー本番
1. と同じ手順でデプロイを実行すると・・・・
$ heroku create $ git push heroku master ! No such app as xxxx-yyyy-9999.
なんと、エラー! 何度かリトライする内に原因が判ったのですが、焦っていたので失敗し、時間切れでデモは中止 (v_v)
失敗しない手順 (1)
1. セミナー等でデモを行う場合は事前に練習!!
と言うことで以下の手順で無事にデプロイ出来る事を確認
$ heroku create $ git push heroku master $ heroku run rake db:migrate $ heroku open
2. heroku コマンド でアプリを削除
$ heroku list === My Apps xxxx-zzzzz-2222 ← アプリのID. destroyコマンドで指定 $ heroku destroy xxxx-zzzzz-2222 ! WARNING: Potentially Destructive Action ... > xxxx-zzzzz-2222 ← アプリのID を再入力
3. セミナー本番
1. と同じ手順でデプロイを実行すれば 成功!
「クラウドxスマフォ時代のRuby on Rails入門」セミナーで使ったコードをGitHubに置きました
昨日、行われた クラウドxスマフォ時代のRuby on Rails入門 セミナーで使ったコードをGitHubに置きました。
簡単な Ruby on Rails で作ったサーバーと連携できる iOSアプリです。