突撃!隣のキーボード 2021

はじめに

フロントエンドエンジニアの関(@kur0buchi)です。

新型コロナウイルス感染症(COVID-19)により世の中の様々なコトモノが変わっていった2020年も明けたものの、未だ家にいる方が長い日を過ごす方も多いのではないかと思います。在宅での仕事を快適にしようとデスク周りの整備に務める方も多いのではないでしょうか。

今回はパソコン仕事には欠かせない「キーボード」にフォーカスして弊社の技術開発部にアンケートを取りました!
長引くテレワークで気分転換が欲しいという方も、一番多く触れるであろうキーボードにちょっとこだわってみてはいかがでしょうか。

本記事は同名タイトルで様々な企業様が書かれている記事に便乗したものとなっております。以下にその一部を紹介しますので、興味が湧いたという方は以下の記事もご覧になってください。

www.m3tech.blog

tech.fusic.co.jp

blog.yushakobo.jp

メンバーのキーボード

村山

f:id:KeytacK:20210210143105j:plain

ひとこと

キーボードの打鍵が気持ち悪くなければあと何でも大丈夫です!

筆者コメント

我らが技術開発部マネージャー、村山のキーボードは「孤高の遺伝子を受け継ぐ真の最高峰」でおなじみ Happy Hacking Keyboard (通称 HHKB) です!
実は弊社開発部は HHKB ユーザが結構多く、一時期並んだ机が一列 HHKB といった状況があったりしました。
「打鍵が気持ち悪くなければあと何でも」と言って HHKB を選ぶあたりなんとなくこだわりも感じますね!(?)

ijima

f:id:KeytacK:20210210191655j:plain

ひとこと

静電容量無接点方式を試したかったのと、テンキーがほしかった

筆者コメント

こちらはエンジニアのみならず弁護士や小説家など、幅広い分野で人気(公式サイトより)の静電容量無接点方式キーボード、東プレ REALFORCE for Mac です!
待望の Mac 向けキーボードとして話題になったキーボードですね、テンキーレスモデルも人気ですが、インフラエンジニアとしてパラメータ操作等数字を扱うことが多いとフルサイズも欲しくなりますね!

shimar

f:id:KeytacK:20210210143927p:plain

ひとこと

カラーキートップを着けて使っています

筆者コメント

こちらは HHKB にオプションのキートップを着けてますね!
HHKB はメカニカルキーボードと違ってキーキャップが特殊でアレンジがしにくいイメージがありますが、公式でカラーキートップを出していたりするので結構遊べますね!

小石

f:id:KeytacK:20210210144140j:plain

ひとこと

中古のHHKBです。会社とキーボード揃えたかったので買いました。満足してます。
写真のキッチンタイマーはポモドーロタイマーとして活用してます。

筆者コメント

リモートワークに合わせて HHKB を買い足すという人も……!
一般的なキーボードと比べるとキー数も少なく Professional モデルになると方向キーまでなくなってしまったりと「使いづらそう」というイメージがあったりもしますが、慣れてしまうとコードを書くのに凄く合理的な作りだったりもするので手放せなくなるキーボードですね!

@taroodr

f:id:KeytacK:20210210144242j:plain

ひとこと

つい一週間ほど前に購入しました。
手首が痛むので体への負担がなるべく少ないものを選んで使っています。

筆者コメント

キーボードは静電容量タイプだけではありません! Mistel BAROCCO はメカニカルスイッチ(写真は CHERRY MX 静音赤軸モデル)採用の分割キーボードです!
巷では「肩が開いて人間工学的に良い」なんて言われたりする分割キーボードですが、実際自分の思う自然な姿勢でタイプができるためかなりオススメです。

これがやりたかっただけだろのコーナーです

f:id:KeytacK:20210210144700j:plain

Lily58 + Domikey SA Dolch Orange + DUROCK T1
いわゆる「自作キーボード」と呼ばれる、キットを購入して自分で組み立てるタイプのキーボードを使っています。
私が使っているのは Lily58 という分割式のキーボードで、会社用と自宅用で2台作るほど愛用しています。
少ないキー数と特徴的な配列で限りなく最小限の手の動きで全てのキーをタイプできるよう設計されているので、慣れたときの快適さは言葉では言い表せられません。
細かく紹介していくとキリがないのでいずれ機会があれば。

f:id:KeytacK:20210210144825j:plain

FILCO Majestouch 2SS TKL
今回のアンケートでは静電容量無接点方式のキーボードが多かったので、どうしても紹介したいメカニカルキーボードをもう一つ紹介します。
王道一直線のメカニカルキーボードといえばこの Majestouch シリーズかと思います。軸も多種多様でサイズもフルから60%まで用意されているので、買ったことないけど外付けキーボードが気になるという方にお勧めしやすいシリーズです。
(写真は2021年1月に発売されたばかりのスピード銀軸モデル)

他にも……

実は以前にもアンケートを実施し、筆者都合で記事化には至らなかったキーボードの写真をご紹介します!
ご協力頂いた方にはこの場をお借りしてお詫びと感謝を申し上げます
m(_ _)m

f:id:KeytacK:20210210145042j:plain

f:id:KeytacK:20210210145118j:plain

f:id:KeytacK:20210210145204j:plain

f:id:KeytacK:20210210145245j:plain

おわりに

今回紹介したキーボードは高額なキーボードばかりでしたが、もっと安価で使い勝手のいい高コスパなキーボードもたくさんあります!
在宅ワークが捗らないなぁと感じる方も是非外付けキーボードをご検討してみてはいかがでしょうか?

求人

vivitではコダワリ派のあなたの応募もお待ちしております

www.wantedly.com

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