Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

やりたいことベースでWebpackにCSS周りの設定をする

こんにちは、ForSchool事業部の石上です。

あるSPAを作る際、CSSを書きやすくするためにいくつかWebpackの設定を書きました。 今回は、これらの設定がなぜ今こうなっているのかを社内のメンバーに説明するつもりで、どれが何のために必要な設定なのかを書いてみます。

背景

ウェブフロントエンド全般に言えることですが、CSS周りにもツールや設定方法はたくさんあります。一から用意する際は、毎回何を選んでどうすればいいのか悩んでしまいます。そこで今回のプロジェクトに使う設定をやりたいことベースで整理したところ、以下のような要件となりました。

  • 配信するときは1ファイルで、なるべくサイズを小さくしたい
  • コンポーネントごとにスタイルを閉じたいけど、BEMは面倒
  • デザイナーさんに用意してもらった変数を一箇所で管理したい
  • ベンダープレフィックスを自動でつけてほしい

設定

MiniCssExtractPlugin

その前段階で何をするにしろ、最終的にはサイズを小さくした1つのCSSファイルに出力したいです。これにはMiniCssExtractPluginを使います。

f:id:shgam:20190405140655p:plain
1つのCSSファイルにする

CSS Modules

BEMという設計方法があります。Block, Element, Modifierに分ける命名規則です。

class名をblock__element--modifierの形にすることで、コンポーネント間の名前の衝突を避けることができます。

しかし、グローバルで一意な名前を付けるためにクラス名は長くなり、命名も面倒になってくるという問題があります。

そこで、CSS Modulesという仕様があります。

今回のプロジェクトでは、モジュールバンドラーにWebpackを利用しているので、Webpackのloaderを利用します。 CSS Modules形式で書いたCSSをcss-loaderというloaderに通し、これを実現しました。

f:id:shgam:20190405140420p:plain
CSS Modulesの仕様に従ってクラス名を変換したCSSにする

typed-css-modules

CSS Modulesを使って、CSSをJavaScript側からモジュールのように参照できます。

TypeScriptで書いている場合は、これに型を付けたくなります。

import * as styles from './styles.scss'

こういうものに対して、

<p className={styles.hoge}>
</p>

こう書いて参照しようとすると、エラーになります。stylesの型が決まっておらず、styles.hogeの存在を認識できないからです。

そこで、以下のようなファイルをstyles.d.tsとして用意してhogeを定義しておくと、このエラーはなくなります。

export const hoge: string;

これを自動でやってくれるのが、typed-css-modulesです。

作者のQuramyさん的にはバンドルの前にd.tsを生成しておくべきという意向のようで、loaderが提供されていません。ただこれが必要だと思う方もいて、loaderを書いた方がいました。CSSを変えるたびにコマンドを打ったりしないといけないのは面倒なので、そちらを使っています。

ここまでの流れはこうなりました。

f:id:shgam:20190405140330p:plain
型定義ファイルを自動生成

Sass

今回のプロジェクトでは、デザイナーさんがInVisionのDSMというツールで、色の変数などを管理しています。Sketchから上げられたこのデータを、DSM上でSassの変数として出力することができます。 これをそのまま使うべく、Sassも利用することにしました。

Webpackでの設定にはsass-loaderというものがあり、これを使いました。

先程のCSS Modulesの設定に加えると、流れは以下のようになります。

f:id:shgam:20190405140152p:plain
CSS Modulesの設定を加えた

PostCSS

ベンダープレフィックスを一つずつ手で書きたくなかったため、自動でベンダープレフィックスを付けてくれるautoprefixerの設定も必要でした。

以上までで、バンドリングの流れはこうなりました。

f:id:shgam:20190405134808p:plain
最終的なバンドリングの流れ

まとめ

ここまでで紹介したものは、最先端のイケてる設定というよりは、必要なものをなるべく単純に実現するための設定だと認識しています。

実際これを書きながら、

「DSMの変数をそのまま利用するためにSassを入れたが、これはCSS Modulesのcomposesで表現するべきでは?」

とか、

「最近はstyled-componentsがイケているというのをよく聞くし、CSS Modulesだと何か困ることがあるのか…?」

みたいな迷いも出てきました。最適化できる部分はまだありそうです。

今後も粛々と、ビルド設定を保守・改善していこうと思っております。

スタディプラス発OSSライブラリ Jasperについて

こんにちは、Androidチームの若宮(id:D_R_1009)です。 先日、JasperというOSSライブラリをリリースしました!

github.com

今回は開発目的やモチベーション、今後の方針などを記したいと思います。

Jasperとは

スタディプラス Androidアプリ内で利用しているUIパーツをベースに開発した、UIコンポーネントライブラリです。 2019年3月時点ではJasper-BottomNavigationViewのみとなりますが、今後ゲージやグラフなどのパーツを追加する予定です。

Jasperを作成した理由について

遡ること2016年、Androidアプリは大規模なリニューアルを行いました。 ありがたいことにGoogle Play「ベストオブ2015」Google Play「ベストオブ2016」を受賞するなど、高く評価していただいています。

2016年以後も順調に機能開発、メンテナンスを続けております。 しかしながら基本的に既存機能に新たな機能を付け足す方向で開発を進めた結果、コード全体を見渡すと現在の時流に合わない実装が散見されるようになってきました。

この傾向は特に独自のUIカスタムビューに現れています。 一般にUIカスタマイズを追求していくと、Viewクラス内でCanvasの利用を行なったり、FrameLayoutを親クラスとして複数のViewを重ねがちです。 これらの手法はやりたいことに対する柔軟性が高い一方、メンテナンスのしやすさや高い描画パフォーマンスを保つ点において、開発者の技量に大きく依存していきます。

Jasperはこの問題に対する、一つの試みとなります。 開発にあたり、次の3つの目標を立てました。

  • 多くの人の目を通してUIコンポーネントの質を高める
  • 様々な要望を想定することでAPI設計を洗練させる
  • 依存関係をクリーンな状態に保つ

多くの人の目を通してUIコンポーネントの質を高める

当然の話になりますが、OSSとしてライブラリを公開すると、全てのコードを一般公開することとなります。 結果として、スタディプラス社外の人にレビューをしてもらうこと以上に、スタディプラス社内のメンバーのコードを書く意識を引き上げると感じています。

社内にのみ開かれたコードベースを触っていると、どうしてもコードに対して妥協する余地が残ってしまいます。 コードに対して、満足いくまで検討することが難しい状況な場合もあります。また歴史的な経緯により、「後ほど検討する」こととして開発を進めることもあります。

OSSとして開発することで、より純粋に「より良いコードとは何か」を考えながら開発が進められると考えています。 これは開発チームにとって、コードの技術を高める非常に良いきっかけになるのではないでしょうか。

様々な要望を想定することでAPI設計を洗練させる

UIコンポーネントの開発において、個人的に一番難しいのはAPIの設計ではないかと考えています。

とりわけどういったケースでどういった表現を許容するか、その検討に時間を取られる印象があります。 必要性に駆られて開発をスタートする際は、開発目的に沿った設計であればまず十分と判断できます。 しかし将来的な機能の拡張やinterfaceによる抽象化など、一歩踏み込んだ設計は難しい設計に分類されると思います。

OSSで開発を進めていくと、将来的には「社外のケースにおいて必要となる」条件が生まれてきます。 また、開発やレビュー時にそういったケースを考慮に入れなければなりません。 そうなると自然と検討が必須となるケースが増えることとなるでしょう。

直接的にか間接的にかは明らかではありませんが、このステップはAPIの設計を少しずつ洗練させていくと考えています。

依存関係をクリーンな状態に保つ

既存のアプリからUIコンポーネントを外部へ切り出そうとすると、当然アプリ内部の依存グラフを整理することとなります。 この結果として、クラス間の関係性がライブラリ内で完結するようになります。

すでに開発をしているBottomNavigationBarでは、AndroidSDKのMenuクラスをそのまま読み込めるよう開発しました。 このための実装は一苦労だったのですが、この結果としてJasper独自のMenuオブジェクト開発を避けることができました。

依存関係については、ライブラリ間のスイッチングコストを抑える点でも役に立ちます。 特に2019年はSupport LibからAndroidXへの移行が必須となるため、AndroidSDKとKotlinへの依存のみで開発を進める予定です。

動作だけではなく、依存関係も軽量に開発を進めていければと考えています。

Jasperの今後

スタディプラスのAndroidアプリ内で使うUIコンポーネントは、可能な限りJasperとして実装していく予定です。 このためスタディプラスのAndroidアプリを開発する限り、Jasperのメンテナンスを続けていくこととしています。

2019年はまず既存のコンポーネントをOSS化すること、そしてandroidxのサポートに取り組む予定です。 機能追加や拡張についても順次取り組みたいと考えていますが、まずはUIコンポーネントライブラリとして立ち上げたいなと考えています。

スタディプラスではすでにJinraiというライブラリを公開しています。 社内ですでにOSSを開発し利用する先例があるため、Androidチームもその流れに乗ってやりきっていこうと話し合っています。

github.com

Jinraiの紹介はこちらの記事をご参照ください。

tech.studyplus.co.jp

終わりに

Jasperについて、長々と書き連ねてしまいましたがいかがだったでしょうか。 もし関心を持っていただけましたら、ぜひGithubやGitterからご連絡ください。issueなどもお待ちしております。

github.com

gitter.im

また、スタディプラスでは一緒にJasperだけでなくスタディプラスを開発してくれるエンジニアを募集しています。 ご興味がありましたら、ぜひこちらからご連絡いただければ幸いです。

Flutterもくもく自習室をはじめました

スタディプラスでiOSと新規事業を兼務している須藤(id:kurotyann)です。

今回のブログでは、3月9日(土)に開催した「Flutterもくもく自習室 in スタディプラス」の結果についてまとめます。

Flutterもくもく自習室とは?

connpass.com

弊社の新規事業では、Flutterを使って新たなサービスを開発しています。 まだ開発中なのでサービスはリリースしていませんが、Flutterでの開発は楽しいです。

そして最近、「この楽しさを社外にも共有できるイベントを企画できないか」という話が社内で出ました。 そこで、もくもく会という形式で今回のイベントを開催するに至りました。

成功しているもくもく会の特徴とは?

もくもく会の形式でイベントを企画するにあたり、成功しているもくもく会の事例をいくつか参考にしました。 ここでの成功とは、継続して現在も開催していることを指します。 そこで見つけた特徴は以下の二つです。

  • (1)週末の昼下がりに開催する
  • (2)特定の言語やフレームワークのもくもく会だと明示しつつも、それ以外の参加者も受け入れる

特に、(1)の週末の昼下がりに開催するのはとても大事だと実際に運営して感じました。 弊社の場合は、 土曜日の13:00~17:00の早起きしなくても大丈夫で夕方の予定をロックしない という適度なゆるさにしました。

平日の場合、仕事終わりなので夜が遅くなり、まったくゆるくありません。 一方、週末は自宅やカフェなどで開発しているエンジニアの方は多いので時間がとりやすく参加しやすいです。

さらに週末に自宅以外の場所で開発したい場合、飲み物・トイレ・作業場所の確保と、離席時の持ち物の防犯が課題になります。

弊社のオフィスでは、コーヒーやお茶などの一通りの飲み物は無料で用意しましたし、綺麗なトイレももちろんあります。 作業場所も、ソファ、テーブル、スタンディングの3タイプあるので、有料のカフェよりもコスパが良いと感じてくれるかもしれません。 防犯に関しても、運営スタッフがオフィスの入退室を見てますし、忘れ物があった場合の対応も可能です。

(2)の対応は簡単で弊社の場合はFlutterですが、Rubyなど別の言語でもくもくしたい方も当日の参加者として受け入れました。 最後に、弊社のミッションは 「学ぶ喜びをすべての人へ」 なので、もくもく自習室という少し学習に寄ったネーミングへ変更しています。

運営スタッフから見た第一回の様子

運営スタッフは弊社の社員の2名で行いました。 参加者の様子は当日のTwitterのハッシュタグ #flutter_studyplus で見れます。

須藤(id:kurotyann)

初めての開催だけでなく、私がイベントの企画と運営自体が初めてだったので少し不安でした。 しかし、想定したよりも参加者が多く、無事に終わって良かったです。

当日は業務でFlutterを使っている方のサポートなどもあり、コミュニケーションは活発にできたと思います。

その結果、Flutterのバグが発見でき、参加者の方が初PRを出すところまで進みました。 最終的には、報告したissueの通りにコントリビュータの方がPRを作成してマージされました。

github.com

もくもくした結果、参加者がOSSに貢献でき、とても良い成果がでたと思います。

若宮(id:D_R_1009)

もくもく会に参加することはあるのですが、開催したのは初めてだったので運営できてホッとしています。 開催日になって複数名の方がオフィスを訪れてくださったことがまず嬉しかったです。

もくもく会ではFlutterの環境を構築している人から、相談しながらアプリを開発している人まで幅広い取り組みが行われました。 Flutterの自習室として、そして何か困ったことがあった時に質問ができる交流の場として歩み出せたと思います。

運営はまだまだ模索するべき箇所がたくさんありますが、定期的に開催していければなと思っています。 Twitterなどを見ていて気になった方には、ぜひ参加していただきたいです!

次回の開催日は?

次回の開催日も決定しました。 開催日は、4月6日(土)13:00~17:00です。 週末にもくもくとFlutterを開発したい方、またFlutter以外の方もOKですので、ゆる〜〜く参加してください。

connpass.com

GCP Cloud Firestore をRailsから使う

スタディプラスでサーバーサイドを担当している花井です。

先日田口さんが投稿したこちらのプロジェクトで、実験的にCloud Firestore / Cloud StorageとRailsでAPIを構築したので、その顛末を紹介します。

Firestoreの理由

今回のプロジェクトの要件に、一度データを入稿してしまえば変更はほとんど必要ないような要件がありました。 極端なことを言えば、yamlかjsonファイルをマスタデータとして扱うという選択でもよかったのですが、それだと変更のたびにデプロイが必要なのとスケールするかが心配でした。 また、普段AWSを使っているので単純にGCP環境を使う実績解除という意味あいも多分にありました。そんなわけで、画像の置き場としてCloud Storage、データベースがわりにCloud Firestore を使う構成になりました。

Railsから使う

install Gem

Cloud Firestoreの操作には公式のGemを使いました。

gem 'google-cloud-firestore'

ローカルでテストしている時にGCPにつながらず、地味にはまりました。認証のための情報はこちらを参考に環境変数を設定しています。

データ構造と/modelsの構成

データ構造は以下のようにしています。

マスターデータCollection
|_ 親モデルDocument
|  |_ url : 遷移先URL
|  |_ banner_image_url : バナー画像のURL(Cloud Storageの公開URL)
|  |_ published: 公開フラグ
|  |_ 子モデルのMap
|  |  |_ 子モデル識別子
|  |    |_ [いろいろな情報]
|  |    |_ 孫モデルのMap
|  |_ [いろいろな情報]

Clientから取得するデータCollection
|_  Document
|   |_ 子モデル識別子
|     |_ [いろいろな情報]

DBとして扱うので、Railsのお作法にしたがってCloud Firestoreを操作するクラスは /modelsに定義しました。 GCPへの接続を各モデルに書かなくていいように、GCPへの接続とFirestore操作を司るクラスは別々に用意しています。

class Gcp
  require 'google/cloud'
  class_attribute :client

  self.client = Google::Cloud.new(ENV['gcp_project_id'])
end
class Firestore < Gcp
  class_attribute :connection

  self.connection = client.firestore
end

Firestoreから取得したデータをModelに変換するところはActiveModel::Attributesを使いました。

class Collection名 < FirebaseFirestore
  include ActiveModel::Model
  include ActiveModel::Attributes

  class_attribute :parents
  self.parents = connection.collection ENV['parent_collection_name']

  attribute :id, :string
  attribute :url, :string
  attribute :banner_image_url, :string
  attribute :published, :boolean

  attr_reader :child_model

  class << self
    def available
      parents.where('published', '==', true).get.map do |parent|
        new(parent_params(parent))
      end
    end

    private

    def parent_params(parent)
      # parentはGoogle::Cloud::Firestore::DocumentSnapshotを想定
      # parentのドキュメントIDをidとして扱うためにここで操作
      parent.data.merge({ id: parent.document_id })
    end
  end

  def child_model=(child_models)
    @child_models = child_models.map do |id, fields|
      params = { id: id }
      child_model_params = params.merge(fields)
      ChildModel.new(child_model_params)
    end
  end
end

今回の要件では一定数を超えないことがわかっていたので、child_model =の中でループを回してしまっていますが、もうちょっとうまいやり方もあったかなぁとは思っています。

はじめて使ってみてこのあたりのモデリングをした感想としては、階層関係が深くなったり、件数に制限がなくなったりするデータをCloud Firestoreから読むのは厳しそうと思いました。

DroidKaigi 2019参加報告

こんにちは。 Studyplus Androidチームの中島、若宮(id:D_R_1009)です。

2月上旬のこととなりますが、我々AndroidチームもDroidKaigiに参加してきました! 本日は、今年のDroidKaigi参加の経緯から、それぞれが注目しているセッションについてまとめておきたいと思います。

DroidKaigi 2019にスポンサード!

スタディプラスはDroidKaigi 2019にスポンサードしました!

droidkaigi.jp

SUPPORTERSの左上から2番目に見慣れたロゴが見えます。 会場で気づかれた方もいらっしゃるのではないかと!

今年もスタディプラスはAndroidに注目していきます!

注目したセッション

R8、Proguard徹底比較

droidkaigi.jp

satoshun.github.io

昨年度のなんとなく動いているProGuardから脱出するためにに続き、今年も最適化・難読化の話! 毎日の業務や開発の中では知ることのできない、しかし重要な項目について理解を深められる。DroidKaigiは本当にありがたいなと感じます。

R8やD8については、急にAndroidStudio3系になったら登場した印象があります。おそらく、ブログを読み飛ばしてしまっているのですが……。 そのためR8が「なにをやっているのか」と「なにがProguardよりすごいのか」を知ることができるこのセッションは、非常に素晴らしいものだったと感じています。

Studyplus-AndroidアプリはAndroidStudio 3.3.1でのビルドに移行しているため、開発が安定してきたタイミングでR8に移行する予定です。 ProguardとR8の両方を一度テストし、実際の圧縮率などを調べられればなと思っています。

All About Test of Flutter

droidkaigi.jp

Flutter Meetup Tokyoでも数回登壇(飛び込み登壇?)されている、Flutterにおけるテストについて。 Studyplusでは、新規事業にてFlutterを採用していることもあり、強い関心を持っての参加となりました。

公開資料だけでそのままテストが始められる! という点に関心が引っ張られますが、個人的には「自動テストのコストとベネフィット」が非常に勉強になりました。 ユニットテストとUIテストの議論になると、大抵の場合「UIテストはコストが高いから」の一言でまとめられてしまいます。「人がテストし続ける積算コストと、自動テストの積算コストがどのタイミングで逆転するか」という観点から整理するのは、Flutterという軽量なGUIプラットフォームには非常に有用だなと感じました。

Android Studio設定見直してみませんか?

droidkaigi.jp

shiraji.hatenablog.com

AndroidStudioについて、知ってると便利な実践的なものから場を和ませるファンシー(?)なものまで、多種多様なTipsを紹介するセッションでした。 紹介内容自体の濃さもさることながら、開始2~3分でスライドを終わらせてからの「Mac見ずに俺を見ろ」宣言にはいい意味で驚かされました。 紹介される数々の便利なTips、特にオリジナルポップアップメニューの作成コード内のStringに対するJSONやYAMLのサポートなどは、もう見た瞬間設定したい欲に駆られてしまいました。 なお、常時「おぉ〜…」と感嘆の声が響き渡る空間でしたが、会場が一番湧いた瞬間はAndroidStudioが喋った瞬間でしょう。

WebView+ViewGroupを実現するAOSPメールアプリの内部実装とニュースアプリへの応用

droidkaigi.jp

言葉にすると簡単(?)に見えても、実際に実装しようとするとどうすればいいかわからない仕様って多いと思います。 WebViewの上or/and下にネイティブのViewGroupを表示し、その配置のまま一緒にスクロールさせる。 これを見た瞬間、まず試してみようと思うのはScrollView内にLinearLayoutで ViewGroup WebView ViewGroup を順に配置する実装でしょうか。 しかしそれではうまくいかない、上記の仕様を実現するにはこれだけの試行錯誤をしたんだと、苦労の大きさと発想力の豊かさを感じさせるセッションでした。 AOSPのソースコードは知見の塊……染み入る言葉だと思います。

技術顧問 : kobakeiさんによるセッション

kobakei.hatenadiary.jp

昨年の暮れごろより技術顧問をしていただいているkobakeiさんが登壇されました! もちろん弊社2名ともにセッションを聴講いたしました。

DIといえばもはや定番とも言えるDagger2に加え、最近頭角を現しているKoinについても言及されている素晴らしいセッションだったと感じました。

Studyplus Androidは未だDIを導入できていないのですが、今年中に対応したい項目となっています。 導入の際には資料とkobakeiさんのアドバイスを元に、着実に進めていく予定です!

来年に向けて

今年は中島、若宮の両名がDroidKaigi 2019 official Android appに貢献できました! DroidKaigiアプリは、その年に注目されている新技術が詰まったおもちゃ箱のようなアプリだと感じています。 普段利用していないアーキテクチャやライブラリについて触れる機会であり、社外の皆様と協力できることは大きな刺激となりました。 この活動については、来年以降もぜひ継続していきたいと考えています。

一方で反省点としては、DroidKaigiへのCfPが1本しか出せなかったことがあります。 Android Reject Conference 2019にて、FiNCさんでは社内でCfPのレビュー大会を開いていると聞きました。 来年はStudyplus Androidチーム(+α)一丸となって、登壇を狙っていきたいと思います! ※もちろん、登壇できるだけの品質やチャレンジに取り組んでいきます!

一緒に大きなチャレンジをしていきたい方がいらっしゃいましたら、ぜひこちらからお声がけください!

第11回 Rails Girls Tokyoに参加して

こんにちはスタディプラスのCTO 島田です。

スタディプラスは2月22・23日に開催された第11回 Rails Girls Tokyoへスポンサーと、コーチとして島田・冨山の2名が参加しました。

f:id:yo-shimada:20190227112152j:plain

Railsgirlsとは

RailsgirlsとはWebプログラミングに興味・関心はあるがプログラミング未経験の女性を想定とした、初めてプログラミングを書く人や、はじめてRailsを使う人を対象としたワークショップです。
現役のエンジニアをコーチとして、Railsを使ってWebアプリを作成し、Web上に公開するという内容です。

なぜスポンサー

スタディプラスでは第10回 Rails Girls Tokyo にもスポンサーをさせていただき、2回目のスポンサーとなります。 スポンサーをした目的は、

  • StudyplusというプロダクトのサーバーサイドはRubyを利用している事もあり、Rubyコミュニティへ貢献したい
  • RailsGirlsの趣旨と弊社の「学ぶ喜びを全ての人へ」というミッションに通じるところがあり、何か協力がしたい

といった事があります。

f:id:yo-shimada:20190227112621j:plain
スポンサーのノベルティ

このイベントに併せて、ステッカーを作りました。

なぜコーチ

今回、スポンサーの他に個人的にコーチとして参加したのは

  • 第10回でスポンサーLTをさせていただいた際に、会場の雰囲気を見て楽しそうだなと感じた
  • 弊社のデザイナーが第10回にgirlsとして参加し、楽しかったという感想を持った

という事をキッカケにコーチへ興味を持ち、自分自身も協力してみたいという思いからコーチとして参加しました。

感想

コーチとして参加した2人の感想

島田

普段自分が素通りしている事を改めて人に伝える事の難しさを感じましたが、誰かに教えるという体験を通じて自分自身のスキルアップにつながったと思っています。
教える事で感謝されたり、自分の説明が理解されたりする経験は得難いものがあると感じました。

f:id:yo-shimada:20190227112916j:plain

冨山

今回はじめてコーチとして参加してまいりました。周りのコーチのかた、またガールズのかたとワイワイrailsを書くというのは大変楽しい体験でした。
Railsを書いていくなかで自分も勉強させてもらうこともあり、完成したときにはこちらも達成感を覚えました。
普段こんなに1日中話すことはあまりなかったので体力的には疲れましたが、ワークショップ後のアフターパーティーで色々な方と交流もでき楽しく過ごすことができました。
今回のイベントを通してRubyを使うだけではなくRubyコミュニティを今後も盛り上げて行くために今後も何かと貢献できたらと思いました。

f:id:yo-shimada:20190227112945j:plain

最後に

今後もスタディプラスではRails Girlsをもちろん、Rubyコミュニティへの貢献ができればと考えています。

AWS Lambda上でnode-canvasを使ってグラフを描画する

ForSchool事業部でStudyplus for Schoolのサーバーサイドを担当している松田です。

Studyplus for Schoolでは、一部でChart.jsを利用したグラフの表示をしています。
Chart.jsはHTMLのCanvasでグラフを描画するライブラリです。
今回はこのグラフをサーバーで出力したくなったので、どうしたかを書いてみたいと思います。

はじめに

まず最初にサーバーサイドはRailsを使っているのでRubyを利用した出力を考えましたが、フロントと同様の見た目にしたいのでどうにかChart.jsをサーバーサイドで使いたいです。
サーバーサイドJSといえばNode.jsですが、Node.js上にはCanvasのAPIは用意されていません。
が、SeanSobey/ChartjsNodeCanvasを利用することで、Node.jsでChart.jsがレンダリングできます。
このライブラリは内部で Automattic/node-canvas というCanvas APIをcairoで再実装したライブラリを利用しています。
オンデマンドな環境で利用したかったので、Lambda上でこのライブラリを使ったグラフのレンダリングを試してみます。

インフラまわり

今回は外部からHTTPで気軽に扱えるよう、Lambdaプロキシ統合に設定したAPI GatewayとLambdaを併せて利用することを想定しています。
レンダリングした画像をS3にアップロードしたいので、LambdaにS3へのアクセス権限も付与しました。
Node.jsのランタイムは8.10です。

ビルド

Lambdaはその特徴上、依存ライブラリも含めたビルド済みのコード群をzipで圧縮したものをアップロードする必要があります。
先述の通りnode-canvasはcairoを利用しているので、Lambda上で動作するようにcairoをビルドした上で、そのバイナリをzipに含ませなければなりません。
Lambdaの実行環境はLambda 実行環境と利用できるライブラリに記載があるようにAmazon Linuxのようなので、Amazon Linuxのdocker imageを使ってビルドをすればよさそうです。
以下のコードはNot working on AWS Lambda · Issue #1231 · Automattic/node-canvasで紹介されているこちらのgistを参考にしました。

# Dockerfile

FROM amazonlinux:latest

RUN curl --silent --location https://rpm.nodesource.com/setup_8.x | bash -
RUN yum install -y nodejs zip
RUN npm install -g yarn

RUN mkdir /test
COPY ./package.json /test/
COPY ./yarn.lock /test/

WORKDIR /test

ENTRYPOINT ["yarn"]
CMD ["install"]
// package.json

{
  "name": "lambda-chartjs",
  "version": "1.0.0",
  "dependencies": {
    "canvas": "^2.3.1",
    "chart.js": "^2.7.3",
    "chartjs-node-canvas": "^2.0.0"
  },
  "scripts": {
    "prebuild": "docker build -t lambda-build .",
    "build": "docker run -v $(pwd)/node_modules:/test/node_modules lambda-build"
  }
}
// handler.js

'use strict';

const { CanvasRenderService } = require('chartjs-node-canvas');
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

exports.handler = async function(event, context, callback) {
  try {
    const renderService = new CanvasRenderService(800, 600);
    const options = {
      type: 'bar',
      data: {
        labels: ["Red", "Blue", "Yellow", "Green", "Purple", "Orange"],
        datasets: [{
          label: '# of Votes',
          data: [12, 19, 3, 5, 2, 3],
          backgroundColor: [
            'rgba(255, 99, 132, 0.2)',
            'rgba(54, 162, 235, 0.2)',
            'rgba(255, 206, 86, 0.2)',
            'rgba(75, 192, 192, 0.2)',
            'rgba(153, 102, 255, 0.2)',
            'rgba(255, 159, 64, 0.2)'
          ],
          borderColor: [
            'rgba(255,99,132,1)',
            'rgba(54, 162, 235, 1)',
            'rgba(255, 206, 86, 1)',
            'rgba(75, 192, 192, 1)',
            'rgba(153, 102, 255, 1)',
            'rgba(255, 159, 64, 1)'
          ],
          borderWidth: 1
        }]
      },
      options: {
        scales: {
          yAxes: [{
            ticks: {
              beginAtZero: true
            }
          }]
        }
      }
    };
    const buffer = await renderService.renderToBuffer(options)
    const params = {
      Bucket: '******',
      Key: 'hoge.png',
      Body: buffer,
      ContentType: 'image/png',
      ACL: 'public-read',
    };
    const data = await s3.upload(params).promise();
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({ url: data.Location }),
    }); 
  } catch (e) {
    console.log(e);
    callback(null, {
      statusCode: 500,
      body: JSON.stringify({ message: 'unknown error!' }),
    });
  }
}

ハンドラのコードは、「Usage · Chart.jsと同じグラフを描画してS3に保存し、そのURLを返す」ことをしています。
これら3つのファイルを同じ階層に配置し、 yarn run build を実行するとdockerのAmazon Linuxの環境内でビルドが走り、成果物が node_modules へ出力されます。
あとはその node_modules とハンドラのコードをzipで圧縮してアップロードすれば完了です。

さて、できたAPIを実際に叩いてみると…

{
  "url": "https://******.s3.ap-northeast-1.amazonaws.com/hoge.png"
}

というレスポンスが返ってきて、さらにこのURLにアクセスすると… image.png (51.2 kB) この画像が得られました!
よさそうです。

外側から設定値を渡せるようにしてみる

動作が確認できたので、続いて外部からChart.jsの設定値を指定できるようにしてみます。

// handler.js

'use strict';

const { CanvasRenderService } = require('chartjs-node-canvas');
const AWS = require('aws-sdk');
const s3 = new AWS.S3();

exports.handler = async function(event, context, callback) {
  try {
    const options = JSON.parse(event.body);
    const queryParams = event.queryStringParameters || {};
    const width = parseInt(queryParams.width, 10) || 800;
    const height = parseInt(queryParams.heght, 10) || 600;
    const renderService = new CanvasRenderService(width, height);
    const buffer = await renderService.renderToBuffer(options)
    const params = {
      Bucket: '******',
      Key: 'hoge.png',
      Body: buffer,
      ContentType: 'image/png',
      ACL: 'public-read',
    };
    const data = await s3.upload(params).promise();
    callback(null, {
      statusCode: 200,
      body: JSON.stringify({ url: data.Location }),
    }); 
  } catch (e) {
    console.log(e);
    callback(null, {
      statusCode: 500,
      body: JSON.stringify({ message: 'unknown error!' }),
    });
  }
}

クエリパラメータで画像のサイズを、リクエストボディのJSONでChart.jsに渡す引数を指定するようにしました。

{
  "type": "bar",
  "data": {
    "labels": [
      "6日(火)",
      ...
      "12日(月)"
    ],
    "datasets": [
      {
        "label": "古文",
        "data": [
          0,
          0,
          142,
          0,
          0,
          150,
          0
        ],
        "lineTension": 0,
        "borderColor": "#e30c0c",
        "borderWidth": 1,
        "fill": "start",
        "backgroundColor": "rgba(255,75,49,1)"
      },
      ...
      {
        "label": "English",
        "data": [
          0,
          0,
          0,
          0,
          0,
          0,
          0
        ],
        "lineTension": 0,
        "borderColor": "#33b377",
        "borderWidth": 1,
        "fill": "start",
        "backgroundColor": "rgba(98,220,156,1)"
      }
    ]
  },
  "options": {
    "scales": {
      "yAxes": [
        {
          "ticks": {
            "beginAtZero": true
          },
          "stacked": true
        }
      ],
      "xAxes": [
        {
          "stacked": true
        }
      ]
    }
  }
}

できたAPIに対して上記のようなJSONでリクエストをして得た画像がこちらです。 image.png (70.2 kB) 日本語が豆腐になっている!!!!

色や値は指定通りですが、テキストに問題がありそうです。
原因はAmazon Linuxに日本語フォントがインストールされていないことなので、さくっとフォントを追加しましょう。
TTF形式の日本語対応フォント(ここではIPAexフォントを例にします)を用意し、同一フォルダ内に置いて以下のコードをハンドラに追加します。1

const path = require("path");

Canvas.registerFont(path.join(__dirname, 'ipaexg.ttf'), { family: 'ipaex' });

これで再度リクエストしてみると、問題なく日本語もレンダリングできるようになりました! image.png (69.6 kB) 完璧ですね。

まとめ

AWS Lambdaは汎用性が高く、想像以上の幅広い要件に対応することができます。
また、node-canvasのようなライブラリを利用することで、画像加工等のリソース配分が難しい処理をオンデマンドな環境で対応することが可能になりました。
うれしいですね。


  1. フォントのライセンスによっては使用できない場合があるので注意してください。