petitviolet_blog

@petitviolet blog

HTTPリクエスト/レスポンスを記録してリプレイするためのruby gem作った

RubyGem作ってみよう、Rackミドルウェア書いてみよう、の習作。

みたいな使い方。

実用上は、Goで書かれたgoreplayなど、もっと便利なツール群がある。 名前はこれをオマージュしてつけた。

どんなものか

使い方は大体こんな雰囲気でconfig.ruとかに書く。

use Rack::Rreplay.Middleware(directory: './tmp', format: :json),
    { sample: 5, extra_header_keys: %w[X-ACCESS-TOKEN], debug: true }

HTTPリクエスト/レスポンスのdumpを吐き出すdirectoryとformat(:msgpack or :json)を与えつつ、オプションをいくつか渡せるようになっている。 この設定であればHTTPリクエスト5回につき1回dumpするようになっている。

出力結果はJSONなら例えばこんな感じ。

{
  "request": {
    "body": null,
    "headers": {
      "X-ACCESS-TOKEN": "eyJhY2Nlc3MtdG9rZW4iOiJGdkNyczdnT0pLYUN3SU5lTWNmMjVnIiwiY2xp%0AZW50IjoiZnlsc01qMHlfYTl3cHVpWmJiWUJodyIsInVpZCI6ImFsaWNlQGV4%0AYW1wbGUuY29tIn0%3D%0A",
      "content-type": "application/json",
      "cookie": null,
      "user-agent": "petitviolet curl"
    },
    "method": "GET",
    "path": "/api/whoami",
    "query_strings": ""
  },
  "response": {
    "body": "{\"id\":\"f7fb4b22-1792-477f-ab28-cd36491fd09f\",\"name\":\"alice\"}",
    "headers": {
      "Cache-Control": "max-age=0, private, must-revalidate",
      "Content-Type": "application/json; charset=utf-8",
      "X-Access-Token": "...",
      ...
    },
    "status": 200
  },
  "response_time": "0.395582",
  "time": "2019-12-30T23:43:41+09:00",
  "uuid": "c80468a9-5424-4a17-a1bc-6ab325252d36"
}

このログを使ってHTTPリクエストをリプレイするとこんな感じになる

$ bundle exec rreplay 'http://localhost:3000' ./tmp/rreplay.log.json -f json --verbose | jq -S .
{
  "response": {
    "actual": {
      "response": {
        "body": "{\"id\":\"f7fb4b22-1792-477f-ab28-cd36491fd09f\",\"name\":\"alice\"}",
        "headers": {
          "Cache-Control": "max-age=0, private, must-revalidate",
          "Content-Type": "application/json; charset=utf-8",
          "X-Access-Token": "...",
          ...
        },
        "status": "200"
      },
      "response_time": "0.029326"
    },
    "recorded": {
      "record": {
        "body": "{\"id\":\"f7fb4b22-1792-477f-ab28-cd36491fd09f\",\"name\":\"alice\"}",
        "headers": {
          "Cache-Control": "max-age=0, private, must-revalidate",
          "Content-Type": "application/json; charset=utf-8",
          "X-Access-Token": "...",
          ...
        },
        "status": 200
      },
      "response_time": "0.395582"
    }
  },
  "time": "2020-01-02T22:35:47+09:00",
  "uuid": "c80468a9-5424-4a17-a1bc-6ab325252d36"
}

このように、あるリクエストをもう一度投げた時にデグレしてないかとかレスポンスタイムに大きな差がないか、というのをいい感じに頑張ればテストや性能検証とかに使えないかな〜とは思っているが何も実装はしていない。

実装について

Rackミドルウェアという都合上、initialize(app, ...)call(env)が定義されたクラスであることが求められる。 HTTPのリクエストとレスポンスを吐き出していくためファイルのローテーションをやりたいというモチベーションが湧いてきて、initializeするたびにLoggerをnewするのは流石に避けたかったのでClass.newミドルウェアを作ることにした。

use Rack::Rreplay.Middleware(directory: './tmp', format: :json),
    { sample: 1, extra_header_keys: %w[X-ACCESS-TOKEN], debug: true }

このようにconfig.ru等に書くとミドルウェアが有効になるが、Rack::Rreplay.MiddlewareはClassオブジェクトを返す関数となっていて実装はこのあたりrreplay/rreplay.rb

関数の引数に与えたディレクトリ名を使ってログローテーションするLogger(正確にはLogger::LogDevice)のインスタンスをnewして使い回すためにClass.newで作ったクラスのクラス変数にloggerを埋め込むというやり方をしている。 クラス変数、というだけでなるべく使いたくない気持ちにはなるので別の方法があればよかったが思いつかなかった。

また、JSONとMessagePackの2種類のフォーマットをサポートしてみている。JSONをMessagePackにして少し圧縮しているだけだが。 MessagePackはUTF-8なStringではないのでbinaryとしてファイルに書き込む必要があり、内部的に使用しているLogger::LogDeviceのコンストラクタにbinmode: trueを渡すことで動くようになる。

Loggerを使い回すという観点だとClass.newではなくLoggerをユーザ側から差し込んでもらう方がシンプルにはなるが、こういったフラグ管理などを考えるとこれで良かったのかもしれない。(一応差し込めるようにはなっている)

ReactからAceEditorを使ってリッチなエディタを表示する

これはなに

リッチなエディタとしてajaxorg/aceというものがあり、Reactから使おうとするとsecuringsincity/react-aceが便利。

react-aceが提供するコンポーネントをラップするような独自のコンポーネントを実装する。

具体的には、以下の三点。

  • ファイルタイプに応じて動的にrequireする
  • React hooksを使う
  • readonlyモードを用意する

実装

大した量でもないので実装を貼り付ける

import * as React from 'react';
import AceEditor from 'react-ace';

// AceEditor周りで使うデータ
export type EditorProps = {
  fileType: string;
  contents: string;
  readOnly: boolean;
  onChange?: (content: string) => void;
  theme?: string;
};

const MIN_LINE = 10;
const MAX_LINE = 50;

const DEFAULT_THEME = 'monokai';

export const EditorComponent = (props: EditorProps) => {
  const theme = props.theme || DEFAULT_THEME;

  // load theme
  React.useMemo(() => {
    try {
      require(`ace-builds/src-noconflict/theme-${theme}`);
    } catch (e) {
      console.log(`error new theme(${theme}): ${e}`);
    }
  }, [props.theme]);

  // load mode
  React.useMemo(() => {
    if (props.fileType == null) {
      return;
    }
    try {
      require(`ace-builds/src-noconflict/mode-${props.fileType}`);
    } catch (e) {
      console.log(`error new mode(${props.fileType}): ${e}`);
    }
  }, [props.fileType]);

  const onChange: (string) => void = props.onChange ? props.onChange : () => {};

  if (props.readOnly) {
    return (
      <AceEditor
        mode={props.fileType}
        theme={theme}
        value={props.contents}
        width={null} // nullにしておくとwidthを外から変えられる
        minLines={MIN_LINE}
        maxLines={MAX_LINE}
        readOnly={true}
        focus={false}
        highlightActiveLine={false}
        enableBasicAutocompletion={false}
        onLoad={editor => {
          // readOnlyでも表示されるカーソルを消す
          editor.renderer.$cursorLayer.element.style.opacity = 0;
        }}
      />
    );
  } else {
    return (
      <AceEditor
        mode={props.fileType}
        theme={theme}
        value={props.contents}
        width={null}
        minLines={MIN_LINE}
        maxLines={MAX_LINE}
        readOnly={props.readOnly}
        enableBasicAutocompletion={true}
        onChange={onChange}
      />
    );
  }
};

動的なtheme, modeの読み込み

aceで用意されているthemeやmodeはここから見ることが出来る。 https://github.com/ajaxorg/ace-builds/tree/master/src-min-noconflict

本当にこれでいいのかはよくわからんが、ace-builds/src-noconflict/配下をrequireすることで動的に読み込み出来ている様子。

そしてtheme, modeの読み込みにはReact.useMemoを使用しているが、初期化が遅いとかではなく副作用を起こすようなものなのだからReact.useEffectの方が適切では?と思うかもしれないが、実行タイミングが異なり、useMemoは同期的だがuseEffectコンポーネントの初期化が終わったあとに実行される。
ドキュメントにもあるように従来のcomponentDidMountと同じに思えばよい。 そのためAceEditorコンポーネントレンダリングする前にthemeやmodeは読み込み終わっていてほしいため、useEffectだとタイミングが遅すぎるため、useMemo(あるいはuseContext)の方が適切となる。

readonlyモード

readOnlytrueを与えればreadonlyにはなるが、それは単に更新は出来ないだけでカーソルはフォーカス出来てしまう。 そこでonLoadでstyleを弄って透明にしてしまうことで見た目上はフォーカスされていないように見せかけることで対応。

readOnly={true}
focus={false}
highlightActiveLine={false}
onLoad={editor => {
  // readOnlyでも表示されるカーソルを消す
  editor.renderer.$cursorLayer.element.style.opacity = 0;
}}

随分古いissueのコメントを参考にした。 Option to make editor disabled · Issue #266 · ajaxorg/ace

react-routerで同じComponentにLinkで遷移したときにstateがリフレッシュされない

まとめ

Routeに異なるkeyを設定する

<Route exact key={'new'} path={'/posts/new'} component={EditPostComponent} />
<Route exact key={'edit'} path={'/posts/:id/edit'} component={EditPostComponent} />

<li>などでリストを表示したときにkeyを設定してないとwarningが表示されるが、それと同じことが起きていると思えばよい。 https://reactjs.org/docs/lists-and-keys.html#keys

サンプル

コードを貼り付けるとこんな感じ。

import * as React from 'react';
import {Link, Route, Router, Switch, useLocation} from 'react-router-dom';

const MyComponent = (props) => {
  const current = useLocation().pathname;
  const next = current == '/a' ? '/b' : '/a';
  const [counter, setCounter] = React.useState<number>(0);
  return (
    <>
      <div>
        <div>Counter: {counter}</div>
        <button onClick={() => setCounter(counter + 1)}>Increment</button>
      </div>
      <Link to={next}>Current: {current}, Next: {next}</Link>
    </>
  );
};

const App: React.FC = () => {
  const history = require('history').createBrowserHistory;
  // 同じMyComponentを表示する異なるパスがある
  return (
    <Router history={history()}>
      <Switch>
        <Route exact path={'/'} render={() => <Link to={'/a'}>To /a</Link>} />     
        <Route exact path={'/a'} component={MyComponent} />
        <Route exact path={'/b'} component={MyComponent} />
      </Switch>
    </Router>
  );
};

動かしてみると、パスが変わってもstateが保持されてしまっているのがわかる。 CodePenのとはimportしてたりわずかに違うが、コードとしては同じ。

See the Pen React Router missing key by petitviolet (@petitviolet) on CodePen.

先程のコードのRoutekeyを追加してみる。

-       <Route exact path={'/a'} component={MyComponent} />
-       <Route exact path={'/b'} component={MyComponent} />
+       <Route exact key={'a'} path={'/a'} component={MyComponent} />
+       <Route exact key={'b'} path={'/b'} component={MyComponent} />

動かしてみると、期待しているように動いているのがわかる。

See the Pen React Router missing key by petitviolet (@petitviolet) on CodePen.