Cluex Developersブログ

株式会社Cluexでは、子育てをするママのためのメディア - mamanoko(ままのこ)をRuby on Railsで運営しております。

RailsコードをGoで書き直して、FFIを使ってRailsからGoの関数を実行させて実行時間を5%以下に短縮させた

こんにちは、Webチームのエンジニアの柴山です。

今回は、弊社Webアプリ「mamanoko」の一部に処理高速化のためにGoを導入したので、どのように導入したかをご紹介しようと思います。

Railsで開発しているWebアプリ内にどのようにGoを取り入れたのか、実際のコード例を交えながら解説していきたいと思います。

なぜRails環境にGoを導入したのか

そもそもなぜGoを導入することになったのかと疑問に思われるかと思います。

mamanokoはメディアサービスのため、ライターさんに文章を書いていただいています。複数のライターさんがおり、文章量も膨大になるため、表記揺れや誤字がどうしても出てきてしまいます。

そのため、文章校正が必要であり、以前からその機能を提供していました。

しかしこの文章校正の機能は記事の文章に対して表記揺れなどチェックしたい単語を総なめする処理をしているため、パフォーマンスがかなり悪かったです。記事の文字数にもよりますが、だいたい1分くらい処理に時間を要していました。また負荷も大きいため、実行中にサーバが落ちてサービスダウンするといったことも懸念されていたため、文章校正機能をピークタイムには実行できず、最大同時実行数は4までという制限も設けていました。

しかしメディアという特性上、少しでも早く、1本でも多く記事を公開してユーザに情報を提供することが事業KPI的にも求められるため、文章校正が使えずライターさんの業務効率が悪くなる、執筆・編集スピードが落ちるといったことは望ましくない状況でした。

そこで文章校正機能の高速化を図るための施策として上がったのが、Goの関数をShared Libraryとしてビルドし、FFIを使ってRailsから実行するというものでした。

追記(2018/07/02)

想像以上に多くの方に読んでいただき、コメントまでたくさんいただきました。当記事を読んで下さった方、コメントして下さった方、ありがとうございました。今後の執筆の励みになります。

ここまでの反響を呼ぶとは思っていなかったため、この記事を新しく読む方に誤解を与えてしまわないように追記を残させていただきます。

今回は実装を行う上でマイクロサービス化や別サーバーを立てるといったことも当初検討していましたが、大掛かりな実装になる可能性があり、時間的な制約がある中で可能な限りスモールな実装でパフォーマンス改善を行いたいと当初考えていたので、やってみて効果が期待できそうならそれで進めようと試験的な取り組みから始めたという経緯があります。結果的にプロトタイプ作成の段階で期待以上の効果があったのでそのまま実装を進め、リリースしました。

しかし、皆さんのコメントから筆者もRubyまたはRailsの範囲内で、アルゴリズムの最適化等で高速化を図れることが理想なのではないかと感じました。また、RubyGolangアルゴリズムに対する理解が少なかったのも事実です。

そういう背景もあり、本記事はあくまで一つの事例として捉えていただけると幸いです。

この記事を公開して、幸いにも多く方からとても勉強になるアドバイスをいただくことが出来ました。 皆様から頂いたアドバイスを元に、今後RubyGolangアルゴリズムの理解を深めて実装を見直すというアプローチを取っていければと考えております。

アドバイスをくださった皆さまに重ねて感謝申し上げます。ありがとうございました。

前置きが長くなりましたが、次項から実際にどのように導入したのかについて解説します。

Rails環境でどのようにGoの関数を実行するか

先にも挙げた通り、FFIという機能を使って実現しています。FFIとは、Foreign Function Interfaceの略です。詳しくはWikipediaをご覧ください。超ざっくり簡単にいうと別の言語で実装された関数を実行する機能です。

RubyにはRuby FFIというGemがあるので、そちらを使用しました。このGemを使って、RubyからCの関数を実行することができます。

簡単なサンプルコードで解説します。

Ruby

まずffiをインストールします。

$ gem install ffi

そして以下のようにしてCの関数を実行します。

require 'ffi'

module Sum
  extend FFI::Library
  ffi_lib 'sum.so'
  attach_function :sum, [:int, :int], :int
end

pp Sum.sum(1, 2)

これでsum.sosum関数を実行するようになります。

Go側

package main

import "C"

// export sum
func sum(a int, b int) int {
    return a + b
}

func main() {}

mainには何も書きません。代わりにFFIで実行したい関数の上に// export 関数名とコメントを書きます。

そして、GoのコードをCのShared Libraryとしてビルドするわけですが、Goのコマンドを以下のように実行するだけです。

$ go build -buildmode=c-shared -o sum.so sum.go

実行するとsum.goコンパイルしてsum.soというファイルがビルドされます。

これでRubyからGoの関数を実行することができます。

以下参考記事です。

c7.se

qiita.com

mamanokoでの実装例

ではここからは実際にmamanokoでどういう実装をしたかをご紹介します。

まず機能的な要求からおさらいすると、以下のようになります。

1. 校正対象の文章をDBから抽出
2. チェックする単語とチェックから除外する単語をDBから抽出
3. 除外単語を除き、校正対象の文章にチェック対象の単語が存在するか精査
4. チェックに引っかかった箇所をハイライトして表示

ここで除外する単語が出てくるのは、例えば「時」という表記は「とき」にするといった場合に、「時間」など「時」が入る単語でチェック対象からは除外したい単語が存在するからです。

ここの3番に該当する処理が相当重たくボトルネックになっていたので、ここをGoでリプレースするのが今回の目的になります。

データはGo側でDBに接続して取得してもよかったのですが、ORMがActiveRecordとの二重管理になるのは避けたかったため、ActiveRecordで取得してシリアライズしてGo側に渡すことにしました。 ここで、Go側で受け取る時の型について1番詰まりました。

結論から言うと、Railsからはstringとして渡すのですが、Go側ではstringでは受け取ることができず、*C.char型で受け取る必要がありました。 いまだになぜstringで受け取れないのかわかってないのですが、Cにコンパイルしているからなんでしょうか。

jsonのstringを渡すわけですが、Go側で*C.charからstringにキャストしなければなりません。それには、C.GoString(articleJSON)とします。

さらに今度はstringにキャストしたJSON文字列をstruct構造体にマッピングする必要があるので、json.Unmarshal([]byte(C.GoString(articleJSON)), &article)となります。

Cのchar型からGoのstringにキャストして、さらにバイトコードにキャストして、構造体にマッピングさせる。ややこしいですね。ここ最近暗黙型変換の世界にずっといたので、明示的にキャストさせるのがとても面倒に思いました・・・

そして色々とごにょごにょした結果を最終的にjsonにしてRails側に戻してやるのですが、ここでもまたキャストが必要になります。構造体 -> []byte -> string -> *C.charという流れになります。

コードにするとこうです。

resultJSON, err := json.Marshal(result)
if err != nil {
    log.Fatalf("result json encode error : %s\n", err)
}

return C.CString(string(resultJSON))

これでFFIRails・Go間の値の受け渡しがひとまずできるようになりました。

それではここまでをコードでまとめます。

まずRails側は、lib配下にlib/ffi/textlint.rbとして作りました。

require 'ffi'

class FFI::Textlint
  extend FFI::Library

  ffi_lib 'lib/ffi/bin/textlint.so'

  attach_function :textlint, [:string, :string], :string
end

そしてGo側です。lib配下にsrcbinディレクトリを切り、GOPATHを設定してRailsと同じプロジェクト内で開発しました。ビルドしたバイナリをbin配下に吐き出すようにしています。

構造体はstructsとパッケージを分けていますが、定義についてはここでは割愛させていただきます。

package main

import (
    "C"
    "encoding/json"
    "ffi/app/struct"
    "log"
    "string"
)

var dictionaries []structs.Dictionary

//export textlint
func textlint(articleJSON *C.char, dictionariesJSON *C.char) *C.char {
    article := new(structs.Article)
    articleErr := json.Unmarshal([]byte(C.GoString(articleJSON)), &article)
    if articleErr != nil {
        log.Fatalf("article json parse error : %s\n", articleErr)
    }

    dictionariesErr := json.Unmarshal([]byte(C.GoString(dictionariesJSON)), &dictionaries)
    if dictionariesErr != nil {
        log.Fatalf("dictionaries json parse error : %s\n", dictionariesErr)
    }

    result := new(structs.Result)

    // lint実施...

    resultJSON, err := json.Marshal(result)
    if err != nil {
        log.Fatalf("result json encode error : %s\n", err)
    }

    return C.CString(string(resultJSON))
}

func main() {}

これでRails側からGoに好きなデータを渡してあげて、あとはGoの方でゴリゴリ処理を実行することができるようになりました。

FFIを使った時のパラメータの型については、以下の資料が参考になりました。

www.slideshare.net

実際のパフォーマンスへの影響

RailsのコードをGoにリプレイスすることでどれだけパフォーマンスが向上したのか気になるかと思います。

記事の文字数に依存するため一概には言えませんが、だいたい以下の通りだと思います。

Before
45~60sec

After
1~3sec

計測はChromeのDevツールのNetworkでサーバリクエストのレスポンスタイムを指標にしました。

めちゃくちゃ速くなりました。サーバへの負荷も軽減され、ピークタイムには使用できなかったものが24時間いつでも使えるようになりました。

今の所リリースから4ヶ月くらい経ちますが、一度もサービスダウンもエラーも発生していません。滞っていたライターさんたちの校正作業も捗るようになったらしく、めちゃくちゃ感謝されました。やはりエンジニアとしてユーザから感謝される、ユーザに喜んでもらうというのが一番嬉しく、モチベーションになりますね。

Mamanoko Tips

最後に文章校正機能の実装の一部をご紹介しようと思います。

文章に対して単語のチェックをする処理からまず見ていきます。

文章のstringから該当する単語のindexのsliceをとれば良さそうと考えましたが、stringパッケージにそれらしい関数が見当たらなかったので、regexパッケージのFindAllStringIndexを使うことにしました。しかしこいつが曲者で、stringのindexではなく、文字のバイトの開始位置と終了位置のsliceのsliceを返します。つまり返り値は[][]byteとなっています。

本当はregexはパフォーマンス的にあまり使わない方が良いらしいので避けたかったのですが、致し方なくこのようにしました。他に良い方法があればご教示いただきたいです。

コードにするとこうです。dictionary.Wordが校正対象単語で、textが文章です。

dictionaryWord := regexp.MustCompile(regexp.QuoteMeta(dictionary.Word))
highlights := dictionaryWord.FindAllStringIndex(text, -1)

さらに、ここで抽出されたものの中から、除外単語に該当する単語を除かなければなりません。

同じ要領で除外単語のindexをとります。そして、highlightsからindexがかぶるものを除外します。

var highlightBytesList [][]int
for _, highlightBytes := range highlights {
    isHighLight := true
    for _, unhighlightBytes := range unhighlights {
        if unhighlightBytes[0] <= highlightBytes[0] && highlightBytes[1] <= unhighlightBytes[1] {
            isHighLight = false
            break
        }
    }
    if isHighLight {
        highlightBytesList = append(highlightBytesList, highlightBytes)
    }
}

ここではチェック単語より除外単語の方が文字数が必ず長くなるという特性を活かして、除外単語のバイトの範囲内にチェック単語のバイトがあれば除外するようにしました。

これで、文章の中のどこに校正すべき単語が存在するかがわかりました。

最後に、校正単語の箇所をハイライト表示させます。該当単語を<span class="hightlight"></span>で囲みます。

var lintedText []string
for index, textRune := range text {
    isHighlight := false
    for _, highlightBytes := range highlightBytesList {
        if highlightBytes[0] <= index && index < highlightBytes[1] {
            isHighlight = true
            break
        }
    }

    if isHighlight {
        lintedText = append(lintedText, "<span class=\"highlight\">"+string([]rune{textRune})+"</span>")
    } else {
        lintedText = append(lintedText, string([]rune{textRune}))
    }
}

return strings.Join(lintedText[:], "")

textはstringですが、stringに対してfor rangeでループを回すと、1文字ごとのバイトの開始位置とrune文字を取得できます。

これを利用して、先ほど取得した校正対象単語のバイト位置が格納されたsliceを比較してハイライト表記するかどうかを判別します。

rune文字は[]rune{textRune}とすることで元の文字列に変換できます。

そして出来上がった[]stringをstringパッケージのJoinを使って繋げて一つの文字列にしてあげれば完成です。

所感

実は今回Go言語を触るのは初めてで、1週間のインプット期間を設けてから本機能の実装に取り掛かりました。

Go言語は言語仕様がかなりシンプルで、実装していてどの関数を使うべきか迷うことがなかったり、標準パッケージがかなり強力で大体それだけでなんとかなるというのがとても良いと思いました。フォーマッタもあるのでフォーマットを気にすることなくガリガリ実装できるのは良い開発体験につながりました。

ポインタのところが自分には難しく、まだ理解しきれていないですが、それでもすんなり入ることができて、Go言語好きになりました。

しかしRubyRailsなど高級多機能な言語、FWから移ってくると「え、この関数ないんか」といった事態に遭遇して自分で実装することになるといったことが多くて大変そうです・・・

ともあれ今回の目的であった文章校正機能の高速化はGoでリプレースすることで見事達成することができました。しかしまさかここまでパフォーマンス向上するとは自分でも驚きです。

We're Hiring!!

弊社ではWeb / ネイティブエンジニアを募集しております。

ご興味がありましたらお気軽にご連絡ください!

www.wantedly.com