Goでsitemap.xmlを生成する

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

hinata spot

今回はsitemap.xmlをGoで生成する方法についてまとめてみました。標準パッケージで割と簡単に出来るかなと思います。

始めに

hinata spotでは、sitemap.xmlの生成もGoで書いています。ちなみにsitemap.xmlとは、ウェブサイト内の各ページのURLや優先度、最終更新日、更新頻度などを記述したXML形式のファイルのことです。

railsだとsitemap_generatorというgemを使って生成したりするかもしれません。Goにもそのようなライブラリがあるかもしれないですが、標準packageのencoding/xmlで十分実装できると考えました。

処理の流れ

私の担当するサービスの場合、sitemap.xmlに記載されるurlを構成する要素はDBから取得する必要があります。そのため下記のような処理の流れで生成します。

  • DBから対象のデータ取得
  • URLの組み立て
  • sitemap.xmlの生成
  • sitemap.xmlgzip
  • GCSへアップロード

この記事では主に、sitemap.xmlの生成部分について紹介していきます。

実装部分

sitemap.xmlは以下のようなファイルです。sitemap固有のタグなど生成ルールが決まっています。

<?xml version="1.0" encoding="UTF-8"?>
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
      <url>
          <loc>https://hinata-spot.me/spots/taito-beach</loc>
          <changefreq>2020-10-20T14:56:46+09:00</changefreq>
      </url>
      <url>
          <loc>https://hinata-spot.me/spots/tanpopomura</loc>
          <changefreq>2020-10-20T14:56:46+09:00</changefreq>
      </url>
  </urlset>

まず、sitemapxmlをGoのstructで表現するために、structを以下のように定義しています。 encoding/xmlの場合、structの各フィールドにxmlタグと付与したいメタ情報を適用すると、適用したメタ情報をもとにxml生成時にタグで値を囲います。 attr と記載すると上位のタグの中にattrで指定した値を埋め込みます。

実際に生成したいsitemap.xml(上の例)と以下のstructでマッチする部分としては、 XMLName、Version、Xhtmlフィールドが実際に生成したいsitemap.xmlのurlsetタグの部分になります。 SiteListフィールドがsitemap.xmlに設定したいサイトのページのURLになります。

package models

import "encoding/xml"

type SiteMapXML struct {
    XMLName  xml.Name `xml:"urlset"`
    Version  string   `xml:"xmlns,attr"`
    Xhtml    string   `xml:"xmlns:xhtml,attr"`
    SiteList []*Site  `xml:"url"`
}

type Site struct {
    URL       string `xml:"loc"`
    UpdatedAt string `xml:"changefreq"`
}

サイトマップ生成の関数が実行されると、DBから対象のデータを取得し上記のSite structを生成するようにします。 以下のような関数を使ってsitemapに設定したいURLを生成しています。 最終的に、[]*Site型 を生成します。

func CreateSite(path, updatedAt string) *models.Site {
    u := url.URL{Scheme: "https", Host: spotHost, Path: path}
    site := &models.Site{
        URL:       u.String(),
        UpdatedAt: updatedAt,
    }

    return site
}

// dbResultはDBから取得したデータとする
siteList := make([]*models.Site, len(dbResult))
var index int

for i := range dbResult {
  path := "spots/" +  dbResult[i]
  site := CreateSite(path, dbResult[i].UpdatedAt)

  siteList[index] = site
  index++
}

sitemap.xml生成するパッケージは以下のように定義しています。このパッケージに定義したCreateXMLgzip 関数の引数に、上の例で生成した[]*models.Siteを渡すことで、sitemap.xmlの生成とgzip化を行います。 sitemap.xml固有の値は定数にて定義しています。

package sitemap

import (
  // 省略
)

const (
    version         = "http://www.sitemaps.org/schemas/sitemap/0.9"
    xhtml           = "http://www.w3.org/1999/xhtml"
    spotHost        = "hinata-spot.me"
)

// CreateXMLgzip creates site map xml from struct.
func CreateXMLgzip(siteList []*models.Site) (io.ReadWriter, error) {
    ss := &models.SiteMapXML{
        Version:  version,
        Xhtml:    xhtml,
        SiteList: siteList,
    }

    data, err := xml.MarshalIndent(ss, "  ", "    ")
    if err != nil {
        return nil, err
    }

    // xml.Header は、encoding/xmlパッケージで以下のようにconstで宣言されています。
    // <?xml version="1.0" encoding="UTF-8"?>
    bss := [][]byte{[]byte(xml.Header), data}
    bs := (bytes.Join(bss, []byte("")))

    var result bytes.Buffer
    zw := gzip.NewWriter(&result)

    _, err = zw.Write(bs)
    if err != nil {
        return nil, err
    }

    if err := zw.Close(); err != nil {
        return nil, err
    }

    return &result, nil
}

CreateXMLgzip 関数は、戻り値をインターフェース io.ReadWriter型にしています。理由はGCSにアップロードするオブジェクトをインターフェースのio.Reader型で受け取るようにしているからです。 io.Reader で受け取れるようにすることで、ストレージへのアップロードなど共通で使うような関数を使いやすくしています。

結構簡単に実装できたと感じるのではないでしょうか。サービスの内容によるかもしれないですが、SEOを考えるとXMLサイトマップを生成し管理することがあると思いますので、その際に役に立つことができたら嬉しいです。

コーディング時のデザイナーとのコミュニケーションについてガイドラインを作成してみました

vivit株式会社でアウトドアメディア:hinataの開発をしています河村です。 今回はコーディング時のデザイナーとのコミュニケーションについてガイドラインを作りましたのでそのお話を書こうと思います。

きっかけ

以前からデザイン改修やLP実装などの開発案件が入る際、フロントエンドエンジニア・デザイナー間で細かい部分の仕様のやりとりが増えていたという問題が発生していました。 簡単な例ですが、以下のような一覧の要素があったとします。

f:id:Kawam:20201001093052p:plain

この内容が3行を超えた場合はどうなるでしょうか?

(4行に高さが延びる場合) f:id:Kawam:20201001093054p:plain

(3行で収めたい場合) f:id:Kawam:20201001093057p:plain

エンジニア側としては4行目に達することも想定して実装をしなければならないため、その場合にどちらのデザインで実装すべきなのかは把握しておく必要があります。 こういった一つ一つについて確認のやりとりが発生すると、結構なコミュニケーションコストになります。

作成したガイドライン

そこで、デザインが納品される際にどのようなことが明確であれば開発がスムーズに行えるのかという点を洗い出してチェックシートを作成しました。 内容は以下の通りです。

  • モニターサイズによる可変

    • PCのウインドウサイズが変わった場合やSPでデバイスの画面サイズが違う場合の表示
  • カラーコードの指定

    • カラーコードは既存通りのものなのか、別に指定しているのか
  • アニメーションの挙動

    • モーダル・サイドメニュー・スクロール固定ヘッダーなどのアニメーションについてはデザインに落とし込むのが難しいため、参考サイトのURLを載せておくなどして対応しています。
  • クリックon,off、マウスオーバー時、disabledのデザインパーツは用意されているか

  • 文字数が表示領域を超える場合、要素が増える場合、要素が一つもない場合の想定をされているか

  • フォントサイズの指定(h1,2,3,本文)ごとにルールが決まっているか

  • フォントの指定はあるか

  • マージンのルールが決まっているか

  • 文字の視認性が保たれているか

  • 機能デザインがあれば仕様が固まっているか

    • 一画面の中にフォームのエラー表示や決済・画像アップロードなどの完了の文言を表示する場合に、デザインが用意されているか
  • pxは2で割り切れるか?

    • retina対応で倍のサイズに書き出す必要がある場合、実際の表示サイズ指定は半分になるのでその指定ができるようになっているか。
  • テキストに誤字・脱字・スペルミス・機種依存文字macの絵文字とか)がないか

  • 意図的ではない表記のゆれがないか

    • 例:WEB・Web・ウェブなど
  • 文字の表記が統一されているか

    • 半角・全角、日付の表現など

項目としては少々多いかもしれませんが、項目追加などでは既存のデザインに合わせる場合などもあるので毎回全てが当てはまるわけではありません。

やってみた結果

こちらのガイドラインを導入してみた結果、体感的にではありますが確認のやりとりは少なくなりました。 また、デザイナー側からもエンジニアが実装する上でどんな情報が必要なのかが理解できて、やりとりがスムーズなったというフィードバックもいただきました。

まとめ

毎回同じようなやりとりをしているのであれば、確認事項は先にわかっていた方がお互いに健全に仕事ができます。 特にエンジニアは仕様が不明確だと実装時に不安になってしまうものなので、こういったガイドラインがあるとよいアウトプットができるようになると思います。

GKEに対し、GitOpsやってます

インフラエンジニアをやっている井島です。

hinata の各プロダクト(Rental, Spot, Media)はGKEで稼働しており、GitOpsの考え方でデプロイを行っています。 今回はvivit で実際に使っている具体的なデプロイ構成についてご紹介したいと思います。

vivit では、開発環境は各人のローカルPC上とし、ステージング環境、本番環境はGKE上にあります。 開発環境で開発したものは、各々がステージング環境にデプロイして、最終的に本番環境にデプロイして、リリース完了、という開発の大きな流れになっています。

vivit では、1つのチームが1つのマイクロサービスの開発を行っているのではなく、基本的にプロダクト毎に開発チームが別れています。1つのチームが複数のマイクロサービスを扱っており、開発内容によってリリース範囲が変わってきます。

構成

f:id:ijimakenta:20200928201145p:plain

GitHubレポジトリは基本的にモノレポ構成で、プログラムコードとk8sマニフェストも同じレポジトリに入っています。k8sへのデプロイにはArgoCDを使用しており、CIには主にCircleCIを使用しています。

開発者がコードをレポジトリにPushするとCircleCIでDocker build が実行され、コンテナがGCSにPushされます。

そして、開発者がGKE上の環境にデプロイしたい時は、git tag をレポジトリにPushして、デプロイをトリガーします。この時、CircleCIではデプロイ用の命名規則のgit tag 名だった場合にデプロイ処理を発火するように設定してあります。

このデプロイ処理は、k8sマニフェストでコンテナイメージのタグを更新し、レポジトリのmaster ブランチに対してPull Request を作成する処理です。

そして、開発者はCIによって作成されたPull Request を自分でApproveし、master にMergeすることで、一連の操作が完了です。

このPull RequestのMergeが、実際に環境へのデプロイ(Podの更新)処理開始のトリガーとなります。

ArgoCDはいずれの環境へもGitHubのmaster ブランチを参照するよう設定しており、ステージング環境であっても、master ブランチにあるマニフェストが正とすることを大切にしています。

詳細

この構成の欠点は、デプロイをトリガーする前に、GCR上にコンテナが出来上がっていることを事前に開発者が確認する必要があることです。

しかし、現在のvivit の開発チーム構成からデプロイの柔軟性を優先して、この構成にしています。

このデプロイ手順については以下の要求に対応できるよう設計しています。

  • 1つのマイクロサービスだけリリースしたい

  • 複数のマイクロサービスを同時にリリースしたい

  • チーム内で管理しているすべてのマイクロサービス(≒プロダクト)を同時にリリースしたい

  • 特定のブランチ(で作成されたコンテナ)を特定の環境にデプロイしたい

  • ステージングや本番への各環境へのデプロイ手順に差異がないようにする

  • いま各環境にデプロイされているアプリケーションのバージョンなど(どのコンテナを使っているか)は、k8sマニフェストから追えるようにしたい

上記条件でデプロイを行うためにgit tag 作成でデプロイのトリガー、git tag の内容によって、どのブランチの、どのマイクロサービスを、どの環境にデプロイするかを制御しています。 具体的なgit tag の内容は以下です。

git tag 命名規則
<プロダクト名>/<環境名>/<年>/<月>/<日>/<時分>-<氏名>-<マイクロサービス(コンマ区切りで複数指定可)>
「ブランチ」の指定は、目的のブランチに移動してからgit tag を行う。
product1 の service1 とservice2 を production 環境にデプロイする時
product1/production/2020/09/29/1234-ijima-service1,service2
product1 のすべてのマイクロサービスをstaging 環境にデプロイする時
product1/staging/2020/09/29/1234-ijima

CircleCI では git tag 付与をトリガーとしてCIを実行できるので、その構成を行い、git tag のパース、指示されたデプロイ内容に基いたマニフェストの更新(コンテナイメージタグ部分)、Pull Request 作成などの処理を実装しています。

最後に

CircleCIなどの具体的なコードは割愛していますが、実際に使っている GitOpsなデプロイの具体的な構成の紹介でした。
デプロイ手順は、会社やチームによって、いろいろな要件が出てくるかと思います。なにかの参考になれば幸いです。

Google Analytics API を使ってディレクトリごとにドリルダウンする

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

リモート環境整備として椅子の検討を数ヶ月間予算や材質など検討した結果、現在バランスボールに座って仕事をしています。 ちなみにvivit社内でも希望者はバランスボールに座って仕事ができます。

弊社が運営しているキャンプ場検索サービス hinata spot で、よく見られているキャンプ場の一覧を作りたいということになり、PVランキングのような仕組みが必要になりました。

幸い当サービスでは特定のディレクトリをドリルダウンしていけば特別に解析タグを用意したりせずにキャンプ場ページのPVランキングを取得できる構造になっているので、今回は Google AnalyticsCore Reporting API を使ってコンテンツのドリルダウンを行います。

developers.google.com

API クエリは Query Explorer で実際にレスポンスを確認することができます。

ドリルダウンでPVを取得する

example.com/hoge/ 配下のPV順を見たいと仮定し、まずはパラメータから出します。

  • start-date: 30daysAgo*
  • end-date: yesterday*
  • metrics: ga:pageviews
  • dimensions: ga:pagePathLevel2
  • filters: ga:pagePathLevel1==/hoge/

* 期間は任意です

filters: ga:pagePathLevel1==/hoge/ で配下を見たいディレクトリを絞り込み、 dimensions: ga:pagePathLevel2 でその直下のディレクトリを見るといった具合です。

ドリルダウンということで更に example.com/hoge/fuga/ 配下を見たい場合は dimensionsfilters を以下のように書き換えます

  • dimensions: ga:pagePathLevel3
  • filters: ga:pagePathLevel1==/hoge/;ga:pagePathLevel2==/fuga/

Google Analytics Webコンソール上でディレクトリを下っていくように、ディレクトリを指定してその直下を見るというイメージで下っていくことができます。

最後に

hinataはWebメディアの枠を超え、実店舗や各種リアルイベントでもアウトドアの良さを発信しはじめました。

vivitでは領域を問わずサービスを成長させていく仲間を募集しています。 www.wantedly.com

参考

jamesdoc.com

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

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

hinata-spot.me

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

github.com

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レンタルというキャンプ用品のレンタルサービスで導入しているチャネルトークでチャットボットを開発した話をします。

チャットボットを作った背景は、弊社の菅谷がnoteに詳細を記載しているので気になった方はこちらも合わせてご覧ください。

note.com

目次

チャネルトークとは

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

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

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

channel.io

チャネルトーク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でアプリケーション開発をしていきたいフロントエンドエンジニアを絶賛採用中です。

www.wantedly.com

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でスコア計測を行ってみてください。