PLAIDのインターンでやったこと ~ KARTE接客の施策の基盤改善の試み

こんにちは。たくりんとん(@takurinton) です。

僕は7月初めから9月末までの3ヶ月間、プレイドでソフトウェアエンジニアとしてインターンをしていました。僕は、フロントエンド開発が好きで、個人開発ではよくフロントエンドのコードを書いていましたが、業務でのフロントエンド開発の経験がなく、就職してエンジニアとして働いていくことに対して不安を持っていました。そんな中、プレイドという会社を知り、フロントエンド開発をするならここだなと感じました。また、色々調べていくうちに事業やフロントエンド以外の技術にも興味が惹かれたため、プレイドのインターンに応募することを決めました。

実際にプレイドのインターンに参加してみて、3ヶ月という短い期間でしたがとても成長することができ、学ぶことも多かったため、僕が学んだことをここに記録したいと思います。

プレイドのインターンについて

まず最初にプレイドのインターンについて簡単に説明します。詳細は Wantedly に書いてあります。

プレイドのインターンは、タイトルからも想像がつく通り、非常に高いレベルが求められます。インターンが始まって数ヶ月は試用期間として扱われ、最初の数ヶ月が終了した時点でメンターさんと採用担当者と面談が行われます。面談までの期間の成果や働き方をみて、継続が難しいと判断された場合には契約の更新が行われない場合があるとのことでした。また、その後も数ヶ月ごとに同じ形式で面談が行われ、その都度、契約を更新していく形になります。そのため、常に気を引き締めて行動することが重要になり、とても成長をすることができます。

自分自身、この仕組みがあったからこそ、インターンに集中することができ、自分の想像以上に成長することができたと思っているので、とてもよかったと思っています。

僕のチーム

プレイドはメインのプロダクトとして、KARTE を提供しています。KARTE は CX(顧客体験) を向上させるための SaaS です。Web サイトに訪問したユーザーの行動を可視化し、Web サイトを運営する企業が自分たちのサービスをより良いものに改善するための補助をします。

KARTE は接客と呼ばれる、自分たちの Web サイトに訪問したユーザーに適したモーダルや埋め込みHTMLを提供することができます。

※以下の画像は接客の例です。

僕が所属していたチームは、この接客を編集して、リアルタイムでプレビューを表示する部分の開発を行なっているチームでした。SaaS としてはかなり特殊で、KARTE を利用するユーザーが JavaScript や Svelte で書かれたファイルを直接編集することができ(※もちろん JavaScript を知らなくても編集することはできます)、そのコードをフロントエンドでビルドして配信するという技術的にかなり面白いことをしているチームでした。元々、プレイドの選考を受ける前に フロントエンドでフロントエンドをビルドする というプレイドのテックブログを読んでいたので、ここの記事に書いてある内容の開発をすることができると思うととてもワクワクしていました。

上で少し触れた、ユーザーが JavaScript や Svelte を記述することができるエディタは以下のようになっています。

実際の接客のコードの一部はこのような形になっています。このように接客に対しての定義をユーザー側でカスタマイズすることができるため、非常に面白いです。この例では、接客を動的に出したり隠したり、またそのアニメーションなどを定義しています。

import type { KarteAction, Variables } from "./gen";
import { props } from "./gen";
import App from "./App.svelte";
import {
  createModalWindow,
  createFog,
  onScroll,
  onTime,
} from "https://cdn.skypack.dev/@plaidev/karte-action-utils"; 

const closeApp = (onClose: () => void) => {
  // 接客を閉じる処理
};

const makeShowApp = (variables: Variables, send: Function) => {
  // 接客を見せる処理
};

// 接客の定義
const action: KarteAction = (options) => {
  const { close, showApp } = makeShowApp(options.variables, options.send);

  closeApp(close);

  options.send("message_open");

  if (props.show_on_scroll && props.show_on_scroll_rate) {
    // スクロール率による表示の変化
    const cleanup = onScroll(props.show_on_scroll_rate / 100, () => {
      showApp();
      return props.show_on_scroll_reenter;
    });
    return () => {
      cleanup();
      close();
    };
  }
  // ... その他の処理など
};

export default action;
接客

僕の行ったタスク

プレイドでは本当にたくさんのタスクを経験させてもらいましたが、大きく分けると以下の3つになります。それぞれ、新しい機能を実装するといった内容のもので、とても刺激的で楽しくこなすことができたと思っています。

接客の細かい修正

インターンが始まってからしばらくの間、KARTE を知ったり、接客とは何かを知ったりするために小さめのタスクを中心にこなしていていきました。

現在、KAETE ではもともとの接客から、新しい接客に仕組みを作り替えている最中で、仕組みを変えてる段階で潜んでいた小さいバグなどを潰す作業をしていました。

その中の一つを紹介すると、任意の接客を、スクロール率によって表示・非表示を切り替えたいけど、プレビュー画面でイベントが動作しないというものでした。上の接客の例でも出てきた onScroll 関数の部分です。

onScroll 関数では、Intersection Observer API を使用してスクロール率を監視していて、変更前は以下のようなコードになっていました。

const scroll = 0.8; // height の80%までスクロールされたら表示したい
const options = {
    root: null,
    rootMargin: '-100% 0px 1000% 0px',
    threshold: 1 - scroll,
}

const observer = new IntersectionObserver((targets) => {
    for (const target of targets) {
      if (target.isIntersecting) {
        // 表示・非表示する処理
      }
    };
}, options);

const targets = document.querySelectorAll('.hoge'); // 監視する要素
targets.forEach(target => observer.observe(target)); 

ドキュメントには options の root に null を渡すとデフォルトでブラウザーのビューポートが使用されると書いてあります。

ターゲットが見えるかどうかを確認するためのビューポートとして使用される要素です。指定されなかった場合、もしくは null の場合はデフォルトでブラウザーのビューポートが使用されます。

そのため、これでいいと思っていたのですが、このプレビューは iframe 内で動作するため、監視対象の要素は iframe 内にあるのに、root が iframe の外のビューポートになってしまい、うまく動きませんでした。このような場合、root には明示的に document を指定する必要があり、そうすることで解決することができました。

(参考:https://w3c.github.io/IntersectionObserver/#intersection-observer-interface)

const options = {
    root: document,
    rootMargin: '-100% 0px 1000% 0px',
    threshold: 1 - scroll,
}

ここに関しては、ブログ(iframe のスクロール | たくりんとんのブログ)にもまとめてあるのでもしよかったら読んでみてください。

接客のサムネイル用のスクリーンショットを撮る

先述した接客を更新したときに、その接客のサムネイル用のスクリーンショットを撮って保存してアップロードするというタスクでした。従来の接客のサムネイル用のスクリーンショットを撮る仕組みは存在していたのですが、新しい接客のスクリーンショットを自動で撮る仕組みはまだなかったので新しく作ることにしました。

動作の流れとしては、以下のようになっています。

もともとあった接客のサムネイルのスクリーンショットを撮る仕組みでは puppeteer が使用されていましたが、せっかく新しいものを1から作るので、スクリーンショットの撮影には playwright を使用することにしました。puppeteer では使用できるブラウザが chromium のみですが、playwright は puppeteer で使用できるブラウザに加えて webkit と firefox を使用することができ、puppeteer のコアチームのメンバーが数人 playwright の開発をしているため、puppeteer に非常に近い書き方でマルチブラウザ対応をすることができます。スクリーンショットのマルチブラウザ対応をしたいことや、将来的には lighthouse によって接客のスコア化がしたいことなどから、今回 playwright を導入しました。

このタスクには1ヶ月くらい時間を使っていたのですが、中でも screenshot コンテナの Dockerfile をどうするかについて悩んでいました。アプリケーションコードはすぐ書くことができ、時間がかからずに実装することができたのですが、それを Docker 上で動かし、さらにデプロイするというところまで含めると、自分の知識では足りない部分がありました。

まずやったこと

そもそも、Docker 周りの知識が浅かったので、まずはドキュメントを読んだり、Docker のパフォーマンスを上げる手法などについて調べました。具体的には以下の記事やリポジトリを読みました。

また、playwright が公式でサポートしてるディストリビューションが Ubuntu ということを確認しました。

(参考:https://playwright.dev/docs/library/#linux

前提として、これから書く内容は、Ubuntu 上で npx playwright install-deps というコマンドを使用すると playwright でブラウザを使用する上で必要な依存関係が全て install されることを把握した上で足掻いた結果です。また、Docker のイメージに関しては、Node.js 用のイメージ があったのでそれを使用していました。

alpine を試す

まずは alpine から試していきました。理由としては、KARTE 全体で見ても、この部分は Docker のイメージのサイズが大きく、build の際にボトルネックになっていると感じたからです。そのため、少しでも小さいサイズのイメージを使用しようと思いました。

しかし、alpine では必要なドライバが足りず、うまく構築することができませんでした。全てのドライバを網羅的に手動で install するのは無理な話で、さらに手動で install してイメージのサイズが大きくなっても本末転倒なので、この作戦はやめることにしました。また、マルチステージビルドを使用して、Ubuntu で playwright を install して動くようにしたもののバイナリを alpine に持たせることも試したのですが、こちらもうまくいきませんでした。個人的に takurinton/playwright_with_alpine というリポジトリを作成し、同じような状態を再現して実験しましたが、同様にうまくいきませんでした。エラーの内容は各プルリクエストのコメントに記載してあります。

ここは時間の都合でしっかり調べられていないのですが、build はうまくいき、ランタイムでエラーが出るので build とは別に、実行するときに足りないものがたくさんあるのではないかなと思います。

debian を試す

次に、debian と debian-slim で動かそうとしました。しかし、これも alpine と同じように足りないものが多く、動かすことができませんでした。

Ubuntu にたどり着く

やはり公式がサポートしてる環境に戻ってきました。playwright とそこに依存するドライバを install したところ、無事開発環境で動作することが確認できました!

アプリケーションのコード

ここまで触れてこなかった、アプリケーションのコードについて少しだけ触れます。そもそも、playwright は E2E テストを回したり、GitHub Actions などの CI で使用することが多いですが、今回はランタイムで使用するという割とレアなケースだったのかなと思っています。接客が保存された時に、それがトリガーとなり動作し、画像をアップロードする仕組みは非常に面白く、コードを書いていて楽しかったです。

実際にスクリーンショットを撮る部分のコードは以下のようになっています。この部分は express で書かれていて、リクエストが来るとこの関数が呼ばれて処理が走ります。body には、スクリーンショットを撮りに行くリンク(上の図でいう thumbnail server の場所)と、スクリーンショットを撮りたいセレクタが渡されます。指定されたリンクの指定されたセレクタの部分のスクリーンショットを撮影して、レスポンスとして画像の buffer を返します。

import { chromium, firefox, webkit, Browser, Page } from 'playwright';
import { Request, Response, NextFunction } from 'express';
import logger from '@plaidev/logger';

// スクリーンショットを撮りにいく
export const takeScreenshotBlitz = async (req: Request, res: Response, next: NextFunction) => {
  let browser: Browser | undefined;
  let page: Page | undefined;
  try {
    const { url, selector } = req.body;

    if (!(typeof url === 'string' && typeof selector === 'string')) {
      const err: any = new Error('param is invalid');
      err.status = 400;
      return next(err);
    }

    logger.log('taking screenshot...');
    const args = ['--disable-setuid-sandbox', '-disable-dev-shm-usage'];
    if (process.env.DEVELOPMENT) {
      args.push('--ignore-certificate-errors');
    }

    let images: { data: Buffer; browserType: string }[] = [];
    for (const pwBrowser of [
      { type: chromium, executablePath: process.env.PW_CHROME },
      { type: firefox, executablePath: process.env.PW_FIREFOX },
      { type: webkit, executablePath: process.env.PW_WEBKIT },
    ]) {
      // webkit は上の引数を渡すとエラーになるので分岐してる
      if (pwBrowser.type === webkit) {
        browser = await pwBrowser.type.launch({
          executablePath: pwBrowser.executablePath,
        });
      } else {
        browser = await pwBrowser.type.launch({
          executablePath: pwBrowser.executablePath,
          args,
        });
      }

      const context = await browser.newContext({
        ignoreHTTPSErrors: true,
      });

      page = await context.newPage();
      page.setDefaultTimeout(8000);

      await page.goto(url);
      await page.waitForSelector(selector);

      const element = await page.$(selector);
      if (!element) throw new Error(`selector ${selector} is not found`);
      const img = await element.screenshot();

      images.push({ data: img, browserType: pwBrowser.type.name() });
      await browser.close();
    }

    logger.log('took a screenshot!');
    res.send(images);
  } catch (err) {
    if (page != undefined) {
      page.close().catch(err => {
        logger.error(err);
      });
    }
    if (browser != undefined) {
      browser
        .close()
        .then(() => {
          logger.log('screenshot failed, browser close succeeded');
        })
        .catch(err => {
          logger.error(err);
        });
    }
    next(err);
  }
};

そこまで難しい処理をしているわけではないのですが、Docker の環境を作る部分でだいぶ時間を使ってしまったので、個人的には大変な実装だったように感じます。しかし、リリースすることができた時はとても嬉しかったですし、充実感もとてもあったのでやってよかったなと思っています。

ユーザー情報変数の構成を GraphQL にする

ユーザー情報変数とは、KARTE の接客を表示する際に、そのユーザーに関する情報を自由に接客に埋め込んで配信することができる機能のことです。この部分はこれまでは KARTE の独自実装になっていました。

ここからの内容は絶賛策定中で、確定事項ではないのですが、これではクライアントのエンジニアがユーザー情報変数を使用する際に独自仕様だとフレンドリーではないので、オープンソースとして一般化されている GraphQL の形でやりとりをすることができるようにしようという方向で話が進んでいて、ユーザー情報変数の構成を GraphQL で実装してみようということになりました。

ここは、自分がアサインされる前に mizchi さん(@mizchi)と、インターンのとさくん(@tosa_now)が中心となって仕様を考えてくれていました。自分はその仕様について話したり、その仕様に沿って実装を行ったりしていました。

使用技術や構成について

ここのアプリケーションの部分は、ほぼゼロから1人で作りました。技術選定も一応ですが行い、現在チームとして React 化を進めているということもあり React を使って書くことにしました。また、バンドルには Vite、UI には chakra-ui を使用することにしました。

Vite は KARTE の他の部分でも多数使われていたのもあるのですが、それ以外にも個人的に好みで、翻訳プロジェクトも立ち上げてるので使ってみました。ちなみにこのレポジトリは今は本家に移行されています。

chakra-ui は、自分が使ったことがなかったため、興味本位で入れてみました。linaria という線も考えたのですが、style を書きたくないこと、props が渡せないこと、他の部分が chakra-ui を使用していることなどから chakra-ui にしました。

また、この部分のプロトコーディングは ブログ(GraphQL の parse エディタ | たくりんとんのブログ) にまとめてあります。ここでは Preact を使用していますが、大体同じような構成になってると思います。

実装について

実装についてですが、9月中にできるところまで頑張ったのですが、実装は終わらなかったので引き継ぎをして退社しました。form に入力した値からランタイムで GraphQL の query を生成するということをしています。試作ですが、以下のようなモーダルの中に form があり、その form に値を入力すると動的に query が生成されるようになっています。

具体的には、React の Context API を使用して状態を管理しています。以下のような context があります。今回、query の引数である key と、欲しい値を絞るための period を更新する必要があったので、それぞれの update 用の関数を定義しています。

export const TransformerContextProvider = ({
  children, // React component
  root, // 全体の DocumentNode
  onChangeNode, // 状態の変更用の関数
}: {
  children: React.ReactNode;
  root: DocumentNode;
  onChangeNode: (root: DocumentNode) => void;
}) => {
  const api: TransformerContextType = {    
    onUpdateAST(key, period) {
      const newNode = visit(root, { 
        // 期間指定を更新する
        Field: field => {
          if (
            field.name.kind === 'Name' &&
            getPeriods()
              .map(p => p.name)
              .indexOf(field.name.value) !== -1
          ) {
            return {
              ...field,
              name: {
                ...field.name,
                value: period,
              },
            };
          }
        },
        
        // 変数の情報を更新する
        ObjectField: of => {
          if (of.name.value === 'key') {
            if (of.value.kind === 'StringValue') {
              return {
                ...of,
                value: {
                  ...of.value,
                  value: key,
                },
              };
            }
          }
        },
      });
    }
  };
  return <RendererContext.Provider value={api}>{children}</RendererContext.Provider>;
}

これを、form を定義してるコンポーネントで囲って、useEffect で form の state が更新されるたびに呼ぶことで、AST を動的に更新し、query も同時に更新します。

const api = useTransformerContext();

...

useEffect(() => {
  api.onUpdatePeriod(period);
  api.onUpdateKey(key);
}, [state]);

...

このような形で、ランタイムで query を変更して反映する実装を行っています。実装自体は中途半端なところで終わってしまいましたが、ここはこれから面白くなる部分だと思うので、退社後も注目していきたいなと思っています。

プレイドのインターンで学んだこと

プレイドのインターンを通して、非常にたくさんのことを学ぶことができました。その中でも、大きく分けて2つのことを学ぶことができたと思っています。

1人で悩む時間の大切さ

僕は普段、わからないことがあったら時間を決めて質問をするようにしていました。例えば15分1人で考えてわからなかったら誰かに質問をしようといった具合です。しかし、プレイドに入社してから面談をした際に、自分で悩む時間もあって良くて、自分1人で解決することができる幅を増やすという意味でも大切だということを言われてから自分で悩む時間も大切にするようになりました。

実際に、1人で開発する時間を増やしてから、自分で解決する能力がついたと同時に、関係ないコードも読んでみたり、中身をデバッグしてより理解を深めることでプロダクト全体の理解に繋げることができ、とても良かったと思っています。インターンの目的として、戦力としてプロダクトにコミットすることももちろん大事ではあるのですが、それよりもインターン生の成長を大切にしているということを感じました。

周りを巻き込んで開発することの大切さ

上の1人で悩む時間を大切にするということと少し矛盾してしまいますが、周りを巻き込んで開発することの大切さも学ぶことができました。

これはプレイドのいい点なのですが、Slack で投げかけると大体誰かしらが拾ってくれます。例えば、以下のように何かわからないことがあったときに質問をすると、誰かしらが拾ってくれて、解決に導くことができます。質問をした時がいい時と、1人で悩んだ方がいい時の境界線としては、このように知ってる人に聞いた方が早い時には質問をしてしまう、逆に、コードを読む段階だったり、アルゴリズムや最適化について悩んでいる時は自分自身で考えて1人で悩む時間を大切にした方がいいのではないかなと思います。

今回、人を巻き込んだ例として、片居木さん(@mkataigi)と、2020年にインターン生からプレイドの正社員となった大矢さん(@Kosuke Oya)さんと一緒に調査をしたというものがありました。具体的な内容としては、先述した接客のスクリーンショットを撮るというタスクが 検証環境でのみ動作がおかしかったのでそこの調査を一緒にやっていただきました。

(調査スレが立った、すごいメッセージ量)

まず、検証環境の redis のログを確認しました。ログを確認したところ、そもそも karte job の部分が動いていないことがわかりました。開発環境では lazydocker を使用してログを観察していて、そこでは動いてることが確認できていました。

次に、どこまで処理が走ってるかを確かめるためにログを埋め込んでみました。その結果、やはり redis の手前で止まっていることがわかりました。

redis がおかしいことはわかったので、次は環境変数まわりを疑い始めました。それでもいまいちわからず...。

次に、redis-cli を使用してコマンドを叩く作戦にしました。しかし、その際、パスワードの認証が通らないことがわかりました。

ここで、パスワードを指定している環境変数が本番環境にはあるけど、検証環境にはないということがわかりました。

環境変数を設定したら、うまく動きました!!!

結果として、検証環境に redis のパスワードを入れてるはずの環境変数が定義されていなかったことが原因で動作していなかったのですが、調査の段階でさまざまな可能性をつぶしていくことによって解決まで導くことを肌で感じることができ、非常に勉強になりました。自分1人では絶対に解決できなかった問題だったと思うので、人を巻き込んで、有識者に頼りながらうまく開発をすることの重要性を学ぶことができました。

インターンを終えて

プレイドでのインターンを終えて、とても成長できたと感じていますし、自分の中で殻を破ることができたと思っています。3ヶ月という短い期間でしたが、さまざまな経験をさせていただき、感謝の気持ちしかありません。当初、自分の中であった、フロントエンドの実務経験がないという不安はすっかりなくなり、これからのキャリアを歩むにあたっての自信になりました。また、技術力以外の部分でも、働き方やコミュニケーション能力、技術に対する向き合い方など様々な面で成長することができたと思っています。

チームメンバーの皆さんや、一緒に開発してくれた方々をはじめ、プレイドの社員の方々、インターン生のみんな、ありがとうございました。とても充実した時間を過ごすことができました。

※チームメンバーの皆さんと写真撮影

※社員の方々と写真撮影

最後に

CX(顧客体験)プラットフォーム「KARTE」を運営するプレイドでは、KARTEを使ってこんなアプリケーションが作りたい! KARTE自体の開発に興味がある!というエンジニア(インターンも!)を募集しています。0詳しくは弊社採用ページまたはWantedlyをご覧ください。 もしくはお気軽に、下記の「話を聞きに行きたい」ボタンを押してください!