SwiftでiTunes ライブラリファイルを編集するMac OS X アプリを作ってみた

一昨日のMP3ファイルのID3タグを一度に変更できるツールを作ってみた に続き、iTunes ライブラリファイル (iTunes Music Library.xml) を編集するMac OS X アプリをSwiftで作ってみたので、簡単にSwift言語とQuickというBDDフレームワークに付いて書きます。

コードはGitHubに置きました 。Xcode6 beta6で動作確認しています

f:id:yuum3:20140822100446p:plain

このアプリの使い方

起動するとTunes ライブラリファイルiTunes Music Library.xmlを読み込み上のような画面が表示されます。Alubm Title名を入力しSearchボタンを押すと、そのアルバムの曲がテーブルに表示されます。ここでタイトルを変更したい場合は、AmazonでCDを検索し下のような曲名一覧をコピーし、テキストビューにペーストしてReplaceボタンを押すと、アプリ内の曲名情報が更新されます。そしてメーニューのSaveを選択するとデスクトップに更新されたiTunes Music Library.xmlファイルと 一昨日ツールで使う曲名情報のファイル MpegTitleList.txt が保存されます。

f:id:yuum3:20140822111906p:plain

注意 このアプリにはバグがあるかもしれませんので、実際に使う際には絶対に 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.xmlNextSTEP由来のオブジェクトをシリアライズしたplistファイルで今風に言えばJSONみたいなものです、Mac OS X, iOSで設定情報を保存するによく使われています。 詳細はWikipedaのプロパティリスト したがって、Mac OS X, iOSCocoaフレームワークのクラス(例 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
    }

SwiftObjective-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プログラミングをしたことがある方は、だいたいわかると思います。

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)
    }

}

感想

  • Swiftは読み書きし易い、良い言語だと思う
  • 正規表現リテラルやヒアドキュメントが入ると良いなぁ〜
  • Optionalは面倒だけど安全なコードを書くためには良いと思う
  • Swiftは強い型付けの静的な言語だが、Objectvie-Cは弱い型付けでメソッドは動的に決定されるのでObjectve-Cで作られたCocoa(Mac OS X, iOS)のクラスを利用すると途端に煩雑なコードになって行く!
  • iOS, Mac OS Xプログラミングで Cocoaを使わない事はありえないので、もう少し良いソリューションが提供されないのかな・・・・

MP3ファイルのID3タグを一度に変更できるツールを作ってみた

昔にリッピングしたMP3ファイルには下のように iTunesで曲名が 01 AudioTrack 01.mp3 などと表示されています。これはiTunesの「情報を見る」ダイアログで変更できますが、いちいちGUIから変更していくのは大変です。そこで、ファイル名、曲名を書いたファイルから一括で変更できるツールを作ってみました。

f:id:yuum3:20140820170453p:plain

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 の発表資料です。

RubyRuby on Rails開発者を増やしたいと考えている開発マネージャーやリーダーの方は是非読んでみて下さい。社内で教育いくして行く際に役立つヒントがたくさん書かれていると思います (自画自賛 ^^;)。

Dockerを使いRuby on Railsアプリ、PostgreSQL、Nginxなどのコンテナーをクラウドサービスで動かしてみた

Docker

環境構築

普通に Boot2DockerMacにインストールしました。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つのコンテナーで動かす事にします

ちなみに、今回参考にした 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環境ではRDBPostgreSQLになるように、またサーバーはUnicornに変更しました。

pgコンテナー

pgコンテナーは DockerのドキュメントにあるDockerizing a PostgreSQL serviceを、ほぼそのまま使っています。

行っているのは、ubuntu14.04にPostgreSQLをインストールし docker というデータベースを作成し、runPostgreSQLサーバーが起動するようにしています。また、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 を参考に作りました。内容は

  1. ubuntu14.04 + ruby2.1.2のインストール(後ほど説明します)
  2. Gemfileをコピーしてbundleコマンドで必要な Gem をインストール
  3. アプリケーションのコピー
  4. 不要なファイルの削除

やはり、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 ほぼそのままです。ありがとうございます! 内容は

  1. Rubyコンパイルするためのツール・ライブラリーのインストール
  2. Rubyコンパイル・インストール
  3. gemのアップデート、bundler gemのインストール

  4. 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アプリの動作確認。

f:id:yuum3:20140806111148p:plain

動いた ^^)/

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も従来のままでもだいじょぶです。

f:id:yuum3:20140727115427p:plain

Herokuへのデプロイのデモで失敗しないための手順

先日の セミナーの中で Heroku を使えば Ruby on Rails アプリの公開(デプロイ)は超簡単! という話しをしデモを行ったのですが、見事に失敗してしまいました ^^;

2度と失敗しないように記事を書きました。


失敗する手順

1. セミナー等でデモを行う場合は事前に練習!!

と言うことで以下の手順で無事にデプロイ出来る事を確認

$ heroku create
$ git push heroku master
$ heroku run rake db:migrate
$ heroku open
2. heroku の dashboard でアプリを削除

上の画像のような dashboard で練習で作ったアプリを削除

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. と同じ手順でデプロイを実行すれば 成功!

失敗しない手順 (2)

1. セミナー等でデモを行う場合は事前に練習!!

と言うことで以下の手順で無事にデプロイ出来る事を確認

$ heroku create
$ git push heroku master
$ heroku run rake db:migrate
$ heroku open
2. heroku のdashboard でアプリを削除

上の画像のような dashboard で練習で作ったアプリを削除、そして以下のgitコマンドを実行。

$ git remote remove heroku
3. セミナー本番

1. と同じ手順でデプロイを実行すれば 成功!

失敗した理由

失敗しない手順 (2) で判ると思いますが、heroku create コマンドは .git/config に heroku というリモートリポジトリーを設定するのですが、既にある場合は変更しません。
失敗した手順では git push heroku master を行った時点でアプリが無いのでエラーになったわけです。
したがって herokuコマンドでアプリを削除(コマンドはリモートリポジトリーの設定を削除してくれます)するか git コマンドで heroku リモートリポジトリーを削除すれば OK です。

「クラウドxスマフォ時代のRuby on Rails入門」セミナーで使ったコードをGitHubに置きました

昨日、行われた クラウドxスマフォ時代のRuby on Rails入門 セミナーで使ったコードをGitHubに置きました。
簡単な Ruby on Rails で作ったサーバーと連携できる iOSアプリです。













コードの特徴

Rails側は

  • CarrierWaveを使った画像アップロート・サムネール付き
  • Deviseを使った認証
  • デザインはTwitter Bootstrap
  • iOSアプリの認証(basic認証)
  • JSONでの画像受け取り
  • Herokuへデプロイ出来ます
  • モデルにRSpecが書いてあります

iOS側は

  • UICollectionView
  • UIImagePickerController
  • NSRailsを使ったRailsとの通信

などです。