SYM's Tech Knowledge Index & Creation Records

「INPUT:OUTPUT=1:1以上」を掲げ構築する Tech Knowledge Stack and Index. by SYM@設計者足るため孤軍奮闘する IT Engineer.

Knowledge Stack & Index (全記事一覧)

目指していた INPUT:OUTPUT = 1:1以上 の理想形は Link & TechTips Stack の形なのかもしれない(※仮挿入) 本ブログと並列運用(更新不定期)


本ページは投稿記事一覧です。 (自動更新)

※ 旧ページ:SYM's Knowledge Index 徐々に移行予定

自作ツール(Githubリンク) により本ブログへの投稿/更新はほぼ自動化

React + GraphQL + Pagination 実装 & コンポーネント分割

React + GraphQL + Pagination 実装 & コンポーネント分割

作成物と本記事で触れること概要

以下を使って

(開発中のフロントエンドアプリ(と言うよりはツールレベル)のほんの1部品でしかないが) Github の Repository 一覧表示&Pagination ができるコンポーネントを作っている

(他にももう少し機能追加したり装飾を良くする予定だが) 現時点では、pagination 以外にも

  • 1 ページ辺りの表示件数変更 (右上の items per page)
  • ピン留め(右端のピンを ON にすると1ページ目の先頭に固定)
  • ソート(左上の order by)

をできるようにしている。これを実装するにあたって悩んだこと 以下2つに触れる

Github GrapQL API 利用での pagination 実装

今回訳あって、自前で pagination の仕組みを実装した。

Github GrapQL API さんは、そもそも pagination の仕組みをサポートしているため、以下のような指定でき、やろうと思えば簡単に実現できると言えばできる。

  • 何件取得するか
  • ソート
  • どこから取得するか (cursor 指定)

ただし、「2 ページ先、3 ページ先、... のデータ取得」ができない(1ページ前後のデータ取得はできる)

ユーザビリティの観点から、これがアプリでできないことを許容しきれず、実現方法を考えた結果が以下3つ。

  • 最初に全件取得して保持する
    • デメリット:
      • データ多量の場合、パフォーマンス劣化に繋がる恐れがある
      • API を複数回実行する必要があるため、コンポーネントの読込中かの状態を自前で管理する必要がある(実装コスト増)
  • 最初に全件取得して各ページの先頭の cursor を保持する(ページ遷移時は、そのページの先頭の cursor を指定して GraphQL API を実行しデータを取得する)
    • デメリット:
      • ソートや1ページ辺りの表示件数が変更された際に全件再取得が必要(キャッシュがあるので最初の1回のみ)
        • 初めてその操作を行われた時に取得とすると、途中で Repository が作られた場合に、条件次第で表示されたりされなかったりする Repository ができてしまう (データ不整合)
        • それを回避しようとキャッシュの有効期限を短くしてしまうと、操作する毎に頻繁に読込中になり、ユーザビリティが落ちる
  • そもそも GraphQL API 使うのを諦めて REST API を使う
    • デメリット:
      • 要らないフィールドも一緒に沢山取れてしまう(パフォーマンス劣化)
      • 実現したいことにより1度に複数の API を使用する必要が出た際に面倒になる(実装コストが上がる)
      • ページ遷移のために都度 API 実行のため読込中になる(ユーザビリティは 1 点目より劣る)

この中から今回は、ユーザビリティ優先で、1 点目の「最初に全件取得して保持する」とした。

デメリットの「データ多量の場合、パフォーマンス劣化に繋がる恐れがある」に関して、特に初期描画の場合、全データ取得してから読込中解除 → 操作可能としたのでは、データ多量の場合に操作可能になるまでに時間を要することが考えられる。それを回避するために以下の工夫した。

  • 初回描画時、1 ページ目に表示する内容のみを取得し完了すれば読込中解除して一覧を表示。
  • 全件データ取得は別途実行し、取得完了しても一覧の表示を更新しないよう useRef を使用して保持(useRef を使用すれば更新しても再レンダリングは発生しない)
  • 全件データ取得が終わるまでは、(1 ページ目に表示する内容のみ取得が済めば一覧表示されるが)pagination の機能は無効化
export const CheckableLineBoxesPagination = ({
  selectedItems,
  setSelectedItems,
  fetchFirstPageData,
  fetchAllPageData,
  currentViewItems, // 現在のページに表示するデータのみ持つstate
  initCurrentViewItems,
  updateCurrentViewItems,
  children,
}: CheckableLineBoxesPaginationProps) => {
  const [activePage, setActivePage] = useState(1); // 現在のページ
  const [totalPages, updateTotalPages] = useTotalPages(); // ページ数合計
  const [itemsPerPage, setItemsPerPage] = useItemsPerPage(20); // 1ページ辺りの表示件数
  const [isLoading, setIsLoading] = useState(true);
  const [enabledPagination, setEnabledPagination] = useState(false);
  const allItems = useRef<CheckableLineData[]>([]);

  useEffect(() => {
    // 1ページ目のデータのみ取得するための処理
    const initialize = async () => {
      const data = await fetchFirstPageData(itemsPerPage);
      if (data) {
        initCurrentViewItems(data.items);
        updateTotalPages(data.totalCount, itemsPerPage);
      }
      setIsLoading(false);
    };
    // 全件データ取得するための処理
    const fetchAllData = async () => {
      const repos = await fetchAllPageData();
      allItems.current = repos;
      setEnabledPagination(true);
    };
    initialize();
    fetchAllData();
  }, []);

  // 省略 全体は次節参照
};

pagination + α 実装コンポーネントの分割

Before

リポジトリ一覧表示(pagination 付)の当初ソース

※この時点は、ソート機能未実装で、リポジトリ名昇順固定

type Props = {
  selectedRepositories: string[];
  setSelectedRepositories: (items: string[]) => void;
  fetchFirstPageRepositories: (
    itemPerPage: number
  ) => Promise<{ items: CheckableLineData[]; totalCount: number } | undefined>;
  fetchAllRepositories: () => Promise<CheckableLineData[]>;
};

export const RepositoryList = ({
  selectedRepositories,
  setSelectedRepositories,
  fetchFirstPageRepositories,
  fetchAllRepositories
}: Props) => {
  const [pinnedRepoNames, getPinState, togglePin] = usePinnedRepos();

  const sortPinnedReposToTop = (repos: CheckableLineData[]): CheckableLineData[] => {
    const pinnedRepos = repos.filter((repo) => pinnedRepoNames.includes(repo.value)).sort(sortLogic);
    const nonPinnedRepos = repos.filter((repo) => !pinnedRepoNames.includes(repo.value)).sort(sortLogic);
    return [...pinnedRepos, ...nonPinnedRepos];
  };
  const [currentViewItems, initCurrentViewItems, updateCurrentViewItems] = useCurrentViewItems(sortPinnedReposToTop);

  return (
    <CheckableLineBoxesPagination
      selectedItems={selectedRepositories}
      setSelectedItems={setSelectedRepositories}
      fetchFirstPageData={fetchFirstPageRepositories}
      fetchAllPageData={fetchAllRepositories}
      currentViewItems={currentViewItems}
      initCurrentViewItems={initCurrentViewItems}
      updateCurrentViewItems={updateCurrentViewItems}
    >
      {currentViewItems.map((item, index) => {
        let marginStyle = { margin: '0.5rem' };
        if (index === 0) {
          marginStyle = { margin: '0.1rem 0.5rem 0.5rem 0.5rem' };
        } else if (index === currentViewItems.length - 1) {
          marginStyle = { margin: '0.5rem 0.5rem 0.1rem 0.5rem' };
        }
        return (
          <CheckableLineBox
            key={item.key}
            value={item.value}
            title={item.title}
            subText={item.subtext}
            style={marginStyle}
            suffixNode={<TogglePin pinned={getPinState(item.value)} togglePin={() => togglePin(item.value)} />}
          />
        );
      }
    </CheckableLineBoxesPagination>
  );
};
type CheckableLineBoxesPaginationProps = {
  selectedItems: string[];
  setSelectedItems: (items: string[]) => void;
  fetchFirstPageData: (
    itemPerPage: number
  ) => Promise<{ items: CheckableLineData[]; totalCount: number } | undefined>;
  fetchAllPageData: () => Promise<CheckableLineData[]>;
  currentViewItems: ReturnType<typeof useCurrentViewItems>[0];
  initCurrentViewItems: ReturnType<typeof useCurrentViewItems>[1];
  updateCurrentViewItems: ReturnType<typeof useCurrentViewItems>[2];
  children: ReactNode;
};

export const CheckableLineBoxesPagination = ({
  selectedItems,
  setSelectedItems,
  fetchFirstPageData,
  fetchAllPageData,
  currentViewItems,
  initCurrentViewItems,
  updateCurrentViewItems,
  children,
}: CheckableLineBoxesPaginationProps) => {
  const [activePage, setActivePage] = useState(1);
  const [totalPages, updateTotalPages] = useTotalPages();
  const [itemsPerPage, setItemsPerPage] = useItemsPerPage(20);
  const [isLoading, setIsLoading] = useState(true);
  const [enabledPagination, setEnabledPagination] = useState(false);
  const allItems = useRef<CheckableLineData[]>([]);

  useEffect(() => {
    const initialize = async () => {
      const data = await fetchFirstPageData(itemsPerPage);
      if (data) {
        initCurrentViewItems(data.items);
        updateTotalPages(data.totalCount, itemsPerPage);
      }
      setIsLoading(false);
    };
    const fetchAllData = async () => {
      const repos = await fetchAllPageData();
      allItems.current = repos;
      setEnabledPagination(true);
    };
    initialize();
    fetchAllData();
  }, []);

  useEffect(() => {
    updateCurrentViewItems(allItems.current, itemsPerPage, activePage);
  }, [activePage]);
  useEffect(() => {
    updateTotalPages(allItems.current.length, itemsPerPage);
    updateCurrentViewItems(allItems.current, itemsPerPage, activePage);
    setActivePage(1);
  }, [itemsPerPage]);

  if (isLoading) {
    // Todo
    return <div>Loading...</div>;
  }

  if (currentViewItems == null || currentViewItems.length == 0) {
    // Todo
    return <div>Empty</div>;
  }

  return (
    <>
      <Flex justify="flex-end" align="center" direction="row">
        <SegmentedControl
          value={itemsPerPage.toString()}
          onChange={setItemsPerPage}
          data={itemsPerPageList}
          disabled={!enabledPagination}
        />
        <Text fz="sm" sx={{ marginRight: "1rem" }}>
          : Items per Page
        </Text>
      </Flex>
      <ScrollAreaWapper>
        <Checkbox.Group value={selectedItems} onChange={setSelectedItems}>
          {children}
        </Checkbox.Group>
      </ScrollAreaWapper>
      <Pagination
        sx={{
          padding: "0.5rem 0.25rem",
        }}
        total={totalPages}
        position="center"
        value={activePage}
        onChange={setActivePage}
        disabled={!enabledPagination}
      />
    </>
  );
};

1 コンポーネントに useEffect が 3 つ…

責任分担できてなくて、ひとまとめになっている感じなので、分割

After 1

以下のイメージで部品ごとに分割を試みる

export const RepositoryList = ({
  selectedRepositories,
  setSelectedRepositories,
  fetchFirstPageRepositories,
  fetchAllRepositories,
}: Props) => {
  const [currentViewItems, initCurrentViewItems, updateCurrentViewItems] =
    useCurrentViewItems();
  const [getAllItems, setAllItems] = useAllItemsAccessor();
  const [sorter, dispatch] = useSorterReducer();
  const [pinnedItemNames, getPinState, togglePin] = usePinnedItems();

  const pinnedItemsToTopSorter = (
    items: CheckableLineData[]
  ): CheckableLineData[] => {
    const sortedPinnedItems = sorter(
      items.filter((item) => pinnedItemNames.includes(item.value))
    );
    const sortedNonPinnedItems = sorter(
      items.filter((item) => !pinnedItemNames.includes(item.value))
    );
    return [...sortedPinnedItems, ...sortedNonPinnedItems];
  };

  const handleClickPin = () => {
    setAllItems(pinnedItemsToTopSorter(getAllItems()));
  };

  return (
    <CheckableLineBoxPagination
      allItemsAccessor={[getAllItems, setAllItems]}
      selectedItems={selectedRepositories}
      setSelectedItems={setSelectedRepositories}
      fetchFirstPageData={fetchFirstPageRepositories}
      fetchAllPageData={fetchAllRepositories}
      currentViewItemsStateSet={[
        currentViewItems,
        initCurrentViewItems,
        updateCurrentViewItems,
      ]}
      sorterReducerSet={[pinnedItemsToTopSorter, dispatch]}
    >
      <CheckableLineBoxListWithPin
        currentViewItems={currentViewItems}
        handleClickPin={handleClickPin}
        pinnedItemsStateSet={[pinnedItemNames, getPinState, togglePin]}
      />
    </CheckableLineBoxPagination>
  );
};
type CheckableLineBoxesPaginationProps = {
  allItemsAccessor?: ReturnType<typeof useAllItemsAccessor>;
  selectedItems: string[];
  setSelectedItems: (items: string[]) => void;
  fetchFirstPageData: (
    itemPerPage: number
  ) => Promise<{ items: CheckableLineData[]; totalCount: number } | undefined>;
  fetchAllPageData: () => Promise<CheckableLineData[]>;
  currentViewItemsStateSet?: ReturnType<typeof useCurrentViewItems>;
  sorterReducerSet?: ReturnType<typeof useSorterReducer>;
  children: ReactNode;
};

export const CheckableLineBoxesPagination = ({
  allItemsAccessor: [getAllItems, setAllItems] = useAllItemsAccessor(),
  selectedItems,
  setSelectedItems,
  fetchFirstPageData,
  fetchAllPageData,
  currentViewItemsStateSet: [
    currentViewItems,
    initCurrentViewItems,
    updateCurrentViewItems,
  ] = useCurrentViewItems(),
  sorterReducerSet: [sorter, dispatch] = useSorterReducer(),
  children,
}: CheckableLineBoxesPaginationProps) => {
  const [activePage, setActivePage] = useState(1);
  const [totalPages, updateTotalPages] = useTotalPages();
  const [itemsPerPage, setItemsPerPage] = useItemsPerPage(20);
  const [isLoading, setIsLoading] = useState(true);
  const [enabledPagination, setEnabledPagination] = useState(false);

  useEffect(() => {
    const initialize = async () => {
      const data = await fetchFirstPageData(itemsPerPage);
      if (data) {
        initCurrentViewItems(data.items);
        updateTotalPages(data.totalCount, itemsPerPage);
      }
      setIsLoading(false);
    };
    const fetchAllData = async () => {
      const items = await fetchAllPageData();
      setAllItems(sorter(items));
      setEnabledPagination(true);
    };
    initialize();
    fetchAllData();
  }, []);

  const handleSelectOrderBy = () => {
    if (!enabledPagination) {
      // 初回描画時に 全データ取得が終わっていない状態でこの関数が呼ばれ、空になるため終わるまでは何もしない
      return;
    }
    setAllItems(sorter(getAllItems()));
    updateCurrentViewItems(getAllItems(), itemsPerPage, activePage);
  };

  const handleSelectActivePage = () => {
    if (!enabledPagination) {
      // 初回描画時に 全データ取得が終わっていない状態でこの関数が呼ばれ、空になるため終わるまでは何もしない
      return;
    }
    updateCurrentViewItems(getAllItems(), itemsPerPage, activePage);
  };

  const handleSelectItemsPerPage = () => {
    if (!enabledPagination) {
      // 初回描画時に 全データ取得が終わっていない状態でこの関数が呼ばれ、空になるため終わるまでは何もしない
      return;
    }
    const firstPage = 1;
    updateTotalPages(getAllItems().length, itemsPerPage);
    setActivePage(firstPage);
    updateCurrentViewItems(getAllItems(), itemsPerPage, firstPage);
  };

  if (isLoading) {
    // Todo
    return <div>Loading...</div>;
  }

  if (currentViewItems == null || currentViewItems.length === 0) {
    // Todo
    return <div>Empty</div>;
  }

  return (
    <>
      <Group position="apart" sx={{ margin: "0 1rem" }}>
        <OrderSelectBox
          handleSelectOrder={handleSelectOrderBy}
          sorterReducerSet={[sorter, dispatch]}
        ></OrderSelectBox>
        <ItemsPerPageSelection
          enabled={enabledPagination}
          handleSelectItemsPerPage={handleSelectItemsPerPage}
          stateSet={[itemsPerPage, setItemsPerPage]}
        />
      </Group>
      <ScrollArea h={window.innerHeight - 135} sx={{ padding: "0.5rem" }}>
        <Checkbox.Group value={selectedItems} onChange={setSelectedItems}>
          {children}
        </Checkbox.Group>
      </ScrollArea>
      <Pagination
        totalPages={totalPages}
        enabled={enabledPagination}
        handleSelectActivePage={handleSelectActivePage}
        activePageStateSet={[activePage, setActivePage]}
      />
    </>
  );
};
import { Text, Flex, NativeSelect, SegmentedControl } from "@mantine/core";
import { useEffect, useState } from "react";
import { Directions, Orders, Sorter } from "./types";
import { useSorterReducer } from "./hooks/useSorterReducer";

const defaultOrderByValues = [
  { label: "name", value: "NAME" },
  { label: "created time", value: "CREATED_AT" },
  { label: "updated time", value: "UPDATED_AT" },
];

const defaultDirectionValues = [
  { label: "Asc", value: "ASC" },
  { label: "Desc", value: "DESC" },
];

type Props = {
  handleSelectOrder: (itemsSorter: Sorter) => void;
  sorterReducerSet?: ReturnType<typeof useSorterReducer>;
};

export const OrderSelectBox = ({
  handleSelectOrder,
  sorterReducerSet: [sorter, dispatch] = useSorterReducer(),
}: Props) => {
  const [order, setOrder] = useState<string>("NAME");
  const [direction, setDirection] = useState<string>("ASC");

  useEffect(() => {
    handleSelectOrder(sorter);
  }, [order, direction]);

  return (
    <Flex align="center" sx={{ "&>*": { margin: "0 0.25rem" } }}>
      <Text size="sm">order by:</Text>
      <NativeSelect
        value={order}
        onChange={(event) => {
          const value = event.currentTarget.value;
          setOrder(value);
          dispatch({
            order: value as Orders,
            direction: direction as Directions,
          });
        }}
        data={defaultOrderByValues}
      />
      <SegmentedControl
        value={direction}
        onChange={(value: string) => {
          setDirection(value);
          dispatch({
            order: order as Orders,
            direction: value as Directions,
          });
        }}
        data={defaultDirectionValues}
      />
    </Flex>
  );
};
import { Flex, SegmentedControl, Text } from "@mantine/core";
import { useEffect } from "react";
import { useItemsPerPage } from "./hooks/useItemsPerPage";

const defaultItemsPerPageChoices = [
  { label: "10", value: "10" },
  { label: "20", value: "20" },
  { label: "30", value: "30" },
  { label: "50", value: "50" },
  { label: "100", value: "100" },
];

type Props = {
  itemsPerPageChoices?: typeof defaultItemsPerPageChoices;
  enabled: boolean;
  handleSelectItemsPerPage: () => void;
  stateSet?: ReturnType<typeof useItemsPerPage>;
};

export const ItemsPerPageSelection = ({
  itemsPerPageChoices,
  enabled,
  handleSelectItemsPerPage: handleChangeItemsPerPage,
  stateSet: [itemsPerPage, setItemsPerPage] = useItemsPerPage(20),
}: Props) => {
  useEffect(() => handleChangeItemsPerPage(), [itemsPerPage]);

  return (
    <Flex align="center" sx={{ "&>*": { margin: "0 0.2rem" } }}>
      <SegmentedControl
        value={itemsPerPage.toString()}
        onChange={setItemsPerPage}
        data={itemsPerPageChoices ?? defaultItemsPerPageChoices}
        disabled={!enabled}
      />
      <Text fz="sm">: Items per Page</Text>
    </Flex>
  );
};
import { Pagination as MantinePagenation } from "@mantine/core";
import { useActivePage } from "./hooks/useActivePage";

type Props = {
  enabled: boolean;
  totalPages: number;
  activePageStateSet?: ReturnType<typeof useActivePage>;
};

export const Pagination = ({
  enabled,
  totalPages = 1,
  activePageStateSet: [activePage, setActivePage] = useActivePage(),
}: Props) => {
  return (
    <MantinePagenation
      sx={{
        padding: "0.5rem 0.25rem",
      }}
      total={totalPages}
      position="center"
      value={activePage}
      onChange={setActivePage}
      disabled={!enabled}
    />
  );
};

分割したものの、以下の違和感を感じている

After 2

フロントエンドのデザインパターン を参考に、以下を適用した

  • レンダープロップパターン
    • これにより、currentViewItems を checkable-line-boxes-pagination コンポーネント に持たせる
  • 複合パターン(&プロバイダーパターン)
    • checkable-line-boxes-pagination コンポーネント は 分割した部品(order-select-box, items-per-page-select-box, pagination)に依存するが、必要なもの(ロジックやステート)を checkable-line-boxes-pagination コンポーネントで管理し、渡すのは中央集権的になりコード量も膨れ上がる
    • 依存度が高いことから、複合パターンを適用し、必要なもの(ロジックやステート)を当該コンポーネント内ではグローバルに共有する形で良いと考えた
  • コンテナ・プレゼンテーションパターン

    • ロジックは、checkable-line-boxes-pagination コンポーネント特有のもののため、分割した部品(order-select-box, items-per-page-select-box, pagination)をラップするようなコンポーネント(=コンテナコンポーネント)を用意して使用するようにした(これにより checkable-line-boxes-pagination コンポーネントから責務の分離ができたように思う)
  • checkable-line-boxes-viewer (旧:checkable-line-boxes-pagination)

import { Group } from "@mantine/core";
import { CheckableLineData } from "../../components/checkable-line-box/checkable-line-box";
import { ReactNode } from "react";
import { useSorterReducer } from "../../components/order-select-box";
import { useCheckableLineItemsRef } from "./hooks/useCheckableLineItemsRef";
import { CheckableLineBoxesContainer } from "./components/checkable-line-boxes-container";

type CheckableLineBoxesViewerProps = {
  selectedItems: string[];
  setSelectedItems: (items: string[]) => void;
  fetchFirstPageData: (
    itemPerPage: number
  ) => Promise<{ items: CheckableLineData[]; totalCount: number } | undefined>;
  fetchAllPageData: () => Promise<CheckableLineData[]>;
  sorterReducerSet?: ReturnType<typeof useSorterReducer>;
  render: (
    currentViewItems: CheckableLineData[],
    itemsRef: ReturnType<typeof useCheckableLineItemsRef>
  ) => ReactNode;
};

export const CheckableLineBoxesViewer = ({
  selectedItems,
  setSelectedItems,
  fetchFirstPageData,
  fetchAllPageData,
  sorterReducerSet: [itemsSorter, dispatchSortOptions] = useSorterReducer(),
  render,
}: CheckableLineBoxesViewerProps) => {
  return (
    <CheckableLineBoxesContainer
      fetchFirstPageData={fetchFirstPageData}
      fetchAllPageData={fetchAllPageData}
      itemsSorter={itemsSorter}
    >
      <Group position="apart" sx={{ margin: "0 1rem" }}>
        <CheckableLineBoxesContainer.OrderBy
          dispatchSortOptions={dispatchSortOptions}
        />
        <CheckableLineBoxesContainer.ItemsPerPage />
      </Group>
      <CheckableLineBoxesContainer.Group
        selectedItems={selectedItems}
        setSelectedItems={setSelectedItems}
        render={render}
      />
      <CheckableLineBoxesContainer.PaginationBox />
    </CheckableLineBoxesContainer>
  );
};
  • checkable-line-boxes-container
import { ReactNode, useEffect, useState } from "react";
import { CheckableLineData } from "src/components/checkable-line-box";
import { useSorterReducer } from "src/components/order-select-box";
import { useCheckableLineItemsRef } from "../hooks/useCheckableLineItemsRef";
import { useCurrentViewItems } from "../hooks/useCurrentViewItems";
import { useItemsPerPage } from "src/components/items-per-page-select-box";
import { useTotalPages } from "../hooks/useTotalPages";
import { CheckableLineBoxesProvider } from "../checkable-line-boxes.context";
import { ItemsPerPage } from "./items-per-page";
import { OrderBy } from "./order-by";
import { Group } from "./group";
import { PaginationBox } from "./pagination-box";

type CheckableLineBoxesContainerProps = {
  fetchFirstPageData: (
    itemPerPage: number
  ) => Promise<{ items: CheckableLineData[]; totalCount: number } | undefined>;
  fetchAllPageData: () => Promise<CheckableLineData[]>;
  itemsSorter: ReturnType<typeof useSorterReducer>[0];
  children: ReactNode;
};

export const CheckableLineBoxesContainer = ({
  fetchFirstPageData,
  fetchAllPageData,
  itemsSorter,
  children,
}: CheckableLineBoxesContainerProps) => {
  const itemsRef = useCheckableLineItemsRef(itemsSorter);
  const [currentViewItems, initCurrentViewItems, updateCurrentViewItems] =
    useCurrentViewItems();
  const [activePage, setActivePage] = useState(1);
  const [totalPages, updateTotalPages] = useTotalPages();
  const [itemsPerPage, setItemsPerPage] = useItemsPerPage(20);
  const [isLoading, setIsLoading] = useState(true);
  const [enabledPagination, setEnabledPagination] = useState(false);

  useEffect(() => {
    const initialize = async () => {
      const data = await fetchFirstPageData(itemsPerPage);
      if (data) {
        initCurrentViewItems(data.items);
        updateTotalPages(data.totalCount, itemsPerPage);
      }
      setIsLoading(false);
    };
    const fetchAllData = async () => {
      const items = await fetchAllPageData();
      itemsRef.allItems = items;
      itemsRef.sort();
      setEnabledPagination(true);
    };
    initialize();
    fetchAllData();
  }, []);

  return (
    <CheckableLineBoxesProvider
      value={{
        itemsRef,
        currentViewItems,
        updateCurrentViewItems,
        activePage,
        setActivePage,
        totalPages,
        updateTotalPages,
        itemsPerPage,
        setItemsPerPage,
        isLoading,
        enabledPagination,
      }}
    >
      {children}
    </CheckableLineBoxesProvider>
  );
};

CheckableLineBoxesContainer.ItemsPerPage = ItemsPerPage;
CheckableLineBoxesContainer.OrderBy = OrderBy;
CheckableLineBoxesContainer.Group = Group;
CheckableLineBoxesContainer.PaginationBox = PaginationBox;
  • checkable-line-boxes-context
import { useItemsPerPage } from "src/components/items-per-page-selection";
import { useCheckableLineItemsRef } from "./hooks/useCheckableLineItemsRef";
import { useCurrentViewItems } from "./hooks/useCurrentViewItems";
import { useTotalPages } from "./hooks/useTotalPages";
import { createContext, useContext } from "react";

type ContextValues = {
  itemsRef: ReturnType<typeof useCheckableLineItemsRef>;
  currentViewItems: ReturnType<typeof useCurrentViewItems>[0];
  updateCurrentViewItems: ReturnType<typeof useCurrentViewItems>[2];
  activePage: number;
  setActivePage: (page: number) => void;
  totalPages: ReturnType<typeof useTotalPages>[0];
  updateTotalPages: ReturnType<typeof useTotalPages>[1];
  itemsPerPage: ReturnType<typeof useItemsPerPage>[0];
  setItemsPerPage: ReturnType<typeof useItemsPerPage>[1];
  isLoading: boolean;
  enabledPagination: boolean;
};

const CheckableLineBoxesContext = createContext<any>({});

export const CheckableLineBoxesProvider = CheckableLineBoxesContext.Provider;
export const useCheckableLineBoxesContext = () =>
  useContext<ContextValues>(CheckableLineBoxesContext);
  • items-per-page
import { useEffect } from "react";
import { useCheckableLineBoxesContext } from "../checkable-line-boxes.context";
import { ItemsPerPageSelectBox } from "src/components/items-per-page-select-box";

export const ItemsPerPage = () => {
  const {
    itemsRef,
    updateCurrentViewItems,
    setActivePage,
    updateTotalPages,
    itemsPerPage,
    setItemsPerPage,
    enabledPagination,
  } = useCheckableLineBoxesContext();

  useEffect(() => {
    if (!enabledPagination) {
      // 初回描画時に 全データ取得が終わっていない状態でこの関数が呼ばれ、空になるため終わるまでは何もしない
      return;
    }
    const firstPage = 1;
    updateTotalPages(itemsRef.allItems.length, itemsPerPage);
    setActivePage(firstPage);
    updateCurrentViewItems(itemsRef.allItems, itemsPerPage, firstPage);
  }, [itemsPerPage]);

  return (
    <ItemsPerPageSelectBox
      enabled={enabledPagination}
      stateSet={[itemsPerPage, setItemsPerPage]}
    />
  );
};
  • order-by
import { useEffect } from "react";
import {
  OrderSelectBox,
  useSortOptions,
  useSorterReducer,
} from "src/components/order-select-box";
import { useCheckableLineBoxesContext } from "../checkable-line-boxes.context";

export const OrderBy = ({
  dispatchSortOptions,
}: {
  dispatchSortOptions: ReturnType<typeof useSorterReducer>[1];
}) => {
  const {
    itemsRef,
    updateCurrentViewItems,
    activePage,
    itemsPerPage,
    enabledPagination,
  } = useCheckableLineBoxesContext();
  const [sortOptions, setSortOptions] = useSortOptions();

  useEffect(() => {
    if (!enabledPagination) {
      // 初回描画時に 全データ取得が終わっていない状態でこの関数が呼ばれ、空になるため終わるまでは何もしない
      return;
    }
    itemsRef.sort();
    updateCurrentViewItems(itemsRef.allItems, itemsPerPage, activePage);
  }, [sortOptions.order, sortOptions.direction]);

  return (
    <OrderSelectBox
      enabled={enabledPagination}
      dispatchSortOptions={dispatchSortOptions}
      sortOptionsStateSet={[sortOptions, setSortOptions]}
    ></OrderSelectBox>
  );
};
  • group
import { ReactNode } from "react";
import { CheckableLineData } from "src/components/checkable-line-box";
import { useCheckableLineItemsRef } from "../hooks/useCheckableLineItemsRef";
import { useCheckableLineBoxesContext } from "../checkable-line-boxes.context";
import { Checkbox, ScrollArea } from "@mantine/core";

export const Group = ({
  selectedItems,
  setSelectedItems,
  render,
}: {
  selectedItems: string[];
  setSelectedItems: (items: string[]) => void;
  render: (
    currentViewItems: CheckableLineData[],
    itemsRef: ReturnType<typeof useCheckableLineItemsRef>
  ) => ReactNode;
}) => {
  const { itemsRef, currentViewItems } = useCheckableLineBoxesContext();
  return (
    <ScrollArea h={window.innerHeight - 180} sx={{ padding: "0.5rem" }}>
      <Checkbox.Group value={selectedItems} onChange={setSelectedItems}>
        {render(currentViewItems, itemsRef)}
      </Checkbox.Group>
    </ScrollArea>
  );
};
  • pagination-box
import { useEffect } from "react";
import { useCheckableLineBoxesContext } from "../checkable-line-boxes.context";
import { Pagination } from "src/components/pagination";

export const PaginationBox = () => {
  const {
    itemsRef,
    itemsPerPage,
    totalPages,
    activePage,
    setActivePage,
    updateCurrentViewItems,
    enabledPagination,
  } = useCheckableLineBoxesContext();

  useEffect(() => {
    if (!enabledPagination) {
      // 初回描画時に 全データ取得が終わっていない状態でこの関数が呼ばれ、空になるため終わるまでは何もしない
      return;
    }
    updateCurrentViewItems(itemsRef.allItems, itemsPerPage, activePage);
  }, [activePage]);

  return (
    <Pagination
      totalPages={totalPages}
      enabled={enabledPagination}
      activePageStateSet={[activePage, setActivePage]}
    />
  );
};

まだ荒はあるが、一旦ここまでとする

最後に

ある程度納得いく形にはできた。ただ、やりすぎか、適切かどうか判断が付かない。

参考になりそうなフレームワーク等を見つけて、どう考えて分割するのが良いか学習が必要そう。

以上

gPRC: Protocol Buffers スタイル規約 & API ベストプラクティスまとめ

gPRC: Protocol Buffers スタイル規約 & API ベストプラクティスまとめ

設計する上で、公式ドキュメント(英語)を翻訳機で訳してまとめただけ。

ファイル & パッケージ構成

  • 全てのファイルにパッケージを定義
  • 同じパッケージのファイルは全てパッケージ名と一致する同じディレクトリに入れる
  • パッケージの最後のコンポーネントは バージョンにする
  • パッケージ名の形式:lower_snake_case
  • ファイル名の形式:lower_snake_case.proto
  • 繰り返しフィールドに複数形の名前を使用
.
└── proto
    ├── buf.yaml
    └── foo
        └── bar
            ├── bat
            │   └── v1
            │       └── bat.proto // package foo.bar.bat.v1
            └── baz
                └── v1
                    ├── baz.proto         // package foo.bar.baz.v1
                    └── baz_service.proto // package foo.bar.baz.v1
  • 同じパッケージ内の全 proto ファイルで、以下オプションは揃える(値と有無)
    • csharp_namespace
    • go_package
    • java_multiple_files
    • java_package
    • php_namespace
    • ruby_package
    • swift_prefix
// foo_one.proto
syntax = "proto3";

package foo.v1;

option go_package = "foov1";
option java_multiple_files = true;
option java_package = "com.foo.v1";
// foo_two.proto
syntax = "proto3";

package foo.v1;

option go_package = "foov1";
option java_multiple_files = true;
option java_package = "com.foo.v1";

Message

  • Message 名は PascalCase
  • フィールド名は lower_snake_case
syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;

  oneof test_oneof {  // セットで扱うものをまとめられる
    string name = 4;
    SubMessage sub_message = 5;
  }
  optional string song_name = 6;
  repeated string keys = 7;
  map<string, Project> projects = 8; // map に repeated は使えない
  reserved 2, 15, 9 to 11;  // reserved: 予約済みフィールド
  reserved "foo", "bar";    // 主に削除済みのフィールドに使用
}

message Project {
  ...
}

Field

種類:

  • oneof
  • optional
  • repeated
  • map
  • reserved

スカラー値の型:

  • double/float
  • int32/int64
  • uint32/uint64:正の値
  • sint32/sint64:int32/int64 より効率的に負の数をエンコードできる
  • fixed32:常に 4bytes。228 を超える場合は uint32 より効率的にエンコード
  • fixed64:常に 8bytes。256 を超える場合は uint64 より効率的にエンコード
  • sfixed32:常に 4bytes。
  • sfixed64:常に 8bytes。
  • bool
  • string
  • bytes: 232 以下の任意のバイトシーケンス

ルール:

  • 1 ~ 15 のフィールド番号は、(1byte のため)非常に頻繁に使用するフィールド用に予約する
  • たまにしか使わないフィールドは、16 ~ 2047 (2byte) を使い、頻繁に使用するフィールドとして予約できる余地を残しておく必要がる
  • フィールド番号の最大は 229 - 1 (= 536,870,911)
  • 19000 から 19999 までの数字は ProtocolBuffers の実装のために予約されているため使用不可(使用した場合はコンパイラから警告)

Nested Type

できるが、ネストされた Message の使用は避ける

message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 1;
}

他のメッセージで利用する場合

message SomeOtherMessage {
  SearchResponse.Result result = 1;
}

Any

  • google/protobuf/any.proto をインポートする必要がある
  • Any には、任意のシリアル化されたメッセージがバイトとして含まれ、そのメッセージのタイプのグローバル一意識別子として機能し、解決される URL が含まれる
  • 言語の実装で、Any 値を型安全な方法でパックおよびアンパックするランタイムライブラリヘルパーがサポート
import "google/protobuf/any.proto";

message ErrorStatus {
  string message = 1;
  repeated google.protobuf.Any details = 2;
}

Enum

  • Enum には allow_alias オプションを設定しない
  • Enum 名は PascalCase
  • Enum 値の名前は UPPER_SNAKE_CASE
  • Enum 値の名前の先頭には、Enum 名の UPPER_SNAKE_CASE を付ける
    • Enum:FooBar の場合、 Enum 値の名前:FOOBAR
  • すべての Enum の 0 の値の末尾には_UNSPECIFIED を付ける
enum Corpus {
  CORPUS_UNSPECIFIED = 0;
  CORPUS_UNIVERSAL = 1;
  CORPUS_WEB = 2;
  CORPUS_IMAGES = 3;
  CORPUS_LOCAL = 4;
  CORPUS_NEWS = 5;
  CORPUS_PRODUCTS = 6;
  CORPUS_VIDEO = 7;
}

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  Corpus corpus = 4;
}

allow_alias:同じフィールド番号を許容

enum EnumAllowingAlias {
  option allow_alias = true;
  EAA_UNSPECIFIED = 0;
  EAA_STARTED = 1;
  EAA_RUNNING = 1;
  EAA_FINISHED = 2;
}

enum EnumNotAllowingAlias {
  ENAA_UNSPECIFIED = 0;
  ENAA_STARTED = 1;
  // ENAA_RUNNING = 1;  // コメントを外すと警告メッセージ表示
  ENAA_FINISHED = 2;
}

※列挙子の数に言語固有の制限がある場合がある (1 つの言語で 1000 以下)

Service

  • Service 名は PascalCase
  • Service 名の末尾には Service を付ける
  • RPC 名は PascalCase にする
  • すべての RPC 要求および応答メッセージは、Protobuf スキーマ全体で一意である必要がある
  • すべての RPC 要求および応答メッセージには、以下の命名にする
    • < MethodName > Request
    • < MethodName > Response
    • < ServiceName >< MethodName > Request
    • < ServiceName >< MethodName > Response
service FooService {
  rpc GetSomething(GetSomethingRequest) returns (GetSomethingResponse);
  rpc ListSomething(ListSomethingRequest) returns (ListSomethingResponse);
}

※(議論の余地はあるが)RPC のストリーミングを避けることを推奨する。(これらは確かに非常に価値のある特定のユースケースを持っていますが、全 体としては多くの問題を引き起こし、RPC フレームワークのロジックをスタックに押し上げるため、通常はより信頼性の高いアーキテクチャの開発を妨げる)

Proto ベストプラクティス

  • タグ番号は再利用しない
  • フィールドのタイプは変更しない
  • 必須フィールドを追加しない
    • 代わりに // required を追加して、API コントラクトを文書化する
    • ※ proto3 から完全に削除された
  • フィールドが多い(数百)メッセージは作らない
  • Enum に 宣言の最初の値としてデフォルトの Unspecified Value(XXX_UNSPECIFIED)を含める
    • デフォルト値が存在しない場合は最初に宣言された値が返されるため
    • enum は最初の値がで 0 あることを要求するため値は 0 にする
  • Well-Known Types と Common Types を使う
    • 以下の共通型、共有型を埋め込むことを強く推奨
      • Well-Known Types
        • duration:符号付き固定長の時間スパン (例:42s)
        • timestamp:例 2017-01-15T01:30:15.01Z
        • field_mask:指定のフィールドのみ取るためのもの
      • Common Types
        • interval: 時間間隔(例:2017-01-15T01:30:15.01Z - 2017-01-16T02:30:15.01Z)
        • date: 例 2005-09-19
        • dayofweek: 例 Monday
        • timeofday: 例 10:42:23
        • latlng: 緯度/経度 の組 (例:37.386051 latitude and -122.083855 longitude)
        • money: 例 42 USD
        • postal_address:郵便の住所
        • color:RGBA カラースペース
        • month :例 April
    • 完全に適した共通型がすでに存在する場合、コード内で int32 timestamp_seconds_since_epochint64 timeout_millis を使用しない
  • 広く使用されることを期待する Message Type や Enum は別のファイルに定義し、誰でも簡単に使えるようにする
  • 削除されたフィールドのタグ番号は予約(reserved)して、後で誤って再使用しないようにする
  • フィールドのデフォルト値は変更しない(proto3 ではデフォルト値を設定する機能は削除)
  • rotated から Scalar に移行しない。クラッシュはしないがデータ失われる(逆は大丈夫)
  • 生成されたコードのスタイルガイドに従う
  • テキスト形式のメッセージをやり取りに使用しない(テキスト形式は、人による編集とデバッグにのみ使用)
  • ビルド間でのシリアライゼーションの安定性に決して依存しない
    • proto シリアライズの安定性は、バイナリ間でも、同じバイナリのビルド間でも保証されない(ビルドする度中身が変わる)ため、キャッシュキーの構築等で依存してはいけない

ref: https://protobuf.dev/programming-guides/dos-donts/

API ベストプラクティス

公式ドキュメントの記載は、長期的でバグのない進化を優先するためにトレードオフを行った上での提案 ref: https://protobuf.dev/programming-guides/api/

フィールドとメッセージを正確かつ簡潔に文書化する

  • 各フィールドの制約、期待、解釈をできるだけ短い言葉で文書化
  • 時間経過とともに長くなるかもしれないが、全体的に見て簡潔にすることを目指す

Wire (≒API?)と Storage でメッセージを使い分ける

  • クライアントに公開するトップレベルの proto が、ディスクに保存する proto を同じにしない
    • クライアントに影響を与えることなく、保存形式を変更できる自由度が必要
    • コードをレイヤー化し、モジュールが client protos、storage protos、translation のいずれかを処理するように分ける
  • 例外
    • proto フィールドが google.type や google.protobuf のような一般的な型であれば、その型をストレージと API の両方として使用可
    • 極めてパフォーマンスに敏感であれば、柔軟性を実行速度と引き換えにする価値があるかもしれない(ミリ秒単位のレイテンシーで数百万 QPS を実 現?)
    • 以下が全て当てはまる
      • サービスがストレージシステム
      • クライアントの構造化データに基づいた意思決定ができない
      • システムが、単に保存、ロード、クライアントのリクエストに応じたクエリーを提供する場合

Mutations (更新 API) の場合、データの完全なリプレイスではなく、部分的な更新または追加のみの更新をサポートする

  • (1) Update Field-mask を使用する。
    • クライアントが変更するフィールドを渡し、それらのフィールドだけを更新要求に含める。
    • サーバは他のフィールドをそのままにし、マスクで指定されたフィールドのみを更新する。
    • 一般的に、マスクの構造は応答 proto の構造を反映する必要がある(= Foo に Bar が含まれている場合、FooMask には BarMask を含める)
  • (2) 個々のピースを変化させる より狭い Mutations API を公開する
    • 例:UpdateEmployeeRequest の代わりに、PromoteEmployeeRequest、SetEmployeePayRequest、TransferEmployeeRequest など(用途特化?)
    • カスタム更新方法は、非常に柔軟な更新方法よりも監視、監査、およびセキュリティが容易かつ実装や呼び出しも簡単。ただし、その数が多いと API の認知負荷が増大する可能性があるため注意。

トップレベルのリクエストまたはレスポンスの proto にプリミティブ型(基本のデータ型)を含めない

  • トップレベルの proto は、ほとんどの場合、独立して成長できる他のメッセージのコンテナである必要がある
  • 必要なプリミティブ型が 1 つだけの場合でも、メッセージにラップすることで、その型を拡張し、類似した値を返す他のメソッド間で型を共有するた めの明確なパスが得られる
message MultiplicationResponse {
  // 悪い例:複素数を返す必要があり、同じ複数フィールド型を返すレスポンスが必要な場合に
  // どちらにもプリミティブ型のフィールドを追加するのは避けたいはず
  optional double result;

  // 良い例:他のメソッドはこのタイプを共有でき、成長可能
  // サービスは、新しい機能 (単位、信頼区間など) を容易に追加可能。
  optional NumericResult result;
}

message NumericResult {
  optional double real_value;
  optional double complex_value;
  optional UnitType units;
}

トップレベルプリミティブの例外

  • プロトをエンコードするが、サーバー上でのみ構築および解析される不透明な文字列 (またはバイト)
  • 継続トークン、バージョン情報トークン、ID は、文字列が実際に構造化プロトのエンコードである場合、すべて文字列として返すことができる。

(現在は 2 つの状態を持つが)後でさらに多くの状態を持つ可能性があるものには boolean を使用しない

フィールドに boolean を使用する場合は、フィールドが(将来的にも)実際に 2 つの可能な状態だけになるかを確認する。

message GooglePlusPost {
  // 悪い例:この投稿を2つのカラムにまたがってレンダリングするかどうか
  optional bool big_post;

  // 良い例:この投稿を表示するクライアントのためのレンダリングヒント
  // クライアントは、この投稿をどの程度目立つように表示するかを決定するために、これを使用。
  // ない場合は、デフォルトのレンダリングを想定。
  optional LayoutConfig layout_config;
}

message Photo {
  // 悪い例: GIFかどうか
  optional bool gif;

  // 良い例: 写真のファイル形式(例:GIF、WebP、PNG)。
  optional PhotoType type;
}

概念を混同する enum に state を追加することには、注意が必要

  • もし state が enum に新しい次元を導入する場合や、複数のアプリケーションの動作を意味する場合、ほぼ間違いなく別のフィールドが必要

ID に整数フィールドをほとんど使用しない

  • オブジェクトの識別子として int 64 を使いたくなるが、文字列を選択する
  • 必要に応じて ID スペースを変更し、衝突の可能性を減らせる(264 は今では大きくはない)
  • 構造化識別子を文字列としてエンコードすることで、クライアントに不透明な Blob として扱うように促すことも可能。
    • この場合、文字列の裏に proto が必要だが、プロトを文字列フィールドにシリアライズすることができる(Web-safe Base64 としてエンコード)。
    • これにより、クライアントが公開する API から内部の詳細を取り除くことができる。(その場合は 「Web-Safe Encoding Binary Proto Serialization で不透明なデータを文字列でエンコードする」 を参照)
message GetFooRequest {
  // Which Foo to fetch.
  optional string foo_id;
}

// websafe-base64-encoded & シリアライズして、 GetFooRequest.foo_idフィールドにセット
message InternalFooRef {
  // Only one of these two is set. Foos that have already been
  // migrated use the spanner_foo_id and Foos still living in
  // Caribou Storage Server have a classic_foo_id.
  optional bytes spanner_foo_id;
  optional int64 classic_foo_id;
}

Web-Safe Encoding Binary Proto Serialization で不透明なデータを文字列でエンコードする

  • クライアントから見えるフィールドの不透明なデータ(継続トークン、シリアル化された ID、バージョン情報など)をエンコードする場合は、クライア ントが不透明な Blob として扱う必要があることを文書化する。
  • これらのフィールドには、必ずバイナリ形式のプロトシリアライゼーションを使用し、テキスト形式や独自の工夫をしない。不透明なフィールドにエンコードされたデータを拡張する必要がある場合、まだ使用していなければ、プロトコルバッファのシリアル化を再発明することになるため。
  • 不透明なフィールドに入るフィールドを保持する内部 proto を定義し (フィールドが 1 つだけ必要な場合でも) 、この内部 proto をバイトにシリア ル化し、その結果を web-safe base-64 で文字列フィールドにエンコードします。
  • プロトシリアライゼーションを使用するまれな例外:非常に時折、慎重に構築された代替形式からのコンパクトさの勝利は価値がある。

クライアントが構築または解析すると予想される文字列内のデータをエンコードしない

ネットワーク上では効率が悪く、proto の利用者にとっては手間がかかり、ドキュメントを読んでいる人にとっては混乱を招く。

クライアントはエンコーディングについても気にする必要がある

  • リストはコンマ区切りか?
  • この信頼できないデータは正しくエスケープされているか?
  • 数字は 10 を底としているか?

クライアントに実際のメッセージやプリミティブタイプを送信させる方がよい。その方が、ネットワーク全体でよりコンパクトになり、クライアントにとってより明確になる

  • サービスで複数言語のクライアントを取得した場合に特に悪化する。各自が適切なパーサーやビルダーを選択しなければならなくなり、最悪の場合それを書かなければならなくなる。
  • より一般的には、正しいプリミティブ型を選択する。
    • 『Protocol Buffer Language Guide』の 「Scalar Value Types」 の表を参照

フロントエンド proto で HTML を返す

JavaScript クライアントには、API のフィールドで HTML や JSON を返さない。 (返すと API を特定の UI に結びつけるため良くない)

具体的な 3 つの危険性:

  • 「スクラッピー」 な非 Web クライアントは、HTML や JSON を解析して、フォーマットを変更した場合の脆弱性や、解析が悪い場合の脆弱性につなが るデータを取得することになる
  • Web クライアントは、その HTML がサニタイズされずに返された場合、XSS エクスプロイトに対して脆弱になる。
  • 返されるタグとクラスは、特定のスタイルシートと DOM 構造を想定しているが、リリースごとに構造が変化し、JavaScript クライアントがサーバーより古くなると、サーバーが返す HTML が古いクライアントで正しくレンダリングされなくなるバージョンスキューの問題が発生するリスクがある。(リリース頻度の高いプロジェクトでは、これはエッジケースではない)

最初のページロード以外では、通常データを返しクライアント側のテンプレートを使用してクライアント上で HTML を構築する方がよい

クライアントが使用できない可能性のあるフィールドを含めない

クライアントに公開する API には、

  • システムとの対話方法を記述するためだけのもののみ含める
  • その中に他の何かを含めると、それを理解しようとする人に認知的オーバーヘッドが加わる

以前は応答プロトコルデバッグデータを返すのが一般的でしたが、今はより良い方法がある

  • RPC レスポンス拡張(「サイドチャネル」とも呼ばれる)により、あるプロトでクライアントインタフェースを記述し、別のプロトでデバッグサーフェスを記述することができる
  • (同様に応答 proto で実験名を返すのは、以前はログ記録の利便性があり、不文律の契約では、クライアントは後続のアクションでそれらの実験を送 り返していた)同じことを実現する方法は、分析パイプラインでログの結合を行うこと。

1つの例外:

  • 継続的なリアルタイム分析が必要で、マシンの予算が少ない場合は、ログ結合の実行は困難な場合がある。
  • コストが決定要因である場合は、ログデータを事前に非正規化することが有効な場合がある。
  • ログデータのラウンドトリップが必要な場合は、不透明な Blob としてクライアントに送信し、要求フィールドと応答フィールドを文書化する。

Caution:

  • 要求のたびに隠しデータを返したり往復したりするのは、サービスを利用するための本当のコストを隠していることになり、良くない。

継続トークンなしでページ区切り API を定義しない

message FooQuery {
  // Bad:最初のクエリと2番目のクエリの間でデータが変更された場合、
  // これらの各戦略によって結果を見逃す可能性があります。
  // 最終的に一貫性のある世界(つまり、Bigtableにバックアップされたストレージ)では、
  // 新しいデータの後に古いデータが表示されることは珍しくない。
  // また、オフセットベースとページベースのアプローチはすべてソート順を前提としているため、
  // ある程度の柔軟性が失われる。
  optional int64 max_timestamp_ms;
  optional int32 result_offset;
  optional int32 page_number;
  optional int32 page_size;

  // Good: 柔軟性がある。これを FooQueryResponse で返し、
  // クライアントが次のクエリでそれを返すようにする。
  optional string next_page_token;
}

ページ分割 API のベストプラクティス

  • シリアル化する内部プロトに裏打ちされた不透明な継続トークン (next_page_token) を使用してから、WebSafeBase64Escape (C++) または BaseEncoding.base64Url().encode (Java) を使用すること。
  • その内部プロトは多くの分野を含む可能性がある。重要なのは、それが柔軟性をもたらし、選択すれば、クライアントに安定した結果をもたらすということ。
message InternalPaginationToken {
  // これまでに確認されたIDを追跡する
  // これにより、継続トークンが大きくなる代わりに、完璧な想起が可能になる
  // --特に、ユーザがページを戻したとき
  repeated FooRef seen_ids;

  // seen_idsストラテジーに似ているが、seen_idsをBloomフィルタにかけることで
  // バイトを節約し、精度を犠牲にする
  optional bytes bloom_filter;

  // 合理的な最初のカットであり、より長く機能する可能性がある.
  // 継続トークンに埋め込んでおけば、後でクライアントに影響を与えることなく変更できる
  optional int64 max_timestamp_ms;
}

関連するフィールドを新しいメッセージにまとめる。親和性の高いフィールドだけをネストする

message Foo {
  // Bad:
  optional int price;
  optional CurrencyType currency;

  // Better: Fooの価格と通貨をカプセル化
  optional CurrencyAmount price;
}

後で関連するフィールドを持つ可能性がある場合、これを回避するために事前に入れる。

message Foo {
  // DEPRECATED! Use currency_amount.
  optional int price [deprecated = true];

  // The price and currency of this Foo.
  optional google.type.Money currency_amount;
}

疎結合はシステムを開発する際のベストプラクティスだが、.proto ファイルを設計する際には必ずしもそのプラクティスが適用されない場合がある。

message Photo {
  // Bad: PhotoMetadataはPhotoの範囲外で再利用される可能性が高いので、
  // 入れ子にせず、アクセスしやすくしておくといいかもしれませんね。
  message PhotoMetadata {
    optional int32 width = 1;
    optional int32 height = 2;
  }
  optional PhotoMetadata metadata = 1;
}

message FooConfiguration {
  // Good: FooConfiguration.RuleをFooConfigurationのスコープ外で再利用すると、
  // 無関係なコンポーネントと密接に結合する可能性が高いため、ネスティングして再利用を防ぐ。
  message Rule {
    optional float multiplier = 1;
  }
  repeated Rule rules = 1;
}

読み取り要求にフィールド読み取りマスクを含める

読み取りマスク

  • クライアント側に明確な期待値を設定し、返すデータの量を制御し、バックエンドがクライアントが必要とするデータのみを取得できるようにする。
  • すべてのフィールドが true に設定された暗黙の読み取りマスクがあるかのように要求を処理する(proto が大きくなるにつれてコストが大きくなる)
  • 暗黙的な (宣言されていない) 読み取りマスク は Bad

これは、プロトコールが大きくなるにつれてコストがかかる可能性があります。

// Recommended: use google.protobuf.FieldMask

// Alternative one:
message FooReadMask {
  optional bool return_field1;
  optional bool return_field2;
}

// Alternative two:
message BarReadMask {
  // 返すフィールドのタグ番号。
  repeated int32 fields_to_return;
}

一貫した読み取りを可能にするバージョンフィールドを含める

(分散システムの話?)

クライアントが書き込みを行った後に同じオブジェクトを読み込む場合、書き込んだ内容が戻ってくることを期待するが、その期待値が基となるストレージシステムにとって妥当でない場合もある。

サーバーはローカルの値を読み、ローカルの version_info が、期待される version_info より小さい場合、リモートレプリカから読み取って最新の値を見つける。

version_info は文字列としてエンコードされたプロトで、変異が起こったデータセンターとコミットされたタイムスタンプを含む。(エンコードは「Web-Safe Encoding Binary Proto Serialization で不透明なデータを文字列でエンコードする」参照)

同じデータ型を返す RPC には一貫したリクエストオプションを使用する

要求オプションを保持する単一の個別のメッセージを作成し、それを最上位の各要求メッセージに含める。

message FooRequestOptions {
  // フィールドレベル読み取りマスク。要求されたフィールドのみ返す。
  // クライアントは(バックエンドが要求を最適化するために)必要なフィールドのみ要求
  optional FooReadMask read_mask;

  // 最大でこの数のコメントが返す。
  // スパムとしてマークされたコメントは、最大コメント数にカウントされない。
  // デフォルトでは、コメントは返されない。
  optional int max_comments_to_return;

  // このサポートされている型リストにない埋め込みを含むfooは、このリストで指定された埋め込みにダウンコンバートされた埋め込みを持つ。サポートされるタイプリストが指定されない場合、埋め込みは返されない。埋め込みをるサポートされている型のいずれかにダウンコンバートできない場合、埋め込みは返されない。クライアントは、EmbedTypes.protoから少なくともTHING_V2埋め込み型を常に含めることを強くお勧めします。
  repeated EmbedType embed_supported_types_list;
}

message GetFooRequest {
  // ビューアーがFooにアクセスできない場合、またはFooが削除されている場合、
  // 応答は空になるが成功する。
  optional string foo_id;

  // クライアントはこのフィールドを含める必要がある
  // FooRequestOptions が空のままだと、サーバの返却は INVALID_ARGUMENT になる
  optional FooRequestOptions params;
}

message ListFooRequest {
  // 検索では100%が再現されるが、より多くの句がパフォーマンスに影響する。
  optional FooQuery query;

  // クライアントはこのフィールドを含める必要がある
  // FooRequestOptionsが空のままだと、サーバーはINVALID_ARGUMENTを返します。
  optional FooRequestOptions params;
}

Batch/Multi-phase リクエス

  • 可能な限り、Mutations を 原始的なものにする。さらに重要なのは、Mutations を冪等性にすること。部分的な失敗の完全な再試行は、データを破損 したり複製したりしてはならない。
  • 時に、パフォーマンス上の理由から、複数の操作をカプセル化した単一の RPC が必要になることがある。部分的な失敗の場合(あるものが成功し、あ るものが失敗した場合)クライアントに知らせるのが一番。
  • RPC を failed に設定し、成功と失敗の両方の詳細を RPC status proto で返すことを検討する。

小さなデータを返す/操作するメソッドを作成し、クライアントが複数の要求をまとめて UI を構成することを期待する

1 回のラウンドトリップで多くの厳密に指定されたデータのビット?を照会できるため、クライアントが必要なものを構成することで、サーバーを変更せずに幅広い UX オプションを提供することができる

これは、フロントエンドや middle-tier サーバーに最も関係がある。(多くのサービスが独自のバッチング API を公開している)

モバイルやウェブで連続したラウンドトリップが必要な場合、1 回限りの RPC を作成する

一般的な進化は、1 つの繰り返しフィールドが複数の関連した繰り返しフィールドになる必要があることである。つまり、並列の繰り返しフィールドを作成するか、値を保持する新しいメッセージで新しい繰り返しフィールドを定義し、クライアントをそのフィールドに移行するかである。

繰り返しのメッセージから始めると、進化は些細なことになる

// 写真に適用される補正の種類
enum EnhancementType {
  ENHANCEMENT_TYPE_UNSPECIFIED;
  RED_EYE_REDUCTION;
  SKIN_SOFTENING;
}

message PhotoEnhancement {
  optional EnhancementType type;
}

message PhotoEnhancementReply {
  // Good: PhotoEnhancementは、enumだけでなく、
  // より多くのフィールドを必要とする拡張機能を記述して成長可能
  repeated PhotoEnhancement enhancements;

  // Bad: もし、エンハンスメントに関連するパラメータを返したい場合は、
  // 並列配列を導入するか(酷い)、このフィールドを非推奨とし、繰り返しメッセージを導入する必要がある。
  repeated EnhancementType enhancement_types;
}

例外:

  • レイテンシが重要なアプリケーションでは、プリミティブ型の並列配列の方がメッセージの単一配列よりも構築と削除が高速である
  • また [packed=true] (フィールドタグを除外する) を使用する場合は、ネットワーク上で小さくすることもできる。固定数の配列を割り当てる方が、N 個のメッセージを割り当てるよりも手間がかからない。※Proto 3 では packing は自動;明示的に指定する必要はありません。

Proto Maps を使用する

  • proto3 からは Map<scalar, **message**> を使用する
  • 事前に構造がわからない任意のデータを表す場合は、google.protobuf.Any を使用

冪等性を優先

  • クライアントが再試行ロジックを持つ場合がある。
  • 再試行が Mutations である場合、データ重複などに繋がる。
  • 重複書き込みを避ける簡単な方法は、クライアントが作成したリクエスト ID を指定できるようにすることで、サーバーがそれを元に重複排除すること(例えば、コンテンツのハッシュや UUID など)。

サービス名はグローバル(ネットワーク上)でユニークなものにする

  • サービス名(つまり、.proto ファイルの service キーワードの後の部分)は、サービスクラス名を生成するためだけでなく、意外と多くの場所で使われる。そのため、この名前は重要。
  • 厄介なのは、これらのツールは、サービス名がネットワーク上で一意であるという暗黙の前提を置いていること。さらに悪いことに、これらのツールが使用するサービス名は、修飾されたサービス名(例:my_package.MyService)ではなく、修飾されていないサービス名(例:MyService)です。
  • このため、たとえ特定のパッケージ内で定義されたサービスであっても、サービス名の命名衝突を防ぐための措置を講じることが理にかなっている。例えば、Watcher という名前のサービスは問題を起こす可能性が高いため、MyProjectWatcher のようなものが良い。

すべての RPC で(許可制の)期限を指定して強制する

  • デフォルトでは、RPC はタイムアウトを持たない。リクエストは完了時にのみ解放される。
  • バックエンドリソースを占有するかもしれないため、すべてのリクエストが終了できるようにデフォルトの期限を設定することは良い防御方法。(過去には、これを実施しなかったために、主要なサービスにおいて深刻な問題が発生したこともある)
  • RPC クライアントは、発信する RPC に期限を設定すべきであり、標準的なフレームワークを使用する場合は、通常デフォルトで設定される。
  • デッドラインは、リクエストに付けられたより短いデッドラインによって上書きされることがあり、通常は上書きされる。
rpc Foo(FooRequest) returns (FooResponse) {
  option deadline = x; // グローバルに通用するデフォルトは存在しない
}

リクエストサイズとレスポンスサイズの境界

  • リクエストとレスポンスのサイズには上限を設ける必要がある
    • 8MiB 程度の制限を推奨。2GiB は多くの proto の実装が壊れるハードリミット
  • 無制限のメッセージは
    • クライアントとサーバーの双方を肥大化させる
    • 予測不可能な高いレイテンシーを引き起こす
    • 単一クライアントと単一サーバー間の長時間接続に依存することで、回復性を低下させる
  • API 内のすべてのメッセージを境界付けるいくつかの方法
    • 境界付きメッセージを返す RPC を定義。(この場合、各 RPC 呼び出しは他の RPC 呼び出しから論理的に独立)
    • 制限のないクライアント指定のオブジェクトリストではなく、1 つのオブジェクトで動作する RPC を定義。
    • string、byte、または repeated フィールドに無制限のデータをエンコードしない
    • 長時間動作を定義
    • 結果は、スケーラブルな同時読み取り用に設計されたストレージシステムに保存
    • ページ分割 API を使用 ( 「継続トークンなしでページ区切り API を定義しない」 を参照) 。
    • ストリーミング RPC を使用。

ステータスコードの伝搬は慎重に

RPC サービスは、エラーを調査するために RPC 境界で注意を払い、意味のあるステータスエラーを呼び出し元に返す必要がある

例:

引数をとらない ProductService.GetProducts を呼び出すクライアントを考える。 GetProducts の一部として、ProductService はすべての製品を取得し、各製品に対して LocaleService.LocaliseNutritionFacts を呼び出すかもしれな い。

digraph toy_example {
  node [style=filled]
  client [label="Client"];
  product [label="ProductService"];
  locale [label="LocaleService"];
  client -> product [label="GetProducts"]
  product -> locale [label="LocaliseNutritionFacts"]
}

ProductService の実装が不適切な場合、LocaleService に誤った引数を送信し、INVALID_ARGUMENT が発生する可能性がある。

ProductService が不用意に呼び出し側にエラーを返すと、ステータスコードが RPC 境界を越えて伝播するため、クライアントは INVALID_ARGUMENT を受け取る。しかし、クライアントは ProductService.GetProducts に何の引数も渡していない。つまり、このエラーは役に立たないどころか、大きな混乱を招くことになる

その代わりに、ProductService は、RPC 境界(つまり、実装している ProductService RPC ハンドラ)で受け取ったエラーを照会する必要がある。呼び 出し元から無効な引数を受け取った場合は、INVALID_ARGUMENT を返すようにする。下流のものが無効な引数を受け取った場合、INVALID_ARGUMENT を INTERNAL に変換してから呼び出し元にエラーを返すべきである。

不用意にステータスエラーを伝播させると、混乱を招き、デバッグに多大なコストがかかることになる。さらに悪いことに、すべてのサービスがクライアントエラーを転送し、何のアラートも発生させないという、見えない停止につながる可能性がある。

一般的なルールとして、RPC の境界では、エラーを問い合わせ、適切なステータスコードで、意味のあるステータスエラーを呼び出し側に返すように注意する。意味を伝えるために、各 RPC メソッドは、どのような状況でどのようなエラーコードを返すかを文書化する必要がある。各メソッドの実装は、文 書化された API 契約に準拠する必要がある。

Reapeated フィールドの Tips

Repeated フィールド の返し方

  • repeated フィールドには hasXxx メソッドは存在しない(repeated フィールドが空の場合、クライアントはそのフィールドがサーバによって入力されなかっただけか、フィールドのバッキングデータが本当に空なのか判断できない)
  • 対処:メッセージ内の repeated フィールドをラップすることは、hasXxx メソッドの代替となる
message FooList {
  repeated Foo foos;
}
  • より全体的な解決方法は、フィールド読み取りマスクを使用すること
    • フィールドが要求された場合、空のリストはデータがないことを意味する。
    • フィールドが要求されなかった場合、クライアントは応答のフィールドを無視する必要がある。

Repeated フィールドの修正(更新)

Bad:クライアントに代替リストを供給することを強制すること。

  • クライアントに配列全体の供給を強制することの危険性は何倍にもなる。
  • 不明なフィールドを保持しないクライアントは、データ損失の原因となります。
  • 同時書き込みはデータ損失の原因となる。
  • これらの問題が当てはまらない場合でも、クライアントはドキュメントを注意深く読み、サーバー側でフィールドがどのように解釈されるかを知る必要がある。

修正方法

  • 繰り返し更新のマスクを使用し、書き込み時に配列全体を供給することなく、クライアントが配列に要素を置換、削除、挿入できるようにする。
  • リクエストプロトで、append, replace, delete の各配列を個別に作成する。
  • リクエストでは、追加またはクリアのみを許可する。
    • これを行うには、repeated フィールドをメッセージでラップする。
    • メッセージがあるが空であればクリア、そうでなければ repeated 要素は追加を意味する。

repeated フィールドにおける順序の非依存性

message BatchEquationSolverResponse {
  // Bad: 解決された値は、リクエストで指定された方程式の順序で返される
  repeated double solved_values;
  // (Usually) Bad: solved_valuesの並列配列
  repeated double solved_complex_values;
}

// Good: より多くのフィールドを含むように成長し、他のメソッド間で共有できる独立したメッセージ // リクエストとレスポンスの間の順序依存性が なく、複数のrepeatedフィールド間の順序依存性がない
message BatchEquationSolverResponse {
  // 非推奨。2014年第2四半期までは、この項目が回答として入力され続ける。
  repeated double solved_values [deprecated = true];

  // Good: リクエストの各方程式には一意の識別子があり、
  // 以下のEquationSolutionに含まれている一意の識別子があり、解答は方程式そのものと関連付けることができる。方程式は並行して解かれ、解が作 られるとこの配列に追加される。
  repeated EquationSolution solutions;
}

パフォーマンスの最適化

場合によっては、型の安全性や明確性を性能の向上と引き換えにすることができる。

例えば

  • 何百ものフィールド、特にメッセージ型フィールドを持つプロトは、より少ないフィールドを持つものよりも解析が遅くなる。
  • 非常に深くネストされたメッセージは、メモリ管理だけでデシリアライズに時間がかかることがある。

シリアライズを高速化するためのテクニック:

  • 大きなプロトをミラーリングするが、一部のタグのみを持ち、平行にトリミングされたプロトを作成する。
    • これを、すべてのフィールドが必要でない場合の解析に使用する。
    • トリミングされたプロトはナンバリングの "穴 "が蓄積していくため、タグの番号が一致し続けることを強制するテストを追加する。
  • [lazy=true]でフィールドを "lazily parsed "として注釈をつける。
  • フィールドをバイトとして宣言し、その型を文書化する。フィールドを解析しようとするクライアントは、手動でそれを行うことができる。
    • この方法の危険性は、誰かが間違った型のメッセージを bytes フィールドに入れることを防ぐものがないこと。
    • PII のためにプロトが吟味されたり、ポリシーやプライバシーのためにスクラブされたりするのを防ぐため、ログに書き込まれるプロトでは決してこの方法を取るべきではない

ref:

GraphQL Code Generator まとめ

GraphQL Code Generator まとめ

公式ドキュメント:

インストール&セットアップ

npm install graphql
npm install -D typescript
npm install -D @graphql-codegen/cli

セットアップ(全てデフォルト指定)

$ npx graphql-code-generator init

    Welcome to GraphQL Code Generator!
    Answer few questions and we will setup everything for you.

? What type of application are you building? Application built with React
? Where is your schema?: (path or url) http://localhost:4000
? Where are your operations and fragments?: src/**/*.tsx
? Where to write the output: src/gql
? Do you want to generate an introspection file? Yes
? How to name the config file? codegen.ts
? What script in package.json should run the codegen? codegen
Fetching latest versions of selected plugins...
(node:224940) ExperimentalWarning: buffer.Blob is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)

    Config file generated at codegen.ts

      $ npm install

    To install the plugins.

      $ npm run codegen

    To run GraphQL Code Generator.

生成された codegen.ts

import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  overwrite: true,
  schema: "http://localhost:4000",
  documents: "src/**/*.tsx",
  generates: {
    "src/gql": {
      preset: "client",
      plugins: [],
    },
    "./graphql.schema.json": {
      plugins: ["introspection"],
    },
  },
};

export default config;

設定ファイル

https://the-guild.dev/graphql/codegen/docs/config-reference/codegen-config

設定ファイル生成

npx graphql-code-generator --config ./path/to/codegen.ts

例(複数指定):

import { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  // documents: ['src/**/*.tsx', '!src/gql/**/*'],
  generates: {
    "./src/gql/github/": {
      schema: "https://docs.github.com/public/schema.docs.graphql",
      preset: "client",
      plugins: [],
    },
    "./src/gql/gitlab/": {
      schema: "https://gitlab.com/api/graphql?remove_deprecated=true",
      preset: "client",
      plugins: [],
    },
  },
  overwrite: true,
};

export default config;

documents (GraphQL Document とは)

gql を定義しているファイルを指定する。指定することで Query や Mutations 等の型が生成される。※ 名前付け必須。なければ自動生成対象にならない

GraphQL Document:

  • GraphQL へ投げる query やら mutation やらの string のことを指す
  • 具体的には、 gql に渡している部分
const GET_GREETING = gql`
    query GetGreeting($language: String!) {
        greeting(language: $language) {
            message
        }
    }
`;

ref: https://zenn.dev/link/comments/600791d07ec1a1

namingConvention

出力の命名規則をオーバーライドできる

  • デフォルト:change-case-all#pascalCase
  • lower、upper、camel、pascal 等変更できる
  • 範囲指定:typeNames、enumValues
  • "keep"を使用してすべての GraphQL 名をそのまま保持することもできる(例:enumValues: 'keep')
  • アンダースコアを保持する場合は、transformUnderscore を true に設定
import { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  // ...
  config: {
    namingConvention: {
      typeNames: "change-case-all#pascalCase",
      enumValues: "change-case-all#upperCase",
    },
  },
  // ...
};
export default config;

ref: https://the-guild.dev/graphql/codegen/docs/config-reference/naming-convention

Lifecycle Hooks

codegen で、特定のイベントで実行できるスクリプトを指定可能

import { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: "http://localhost:4000/graphql",
  documents: ["src/**/*.tsx"],
  generates: {
    "./src/gql/": {
      preset: "client",
    },
  },
  //
  hooks: {
    afterOneFileWrite: ["prettier --write"],
  },
};

export default config;

hooks 一覧:

  • afterStart:
    • codegen の開始時に引数なしでトリガー (codegen.ts がロードされた後)。
  • onWatchTriggered:
    • ウォッチモード使用時に、ファイルが変更されるたびにトリガー。イベントのタイプ(例:changed)とファイルのパスの 2 つの引数でトリガー。
  • onError:
    • codegen に一般的なエラーが発生した場合にトリガー。引数はエラーを含む文字列である。
  • beforeAllFileWrite:
    • codegen が出力を作成した後、ファイルをファイルシステムに書き込む前に実行。 複数の引数(関連するすべてのファイルのパス)でトリガーする 。
  • beforeOneFileWrite:
    • ファイルがファイルシステムに書き込まれる前にトリガー。ファイルのパスで実行される。 前回実行時からファイルの内容が変化していない場合、このフックは発動しない。
  • afterOneFileWrite:
    • ファイルがファイルシステムに書き込まれた後にトリガー。ファイルのパスを指定して実行される。ファイルの内容が前回の実行から変更されていない場合、このフックはトリガーされない。
  • afterAllFileWrite:
    • すべてのファイルをファイルシステムに書き込んだ後に実行されます。複数の引数 (すべてのファイルのパス) でトリガー。
  • beforeDone:
    • 引数なしで、codegen が閉じる直前、またはウォッチモードが停止したときにトリー。

出力先単位で指定できるのは以下のみ。他はルートのみ指定可能。

  • beforeOneFileWrite
  • afterOneFileWrite

プラグイン

  • typescript :GraphQL スキーマに基づいて基本 TypeScript タイプを生成
  • @graphql-codegen/typescript-operations:
    • GraphQL Schema と GraphQL の操作とフラグメントに基づいて TypeScript 型を生成。
    • GraphQL ドキュメントの型を生成:Query、Mutation、Subscription、Fragment
  • @graphql-codegen/typescript-urql : urql 用。 ref: urql をさわってみるぞ

apollo の場合は以下っぽい

plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"

コード自動生成

モック データ生成プラグイン

Github と Gitlab のスキーマから自動生成サンプル

import { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  generates: {
    "./src/gql/github/": {
      schema: "https://docs.github.com/public/schema.docs.graphql",
      documents: "src/data/github/github-queries.ts",
      preset: "client",
      plugins: [], // 'typescript'を入れると型が2重出力される
    },
    "./src/gql/gitlab/": {
      schema: "https://gitlab.com/api/graphql?remove_deprecated=true",
      documents: "src/data/gitlab/gitlab-queries.ts",
      preset: "client",
      plugins: [], // 'typescript'を入れると型が2重出力される
    },
    "./src/mocks/github/mock.ts": {
      schema: "https://docs.github.com/public/schema.docs.graphql",
      plugins: [
        {
          "typescript-mock-data": {
            typesFile: "src/gql/github/graphql.ts",
            prefix: "mockGithub",
            terminateCircularRelationships: true, // 循環関係による無限再帰防止
          },
        },
      ],
    },
    "./src/mocks/gitlab/mock.ts": {
      schema: "https://gitlab.com/api/graphql?remove_deprecated=true",
      plugins: [
        {
          "typescript-mock-data": {
            typesFile: "src/gql/gitlab/graphql.ts",
            prefix: "mockGitlab",
            terminateCircularRelationships: true, // 循環関係による無限再帰防止
          },
        },
      ],
    },
  },
  overwrite: true,
};

export default config;

gql

export const GET_GITHUB_REPOSITORIES = gql`
  query GetGithubRepositories($owner: String!) {
    user(login: $owner) {
      repositories(last: 10) {
        nodes {
          name
          url
        }
      }
    }
  }
`;

msw の handlers.ts

// path: src/mocks
import { graphql } from "msw";
// GET_GITHUB_REPOSITORIES から 生成された type
import { GetGithubRepositoriesDocument } from "src/gql/github/graphql";
// User モックデータ生成関数
import { mockGithubUser } from "./github/mock";

export const handlers = [
  graphql.query(GetGithubRepositoriesDocument, (req, res, ctx) => {
    return res(
      ctx.data({
        // 引数で値の上書き、返す関連データの指定が可能
        user: mockGithubUser(),
      })
    );
  }),
];

gRPC:buf とは、buf でできること

gRPC:buf とは、buf でできること

これから使うために、単にまとめた程度

buf とは

以下が可能

  • proto ファイルのチェック
    • lint
    • フォーマット
    • 破壊的変更のチェック
      • Protocol Buffers を変更した際に前方・後方互換性を保っているかのチェック
  • BSR への登録/更新

Protocol Buffers の定義ファイルのチェックは Buf 一択でよいのでは?

※ .proto のフォーマットに clang-format は古い .proto ファイルの整形に clang-format を使う VSCode で proto ファイルのフォーマットをする

インストール

https://buf.build/docs/installation/

Windows の場合:powershell

PS C:\Users\sym> Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
PS C:\Users\sym> irm get.scoop.sh | iex
Initializing...
Downloading ...
Creating shim...
Adding ~\scoop\shims to your path.
Scoop was installed successfully!
Type 'scoop help' for instructions.

PS C:\Users\sym> scoop install buf
Installing 'buf' (1.15.1) [64bit] from main bucket
buf-Windows-x86_64.exe (24.8 MB) [============================================================================] 100%
Checking hash of buf-Windows-x86_64.exe ... ok.
Linking ~\scoop\apps\buf\current => ~\scoop\apps\buf\1.15.1
Creating shim for 'buf'.
'buf' (1.15.1) was installed successfully!
PS C:\Users\sym>

初期設定&各種設定ファイル

モジュールの初期化 ※buf.yaml が自動作成される

 buf mod init

更新 ※ buf.lock が自動生成/更新される

buf mod update

buf.gen.yaml と buf.work.yaml を作成

設定ファイル

フォルダ構成例

.
├── buf.work.yaml
├── buf.gen.yaml
├── paymentapis
│   ├── acme
│   │   └── payment
│   │       └── v2
│   │           └── payment.proto
│   └── buf.yaml
└── petapis
    ├── acme
    │   └── pet
    │       └── v1
    │           └── pet.proto
    └── buf.yaml
.
├── buf.work.yaml
├── buf.gen.yaml
├── proto/
│   ├── buf.md
│   ├── buf.yaml
│   ├── google
│   │   └── type
│   │       └── datetime.proto
│   └── pet
│       └── v1
│           └── pet.proto

buf でできること

サブコマンド一覧

https://buf.build/docs/reference/cli/buf/

破壊的変更検出

Protobuf スキーマの以前のバージョンを現在のバージョンと比較する

設定は buf.yaml の breaking に行う。常に設定を行うこと推奨されている: https://buf.build/docs/breaking/overview/

  • git の main ブランチとの比較(buf.yaml がルートディレクトリ直下にある前提)
 buf breaking --against '.git#branch=main'
  • git のタグを指定しての比較
 buf breaking --against '.git#tag=v1.0.0'
buf breaking --against '.git#tag=v1.0.0,subdir=proto'
  • 特定のファイルのみ比較(複数ファイル指定可能)
 buf breaking --against '.git#branch=main' --path path/to/foo.proto --path path/to/bar.proto
  • git の リモートパスでの比較(CI 時など上記が使えない時に使える)
buf breaking --against 'https://github.com/foo/bar.git'

Github Actions での利用:https://buf.build/docs/ci-cd/github-actions/#buf-setup

docker での実行や、--config オプションでの設定上書き実行等もできる

ref: https://buf.build/docs/breaking/usage/

Lint & Format

  • Lint  ※出力形式はオプションで変更できる
 buf lint

対象ファイルを絞れる

buf lint \
  --path path/to/foo.proto \
  --path path/to/bar.proto
  • Format
buf format -w

※ -w は ファイルを上書きする指定

コード生成

buf.gen.yaml が必要

version: v1
plugins:
  - plugin: buf.build/protocolbuffers/go # buf のリモートプラグイン(最新)を使用
    #  - plugin: buf.build/protocolbuffers/go:v1.28.1  # リモートのプラグインを使用
    #  - plugin: go  # $PATH に protoc-gen-go がある場合
    out: gen/go
    opt: paths=source_relative
  - plugin: buf.build/grpc/go # buf のリモートプラグイン(最新)を使用
    #  - plugin: buf.build/grpc/go:v1.2.0  # リモートのプラグインを使用
    #  - plugin: go-grpc  # $PATH に protoc-gen-go-grpc がある場合
    out: gen/go
    opt:
      - paths=source_relative
      # - require_unimplemented_servers=false

※ connect-go の場合

version: v1
plugins:
  - plugin: buf.build/protocolbuffers/go
    out: gen/go
    opt: paths=source_relative
  - plugin: buf.build/bufbuild/connect-go
    out: gen/go
    opt: paths=source_relative

コマンド

buf generate  #
buf generate <フォルダ>  # .proto が全てサブフォルダにある場合
buf generate buf.build/acme/petapis
  • 使用ファイルの指定
buf generate --template buf.gen.go.yaml
buf generate --template buf.gen.java.yaml
  • 出力先の指定
buf generate https://github.com/foo/bar.git --template data/generate.yaml -o bar

--path でのファイル指定や、docker 実行等も可能 ref : https://buf.build/docs/generate/usage/#5.4.-limit-to-specific-files

ローカルでコード生成せず、BSR のコードを利用

BSR への登録 ref: https://buf.build/docs/bsr/module/dependency-management/#add-a-dependency

buf.gen.yaml は削除

 package main

 import (
     "context"
     "fmt"
     "log"

-    // This import path is based on the name declaration in the go.mod,
-    // and the gen/proto/go output location in the buf.gen.yaml.
-    petv1 "github.com/bufbuild/buf-tour/petstore/gen/proto/go/pet/v1"
+    petv1 "buf.build/gen/go/<USERNAME>/petapis/protocolbuffers/go/pet/v1"
+    "buf.build/gen/go/<USERNAME>/petapis/grpc/go/pet/v1/petv1grpc"
     "google.golang.org/grpc"
 )

ビルド

json やバイナリ(bin), zip, tar 等出力できる

オプションなしは何も出力しない

buf build

オプションなしは以下と同じ

protoc \
    -I proto \
    -I vendor/protoc-gen-validate \
    -o /dev/null \
    $(find proto -name '*.proto')

buf build の場合のフォルダ構成と buf.work.yaml

.
├── buf.work.yaml
├── proto
│   ├── acme
│   │   └── weather
│   │       └── v1
│   │           └── weather.proto
│   └── buf.yaml
└── vendor
    └── protoc-gen-validate
        ├── buf.yaml
        └── validate
            └── validate.proto
# buf.work.yaml
version: v1
directories:
  - proto
  - vendor/protoc-gen-validate

パッケージ一覧表示

buf build -o -#format=json | jq '.file[] | .package' | sort | uniq | head
$ buf build -o image.bin
$ buf build -o image.json
$ buf build -o image.zip
$ buf build -o image.tar

※ 指定可能フォーマット:https://buf.build/docs/reference/inputs/format/#options

出力対象を絞ったり、docker 実行等もできる

ref: https://buf.build/docs/build/usage/

buf curl による API 実行

Connect、gRPC、または gRPC-Web サーバで RPC を呼び出せる

buf curl \
    --data '{"sentence": "I feel happy."}' \
    https://demo.connect.build/buf.connect.demo.eliza.v1.ElizaService/Say

デフォルトの RPC プロトコルは Connect。変更可能

 buf curl --protocol grpc --http2-prior-knowledge \
    http://localhost:20202/foo.bar.v1.FooService/DoSomething

※ http を使用する場合、HTTP/1.1 が使用されるため、HTTP/2 を使うためには --http 2-prior-knowledge が必要

buf curl の実行は、サーバがサーバリフレクションサービスを公開することを期待する。ない場合は、--schema の指定が必要。値は BSR でも、Git リポジトリでも、ローカルでも可

buf curl \
   --schema buf.build/bufbuild/eliza \
   --data '{"name": "Bob Loblaw"}' \
   https://demo.connect.build/buf.connect.demo.eliza.v1.ElizaService/Introduce

サーバリフレクションとは

ref: https://buf.build/docs/curl/usage/

BSR への 登録/更新

前提: https://buf.build/settings/user にアクセスして トークンを作成

  • ログイン
buf registry login
  • 画面からリポジトリ作成
  • buf.yaml があるフォルダに移動
  • buf.yamlname: <リポジトリ名> を追加
  • buf push  (更新する度実行)

タグ付けも可能

buf push --tag "$(git rev-parse HEAD)"

依存関係がある場合は、以下で更新可能

buf mod update

ref:

分岐を低減する interface 設計 勉強会メモ

分岐を低減する interface 設計 勉強会メモ

勉強会メモ

  • 2023/3/3 エンジニア文化祭
  • 分岐を低減する interface 設計と発想の転換

以下を多分に引用

ref: Speaker Deck - 分岐を低減する interface 設計と発想の転換

前提:題材とする仕様

既存ロジックに分岐をねじ込むのは NG

顧客によって仕組みのニーズはバラバラ。 顧客ニーズの吸収するために

既存存ロジックに分岐をねじ込む = NG

if (赤外線センサー.有効() && 赤外線センサー.検知()) {
  if (SMS通知.有効()) {
    SMS通知.実行();
  }
  if (メッセージアプリ通知.有効()) {
    メッセージアプリ.実行()
  } else if (窓ガラス破損.有効() && 窓ガラス破損.検知()) {
    if (SMS通知.有効()) {
      SMS通知.実行();
    }
  }
  if (メッセージアプリ通知.有効()) {
    メッセージアプリ通知.実行()
  }
}

Interface を活用することで、分岐低減につながる

Interface 設計

Interface 設計の要点2本柱

  • 目的単位で抽象化すること
  • 「作る」と「使う」を分ける

システムは目的達成の手段

  • その構成要素たるクラスもまた目的達成手段

1.目的単位で抽象化

目的の具体化(ツリー構造化)

目的 → 課題 → 対策(解決策)

  • 目的が決まると課題が浮き彫りになる
  • 課題に対処するための解決手段が決まる
  • 目的が違うと課題も解決手段も違ってくる

目的達成手段ベースで Interface 設計流れ

前提:

  • Interface =目的達成手段を抽象化したもの
  • Class = 具体的手段

  1. 仕様を目的別に分類

  1. 目的で抽象化

通知目的

侵入検知目的

映像分析目的

全体

2.「作る」と「使う」を分ける

良くないロジックは、「何を使うかを判断する分岐」と「実行するロジック」が混在(あみだくじ状態)

  • 「作る」側
    • どの部品(interface 実装クラス)を使用するかの判断は Factory や DI コンテナに任せる
    • 部品選択と組み立て(インスタンス生成)は「作る」側にカプセル化
  • 「使う」側
    • どの部品(interface 実装クラス)が使われているかは一切気にせず判断もしない
    • 渡されたインスタンスの実行だけに徹する

クラスとコード

通知

// 通知手段(インターフェース)
interface Alarmable {
  void alarm();
}
// 使う側
// ファーストクラスコレクション。 destination:行き先
class AlarmDestinations {
  private final List<Alarmable> alarmDestinations;
  AlarmDestinations() {
    alarmDestinations = new ArrayList<>();
  }
  void add(Alarmable alarmDestination) {
    alarmDestinations.add(alarmDestination);
  }
  void alarm() {
    // 実態が何であるかは知らないが、ただ実行するだけ
    alarmDestinations.forEach(Alarmable::alarm);
  }
}
// 作る側
class AlarmDestinationsFactory {
  static AlarmDestinations create() {
    AlarmDestinations alarmDestinations = new AlarmDestinations();
    // どの実装クラスを使うか判断して組み込む
    if (SmsAlarm.isEnabled()) {
      alarmDestionations.add(new SmsAlarm());
    }
    if (SmartphoneAppAlarm.isEnabled()) {
      alarmDestionations.add(new SmartphoneAppAlarm());
    }

    return alarmDestinations;
  }
}

侵入検知

// 侵入検知(インターフェース)
interface TrespassDetectable {
  boolean detect();
}
// 使う側
// 侵入検知Interfaceのファーストクラスコレクション
class TrespassDetectors {
  private final List<TrespassDetectable> detectors;
  TrespassDetectors() {
    detectors = new ArrayList<>();
  }
  void add(TrespassDetectable detector) {
    detectors.add(detector);
  }
  boolean detect() {
    // 中身は気にしない。ただ実行するだけ
    return detectors.stream().anyMatch(TrespassDetectable::detect);
  }
}
// 作る側
class TrespassDetectorsFactory {
  static TrespassDetectors create() {
    TrespassDetectors trespassDetectors = new TrespassDetectors();

    if (InfraredSensor.isEnabled()) {  // 赤外線センサー
      trespassDetectors.add(new InfraredSensor());
    }
    if (GlassDestructionSensor.isEnabled()) {  // ガラス破損センサー
      trespassDetectors.add(new GlassDestructionSensor());
    }
    if (MonitorCamera.isEnabled()) {  // 監視カメラ
      PatternDetectors patternDetectors = PatternDetectorsFactory.create();
      trespassDetectors.add(new MonitorCamera(pattenDetectors());
    }

    return trespassDetectors;
  }
 }

侵入検知のうちの映像分析

class MonitorCamera implements TrespassDetectable {
  private final VideoRecoder videoRecorder;  // 映像記録
  private final PatternDetectors patternDetectors;  // パターン検知

  MonitorCamera(PatternDetectors patternDetectors) {
    videoRecorder = new VideoRecorder();
    this.patternDetectors = patternDetectors;
  }
  public boolean detect() {
    // ただ検知を実行するだけ。※階層構造であろうと同様にできる
    return patternDetectors.detect();
  }
}

全体のトップクラス

// 使う側
class HomeSecurityService {
  private final TrespassDetectors trespassDetectors;
  private final AlarmDestinations alarmDestinations;

  HomeSecurityService(
      TrespassDetectors trespassDetectors,
      AlarmDestinations alarmDestinations) {
    this.trespassDetectors = trespassDetectors;
    this.alarmDestinations = alarmDestinations;
  }

  void execute() {
    // どんな侵入検知機能や通知機能を持っているかは知らない。
    // 侵入検知して通知するだけ
    if (trespassDetectors.detect())  {
      alarmDestinations.alarm();
    }
  }
}
// HomeSecurityService の初期化
var trespassDetectors = TrespassDetectorsFactory.create();
var alarmDestinations = AlarmDestinationsFactory.create();

HomeSecurityService homeSecurityService = new HomeSecurityServcie(
  trespassDetectors, alarmDestinations);

目的達成手段のバリエーションと機能性

(私生活同様)状況に応じて最適な手段(interface 実装クラス)を選択

  • interface 設計可能な箇所は、機能性向上の可能性を示唆
    • 同一目的でも、より顧客にリーチする機能(クラス)に置き換えられる可能性を秘めている

その可能性を秘めている個所、例えば

  • 仕様変更で頻繁にコード変更をしている個所
    • モードやバリエーションを切り替えるための分岐が複雑化している個所

そうした箇所は、整理して interface にすると、

  • より高機能なものへ素早く置き換えられる
  • =開発生産性が高まる

まとめ

  • システムは目的達成手段。その構成要素たるクラスや interface も目的達成手段
    • interface は目的単位で抽象化
    • interface 実装クラスは同一目的に対する達成手段のバリエーション
  • interface を用いる場合は、「作る」側と「使う」側を分ける
    • 「作る」側は、どの interface 実装クラスを用いるかの判断を Factory や DI コンテナとして持たせ、すべてを組み込んだ完成品を生成する
    • 「使う」側は、完成品をただ実行するだけのシンプルな構造にする。部品(interface 実装クラス)が何であるかは気にしない
  • interface 設計した箇所はより高機能なものへ置換できる可能性を秘めている
  • interface は銀の弾丸ではない。基本的には「手段のバリエーション」という観点での設計を推奨

目的達成手段を interface として定義。具体的手段(ロジック含む) を interface 実装クラスで定義

具体的手段を条件に応じて、切り替えることで、柔軟性と高機能性を得られる?(Strategy パターン)

データ指向アプリケーションデザイン 勉強会メモ

データ指向アプリケーションデザイン 勉強会メモ

勉強会の内容+ α(メモ)

ref:

データ指向

  • オブジェクト指向:オブジェクトを中心にプログラムを設計
  • データ指向:「データ量」「データの複雑さ」「データの変化速度」を中心に考える設計

データ表現

データ量少ない場合:データの表現、生成しやすさを重視

  • テキストデータフォーマット:CSV, JSON, XML など
  • バイナリデータフォーマット:MessagePack, Thrift, Protocol Buffers, Apache Avro
    • (データとして完結している)自己記述型(self-describinig)データフォーマット
      • 例外:ProtocolBuffers はデータ自体にスキーマを含まない(self-describinig ではない)

データ量多い場合(データ基盤):省メモリ、圧縮しやすさ、ストレージの格納しやすさを重視 データを節約してコンパクトにする方法

  • テーブルフォーマット
  • 行指向(row-oriented)フォーマット
    • RDBMS でよく使われる
    • 1行ずつ表現
    • レコード単位で更新しやすい
  • 列指向(column-oriented)フォーマット
    • 同じ型のデータを集めると圧縮しやすい
    • 分析クエリに特化したフォーマット
      • 高速(キャッシュに乗りやすい)
      • SIMD 演算(1つの命令で複数演算器を動かすコンピュータ演算のやり方)も活用しやすい
    • 単一レコードを複数のカラムブロックで表現するため更新が大変(オーバヘッドも高)
    • 例:Apache Request

データアクセス頻度

読込が頻繁

  • 変化しない = 不変データ(イミュータブル)
    • 蓄積されるログ(列指向フォーマットと相性〇)
  • 読み込み特化型 (read-intensive)
    • データへの高速なアクセス、分析の速さが問われる
    • 高速化手法(データを分散)
      • パーティショニング(シャーディング)
        • データを時間範囲(1h, 1d 等)、キー範囲などで分割
        • 必要なデータのみにアクセスが済む、別領域に分かれるため並列アクセス可(高速化)
      • レプリケーション
        • 同じデータを複数個所(マシン、ディスク)に配備し、並列アクセス&耐障害性を確保
      • 実体化ビュー(Materialized View)
        • 一度計算したクエリ結果を保存、再利用可能にする

更新が頻繁(データが 1 か所の場合)

  • 書き込み特化型(write-intensive)

  • 更新対象を素早く見つけられる必要がある = 高速検索

    • → 解決策:インデックス
      • ディスク上のデータを高速に見つけるためのデータ構造
      • B-Tree、Hash index、SSTable(Sorted String Table)
B-Tree
  • ディスク上のページに、次ページへのルックアップテーブルを格納
    • RDBMS では 基本 CREATE INDEX 命令で作られる
    • ※ 1 回の CREATE INDEX で 1 つの B-Tree が作られる。B-Tree を複数作ると更新時にそれらすべて更新する必要があるのでその分更新速度が落ちる
  • 高い更新性能を発揮。空き領域にデータ挿入しやすいため
  • ページ数が増えても木の深さはあまり増えないため、ディスクのランダムアクセスを減らせる=アクセスの回数を減らすのに役立つ
発展:Log-Structured Merge (LSM) Tree

さらに更新が頻繁な場合にも対応できる。(最近よく使われている)

  • レコードの追加、コンパクション操作(恐らくメモリコンパクション=空き領域の断片化を解消して連続した広い空間に再編。メモリ上を整理して合成する操作)を分離
    • ランダムアクセスに強いメモリ上にどんどん追加。更新される旅にメモリ上をソート
    • バックグラウンドで、定期的にソート済みのデータと合成(marge sort)して次レイヤーに保存(L0,L1,… = ディスク、外部ストレージなど)
  • 性能特性
    • 更新時のランダムアクセスを減らせる(シーケンシャルなアクセスなため)
    • 各種オーバヘッドがある
      • 読み込み時に各レイヤーにアクセスする必要がある
      • 書き込みの増幅(Write Amplification)が生じる (次レイヤーに書き出すため同じデータの書き込みが複数回生じる)

例:LevelDB、RocksDB(C++)、AWS S3(Rust) など

分散データ(データが複数個所)

データを複数個所(複数ノード)に分散する場合、考えるべき問題の複雑さが数段上がる

  • ノード間でデータを送受信するために、分散システムが必須
    • 分散ステートマシン=各ノードが同じ状態になる必要がある(membership)
    • (サーバ間のデータを整える目的で) サーバ・クライアント間でのデータ通信も必要(HTTP 上で実装されることが多い)
  • 複数のノードで頻繁に更新がある場合
    • 同期(ロック取得、タイムアウトトランザクション、ログのリプレイ)
    • どのノードの状態が正解かを決めるための合意(consensus)プロトコルが必要
      • (基本的には) 多数決(Quorum)で決める。 Paxos、2PC など
      • あるいは、そもそも調整を避ける(conrdination avoidance)手法もある
  • 障害対応
    • エラーハンドリング
    • エラー時のリトライ
    • 冪等性(同じ操作を 2 回繰り返しても大丈夫なように設計)
    • 障害からの復旧・リカバリ
    • ノードが嘘を付く(間違ったデータを返す)ビザンチン障害と呼ばれる

以上を踏まえて、システムが正しく動作するように設計する必要がある

24 時間 365 日稼働するサービス設計

  • 稼働率:許容されるダウンタイム
    • 99.9 % availability = 8.7 時間/年
    • 99.99 % availability = 53 分/年
    • 99.999 % availability = 5 分/年
  • アプリケーションのデプロイ (その際には止める必要があるが)
    • サービスが動き続ける必要がある
      • =複数バージョンのアプリケーションが同時稼働する(できる必要がある)
    • デプロイ中、あるいはクラッシュ時に通信が遮断される
      • (そのため) クライアント側でリトライ(再実行)処理を実装する必要がある
        • アップグレード中にリクエストが失敗してもリトライすることで次バージョンにトラフィックを流すことができる
  • 冪等性
    • リトライが起こる前提で設計
      • リクエストが成功したがレスポンスが通信障害などによりクライアントに届かなかった場合に、リトライされても問題ないようにする必要がある
      • =同じ操作(リクエスト)が繰り返されても、データを重複登録させない
    • リクエストに UUID などを付けて重複チェック
Compute - Storage の分離 (常にデプロイし続けるための設計)
  • 近年の標準
  • 常にデプロイし続けられるよう、クエリ実行(Compute)とストレージ(Storage)を分離
    • クエリエンジンとストレージを別々にスケールしたり、ローリングアップグレード(同じ機能を持った複数サーバ構成のシステムをアップデートする手法の一つで、システムの稼働状態を維持しながら1台ずつ順番にアップデートしていく方法)

例:SnowflakeAmazon Redshift(データウェアハウスサービス)など

分散トランザクション

そもそもトランザクション処理
  • ACID :Atomicity(不可分性)、Consistency(一貫性)、Isolation(独立性/分離性)、Durability(永続性)
  • 分離性(Isolation)が最も重要
    • 直列化可能性(Serializability)が基本
      • トランザクションが1つずつ実行されたのと変わらない状態を維持
      • オーバヘッドが大きいため現実的には使われにくい(使う場合は考える必要がある)
    • 解消する手段:弱い分離性
      • Read-Committed、Snapshot Isolation、MVCC(Multi-version concurrency contorol)
    • 妥協しないアプローチ:Serializable Snapshot Isolation (SSI)
      • トランザクション異常(anomaly)の種類
        • ファントム(まだ存在しないレコードに、どうロックをかけるか)※SSI だと防げない
        • Write Skew(同じスナップショットを読んで、違う場所に書き込む)
      • Repeatable Read (ダーティリード:なし、ノンリピータブル:なし、ファントム:あり)とは違う
分散トランザクションの世界
  • 複数ノード間、または複数システム間でトランザクションを実装
    • 一貫性、合意、耐障害性
    • 線形化可能性(linearizability)が重要
      • レプリカが複数あっても、ユーザにはコピーが 1 つしかないように見せる
      • 更新操作が終了した途端、全ノードが同じデータを見られるように保証する
    • 実装するために必要な技術要素 (本 9 章)
      • リーダー選出(leader election)
      • サービスディスカバリ
      • メンバーシップ管理
    • 使用例:Zookeeper、etcd(Kubernetes 内部で使用)
    • 異なった方向性のアプローチ(2022 ~)
極み:Amazon Aurora (SIGMOD 2018)

AWS Aurora: AWS Aurora とは?その特徴とは?

  • クラウド向けリレーショナルデータベース
  • 分散版 MySQL
    • 6つのレプリカを作成
    • 2 copy × 3 AZs 。AZ が一つ落ちても quorum (多数決) が取れる = 4/6
  • MySQL のデータベース操作を「ログ書き込み」のみに特化して簡略化
    • ストレージノードがログを分配、各インスタンスでログをリプレイして同じデータを持つ
      • データのコピーには gossip プロトコル(ランダムにノードを選んで配信、同期システムなしに実装できる)
    • さらに、トランザクション処理も同期をなるべく取らずに処理(Coordination Avoidance)
      • 各ノードの返信を待つ 2 相コミット(2PC)を使わなくても良いよう、MySQL の裏側のストレージ全体を再設計

データの複雑さ

テーブルの意味の変化

  • 従来:RDBMS にある最新データ(Snapshot)
  • 現代:
    • 時間とともに変化するデータ、そこから表す派生するデータ(derived data)を表す
  • 列指向クエリエンジンの発展により、分析クエリが容易になり導出データ(derived data)が大量に
  • バッチ処理とストリーム処理の区別がなくってくる位いろんなデータが作られる

導出データ (derived data)

  • 1つデータから数千の導出データが作られることも (実例:Treasure Data 社)
  • クエリで生成されるデータの依存関係や、データ履歴を管理する必要がある
    • 解決法:
      • dbt:クエリの依存関係を記述できる SQL compiler
      • 新しい Table Format:Delta Lake、iceberg、Apache Hudi など
        • テーブルの更新履歴の確認やスナップショットの管理(time travel)ができる

分散バッチ処理

バッチ処理Unix コマンドでのデータ処理に近い(コマンドのパイプ繋ぎ)

分散(複数ノード)で実行するアイディア

  • Spark (2009)
    • Microsoft DryadLINQ (2008) のアイディアがベース
    • UNIX コマンドのような命令(小さいプログラム)を並列・分散実行するイメージ
  • MapReduce (Google 2004, Hadoop 2006)
    • Mapper (key-value)のペアを出力。
    • Reducer:同じ Key に属する Value を集めて集計結果を出力
    • フレームワーク側が自動で分散実行
      • MapSide Join、Broadcast/Hash Join など様々なテクニックが誕生。SQL も実行可(Hive)

ストリーム処理

  • バッチ処理:保存されているデータに対してクエリ実行
  • ストリーム処理:
    • クエリをデータの入力側に送り、常時クエリを実行し続ける
    • あるいはデータの変更をとらえ処理する。 マイクロバッチ、CDC(Charge Data Capture)
  • (上記の概念を上手にまとめたのが )Dataflow Model の概念が必要
    • 時系列データ処理方法の分類・パターンの定義
    • 遅れてやってくる(late arraival)データの処理を埋め合わせる必要性を提示
      • event time(データを捉えた時刻), processing time(データがシステムで見えるようになった時刻)を 2 次元の watermark で管理
      • 2 次元の軸を分割してもれなくデータを処理する

SLO:システムパフォーマンスのはかり方

事業者が定義・合意した SLA を履行するために、サーバーやネットワーク、ストレージなどの各領域の稼働率、性能、可用性、セキュリティ、サポートといった項目ごとに、パフォーマンスの目標値

  • 平均値を見るだけでは、データ規模が大きく性能が遅くなりやすい重要顧客の様子が見えてこない
  • p99.99 の極端な領域での改善は、コストの割りに利益を生まないこともある

まとめ

  • データ指向アプリケーションデザイン
    • 分散データシステム入門の決定版
    • より深い世界に踏み込むための手引き
    • 最新は自分で情報収集する必要あり。英語論文など