はじめに
この記事は DeNA 23 新卒 Advent Calendar 2022 の 19日目の記事です。
OpenAPI から TypeScript のコードを自動生成する際に便利な swagger-typescript-api の使い方について紹介します!
OpenAPI とは
REST APIの仕様を記述するためのフォーマットのことです。
OpenAPIを適用することで、OpenAPIに対応したAPI開発ツールやサービスを利用することができ、APIの開発・運用がより効率的かつスムーズになります。
今回紹介する、型の自動生成も、OpenAPIを適用することで得られる恩恵の1つです。
swagger-typescript-api の使い方
swagger-typescript-api は OpenAPI のスキーマから TypeScript の型生成するツールの一つです。
cli から実行する方法と、スクリプトで実行する方法があります。
npx swagger-typescript-api -p ./swagger.yaml -o ./src -n myApi.ts
import path from "path";
import sta from "swagger-typescript-api";
sta
.generateApi({
url: path.resolve(__dirname, "swagger.yaml"),
name: "myApi.ts",
})
openapi: 3.0.0
info:
title: OpenAPI example
description: "OpenAPI example"
version: 1.0.0
servers:
- url: https://example.com/api/v1
paths:
/user/{username}:
get:
operationId: getUserByName
summary: Get user by user name
parameters:
- name: username
in: path
required: true
schema:
type: string
responses:
"200":
description: successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id:
type: integer
format: int64
example: 1
username:
type: string
example: user1
email:
type: string
example: user1@example.com
phone:
type: string
example: "000-0000-0000"
出力結果
リクエスト/レスポンスの型と、型を含むAPIクライアントが生成されます。
/* eslint-disable */
/* tslint:disable */
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
export interface User {
/**
* @format int64
* @example 1
*/
id?: number;
/** @example "user1" */
username?: string;
/** @example "user1@example.com" */
email?: string;
/** @example "000-0000-0000" */
phone?: string;
}
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
}
export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">;
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (securityData: SecurityDataType | null) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown> extends Response {
data: D;
error: E;
}
type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = "https://example.com/api/v1";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) => fetch(...fetchParams);
private baseApiParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
};
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]);
return keys
.map((key) => (Array.isArray(query[key]) ? this.addArrayQueryParam(query, key) : this.addQueryParam(query, key)))
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input,
[ContentType.Text]: (input: any) => (input !== null && typeof input !== "string" ? JSON.stringify(input) : input),
[ContentType.FormData]: (input: any) =>
Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob
? property
: typeof property === "object" && property !== null
? JSON.stringify(property)
: `${property}`,
);
return formData;
}, new FormData()),
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
};
protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
};
public request = async <T = any, E = any>({
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken,
...params
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
const secureParams =
((typeof secure === "boolean" ? secure : this.baseApiParams.secure) &&
this.securityWorker &&
(await this.securityWorker(this.securityData))) ||
{};
const requestParams = this.mergeRequestParams(params, secureParams);
const queryString = query && this.toQueryString(query);
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format;
return this.customFetch(`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, {
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}),
},
signal: cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal,
body: typeof body === "undefined" || body === null ? null : payloadFormatter(body),
}).then(async (response) => {
const r = response as HttpResponse<T, E>;
r.data = null as unknown as T;
r.error = null as unknown as E;
const data = !responseFormat
? r
: await response[responseFormat]()
.then((data) => {
if (r.ok) {
r.data = data;
} else {
r.error = data;
}
return r;
})
.catch((e) => {
r.error = e;
return r;
});
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
if (!response.ok) throw data;
return data;
});
};
}
/**
* @title OpenAPI example
* @version 1.0.0
* @baseUrl https://example.com/api/v1
*
* OpenAPI example
*/
export class Api<SecurityDataType extends unknown> extends HttpClient<SecurityDataType> {
user = {
/**
* No description
*
* @name GetUserByName
* @summary Get user by user name
* @request GET:/user/{username}
*/
getUserByName: (username: string, params: RequestParams = {}) =>
this.request<User, any>({
path: `/user/${username}`,
method: "GET",
format: "json",
...params,
}),
};
}
ちなみに、swagger-typescript-api 自体がブラウザ環境のみでの使用を想定しているのか、 現状は Node環境(node-fetch) は対応していないようです。デフォルトでは fetch, axios に対応しています。 (2022/12現在)
つまり、生成された以下のコードはブラウザ環境で動かすことを想定してます。
Node環境向けにコード生成したい場合は、後述するカスタムテンプレートで実現できます。
カスタムテンプレートについて
swagger-typescript-api では、テンプレート(ejs)を元にコードを自動生成しています。
デフォルトではレポジトリの /templates 配下が使用されており、これを修正することでコードの生成結果を変えることが出来ます。
APIのオプションからでも、型構造を設定したり、フックを利用して独自の処理を追加することが可能なので、これらで解決するのが難しい場合に、カスタムテンプレートを検討するのがいいと思います。
テンプレートを修正する手順
How to use it:
- copy swagger-typescript-api templates into your place in project
- from /templates/default for single api file
- from /templates/modular for multiple api files (with --modular option)
- from /templates/base for base templates (templates using both in default and modular)
- add --templates PATH_TO_YOUR_TEMPLATES option
- modify ETA templates as you like
https://github.com/acacode/swagger-typescript-api#--templates
以下のコマンドで、デフォルトのテンプレートを任意のディレクトリ配下に生成できます。
❯ npx swagger-typescript-api generate-templates -o ./templates/default
# ❯ tree ./templates/default
# templates/default
# ├── api.ejs
# ├── data-contract-jsdoc.ejs
# ├── data-contracts.ejs
# ├── enum-data-contract.ejs
# ├── http-client.ejs
# ├── interface-data-contract.ejs
# ├── object-field-jsdoc.ejs
# ├── procedure-call.ejs
# ├── route-docs.ejs
# ├── route-name.ejs
# ├── route-type.ejs
# ├── route-types.ejs
# └── type-data-contract.ejs
このテンプレートを修正することで、生成されるコードを変えることが出来ます。
実践: SWR 向けのコードを自動生成する
SWRについて
SWR は、データ取得のための React Hooks ライブラリです。
たった 1 行のコードで、プロジェクト内のデータ取得のロジックを単純化し、さらにこれらの素晴らしい機能をすぐに利用できるようになります:
- 速い、 軽量 そして 再利用可能 なデータの取得
- 組み込みの キャッシュ とリクエストの重複排除
- リアルタイム な体験
- トランスポートとプロトコルにとらわれない
- SSR / ISR / SSG support
- TypeScript 対応
- React Native
データ取得状態の管理や、データキャッシュを簡単に扱うことができて便利です。
環境構築
まずは React の環境を準備します。今回は vite を使用します。
❯ npm create vite@latest
✔ Project name: … .
✔ Select a framework: › React
✔ Select a variant: › TypeScript
❯ npm i swr
ここでは、 OpenAPI を公開している api.spacex.land へのリクエストを実装します。
とりあえず自動生成してみる
まずは、swagger-typescript-api も SWR も使用しない例から。
import { useEffect, useState } from "react";
type Dragon = ... // APIの実装に合わせた型定義が必要
const BASE_URL = "https://api.spacex.land";
const App = () => {
const [dragon, setDragon] = useState<Dragon | null>(null);
useEffect(() => {
(async () => {
const res = await fetch(`${BASE_URL}/dragon/dragon1`);
const data = await res.json();
setDragon(data);
})();
}, []);
if (dragon === null) {
return <h2>Loading...</h2>;
}
return <div>{dragon.name}</div>;
};
export default App;
https://api.spacex.land/dragon/dragon1
に対して GET する例です。(useEffect を使用したデータ取得になっていて良い例とは言えませんが、本題と関係ないので一旦無視して下さい)
このままでは、取得するデータの構造が分からないので、型をつけられません。
ここで swagger-typescript-api の出番です。公開されている OpenAPI を元に、コードを自動生成します。
❯ npm i swagger-typescript-api
❯ npm i -D ts-node @types/node
import path from "path";
import { fileURLToPath } from "url";
import sta from "swagger-typescript-api";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
sta
.generateApi({
name: "SpaceXLand.ts",
url: "https://raw.githubusercontent.com/SpaceXLand/api/master/swagger.json",
output: path.resolve(__dirname, "../src/__generated__"),
})
.catch((err) => {
console.error(err);
});
"scripts": {
︙
"generate-types": "ts-node -P ./tsconfig.node.json --esm ./script/generate-types.ts"
},
❯ npm run generate-types
これを実行すると、src/__generated__/SpaceXLand.ts
に型付きのAPIクライアントが生成されます。
先程のデータ取得のコードで、生成された型を利用してみます。
import { useEffect, useState } from "react";
import { Api, Dragon } from "./__generated__/SpaceXLand";
const BASE_URL = "https://api.spacex.land";
const api = new Api({
baseUrl: BASE_URL // OAS 側で設定されていれば指定しなくてOK
});
const App = () => {
const [dragon, setDragon] = useState<Dragon | null>(null);
useEffect(() => {
(async () => {
const res = await api.rest.dragonQuery("dragon1");
setDragon(res.data);
})();
}, []);
if (dragon === null) {
return <h2>Loading ...</h2>;
}
return <div>{dragon.name}</div>;
};
export default App;
これで、OpenApI の情報を元に型安全にリクエストを送ることが出来ました。
これだけでも十分便利ですが、応用としてカスタムテンプレートでライブラリ向けのコードを自動生成してみたいと思います。
SWR 向けにテンプレートを修正
現状の生成されたコードを、fetcher(useSWRの第2引数)として扱えば、取得するデータには既に型がついています。
︙
const fetcher = () => api.rest.dragonQuery("dragon1").then((r) => r.data);
const App = () => {
const { data, isLoading } = useSWR(key, fetcher); // fetcher の型から推論され data にも型がついている
︙
しかし、key(useSWRの第1引数)の指定で、パスを知っている必要があり不便です。
(fetcher からはパスが隠蔽されているのでもったいない)
︙
const key = "/dragon/dragon1"; // 結局ここでパスが必要
const fetcher = () => api.rest.dragonQuery("dragon1").then((r) => r.data);
const App = () => {
const { data, isLoading } = useSWR(key, fetcher);
︙
fetcherと合わせて、パスを自動生成コード側に隠蔽し、使う側をもう少しシンプルにするのが目標です。
完成形のイメージ
import useSWR from "swr";
import { Api } from "./__generated__/SpaceXLand";
const BASE_URL = "https://api.spacex.land";
const api = new Api({
baseUrl: BASE_URL
});
const App = () => {
// const [key, fetcher] = api.rest.dragonQuerySWRArags("dragon1")
const { data, isLoading } = useSWR(...api.rest.dragonQuerySWRArags("dragon1"));
if (!isLoading && data) return <div>{data.name}</div>;
};
export default App;
実際にテンプレートを修正します。
デフォルトのテンプレートでは、ここでリクエストメソッドを定義しているので、これに追記する形で出力結果をカスタマイズしていきます。
<%
const { utils, route, config } = it;
const { requestBodyInfo, responseBodyInfo, specificArgNameResolver } = route;
const { _, getInlineParseContent, getParseContent, parseSchema, getComponentByRef, require } = utils;
const { parameters, path, method, payload, query, formData, security, requestParams } = route.request;
const { type, errorType, contentTypes } = route.response;
const { HTTP_CLIENT, RESERVED_REQ_PARAMS_ARG_NAMES } = config.constants;
const routeDocs = includeFile("@base/route-docs", { config, route, utils });
const queryName = (query && query.name) || "query";
const pathParams = _.values(parameters);
const pathParamsNames = _.map(pathParams, "name");
const isFetchTemplate = config.httpClientType === HTTP_CLIENT.FETCH;
const requestConfigParam = {
name: specificArgNameResolver.resolve(RESERVED_REQ_PARAMS_ARG_NAMES),
optional: true,
type: "RequestParams",
defaultValue: "{}",
}
const argToTmpl = ({ name, optional, type, defaultValue }) => `${name}${!defaultValue && optional ? '?' : ''}: ${type}${defaultValue ? ` = ${defaultValue}` : ''}`;
const rawWrapperArgs = config.extractRequestParams ?
_.compact([
requestParams && {
name: pathParams.length ? `{ ${_.join(pathParamsNames, ", ")}, ...${queryName} }` : queryName,
optional: false,
type: getInlineParseContent(requestParams),
},
...(!requestParams ? pathParams : []),
payload,
requestConfigParam,
]) :
_.compact([
...pathParams,
query,
payload,
requestConfigParam,
])
const wrapperArgs = _
// Sort by optionality
.sortBy(rawWrapperArgs, [o => o.optional])
.map(argToTmpl)
.join(', ')
+ const wrapperCallArgs = _
+ // Sort by optionality
+ .sortBy(rawWrapperArgs, [o => o.optional])
+ .map(({ name, optional, type, defaultValue }) => name)
+ .join(', ')
// RequestParams["type"]
const requestContentKind = {
"JSON": "ContentType.Json",
"URL_ENCODED": "ContentType.UrlEncoded",
"FORM_DATA": "ContentType.FormData",
"TEXT": "ContentType.Text",
}
// RequestParams["format"]
const responseContentKind = {
"JSON": '"json"',
"IMAGE": '"blob"',
"FORM_DATA": isFetchTemplate ? '"formData"' : '"document"'
}
const bodyTmpl = _.get(payload, "name") || null;
const queryTmpl = (query != null && queryName) || null;
const bodyContentKindTmpl = requestContentKind[requestBodyInfo.contentKind] || null;
const responseFormatTmpl = responseContentKind[responseBodyInfo.success && responseBodyInfo.success.schema && responseBodyInfo.success.schema.contentKind] || null;
const securityTmpl = security ? 'true' : null;
const describeReturnType = () => {
if (!config.toJS) return "";
switch(config.httpClientType) {
case HTTP_CLIENT.AXIOS: {
return `Promise<AxiosResponse<${type}>>`
}
default: {
return `Promise<HttpResponse<${type}, ${errorType}>`
}
}
}
%>
/**
<%~ routeDocs.description %>
*<% /* Here you can add some other JSDoc tags */ %>
<%~ routeDocs.lines %>
*/
<%~ route.routeName.usage %><%~ route.namespace ? ': ' : ' = ' %>(<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> =>
<%~ config.singleHttpClient ? 'this.http.request' : 'this.request' %><<%~ type %>, <%~ errorType %>>({
path: `<%~ path %>`,
method: '<%~ _.upperCase(method) %>',
<%~ queryTmpl ? `query: ${queryTmpl},` : '' %>
<%~ bodyTmpl ? `body: ${bodyTmpl},` : '' %>
<%~ securityTmpl ? `secure: ${securityTmpl},` : '' %>
<%~ bodyContentKindTmpl ? `type: ${bodyContentKindTmpl},` : '' %>
<%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %>
...<%~ _.get(requestConfigParam, "name") %>,
})<%~ route.namespace ? ',' : '' %>
+ <% if(_.upperCase(method) === "GET") { %>
+ <%~ route.routeName.usage %>SWRArgs<%~ route.namespace ? ': ' : ' = ' %>(<%~ wrapperArgs %>, enabled: boolean = true)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> => {
+ const key = enabled ? [`<%~ path %>`, <%~ (query != null && queryName) ? `...(${queryName} ? [${queryName}] : [])` : '' %>] : null
+ const fetcher = () => this.<%~ route.namespace %>.<%~ route.routeName.usage %>(<%~ wrapperCallArgs %>).then(res => res.data)
+ return [key, fetcher] as const
+ }<%~ route.namespace ? ',' : '' %>
+ <% } %>
元の実装が loadsh を使っていることもあり、読み取るのが大変ですが、デフォルトのテンプレートと生成されるコードを照らし合わせながら必要な情報を埋めていきました。
import path from "path";
import { fileURLToPath } from "url";
import sta from "swagger-typescript-api";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
sta
.generateApi({
name: "SpaceXLand.ts",
url: "https://raw.githubusercontent.com/SpaceXLand/api/master/swagger.json",
output: path.resolve(__dirname, "../src/__generated__"),
+ templates: path.resolve(__dirname, "./templates"),
})
.catch((err) => {
console.error(err);
});
最後に、デフォルトのテンプレートから修正したので、そのパスを指定します。
これで再度、自動生成を実行すると…
❯ npm run generate-types
︙
/**
* No description
*
* @name CapsuleQuery
* @request GET:/rest/capsule/{id}
*/
capsuleQuery = (id: any, params: RequestParams = {}) =>
this.http.request<Capsule, any>({
path: `/rest/capsule/${id}`,
method: "GET",
format: "json",
...params,
});
+ capsuleQuerySWRArgs = (id: any, params: RequestParams = {}, enabled: boolean = true) => {
+ const key = enabled ? [`/rest/capsule/${id}`] : null;
+ const fetcher = () => this.capsuleQuery(id, params).then((res) => res.data);
+ return [key, fetcher] as const;
+ };
︙
GETメソッドの定義下に、新たに 〇〇SWRArgs の定義が追加されました!
最後の引数の enabled は、useSWR で遅延実行をする為のパラメータです。(useSWRでは、keyをnullの場合、リクエストが実行されません)
それでは、実際に、生成されたコードを使ってみます。
import useSWR from "swr";
import { Api } from "./__generated__/SpaceXLand";
const BASE_URL = "https://api.spacex.land";
const api = new Api({
baseUrl: BASE_URL
});
const App = () => {
const { data, isLoading } = useSWR(...api.rest.dragonQuerySWRArags("dragon1"));
console.log({data, isLoading})
if (!isLoading && data) return <div>{data.name}</div>;
};
export default App;
useSWR(...api.rest.dragonQuerySWRArags("dragon1"))
で GET https://api.spacex.land/dragon/dragon1
を実行できています。また、hooks の引数/戻り値には型がついていることが分かります。
無事、SWR向けのコードを生成することが出来ました🎉
今回は key を他の箇所で使うこと(キャッシュの更新等)も想定し、 useSWR の引数を生成する形にしましたが、テンプレートの書き方次第で、まるごとラップした関数を提供することも可能です。
const { data, isLoading } = useDragonQuery("dragon1");
使い方に合わせて比較的自由に定義できるのが、テンプレートのいいところですね。
GET以外について
この記事を執筆している間に丁度 SWR2.0 がリリースされ、GET以外の更新系(POST, PUT, DELETE)向けに useSWRMutation
API が追加されたみたいです。
本記事では useSWRMutation
には触れませんが、同じ要領で更新系の hooks を自動生成することも可能だと思います。
(余談) その他の自動生成系のライブラリについて
OpenAPI から TypeScript の型を生成するライブラリは、他にも沢山あるようです。
今回紹介した 「SWR のフックを生成する」という目的であれば、以下のライブラリのほうが適しているかもしれません。ライブラリ側で生成するオプションが提供されているようです。(自分も触ったこと無いので今度試してみたい...!!)
本記事で紹介した、 swagger-typescript-api は ejs でテンプレートを書ける点から、かなり自由度が高いので、MSWのハンドラを自動生成したり、テストのテンプレを生成したりと、他にも出来ることは多いと思います。
「OpenAPIの型を使って独自のコードを生成したい」「出力を細かく調整したい」といった需要にはマッチしているように感じました。
一方、テンプレート箇所(= ejsの記述)の可読性が低く処理が分かりづらかったり、テンプレート自体に学習コストがかかってしまうというデメリットもありそうです。
それぞれメリット・デメリットがあるのでプロジェクトの状況に応じて選定するのが良いと思います。
おわりに
最後まで読んでいただきありがとうございました!
アドベントカレンダーも後半になりますが、まだまだ面白い記事が更新されていきますので、是非ご覧ください!