dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

NetflixのFast JSON APIを使ってみた

はじめに

はじめまして。 mochizukiです。

クラシルアプリのサーバーサイドをやってます。

昨日はAndroidエンジニアのumemoriさんが

「マルチモジュール時代のDagger2によるDI」

という記事を書いてくれました。

tech.dely.jp

dely Advent Calendar 2019の2日目は
Netflixがつくった Fast JSON API について書いてみようと思います。

qiita.com

adventar.org

Fast JSON API

f:id:mochizuki_pg:20191130101708p:plain

Netflix/fast_jsonapi

A lightning fast JSON:API serializer for Ruby Objects.

Performance Comparison We compare serialization times with Active Model Serializer as part of RSpec performance tests included on this library. We want to ensure that with every change on this library, serialization time is at least 25 times faster than Active Model Serializers on up to current benchmark of 1000 records. Please read the performance document for any questions related to methodology.

Benchmark times for 250 records

$ rspec
Active Model Serializer serialized 250 records in 138.71 ms
Fast JSON API serialized 250 records in 3.01 ms

This gem is currently used in several APIs at Netflix and has reduced the response times by more than half on many of these APIs.

使ってみる

まずは準備


ruby 2.6.3
rails 6.0.0


rails new fast_json_api --api


+ gem 'fast_jsonapi'

追加して

class Recipe < ApplicationRecord
  has_many :ingredients, dependent: :destroy
end


class Ingredient < ApplicationRecord
  belongs_to :recipe
end

今回はクラシルっぽくこんな感じで。

中身は

create_table :recipes do |t|
  t.string :title, null: false
  t.text :introduction
  t.timestamps
end
create_table :ingredients do |t|
  t.references :recipe, null: false
  t.string :name, null: false
  t.string :quantity_and_unit, null: false
  t.timestamps
end

こんな感じで。

データは最近クラシルに実装した
ジャンル別ランキング機能より、殿堂入りの

f:id:mochizuki_pg:20191130095256j:plain

これにします!

recipes = [
  ['ネギダレが美味しい!鶏もも肉のソテー', 'ネギダレが美味しい、鶏もも肉のソテーはいかがでしょうか。']
]
recipes.each_with_index do |array, index|
  title = array[0]
  introduction = array[1]
  Recipe.create({
    title: title,
    introduction: introduction
  })
end
ingredients = [
  ['鶏もも肉', '300g'], ['塩こしょう', '小さじ1/4'], ['片栗粉', '大さじ2'], ['長ねぎ', '1本'],
  ['①しょうゆ', '大さじ1.5'], ['①酢', '小さじ1'], ['①砂糖', '大さじ1'],
  ['①ごま油', '小さじ1'], ['①白すりごま', '大さじ1'], ['①すりおろし生姜', '小さじ1/2'],
  ['サラダ油', '大さじ1'], ['リーフレタス', '2枚']
]
Recipe.all.each do |recipe|
  ingredients.each_with_index do |array, index|
    name = array[0]
    quantity_and_unit = array[1]
    recipe.ingredients.create({
      name: name,
      quantity_and_unit: quantity_and_unit
    })
  end
end

そして

class Api::V1::RecipesController < ApplicationController
end

こんな感じ。

やっと本題

基本的に ActiveModelSerializers と同様に使えます。
rails g で、serializerつくるとこんな感じです。

class RecipeSerializer
  include FastJsonapi::ObjectSerializer
  attributes 
end

なので

class RecipeSerializer
  include FastJsonapi::ObjectSerializer
  has_many :ingredients
  attributes :title, :introduction
end


class IngredientSerializer
  include FastJsonapi::ObjectSerializer
  belongs_to :recipe
  attributes :name, :quantity_and_unit
end

こんな感じで定義します。

今回つくるAPI

殿堂入りレシピの ネギダレが美味しい!鶏もも肉のソテー
レシピ詳細を想定してつくってみます。

f:id:mochizuki_pg:20191130095726j:plain

GET /api/v1/recipes/:id

class Api::V1::RecipesController < ApplicationController
  def show
    recipe = Recipe.find(params[:id])
    json_string = RecipeSerializer.new(recipe).serialized_json
    render json: json_string
  end
end


{
    "data": {
        "id": "1",
        "type": "recipe",
        "attributes": {
            "title": "ネギダレが美味しい!鶏もも肉のソテー",
            "introduction": "ネギダレが美味しい、鶏もも肉のソテーはいかがでしょうか。"
        },
        "relationships": {
            "ingredients": {
                "data": [
                    {
                        "id": "1",
                        "type": "ingredient"
                    },
                    {
                        "id": "2",
                        "type": "ingredient"
                    },

(一部のみ表示)
リレーションが紐付いてます。

Started GET "/api/v1/recipes/1" for ::1 at 
   (0.2ms)  SELECT sqlite_version(*)
Processing by Api::V1::RecipesController#show as JSON
  Parameters: {"id"=>"1"}
  Recipe Load (0.3ms)  SELECT "recipes".* FROM "recipes" WHERE "recipes"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/api/v1/recipes_controller.rb:3:in `show'
   (0.2ms)  SELECT "ingredients"."id" FROM "ingredients" WHERE "ingredients"."recipe_id" = ?  [["recipe_id", 1]]
  ↳ app/controllers/api/v1/recipes_controller.rb:4:in `show'
Completed 200 OK in 34ms (Views: 0.6ms | ActiveRecord: 0.5ms | Allocations: 6624)

(キャッシュなしの速度)

材料と分量も返す

レシピ詳細では、材料と分量も返してあげないといけないです。
READMEに従って、

options = { include: %i[ingredients] }

こういうのをつくって

class Api::V1::RecipesController < ApplicationController
  def show
    recipe = Recipe.find(params[:id])
    options = { include: %i[ingredients] }
    json_string = RecipeSerializer.new(recipe, options).serialized_json
    render json: json_string
  end
end

渡してあげます。

{
    "data": {
        "id": "1",
        "type": "recipe",
        "attributes": {
            "title": "ネギダレが美味しい!鶏もも肉のソテー",
            "introduction": "ネギダレが美味しい、鶏もも肉のソテーはいかがでしょうか。"
        },
        "relationships": {
            "ingredients": {
                "data": [
                    {
                        "id": "1",
                        "type": "ingredient"
                    },
                    {
                        "id": "2",
                        "type": "ingredient"
                    },
                ]
            }
        }
    },
    "included": [
        {
            "id": "1",
            "type": "ingredient",
            "attributes": {
                "name": "鶏もも肉",
                "quantity_and_unit": "300g"
            },
            "relationships": {
                "recipe": {
                    "data": {
                        "id": "1",
                        "type": "recipe"
                    }
                }
            }
        },
        {
            "id": "2",
            "type": "ingredient",
            "attributes": {
                "name": "塩こしょう",
                "quantity_and_unit": "小さじ1/4"
            },
            "relationships": {
                "recipe": {
                    "data": {
                        "id": "1",
                        "type": "recipe"
                    }
                }
            }
        },

(一部のみ表示)
紐付いている材料と分量が返ってきています。

Started GET "/api/v1/recipes/1" for ::1 at 
   (0.2ms)  SELECT sqlite_version(*)
Processing by Api::V1::RecipesController#show as JSON
  Parameters: {"id"=>"1"}
  Recipe Load (0.6ms)  SELECT "recipes".* FROM "recipes" WHERE "recipes"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/api/v1/recipes_controller.rb:3:in `show'
   (0.2ms)  SELECT "ingredients"."id" FROM "ingredients" WHERE "ingredients"."recipe_id" = ?  [["recipe_id", 1]]
  ↳ app/controllers/api/v1/recipes_controller.rb:5:in `show'
  Ingredient Load (0.4ms)  SELECT "ingredients".* FROM "ingredients" WHERE "ingredients"."recipe_id" = ?  [["recipe_id", 1]]
  ↳ app/controllers/api/v1/recipes_controller.rb:5:in `show'
Completed 200 OK in 51ms (Views: 0.4ms | ActiveRecord: 1.2ms | Allocations: 13646)

(キャッシュなしの速度)

キャッシュ

READMEに従って

cache_options enabled: true, cache_length: 1.hours

こんなのつけて

class RecipeSerializer
  include FastJsonapi::ObjectSerializer
  cache_options enabled: true, cache_length: 1.hours
  has_many :ingredients
  attributes :title, :introduction
end


Completed 200 OK in 36ms (Views: 0.3ms | ActiveRecord: 0.8ms | Allocations: 13427)
Completed 200 OK in 13ms (Views: 0.2ms | ActiveRecord: 1.4ms | Allocations: 4940)

その他使い方

基本的に ActiveModelSerializers と同じです。
気になった方は、 README を参照してみてください。

最後に

明日はCXOの坪田さんの「突破するプロダクトマネジメント」です! 楽しみです!!

delyではエンジニアを募集しています。

www.wantedly.com