GraphQL DataLoaderライブラリ dataloden の使い方

こんにちは、spotチームの名嘉眞です。spotチームはキャンプ場検索サービス(hinata spot)を開発しております。私はspotチームのバックエンド担当として日々Goを書いてます。

hinata spot

spotチームでは、GraphQLのDataLoaderライブラリとしてdataloaden を使用しています。今回は、dataloaden の使い方について記載しました。

GraphQLでのデータ取得について

GraphQLは、1回のクエリで必要なフィールドだけ取得できる仕組みになっていて便利です。ただ、よく考えずにバックエンドの実装を行うと、クエリによっては使わないフィールドの値も必ず取得するような実装になったり、DBからデータを取得する場合は、必ずJOINするような実装になる可能性があります。

動的にJOINしたり、バックエンド側でリクエストに応じて取得したいフィールドに合ったデータを生成することは出来ると思いますが、バックエンドの実装が少し複雑になるかと思います。

# schema.graphql

type Contract {
  """
  id: ID!
  """
  name: String!
  """
  payment: Payment!  # paymentフィールド(支払い方法)はcontractとは別テーブルのデータとする
}
query {
  getContract(contract: { id: "1" }) {
    id
    name
    payment # このpaymentフィールドはクライアント側からのリクエストで必ず指定されるわけでは無い
}

このような問題を解決する方法として、対象フィールドのGraphQL resolver(GraphQLバックエンド関数)を別で作成し、GraphQLサーバがレスポンスを組み立てるという方法があります。

この方法を使うと各resolver自体の実装は対象のフィールドのデータ取得だけでシンプルになり、バックエンド側で複雑な実装はせずに済みます。また、クエリに応じて動的にフィールドのデータを取得することが出来ます。

resolverを別で作成する方法に関して詳しくは記載しませんが、spotチームだと gqlgen というライブラリを使用しており、以下のようにgqlgenの設定ファイル(gqlgen.yml)を変更することで作成出来ます。

# gqlgen.yml

models:
  Contract:
    fields:
      payment:
        resolver: true

次のようなインターフェースが作成されます。contract resolver というインターフェースにPaymentというメソッドが追加されました。このインターフェースを満たす実装を行うことでデータの取得ができます。

// generated.go
type ContractResolver interface {
    Payment(ctx context.Context, obj *models.Contract) (*models.Payment, error)
}

ただ、注意しないといけない点があり、resolverを別にしただけだと、N+1問題が発生します。

query {
  getContracts {
    id
    name
    payment # paymentフィールドは別resolverで取得することになっている
}

上記のクエリは、契約情報の一覧を取得するクエリだとします。このクエリは契約名や、契約の支払い情報を複数取得できます。

paymentフィールドは別resolverで取得するようにした場合、バックエンド側でSQLは下記のように発行されます。

# 契約一覧を取得 
SELECT id, name FROM contracts;
# 上記のSQLで取得された契約情報の数だけ、下記のSQLも発行される
SELECT * FROM payments WHERE contract_id = ?
SELECT * FROM payments WHERE contract_id = ?
SELECT * FROM payments WHERE contract_id = ?

上記のようなN+1問題を解決する方法が、DataLoaderを使ったデータ取得です。 DataLoaderを使うと上記のSQLを下記のように変更することが出来ます。

# 契約一覧を取得 
SELECT id, name FROM contracts;
# paymentはまとめて取得
SELECT * FROM payments WHERE contract_id IN (?, ?, ?)

DataLoaderとは

  • DataLoaderとは、データ取得をバッチ化するためのライブラリです。

  • 具体的には、複数のデータを取得するリクエストが発生した時に、定義した待ち時間(Wait)のあいだ、取得したいデータのキー(オブジェクトのIDなど)を蓄積し、まとめてデータ取得処理を実行する仕組みです。データ取得処理の回数を減らすことができます。

  • 取得したいデータのキーがバッチ化する数という感じになります。バッチ化する数(MaxBatch)と、待ち時間(Wait) は任意に指定することができます。

  • 待ち時間(Wait) の値が大きくなるとバッチ化できる範囲が広がりますが、その分レスポンスタイムが遅くなるおそれがあります。

dataloadenの使い方

dataloaden は、GoのDataLoader生成のためのライブラリです。gqlgenの作者が作成しています。 また、gqlgenのDataLoaderの説明 にも、dataloadenを使ったDataLoaderの設定方法が記載されています。

使い方の例として、これまでの説明でもあったpayment(支払い情報)を取得するDataLoaderを、dataloadenを使って作成します。

まず、DataLoaderファイルを生成したいディレクトリで下記のコマンドを実行します。DataLoaderの名前、 int、stringなどのデータを取得するためのキーの型、 取得するデータの型を指定してコマンドを実行します。

# dataloaden `DataLoaderの名前` `keytype(int, stringなど)` `取得するデータの型`
dataloaden PaymentLoader string '*github.com/vivit/spot/model.Payment'

上記のコマンドを実行すると、paymentloader_gen.go というファイルが自動生成されます。 ファイルの中には、下記のように PaymentLoader という構造体が定義されています。 PaymentLoader のfetchフィールドは関数型で、引数に keys []string 、戻り値に []*model.Payment が定義されています。先ほど実行したdataloadenのコマンドで指定した型です。

// PaymentLoader batches and caches requests
type PaymentLoader struct {
    // this method provides the data for the loader
    fetch func(keys []string) ([]*model.Payment, []error)

    // how long to done before sending a batch
    wait time.Duration

    // this will limit the maximum number of keys to send in one batch, 0 = no limit
    maxBatch int

    // INTERNAL

    // lazily created cache
    cache map[string]*model.Payment

    // the current batch. keys will continue to be collected until timeout is hit,
    // then everything will be sent to the fetch method and out to the listeners
    batch *paymentLoaderBatch

    // mutex to prevent races
    mu sync.Mutex
}

次にdataloadenで生成した仕組みを使うプログラムを作成します。(名前はなんでも大丈夫です。) GraphQLサーバーのミドルウェアとして使うための関数(PaymentLoaderMiddleware) と、resolverからDataLoaderを使うための関数(Payment) を定義します。

// paymentloader.go
package loaders

import (
    // 省略
)
const paymentLoaderKey = "paymentLoader"

func PaymentLoaderMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // dataloadenで生成されたファイルに定義されているNewPaymentLoaderを使います。
        // NewPaymentLoaderの引数にはこれまた生成されたファイルに定義されているPaymentLoaderConfigを設定します。
        // PaymentLoaderConfigにMaxBatch, Wait, データをfeatchする関数を定義します。
        loader := NewPaymentLoader(PaymentLoaderConfig{
            MaxBatch: 30,                   // 最大MaxBatchの数だけ、keysに値が設定される(contractID)
            Wait:     10 * time.Millisecond, // 10ms待機する。待機した時間の間で設定されたkeysをもとに後述の処理を行う
            Fetch: func(keys []string) ([]*model.Payment, []error) {
                ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
                defer cancel()

                // DB からpaymentを取得する、この関数は、keysの値で下記のようなSQLを実行する
                // SELECT * FROM payments WHERE contract_id IN (keys[0], keys[1], keys[2])
                reply, err := repogitory.GetPayments(ctx, keys)
                if err != nil {
                    return nil, []error{err}
                }

                payments := map[string]*model.Payment{}
                for _, p := range reply {
                    payments[p.contractID] = p
                }

                // keyの値にあったpaymentを、keysの並び順にしたpaymentのスライスを作る
                result := make([]*model.Payment, len(keys))
                for i, key := range keys {
                    result[i] = payments[key]
                }

                return result, nil
            },
        })
        ctx := context.WithValue(r.Context(), paymentLoaderKey, loader)
        r = r.WithContext(ctx)
        next.ServeHTTP(w, r)
    })
}

func Payment(ctx context.Context, id string) (*model.Payment, error) {
    v := ctx.Value(paymentLoaderKey)
    loader, ok := v.(*PaymentLoader)

    if !ok {
        return nil, xerrors.New("failed to get loader from current context")
    }

    return loader.Load(id)
}

そして上記で作成した関数を使う部分です。 作成した関数、PaymentMiddlewareをミドルウェアとして、http.Handleで定義して使います。 ※このあたりはいろいろ定義の仕方があると思うので簡略した書き方にしています。

// handler.go
http.Handle("/graphql", loader.PaymentMiddleware(http.Handler型が入る))

もう一つ作成した関数、PaymentはcontractResolverからの呼び出して使います。記事の最初の方で記載した、gqlgenで自動生成されたインターフェースを満たすように実装します。

// contract_resolver.go
func (*contractResolver) Payment(ctx context.Context, s *models.Contract) (*models.Payment, error) {
    p, err := loaders.Payment(ctx, s.GetId())
    
    return &model.Payment{Payment: p}, err
}

以上で、設定は完了です。以下のようなクエリを実行したログを確認して、contractを検索するSQLとcontractに紐づくpaymentを検索するSQLが確認できれば成功です。

query {
  getContracts {
    id
    name
    payment
  }
}

paymentフィールドを指定した場合だけpaymentを取得するSQLが実行されるのと、その実行回数も親のオブジェクトであるcontractの件数以下になっていると思います。

まとめ

GraphQLの良さを最大限に活かすには、DataLoaderは必要不可欠かなと思いました。DataLoaderを使うことで、動的に取得したいデータだけを取得できますし、バックエンド側の実装もシンプルになりました。

まだ、datalodenで生成されたファイルの内容を全て理解できたわけでは無いので、これからもいろいろ使ってみるのと、spotサービスでDataLoaderを使えていないフィールドもあるので、その改善も進めていこうと思います。

チャネルトークAPIをNext.js + TypeScript環境で型安全に扱う

フロントエンドエンジニアの中村@taroodrです。

今回は、弊社のhinataレンタルというキャンプ用品のレンタルサービスで導入しているチャネルトークでチャットボットを開発した話をします。

チャットボットを作った背景は、弊社の菅谷がチャネルトークのチャットボットを半日でカスタム開発し、売上を1.5倍にした話に詳細を記載しているので気になった方はこちらも合わせてご覧ください。

目次

チャネルトークとは

熱狂的ファンを作るための顧客コミュニケーションツール

を掲げたチャット・マーケティングツールです。

ユーザ数による課金体系や、充実したコミュニティがなど 弊社のようなベンチャー企業には嬉しいポイントがたくさんあります。

https://channel.io/ja

チャネルトークAPIについて

チャネルトークでは開発者用のAPIが豊富に公開されており、独自のカスタマイズを加えた機能を実装することができます。 また、APIの仕様がOpenAPIで定義されているため型情報を生成することができ、レスポンスに型を付与することが可能なため型安全に利用することが可能です。

https://api-doc.channel.io/

チャネルトークAPIをTypeScriptで型安全に扱う

では実装方法を見ていきます。

OpenAPI定義のダウンロードとコード生成

まずはAPIの定義が書かれた jsonをダウンロードします。

ダウンロードしたAPI定義をもとにクライアント側のコードを生成します。

https://github.com/OpenAPITools/openapi-generator を使うと各種言語のコードが生成できます。 サポートされている言語も多いです。

ActionScript, Ada, Apex, Bash, C, C# (.net 2.0, 3.5 or later, .NET Standard 1.3 - 2.0, .NET Core 2.0), C++ (cpp-restsdk, Qt5, Tizen), Clojure, Dart (1.x, 2.x), Elixir, Elm, Eiffel, Erlang, Go, Groovy, Haskell (http-client, Servant), Java (Jersey1.x, Jersey2.x, OkHttp, Retrofit1.x, Retrofit2.x, Feign, RestTemplate, RESTEasy, Vertx, Google API Client Library for Java, Rest-assured, Spring 5 Web Client, MicroProfile Rest Client), k6, Kotlin, Lua, Nim, Node.js/JavaScript (ES5, ES6, AngularJS with Google Closure Compiler annotations, Flow types, Apollo GraphQL DataStore), Objective-C, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust (rust, rust-server), Scala (akka, http4s, scalaz, sttp, swagger-async-httpclient), Swift (2.x, 3.x, 4.x, 5.x), Typescript (AngularJS, Angular (2.x - 8.x), Aurelia, Axios, Fetch, Inversify, jQuery, Node, Rxjs)

とりあえずインストールします。

$ yarn add -D @openapitools/openapi-generator-cli

今回はTypeScriptを使うので下記のコマンドでコードを生成します。

openapi-generator generate -i oapi/channel-swagger.json -g typescript-fetch -o oapi

-g オプション(GENERATOR)を typescript-fetch にすることで、TypeScriptでfetch apiを使うコードが生成されます。 他の言語やライブラリ用のコードを生成する場合は -g オプションを変更するとOK。 指定できるGENERATORはこちらに一覧があります。

生成されたコードの構成

├── apis
├── swagger.json
├── config.ts
├── index.ts
├── models
└── runtime.ts

TypeScriptエラーの解消

生成されたコードを使って、実装を進めようと思ったところコード内に使用されていない変数があり、tsのエラーが出ていることに気づきました。

生成されたコードに、弊社で利用しているtsconfigのルールだとエラーになる記述が入っていたのです。

// エラーになっていたルール
"noUnusedLocals": true,
"noUnusedParameters": true,

これを解決するために、生成されるコードのもとになるテンプレートを変更することにします。

custom-tmplディレクトリを作成し、独自のテンプレートを記載。 tsのチェックの対象外になるよう ts-nocheck を追加。

├── custom-tmpl
│   ├── apis.mustache
│   └── models.mustache

// copy from https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/resources/typescript-fetch/apis.mustache
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck // 追加
{{>licenseInfo}}

そしてコード生成コマンドに -t オプションでテンプレートを指定。

openapi-generator generate -i oapi/channel-swagger.json -t oapi/custom-tmpl -g typescript-fetch -o oapi

これでエラーが解消できます。

Next.jsのAPI Route

hinataレンタルでは、BFFとしてGraphQLを使っているのですが今回の用途は検証目的で高速で実装したかったため、クライアント側で使っているNext.jsのAPI Route機能を使うことにします。

API Routeは pages/api/user.js のように pages/api ディレクトリ以下にファイルをつくり、下記のようにreq, resを引数にとる関数を実装するだけでAPIが作れる機能です。 今回のようにちょっとした検証などに利用する場合は手軽に実装できて便利です。

export default (req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'application/json')
  res.end(JSON.stringify({ name: 'John Doe' }))
}

実装イメージ

最終的な目標は、ボタンのクリック等ユーザが何らかのアクションをした際に、チャットを開きユーザにメッセージを届けることとします。 ↓実際の画面のイメージは下記になります。 チャネルトークボット実装イメージ

これを実現するためには、

  • 特定のユーザに紐づく新しいチャットを作成する
  • 作成したチャットにメッセージを送信する

機能の実装が必要です。 それぞれ下記のAPIを使って実現します。

  • /open​/v3​/users​/{userId}​/user-chats
  • /open/user_chats/{userChatId}/messages

※March 13th, 2021にサポートが切れるLegacy OpenApiを使っている。新しいAPIである /open/v3/user-chats/{userChatId}/messages を使うべきだが後述の理由で古い方を使った実装を紹介。

生成されたコードを使って実装する

まずはリクエスト時に使う設定を書いていきます。 生成されたコードと同ディレクトリに config.ts を置きます。 ここでポイントとなるのは、fetchApiをisomorphic-unfetchの実装に差し替えているところです。 ブラウザ側から生成されたクライアント側コードをつくう場合は問題ないのですが、今回の場合Node.jsでリクエストをおこなうので必要になります。

import { Configuration } from "./";
import fetch from "isomorphic-unfetch";

const API_ENDPOINT = "https://api.channel.io";
// Name of API KEY is チャットメッセージ
const ACCESS_KEY = "your access key";
const ACCESS_SECRET = "your access secret";

export const config: Configuration = new Configuration({
  fetchApi: fetch,
  basePath: API_ENDPOINT,
  headers: {
    "x-access-key": ACCESS_KEY,
    "x-access-secret": ACCESS_SECRET,
    "Content-Type": "application/json; charset=utf-8",
  },
});

準備ができたので API Route機能を使って実装していきます。

特定のユーザに紐づく新しいチャットを作成

まず、pages/api/channeltalk/user-chats/[userChatId]/index.ts を作成。 パスに [userChatId] のような文字列を含めるとコード内でから const { query: { userChatId } } = req; とするとリクエスト時の文字列を取得できます。

UserChatApiインスタンスを作り、呼び出してあげるだけでOKです。

import { NextApiRequest, NextApiResponse } from "next";
import { UserChatApi, config } from "../../../../../oapi";

export default async function userHandler(
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> {
  const {
    query: { id },
    method,
  } = req;
  switch (method) {
    case "POST":
      const userChatApi = new UserChatApi(config);
      try {
        const result = await userChatApi.userChats1Raw({
          userId: id as string,
        });
        const value = await result.value();
        const newUserChatId = value.userChat?.id;
        if (newUserChatId == null) {
          throw Error("userChat id is null");
        }
        res.status(200).json({ userChatId: newUserChatId });
      } catch (e) {
        console.error(e);
        res.status(500).end(e);
      }
      break;
    default:
      res.setHeader("Allow", ["POST"]);
      res.status(405).end(`Method ${method} Not Allowed`);
  }
}

型もばっちりついていて良い感じです。

作成したチャットにメッセージを送信

前述の通り、新しいAPIである /open/v3/user-chats/{userChatId}/messages を使いたかったのですが、生成されるコードの型に一部不備があったので説明します。

/open/v3/user-chats/{userChatId}/messages へのリクエストを型情報通りに実装すると下記になります。

// 型
type createMessage2 = (requestParameters: CreateMessage2Request) => Promise<MessageView>

export interface CreateMessage2Request {
    userChatId: string;
    message: Message;
    botName?: string;
}

export interface Message {
    message?: string;
    // 一部の型を文面の都合上省略している
}

// 実装
const userChatApi = new UserChatApi(config);
  await userChatApi.createMessage2({
    userChatId,
    botName: BOT_NAME,
    message: {
      message: `{
      "blocks":[
          {
              "type": "text",
              "value": ${message}
          }
        ]
      }`,
    },
  });

すると Request BodyがAPIが求めている形と異なったものになってしまいます。

// 実際に発行されるRequest Body
{
  message: {
    blocks: [
      {
        type: "text",
        value: "メッセージ",
      },
    ],
  },
};

// APIが求めているRequest Body
{
    blocks: [
      {
        type: "text",
        value: "メッセージ",
      },
    ],
};

生成されたコードを使わずにAPI呼び出しを書くことも考えたのですが、型情報は生成されたものを使いたかったので、 /open/user_chats/{userChatId}/messages を使用することにしました。 /open/user_chats/{userChatId}/messages へのリクエストは先程と同様に型情報どおりに実装するだけでOKなので楽です。

// pages/api/channeltalk/user-chats/[userChatId]/messages.ts
export default async function userChatsHandler(
  req: NextApiRequest,
  res: NextApiResponse
): Promise<void> {
  const {
    query: { userChatId },
    body: { message },
    method,
  } = req;
  switch (method) {
    case "POST":
      try {
        const legacyOpenApi = new LegacyOpenApiApi(config);
        await legacyOpenApi.createMessage5Raw({
          userChatId: userChatId as string,
          message: { message },
          botName: BOT_NAME,
        });
        res.status(200).json({ messageCreated: true });
      } catch (e) {
        console.error(e);
        res.status(500).end(e);
      }
      break;
    default:
      res.setHeader("Allow", ["POST"]);
      res.status(405).end(`Method ${method} Not Allowed`);
  }
}

実装したAPIを使ってメッセージを送る

実装したAPIを順番に呼び、最後に window.ChannelIO("openChat", userChatId); でチャットを開いてあげると完了です。 実際はエラー処理も入ります。

const createNewMessage = async (userId) => {
  const response = await fetch(`/api/channeltalk/users/${userId}/user-chats`, {
    method: "POST",
  });
  const result = await response.json();
  await fetch(`/api/channeltalk/user-chats/${result.userChatId}/messages`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json; charset=utf-8",
    },
    body: JSON.stringify({ message: "メッセージ" }),
  });
  window.ChannelIO("openChat", result.userChatId);
};

We Are Hiring

vivitではTypeScriptでアプリケーション開発をしていきたいフロントエンドエンジニアを絶賛採用中です。

https://www.wantedly.com/projects/412534

LighthouseのAccesibilityスコア改善について

vivit株式会社でhinataというアウトドアメディアの開発を行っている河村と申します。 今回はLighthouseのAccessibilityに関するスコア改善のお話を書こうと思います。

Lighthouseとは

f:id:Kawam:20200831150320p:plain

LighthouseとはGoogleが公式に提供しているwebページのための品質監査ツールです。 もともとLighthouseという言葉には「灯台」という意味があり、このツールを使うと以下に記載している5つの項目から特定のWebアプリやWebページをチェックして、その結果を見ながら品質改善をすることができます。

  • Performance(ページ読込速度、レスポンスの速さなどの査定)
  • Accessibility(ユーザーや検索エンジンのロボットに対して最適な作りになっているか)
  • Best Practices(SP向けの品質向上やパフォーマンス低下しない方法の確認)
  • SEO(検索結果のランキングに影響する項目の確認)
  • Progressive Web App(WebサイトをPWA化するうえで必要な項目の査定)

その中でも今年の6月から改善対策を進めたAccessibilityという項目について話を進めます。

対応前の数値

f:id:vivit-dev:20200831140502p:plain
2020年6月18日時点

対応前はこのように36と低い状態でした。 Lighthouseは各項目の問題点も合わせて表示されるので具体的に見ていきます。

警告の項目と具体的な対応

html要素のlang属性に適切な値を設定する

Internationalization and localization - These are opportunities to improve the interpretation of your content by users in different locales.
・<html> element does not have a [lang] attribute

htmlタグにlang属性を追加しました。 <html> → <html lang="ja">

画像要素に適切なalt属性を設定

Names and labels - These are opportunities to improve the semantics of the controls in your application. This may enhance the experience for users of assistive technology, like a screen reader.
・Image elements do not have [alt] attributes
・Links do not have a discernible name

リンクに十分識別可能な名前(aria-label属性やname属性など)が設定されていないという警告内容でした。 一覧などに配置されるリンクは内容を決めてから対応する方針にしましたので一旦保留として、わかりやすい共通パーツから対応しました。

リストのタグ構成見直し

Tables and lists - These are opportunities to to improve the experience of reading tabular or list data using assistive technology, like a screen reader.
・Lists do not contain only <li> elements and script supporting elements (<script> and <template>).
・List items (<li>) are not contained within <ul> or <ol> parent elements.

「リストの親タグ(olやulタグ)内にliタグ・scriptタグ・templateタグ以外が含まれている」「liタグがulタグもしくはolタグ内に含まれていない」という内容の警告です。 ulタグ直下にdivタグが含まれていたため、タグ構成を見直しました。 またliタグの親がdivタグになっていたため、ulタグに変更しました。

ビューポートやスケーリング設定の見直し

Best practices - These items highlight common accessibility best practices.
・[user-scalable="no"] is used in the <meta name="viewport"> element or the [maximum-scale] attribute is less than 5.

<meta name="viewport">タグに関して、user-scalable="no"が設定されているかmaximum-scaleの値が5より小さくなっているという警告内容でしたので、user-scalable="no"を削除しました。

背景色と文字色で十分なコントラスト差をつける(未対応)

Contrast - These are opportunities to improve the legibility of your content.
・Background and foreground colors do not have a sufficient contrast ratio.

デザイナーとの調整が必要だったため今回は一旦見送りましたが、文字が読みやすいようにコントラストをつけてはっきりと見えるようにしてくださいという警告でした。

このような感じでhtmlの書き方について指摘事項が表示されるので、すぐに対応できるものから着手していきました。

対応後の数値

f:id:vivit-dev:20200831140541p:plain
2020年8月26日時点
(実際に対応を収束させたのは7月18日ごろですが、その後も細かい調整など行ったので現在のスコアを表示しています。)

コントラストの調整やリンク要素識別用の名前設定以外を行った結果、90までスコアUPすることができました。

まとめ

Accessibility項目についてはhtmlのコーディングルールに則っていないような指摘項目がほとんどで、実際の対応もタグを書き換えたり属性を追加するだけなので、1つ1つはあまり時間をかけずに対応できました。

ちょっと見直すだけでユーザーにも検索エンジンにもやさしいサイト作りができることがわかりましたので、機会があればlighthouseでスコア計測を行ってみてください。

【書籍レビュー】1冊ですべて身につくHTML&CSSとWebデザイン入門講座

フロントエンドエンジニアの関です。

vivit株式会社では書籍購入補助制度を導入しており、一定の条件はありますが申請すれば補助が出ます。

今回はその制度を活用して購入した書籍のレビューを公開したいと思います。

「1冊ですべて身につくHTML&CSSとWebデザイン入門講座」です。

www.webcreatorbox.com

こんな人におすすめ

著者のおすすめ

  • これからWebサイトを作り始める初心者
  • HTMLとCSSを基本から学びたい人
  • 美しいデザインのWebサイトを作りたい人
  • Webサイト制作の最新技術を学びたい人
  • 初心者向けにWebサイト制作を教えている人

読了後の体感

読了までにかかる目安時間

読み切る時間としては約2h、実際に手を動かして確認する箇所もあるので丁寧にやるともっと掛かりそう。

全く初めてという方は一日少しずつ数日かけて覚えていく感じになるかと思います。

読了後、身に付く知識

  • Webサイトが閲覧できるとはどういうことか、基本的な構造が理解できる
  • HTMLとCSSを使って一般的なレイアウトのWebサイトの作り方を理解できる

購入の経緯

私は実は新卒からバックエンドエンジニアとして3年程やっていて、転職を機にフロントエンドエンジニアに転向しフレームワークを使ったプログラミング的なサイトの構築はできるようになりました。 しかし、サイトを構築するにあたり基本的なマークアップやスタイルの作法をすっ飛ばしているので、流用や見様見真似のいわゆる「なんとなく」でやっていたのが恥ずかしながら実情でした。

個人的な信条として説明できない、根拠のない実装はなるべくしたくないのでこのタイミングで改めて入門しようと、調べ物でいつもお世話になっている Webクリエイターボックス の Mana さんが書いたこの本を購入しました。

www.webcreatorbox.com

内容について

この本の軸は、架空のカフェ「WBC CAFE」のWebサイトを実際に作る過程を一つひとつ要点を抑えながらHTML、及びCSSの基本的な使い方を紹介していくところにあります。 HTMLタグの説明を機械的に紹介していくリファレンスのような技術書ではなく、かなり実践的な手順で進行していくので手応えはあると思います。

また「こんな人におすすめ」として私が「Web開発者・Webデザイナーと一緒に仕事をする人」を挙げた理由にもなりますが、カフェのWebサイトを構築し始めるにあたり「『Webサイト』って何?『Webデザイン』って何?」という点について「Webサイトがどのように作られなぜ見られるのか」を平易な言葉選びで懇切丁寧に解説していたり、見やすい・使いやすいWebサイトを作るための「色使い」や「言葉選び」などのあまり技術的過ぎない部分も解説されているので、Web開発者・Webデザイナーと一緒に仕事をするにあたり高い解像度でコミュニケーションが取れるのではないかと思いました。

数ヶ月もすれば廃れていくくらい流れのある技術で、一過性な最新技術だけでない「考え方」まで学べるので入門から中級の振り返りまで役立つ一冊だと思いました。

最後に

vivit株式会社では各種ポジションでエンジニア採用を行っています。

アウトドア好きでも、もちろんそうでない方も是非一度覗いてみてください。

www.wantedly.com

いまさらだけど、GKEでElasticsearch 構成例

こんにちは、インフラエンジニアをやっている井島です。
hinataメディア では、右上の検索窓でElasticsearchが使われています。
hinataアプリ でも使われていて、結構クリティカルなところになってます。汗..

hinataメディアをAWSからGCPに移行する案件があり、そのなかでElasticsearchもGCPに移行することになりました。 GKE上で自前運用することにしたので、そんなElasticsearchの構成を紹介できればと思います。

AWSからGCPへの移行要件

  • Elasticsearch移行のために、プログラム改修は極力行わないようにする

    • 例えば、Elasticsearchバージョンアップを行い、そのための改修にはあまり工数はかけられない
  • Elasticsearchを使うアプリケーション機能は踏襲する

検討したこと

GCP Marketplaceから購入できるElastic CloudGCP Marketplace

結果:NG

  • メジャーバージョンアップが必要

  • 同義語、ユーザ辞書 まわりの適用方法を、改修する必要がある
    (現行のプログラムでは、ユーザ辞書などをテキストファイルで生成して、Elasticsearchがインデックス作成時にそれを読み込む方式)

GCP Marketplace でのElastic Cloud が使えない時点で、自前で構築するしか選択肢がなくなりましたね... あとは、GCEか、他のプロダクトでも使っているGKEかの2択です。

GCE (VMインスタンスに構築)

結果:NG

単純にVMインスタンスをAnsibleなどで構成管理コストが大きく、お手軽にスケールアウトさせにくいと考えたので、選択しませんでした。

hinataメディアRailsで、GKE上で動いています。
Railsが生成した同義語、ユーザ辞書のテキストファイルをElasticsearchサーバ内に送る必要があります。

  • 複数のElasticsearchサーバから同じファイルが参照される必要があり、 NFSサーバにファイルを置いてElasticsearchからマウントして参照させる方式で行く

  • AWSのEFSのようにマルチゾーンで安く初められるサービスはGCPには無く、NFSサーバも自前構築する
    Cloud Filestore があるのですが、単一ゾーンしか対応していなかったので、見送りました。

上記方式で決めていて、永続ディスクはリージョナルにすることが出来ますが、片方のゾーンが止まった時、NFSサーバをフェールオーバーさせるよう構成する必要があり、運用難易度の高いシステムになってしまいます。

ここの切り替わりの仕組みはkubernetes のDeploymentのPod自動復旧に任せた方がシンプルなので、 これもVMインスタンスを選択しなかった理由です。

GKE上に構築

結果:採用
最終的にGKE上にすべて構築することにしました。

  • 現行と同じElasticsearchバージョン(5.x系)
  • Railsが生成した同義語、ユーザ辞書のテキストファイルをNFS経由でElasticsearchサーバから参照
  • プログラム改修なし

構成

お待ちかね、具体的な構成は以下です。

f:id:ijimakenta:20200823174519j:plain

Railsが検索リクエストを投げます。 インデックス作成にはSidekiq を使っており、Sidekiqがインデックス作成をリクエストします。

Elasticsearchは、役割ごとにコンテナを分けました。
コンテナ1つあたりで使用するリソースが増えて、GKEノードごとで使用されるリソースに偏りが大きくなることを緩和し、必要な役割のみスケールアウトできるように作業範囲を局所化したかったので、このようにバラバラで構成しています。

elasticsearch client はDeploymentで 永続ディスクなし、data, master の役割は Pod再起動時など、データが残っているとクラスタへの復帰が早くなるので、StatefulSetで永続ディスクありで構成しています。

さらにその永続ディスクは、リージョナルSSD永続ディスクを使用しています。
GKEクラスタのノードがリージョナル構成で、Elasticsearchで使う永続ディスクを単一ゾーンのものにすると、 せっかくGKEノードがリージョナルなのに、ElasticsearchのPodがデプロイされるゾーンが片方のゾーンに偏ってしまう恐れがあったので、 リージョナルSSD永続ディスクを使用しています。
実際に偏らずにPodデプロイが出来ています。

NFSサーバで使う永続ディスクも、DeploymentでのNFSサーバは1Podですが、そのPodがデプロイされるゾーンを偏らせないように、 ここもリージョナルSSD永続ディスクを使用しています。

基本的の1つのゾーンが停止してもElasticsearchを継続して使用できるよう考慮しています。

同義語、ユーザ辞書ファイルは、Deployment でNFSサーバ(elasticsearch-nfs-server)を1Pod作成し、それをPresistentVolume でマウントします。 必要なコンテナはPersistentVolumeClaimで、このPVを指定し接続しています。

Elasticsearchでは、インデックス作成する時、同義語、ユーザ辞書ファイルを読み込むように設定しています。この時、data と master 役割のElasticsearchが このファイルを参照する動きをしており、 この2つの役割のコンテナでNFSマウントをしています。

図にelasticsearch-discovery というService がありますが、これは TCP9300で各ノードがMaster とやり取りするときに使われるものになります。

さいごに

kubernetes マニフェストは、ここを参考にしました。
vivit では、Helmは使っておらず、Kustomize を使ってマニフェストを構成しているので、普通のマニフェストに読み直すのが少し大変だった感じですかね!
負荷的には、Data役割のPodが一番CPUを使っている感じです。

NFSサーバ、Docker for Mac 上ではうまく動かなかったので注意です。(はまりポイント)
今回の構成のNFSサーバは alpineイメージ で nfs-utils を使うよう作りましたが、Linux Kernel 付属のnfsdを使うもので、Mac上では動かなかったです.. コンテナ内ではLinux kernel を使う前提ものなのに、Mac のKernelを使おうとしているからかな...?

Mac 上で同じ構成を作りたかったら、PersistentVolume でhostPath で直接Macディレクトリを共有フォルダ(NFSのかわり)としてみてください。

参考

github.com

dzone.com

GraphQLについて

  • こんにちは、spotチームの名嘉眞です。spotチームはキャンプ場検索サービス(hinata spot)を開発しており、私はバックエンド担当として日々Goを書いてます。今回は業務でGraphQLを使っていることと、以下の書籍を読んだので、改めてGraphQLについて基本的なことをまとめてみました。

  • 初めてのGraphQL ――Webサービスを作って学ぶ新世代API

GraphQLとは

  • APIのための問合せ言語 クエリを実行してデータを呼び出すためのランタイムとも言われます。

  • 一般的にはHTTPプロトコルを使用

  • GraphQLは、クライアント/サーバー通信のための言語仕様。なので実際に実装するときは、GraphQLの言語仕様に則って、クライアント側とサーバー側の実装を行います。

グラフ理論について

GraphQLのメリット

書籍を読んでGraphQLのメリットについてまとめると以下のようになるかなと思いました。

  1. 1回のクエリで必要なフィールドだけ取得できる

  2. 1回のクエリで複数の異なる種類(定義された型)のデータを受け取ることができる

  3. GraphQLスキーマで定義された型でバリデーションされる

  4. GraphQLスキーマがフロントエンドとバックエンドの共通のスキーマ設計書とすることができる

  5. いろいろな言語に対応している。クライアント側とバックエンド側で言語が違っても大丈夫

ひとつずつ詳しく書いていこうと思います。

1. 1回のクエリで必要なフィールドだけ取得できる

 GraphQLのスキーマ定義に方法についてはこの記事で説明しませんが、以下のようなスキーマ定義がされているとします。これはキャンプ場(spot)という型を定義しています。

# キャンプ場を定義した型、Spot型はid, name, catchPhrase, description, addressフィールドをもつ
type Spot {
  id: ID!
  name: String!
  catchPhrase: String!
  description: String!
  address: Address!
}

 このSpot型のオブジェクトを取得しようとした時、REST APIだと、http://example.com/api/spots/:id という感じになり、1つのオブジェクトの全てのフィールドが取得されると想像できます。

 ただ、本来はnameとcatchPhraseだけ取得できれば問題ないなど、全てのフィールドの取得は必要ない場面は多いかもしれません。

 GraphQLでは下記のように、取得したいフィールドを指定してリクエストを送ることができます。今回の例ではほとんど差はないですが、余分なデータを取得しないことでより高速にレスポンスを受け取ることができます。

# Spot型のnameとcatchPhraseフィールドのみを取得するクエリ
query {
  getSpot(spot: { id: "1" }) {
    name
    catchPhrase
  }
}

2. 1回のクエリで複数の異なる種類(定義された型)のデータを受け取ることができる

 こちらも例をあげて説明してみます。先ほどのキャンプ場(spot)のレビューという関連オブジェクトが定義されているとします。

type Review {
  id: ID!
  spotId: ID!
  userName: String!
  email: String!
  title: String!
  point: Int!
}

type Spot {
  id: ID!
  name: String!
  catchPhrase: String!
  description: String!
  address: Address!
  reviews: [Review!]!  # review型のリストをspot型に定義
}

 REST APIだと基本的には、1度対象となるオブジェクトのデータを取得し、そのIDなどをもとに、関連するオブジェクトを取得すると思います。

 GraphQLでは、スキーマ定義に関連するオブジェクトを定義することで1回のクエリで複数の型のデータを取得することができ、効率良くレスポンスを受け取ることができます。

# Spot型のnameとcatchPhraseフィールドと関連するレビューを取得するクエリ
# クエリの中で、spot型とreview型が入れ子になる
query {
  getSpot(spot: { id: "1" }) {
    name
    catchPhrase
    review {
      userName
      title
      point
    }
  }
}

3. GraphQLスキーマで定義された型でバリデーションされる

 GraphQLではスキーマ定義した型でバリデーションがされるため、例えば以下のように存在しないフィールドを指定してリクエストを送ろうとするとエラーとなります。

# 存在しないhogeというフィールドを指定してリクエスト
query {
  getSpot(spot: { id: "1" }) {
    name
    catchPhrase
    hoge
  }
}

# レスポンス
# {
#   "error": {
#     "errors": [
#       {
#         "message": "Cannot query field \"hoge\" on type \"Spot\".",
#         "locations": [
#           {
#             "line": 3,
#             "column": 5
#           }
#         ]
#       }
#     ],
#     "data": null
#   }
# }

スキーマ定義した内容でそのままバリデーションまで構築できるのはとても便利ですね。

4. GraphQLスキーマがフロントエンドとバックエンドの共通のスキーマ設計書とすることができる

 GraphQLを使うと、定義したGraphQLスキーマをもとに共通認識を持って、フロントエンドもバックエンドも開発することができます。(スキーマファースト)

  • スキーマとは... データの型の集合

  • スキーマファーストについて

    • 設計の方法論。スキーマファーストではチーム全員がデータ型について理解していて、バックエンドはデータの永続化とリクエストに応じて返すデータ型が何か、フロントエンドはユーザーインターフェースを組み立てるためのデータ型が何かを理解している必要がある。

 また、GraphQLには イントロスペクション というスキーマの詳細を取得できる機能があります。GraphQL PlayGround などで、Docsとschemaが確認できるのはこの仕組みがあるからです。

 実際に業務でも、GraphQL PlayGroundを使用していまして、この機能のおかげで常に最新のAPIドキュメントを共有して開発ができるのと、APIドキュメントの更新忘れなども発生しないというメリットもあります。(スキーマ定義にコメントも記載でき、コメントも反映されます)

5. いろいろな言語に対応している。クライアント側とバックエンド側で言語が違っても大丈夫

 GraphQLは、クライアント/サーバー通信のための言語仕様と記事の冒頭の方に書きました。あくまで言語仕様です。クライアント側はクライアント側の言語でGraphQLのAPIエンドポイントを叩くように実装する、バックエンドはバックエンド側の言語で、GraphQLのAPIが返すと定義したデータを生成できるように実装するということが可能になります。

 また、GraphQLをさらに便利にするライブラリも充実しています。例えば、GraphQLスキーマからGoのバックエンドのテンプレートを生成してくれるライブラリ、gqlgen などがあります。

まとめ

 GraphQLは、使い方によってはレスポンスをより高速に受け取ることができることや、フロントエンドとバックエンドで共通のスキーマ定義をもとに開発することができるメリットがあります。

 私はvivitに入社してからGraphQLを使い始めましたが、フロントエンドエンジニアとGraphQLスキーマをもとに統一した言葉で会話が出来ることや、入社したばかりでもスキーマ定義を読むことで、なんとなく処理の内容が理解できたりしてGraphQLは素晴らしいと思いました。

 今回の記事では細かいスキーマ定義の方法やクエリの組み立て方は記載しませんでしたが、他にも便利な仕組みやライブラリもあるので、引き続き学んでいこうと思います。

vivitにjoinして1ヶ月でやったこと

  • こんにちは、今年の6月に入社したspotチームの名嘉眞です。spotチームはキャンプ場検索サービス(hinata spot)を開発しております。私はspotチームのバックエンド担当として日々Goを書いてます。

  • hinata spot

  • vivit入社前は主にRailsを書いていましたので、Goを業務で書き始めたのは入社してからになります。またGoだけでなく、gRPC、GraphQLも業務で使うのは初めてでした。

この記事はどんな記事?

  • vivitに入社してから1ヶ月の振り返りです。vivitでどんな技術を使っているのか、どんな開発をしているのか知ってもらえると嬉しいです。

joinして1ヶ月で自分がやったことを

入社して2日目ぐらいから、ペアプロしながら処理の流れやサービスの役割を教えてもらいました。

ペアプロVSCodeLive Share で先輩エンジニアが実際にコードを書くところを見ながら説明を受けたり、私が書くのを見てもらいながら行いました。

Live Share だと、どちらかがリモートでもペアプロやろうと思ってすぐにできるのが良いですね。

余談ですが、vivitではVSCodeを使う方が多くて、リポジトリVSCodeのsettings.json を管理しています。もちろん、使うエディタは自由です。(ちなみに私のエディタはvimです)

spotはいくつかのマイクロサービスで構成されています。バックエンドのサービス間はgRPCで通信しており、フロントエンドとバックエンドはGraphQLを使って通信しています。 ちなみにフロントエンドはReact+TypeScriptです。

f:id:shin018:20200720113557j:plain

gRPCの通信方式のうち、いまのところspotでは UnaryServerStreaming を使っています。

基本的には Unary で通信しますが、通信の容量が大きくなるレスポンスを返す場合は、 ServerStreaming を使います。

※gRPCの送受信データサイズの上限が4MBのため

※Unaryは単一のリクエストとレスポンスを返す通信

※ServerStreamingは単一のリクエストに対してstreamレスポンスを返す通信

# example protoファイル
# スポット一覧を取得する、returnsで stream と指定するとServerStreaming方式を定義します
rpc GetSpots(SearchRequest) returns (stream Spot);
# example Goファイル
func (s *spotServer)GetSpots(req *spot.SearchRequest) error {
  for _, spot := range res.Spots {
   // stream.Send でstream方式で送信
    err = stream.Send(spot)
    if err != nil {
      return err
    }
  }

  return nil
}

Goに関しては入社前からプライベートで書いていましたが、業務で通用するレベルかどうか少し不安はありました。 実際にはほぼ問題なかったですが、その理由の1つに、業務で使うGoのライブラリを自分がプライベートでも使ったことがあったということがあるかなと思います。

その1つがgorp です。gorpの特徴として、

  • 構造体へのマッピングが楽ちん
  • SELECT系は SQL を書く
  • EXEC系は SQL を書かなくても良い(書くこともできる)

ということがあります。 このライブラリ自体、難易度が高いということはないですが使ったことがあるということで業務にすんなり入れました。

gorpを導入した方に聞くと、構造体へのマッピングは必要だが、JOIN句やORDER BYなどSELECT文はSQLを書きたいということがあってgorpに決めたとのことでした。

また、vivitの別のチームでは他のライブラリを使っていたりしています。

# example Goファイル
dbmap = &gorp.DbMap{Db: _db, Dialect: gorp.MySQLDialect{Engine: "InnoDB", Encoding: "utf8mb4"}}
var spot *models.Spot
# selectはsqlで自由に書く
err := dbmap.SelectOne(&spot, "SELECT * FROM spots WHERE id = ?", id)

# insertはDBの該当tableと紐づいた構造体であれば下記のように書ける
err = dbmap.Insert(newSpot)

自分の最初のタスクがキャンプ場のレビュー機能の開発でした。

レビューのCRUD機能と投稿されたレビューをもとにキャンプ場の評価を設定するというものです。 このタスクで、Go、GraphQL、gRPCを使った処理の流れを一貫して実装しました。

記事の冒頭にも書きましたが、自分がGo、GraphQL、gRPCを業務で使ったことが無かったこともあり、新しい技術を使えてめちゃくちゃ楽しくできました。

また、実装に迷うところがあってもペアプロしたり、相談しやすい雰囲気、チームで取り組む感じがあったことも楽しくできた理由になります。

f:id:shin018:20200720113625j:plain

まとめ

この記事を書いている今も新機能の開発をしています。

サービス自体にまだまだ足りない機能が多いこともあり、追加する機能の要件定義からエンジニアも携わり、実装・リリースするので、サービスが成長していくことを感じています。 また改善点もあり、その解決に取り組むのも楽しみです。

この1ヶ月では、Go、GraphQL、gRPCを使うことができましたが、フロントエンドや、インフラまわりにも興味があるのでいろいろチャレンジしていきたいと思います。