KAIKETSU Developer's Diary

株式会社KAIKETSU エンジニアチームのブログです

出退勤管理システムのAPI endpointをCrystalに置き換える

背景

前回の記事でDjangoNFCリーダーを用いた簡単な出退勤管理システムを紹介しました。

dev-kk2.hatenablog.com

しばらく動作させていたところ、Raspberry piのCPU使用率が天井に張り付いてしまっていました。(動作がめちゃくちゃ重くなった)

CPU使用率は常時50%となっていましたが、熱対策のためのスロットリングが働いて50%が天井に設定されていると考えられます。実際本体をさわってみると長時間は触っていられないくらいの温度になっていました・・・

対策

各プロセスのCPU使用率を見てみるとDjangoが30%、NFCリーダーのプログラムが15~20%、ngrokとmysqlが数%、といった感じになっていました。*1

DjangoNFCリーダーのプログラムを両方移行したかったのですが、NFCリーダーについてはnfcpy以外にまともに使えるライブラリがないのでひとまずDjangoをCrystalに置き換えてみることにします。さらにmysqlを利用するほどのことでもないと思い、これもjsonファイルに置き換えました。

CrystalにはARMアーキテクチャ向けコンパイラは用意されていませんが、x86_64環境でクロスコンパイルをすることにしました。

環境構築

まず、手元のMacbookにCrystalのコンパイラをインストールします。

$ brew install crystal

また、Raspberry piにはLLVM3.8がインストールしてあり、この環境でCrystalの実行形式ファイルをリンクするには1.0系のopensslが必要だったため、これもインストールしました。

$ sudo apt install libssl1.0.2

プログラム

製作したプログラムはGithubに公開しています。

github.com

Crystalというプログラミング言語はマイナーではありますが最近とある界隈で話題になっていて*2日本語の記事も少しずつ増えているので探してみてください。

Crystalは静的型付き言語なので、JSONのような型が不明なデータをあつかうのが意外と大変です。CrsytalではJSON#parseによってJSON::Anyというクラスに変換してからif文とis_a?メソッドをつかって型を決定していく方法と、JSON#mappingによって予めJSONの型を指定しておく方法があります。今回はLINEのAPIドキュメントによってあらかじめJSONの型がわかるようになっているのでJSON#mappingを書いておきます。

json_map.cr

require "json"

module Response
  class Result
    JSON.mapping({
      events: Array(Event),
    })
  end

  class Event
    JSON.mapping({
      type: String,
      timestamp: Int64,
      source: Source,
      replyToken: { type: String, nilable: true },
      message: { type: Message, nilable: true },
    })
  end

  class Source
    JSON.mapping({
      type: String,
      userId: { type: String, nilable: true },
      groupId: { type: String, nilable: true },
      roomId: { type: String, nilable: true },
    })
  end

  class Message
    JSON.mapping({
      id: String,
      type: String,
      text: { type: String, nilable: true },
      fileName: { type: String, nilable: true },
      fileSize: { type: String, nilable: true },
      title: { type: String, nilable: true },
      address: { type: String, nilable: true },
      latitude: { type: Float64, nilable: true },
      longitude: { type: Float64, nilable: true },
      packageId: { type: String, nilable: true },
      stickerId: { type: String, nilable: true },
    })
  end

  class Reply
    JSON.mapping({
      replyToken: String,
      messages: Array(ReplyMessage),
    })

    def initialize(@replyToken : String, @messages : Array(ReplyMessage))
    end
  end

  class Push
    JSON.mapping({
      to: String,
      messages: Array(ReplyMessage),
    })

    def initialize(@to : String, @messages : Array(ReplyMessage))
    end
  end

  class ReplyMessage
    JSON.mapping({
      type: String,
      text: { type: String, nilable: true },
      fileName: { type: String, nilable: true },
      fileSize: { type: String, nilable: true },
      title: { type: String, nilable: true },
      address: { type: String, nilable: true },
      latitude: { type: Float64, nilable: true },
      longitude: { type: Float64, nilable: true },
      packageId: { type: String, nilable: true },
      stickerId: { type: String, nilable: true },
    })
    def initialize(@type : String)
    end
    def add_text(@text : String)
      self
    end
  end

  class Cardid
    JSON.mapping({
      cardid: String,
    })
  end

  class CardNames
    JSON.mapping({
      cardNames: Array(CardName),
    })
  end

  class CardName
    JSON.mapping({
      idm: String,
      name: String,
    })
  end
end

上記のソースがJSONのパースの仕方を記述したものです。含まれていないかもしれない値にはnilableというクラス変数にtrueを代入して置かなければなりません。

さらに、nilである可能性がある値を利用する場合にはnilでないことが保証されなければなりません。

以下が利用例になります。

event = Response::Result.from_json(body.gets_to_end).events[0]
message = event.message
if message.is_a?(Response::Message)
    puts "message event!"
    groupId = event.source.groupId
    roomId = event.source.roomId
    if groupId.is_a?(String)
      puts "Group ID:#{groupId}"
      text = message.text
      replyToken = event.replyToken
      if text.is_a?(String) && replyToken.is_a?(String)
        replyMessage(replyToken, text, list)
      end
    elsif source.roomId.is_a?(String)
      puts "Room ID:#{roomId}"
    else
      puts "User ID:#{event.source.userId}"
    end
  else
    puts "other event!"
  end

結果

DjangoMySQLを停止した結果、CPU使用率が17,8%程度まで抑えられました。Herokuにデプロイするという選択肢もありましたが、最近Crystalを主に書いていたのと、Raspberry piでCrystalのプログラムを動かしてみたかったのでこの選択肢を選んでいます。

NFCリーダーについても、未だ高めのCPU使用率になっているので、他の選択肢があるなら随時置き換えていきたいと考えています。

*1:さすがPython

*2:諸説あります