Studyplus Engineering Blog

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

スタディプラス発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. フォントのライセンスによっては使用できない場合があるので注意してください。

Apollo-iOSを使用してGraphQLを叩く

こんにちは。
iOSエンジニアの弘田です。

みなさんGraphQLはご存知ですか?
知らない方は弊社のエンジニアがGraphQLの記事を書いているのでぜひ読んでみてください。
GraphQLを導入しようとしている話

Studyplusのアプリで一部GraphQLを使用する際にApolloを使用しました。

Apolloって?

ApolloはGraphQLベースのデータスタックです。
環境別にライブラリがあり、今回はApollo-iOSを使用します。
https://github.com/apollographql/apollo-ios

公式サイト : https://www.apollographql.com/docs/ios/installation.html

Apollo-iOSの選定理由

  • Swift4がサポートされている
  • Apollo-cliでModelを生成できる
    • サーバーが更新されたらクエリを書いてスクリプトを実行すればいいので人為的ミスが減る
      • 詳細は後述

GraphQLをPlaygroundから叩いてみる

まずはApollo-iOSを使用せず、普通にGraphQLのエンドポイントを叩いてみましょう。

GraphQLはクライアント側で必要な情報だけを取得するためにクエリを書く必要があります。
クエリはJSON形式で記述します。

このようなデータがあるとします。

user {
    id
    name
    age
}

このときアプリ側で必要な情報はnameだけあれば問題ない時ありますよね。
REST APIの場合はエンドポイントが/userのようになっていて、
idやageも返却されresponse内容に使わない情報が含まれていることがよくあります。

ではGraphQLのnameだけをリクエストするクエリを書いてみましょう。

{ user { name } }

このクエリをURLRequestのbodyに入れてPOSTします。

import UIKit
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

let url = URL(string: "http://localhost:3000/graphiql")

var request = URLRequest(url: url!)
request.httpMethod = "POST"
request.addValue("application/json; charaset=utf-8", forHTTPHeaderField: "Content-Type")

//認証Headerが必要な場合
request.addValue(String(format: "OAuth %@", token), forHTTPHeaderField: "Authorization")

let query = "{ user { name } }"

let body = ["query": query]
request.httpBody = try! JSONSerialization.data(withJSONObject: body, options: [])
request.cachePolicy = .reloadIgnoringLocalCacheData

let task = URLSession.shared.dataTask(with: request, completionHandler: {data, reposnse, error in
    
    if let error = error { print(error); return }
    guard let data = data  else { print("Data is missing"); return }
    do {
        let json = try JSONSerialization.jsonObject(with: data, options: []) as? [AnyHashable: Any]
        guard let data = json?["data"] as? [AnyHashable: [[AnyHashable: Any]]] else { return }
        guard let users = data["user"] else { return }
        users.forEach{user in
        guard let name = user["name"] else { return }
        print(name)
        }
        
    } catch let e {
        print("Parse error: \(e)")
    }
})
task.resume()

これでnameだけの取得ができます。
ただこれだとJSONに変換して文字列でvalueの取得をする必要があり、型のない世界になってしまします。
Codableでやってもいいですが、
queryに変更があった場合都度Modelクラスを手で直す必要がありミスに気づきにくい点があまりよくありません。

Apolloを使用すれば今あげた問題点を解決することができます。

導入

CocoaPods・Carthage両方対応に対応しています。

pod "Apollo
github "apollostack/apollo-ios"

必要なファイルをApollo-cliでダウンロード&生成する

Apollo-cliでclassの生成をするのでインストール
※Apollo-cliにはnodeが必要なので入っていなければ先にnodeをインストール

npm install -g apollo

schema.jsonを配置するディレクトリに移動し、Apollo-cliのコマンドを実行

apollo schema:download --endpoint="エンドポイント"

認証Headerが必要な場合

apollo schema:download --endpoint=エンドポイント --header="Authorization: OAuth <Token>"

schema.jsonはサーバー側で用意します。
schema.jsonは後述のAPI.swiftを生成するのに必要です。


コマンドが成功したら実行したディレクトリにschema.jsonがダウンロードされます。
次にschema.jsonがあるディレクトリでAPI.swiftを生成するコマンドを実行します。

apollo codegen:generate --queries="$(find . -name '*.graphql')" --schema=schema.json API.swift

指定したディレクトリにAPI.swiftを生成したい場合

apollo codegen:generate --queries="$(find . -name '*.graphql')" --schema=schema.json ./hoge/API.swift

上記のコマンドを実行したら指定したディレクトリに空のAPI.swiftが生成されます。
次に中身を生成するために必要な.graphqlファイルを作成します。

.graphqファイルを作る

Apolloを使用してGraphQLを叩く場合Queryは文字列でなく.graphql拡張子のファイルを作成してqueryを記述します。
一つのQueryに対して一つの.graphqlファイルを用意します。
⌘ + NでEmptyを選択し今回はnameを取得するファイルなのでName.graphqlとします。
※.graphqファイルはAPI.swfit生成コマンドを実行するディレクトリ配下に配置します。

query Query名 {
    取得する情報のkey
}

今回は全ユーザーのユーザー名を取得するQueryです。

query getAllUserName {
    user {
        name
    }
}

.graphqlファイルを作成したらもう一度API.swiftの生成コマンドを実行します。
実行すると先ほど作成したName.graphqlを読み込んでAPI.swiftの中身が生成されます。
API.swiftが生成されるときに.graphqlファイルの[クエリ名Query]というclassが生成されます。
例) GetAllUserNameQuery

Apolloを使用してGraphQLを叩く

let configuration: URLSessionConfiguration = .default
configuration.httpAdditionalHeaders = ["Content-Type": "application/json; charaset=utf-8",
                                       "Authorization": String(format: "OAuth %@", <TOKEN>)]
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData

let apiPath: String = String(format: "エンドポイント")
let url = URL(string: apiPath)
let apollo = ApolloClient(networkTransport: HTTPNetworkTransport(url: url!, configuration: configuration,sendOperationIdentifiers: false))

//queryはapollo codegen:generateコマンドで生成されたGetAllUserNameQueryを指定
apollo.fetch(query: GetAllUserNameQuery, resultHandler: {(result, error) in
            
        guard let error = error else {
            print(error)
            return
        }
       
        //Userクラスの配列
        guard let users = result?.data?.name as? [GetAllUserNameQuery.Data.User] else { return }
        print(users[0].name)
    })

まとめ

手順が多く感じられるかと思いますが、schema.jsonとAPI.swiftの更新をスクリプトで行なっています。
なので弊社での更新手順はこのようになります。
1. サーバー側の更新
2. .graphqファイルの作成 & クエリの記述
3. スクリプト実行

Q.XcodeのRun Scriptではなくなぜスクリプトファイルなのか。
A.担当以外のエンジニアがビルドした際に担当外のファイルに変更が入るのを防ぐため

スクリプトはGistで公開しています。
https://gist.github.com/hirota-ryo/ebb429c57db25f99a3bd584621fa81b5

感想

初めはとっつきにくさを感じましたが、実際に手を動かしてみると便利だと感じました。

Apolloを使用することで型のない世界から型のあるGraphQL実現できました。
keyの指定でtypoに気がつかなくハマることのない世界、すばらしいですね。