一.Electron 基本構造
VS Code は Electron の成功事例として、ソースコードに飛び込む前に、Electron の基本構造を簡単に整理する必要がある
実装から見ると:
Electron = Node.js + Chromium + Native API
つまり Electron は Node 実行環境を持ち、Chromium に依存して Web 技術(HTML、CSS、JS)に基づくインターフェースインタラクションサポートを提供し、さらにいくつかのプラットフォーム特性を持っている。例えばデスクトップ通知など
API 設計から見ると、Electron App は一般に 1 つの Main Process と複数の Renderer Process を持つ:
-
main process:メインプロセス環境下で Node 及び Native API にアクセス可能
-
renderer process:レンダラープロセス環境下で Browser API と Node API 及び一部の Native API にアクセス可能
API 設計がこのようであれば、Electron App のプロジェクト構造も少なくともこれら 2 つの部分を含む
メインプロセス
バックグラウンドサービスに相当し、以下によく使用:
-
複数ウィンドウ管理(作成/切り替え)
-
アプリケーションライフサイクル管理
-
プロセス通信基地局として(IPC Server)
-
自動更新
-
ツールバーメニュー登録
レンダラープロセス
インターフェースインタラクション関連の、具体的なビジネス機能はすべて renderer プロセスが担当する。3 つの基本原则:
-
できるだけ renderer で作業する。ネットワークリクエストを含む
-
時間がかかりすぎるものは Worker で分離する
-
renderer 間で共有する必要があるものはサブプロセスで分離し、main に管理を任せる
You can use all packages that work with Node.js in the main process as well as in the renderer process if you have webPreferences.nodeIntegration set to true in the BrowserWindow options. This is the default.
It's actually recommended to do as much as possible in the renderer process.
P.S.main と renderer の分担に関する議論は、What is the best way to make Http requests using Electron? を参照
二.vscode ソースコード構造
以下の内容はソースコードバージョン v1.19.3 を参照
ディレクトリ構造
├── build # gulp コンパイル構築スクリプト
├── extensions # 内蔵プラグイン
├── gulpfile.js # gulp task
├── i18n # 国際化翻訳パッケージ
├── out # コンパイル出力ディレクトリ
├── product.json # App メタ情報
├── resources # プラットフォーム関連静的リソース、アイコンなど
├── scripts # ツールスクリプト、開発/テスト
├── src # ソースコードディレクトリ
└── test # テストスイート
src 下の構造は以下の通り:
├── bootstrap-amd.js # サブプロセス実際のエントリー
├── bootstrap.js # サブプロセス環境初期化
├── buildfile.js # 構築 config
├── cli.js # CLI エントリー
├── main.js # メインプロセスエントリー
├── paths.js # AppDataPath と DefaultUserDataPath
├── typings
│?? └── xxx.d.ts # ts タイプ宣言
└── vs
├── base # 汎用ツール/プロトコルと UI ライブラリ
│?? ├── browser # 基礎 UI コンポーネント、DOM 操作、インタラクションイベント、DnD など
│?? ├── common # diff 記述、markdown パーサー、worker プロトコル、各種ツール関数
│?? ├── node # Node ツール関数
│?? ├── parts # IPC プロトコル(Electron、Node)、quickopen、tree コンポーネント
│?? ├── test # base 単体テスト用例
│?? └── worker # Worker factory と main Worker(IDE Core:Monaco を実行)
├── buildunit.json
├── code # VS Code メインウィンドウ関連
├── css.build.js # プラグイン構築用 CSS loader
├── css.js # CSS loader
├── editor # IDE Core と連携(編集/インタラクション状態読み取り)、コマンド、コンテキストメニュー、hover、snippet などのサポートを提供
├── loader.js # AMD loader(AMD モジュールを非同期ロードするため、require.js に類似)
├── nls.build.js # プラグイン構築用 NLS loader
├── nls.js # NLS(National Language Support)多言語 loader
├── platform # サービス注入とプラットフォーム関連基礎サービス(ファイル、クリップボード、ウィンドウ、ステータスバー)をサポート
└── workbench # editor を調整し viewlets にフレームワークを提供。例えばディレクトリビューア、ステータスバーなど。グローバル検索、Git、Debug を統合
その中で最も重要な部分(ビジネス関連)は:
-
src/vs/code:メインウィンドウ、ツールバーメニュー作成 -
src/vs/editor:コードエディター、IDE コア関連 -
src/vs/workbench:UI レイアウト、機能サービス連携
P.S.IDE Core は独立して使用可能。Monaco と呼ばれる
各層はターゲット実行環境に応じて細分化・組織化:
-
common:環境を跨いで再利用可能 -
browser:ブラウザ API に依存。例えば DOM 操作 -
node:Node API に依存 -
electron-browser:electron renderer-process API に依存 -
electron-main:electron main-process API に依存
三.起動��ロー
起動フロー関連ファイルの段階関係は以下の通り:
機能エントリー
src/main.js
src/vs/code/electron-main/main.ts
src/vs/code/electron-main/app.ts
src/vs/code/electron-main/windows.ts
src/vs/code/electron-main/window.ts
UI エントリー
src/vs/workbench/electron-browser/bootstrap/index.html
src/vs/workbench/electron-browser/bootstrap/index.js
src/vs/workbench/workbench.main js index ファイル
src/vs/workbench/electron-browser/main.ts
src/vs/workbench/electron-browser/shell.ts インターフェースと機能サービスの接入点
src/vs/workbench/electron-browser/workbench.ts インターフェース作成
src/vs/workbench/browser/layout.ts レイアウト計算、絶対位置指定
Electron CLI アプリケーション起動
起動ステップ:
# コンパイル構築(ts 変換、パッケージング)
npm run compile
# Electron でアプリケーションを起動
./scripts/code.sh
code.sh の役割は Electron Demo で一般的なこれに類似:
"name": "electron-quick-start",
"version": "1.0.0",
"description": "A minimal Electron application",
"main": "main.js",
"scripts": {
"start": "electron ."
}
主要部分は以下の通り:
# Configuration
export NODE_ENV=development
export VSCODE_DEV=1
export VSCODE_CLI=1
export ELECTRON_ENABLE_LOGGING=1
export ELECTRON_ENABLE_STACK_DUMPING=1
# Launch Code
exec "$CODE" . "$ @"
dev 環境変数を設定し、最後にexecで実行:
./.build/electron/Code - OSS.app/Contents/MacOS/Electron .
Electron CLI はpkg.mainをエントリーファイルとしてロード実行:
"name": "code-oss-dev",
"version": "1.19.3",
"distro": "2751aca3e43316e3418502935939817889deb719",
"author": {
"name": "Microsoft Corporation"
},
"main": "./out/main"
つまりエントリーファイルout/main.jsに転送。対応するソースコードはsrc/main.js。重要な部分は以下の通り:
// src/main.js
app.once('ready', function () {
perf.mark('main:appReady');
global.perfAppReady = Date.now();
var nlsConfig = getNLSConfiguration();
process.env['VSCODE_NLS_CONFIG'] = JSON.stringify(nlsConfig);
nodeCachedDataDir.then(function () {
require('./bootstrap-amd').bootstrap('vs/code/electron-main/main');
}, console.error);
});
cacheDataディレクトリ準備完了後、AMD loader を通じてメインプロセスエントリーファイルout/vs/code/electron-main/main.jsをロードし、メインプロセス初期化フローに入る
メインプロセス初期化
メインプロセスエントリーファイル対応ソースコードsrc/vs/code/electron-main/main.jsの主要部分は以下の通り:
// Startup
return instantiationService.invokeFunction(a => createPaths(a.get(IEnvironmentService)))
.then(() => instantiationService.invokeFunction(setupIPC))
.then(mainIpcServer => instantiationService.createInstance(CodeApplication, mainIpcServer, instanceEnv).startup());
その中でCodeApplicationはvs/code/electron-main/app.tsから。起動フロー関連部分は以下の通り:
// Open Windows
appInstantiationService.invokeFunction(accessor => this.openFirstWindow(accessor));
// Post Open Windows Tasks
appInstantiationService.invokeFunction(accessor => this.afterWindowOpen(accessor));
openFirstWindow()の主要内容は以下の通り:
this.windowsMainService = accessor.get(IWindowsMainService);
// Open our first window
const args = this.environmentService.args;
const context = !!process.env['VSCODE_CLI'] ? OpenContext.CLI : OpenContext.DESKTOP;
if (args['new-window'] && args._.length === 0) {
this.windowsMainService.open({ context, cli: args, forceNewWindow: true, forceEmpty: true, initialStartup: true }); // new window if "-n" was used without paths
} else if (global.macOpenFiles && global.macOpenFiles.length && (!args._ || !args._.length)) {
this.windowsMainService.open({ context: OpenContext.DOCK, cli: args, pathsToOpen: global.macOpenFiles, initialStartup: true }); // mac: open-file event received on startup
} else {
this.windowsMainService.open({ context, cli: args, forceNewWindow: args['new-window'] || (!args._.length && args['unity-launch']), diffMode: args.diff, initialStartup: true }); // default: read paths from cli
}
// Only load when the window has not vetoed this
this.lifecycleService.unload(window, UnloadReason.LOAD).done(veto => {
// Load it
window.load(configuration);
}
注意、this.lifecycleService.unload(window, UnloadReason.LOAD)这句は非常に誤解を招く。unloadをトリガーし、理由(UnloadReason)はLOAD。つまり、まずwindowをnewし、すぐに手動でそのunload()を呼び出し、その後手動でload()を呼び出してこのウィンドウをロードする……では、なぜまずunload()を呼び出すのか?
P.S.このload()は非常に重要。後でまた戻る
ここまではエントリー HTML が見えず、windowsMainService.open()がある。追跡する(src/vs/code/electron-main/windows.ts):
public open(openConfig: IOpenConfiguration): CodeWindow[] {
// Open based on config
const usedWindows = this.doOpen(openConfig, workspacesToOpen, workspacesToRestore, foldersToOpen, foldersToRestore, emptyToRestore, emptyToOpen, filesToOpen, filesToCreate, filesToDiff, filesToWait, foldersToAdd);
}
private doOpen() {
// Handle empty to open (only if no other window opened)
if (usedWindows.length === 0) {
for (let i = 0; i < emptyToOpen; i++) {
usedWindows.push(this.openInBrowserWindow({
userEnv: openConfig.userEnv,
cli: openConfig.cli,
initialStartup: openConfig.initialStartup,
forceNewWindow: openFolderInNewWindow
}));
openFolderInNewWindow = true; // any other window to open must open in new window then
}
}
}
private openInBrowserWindow() {
window = this.instantiationService.createInstance(CodeWindow, {
state,
extensionDevelopmentPath: configuration.extensionDevelopmentPath,
isExtensionTestHost: !!configuration.extensionTestsPath
});
}
重要なCodeWindowはsrc/vs/code/electron-main/window.tsで定義されている。つまり初期化プロセスは複数ウィンドウ管理クラス(windows.ts)が VS Code メインウィンドウ(window.ts)を呼び出す。したがってopen()は最終的にCodeWindowインスタンスを返す。簡略化:
// Open our first window
window = new CodeWindow();
// Only load when the window has not vetoed this
window.load(configuration);
次にload()を見る。重要な部分は以下の通り:
public load(config: IWindowConfiguration, isReload?: boolean): void {
// Load URL
mark('main:loadWindow');
this._win.loadURL(this.getUrl(config));
}
private getUrl(windowConfiguration: IWindowConfiguration): string {
// Config (combination of process.argv and window configuration)
const config = objects.assign(environment, windowConfiguration);
return `${require.toUrl('vs/workbench/electron-browser/bootstrap/index.html')}?config=${encodeURIComponent(JSON.stringify(config))}`;
}
HTML が現れた。メインプロセスの使命は完了し、レンダラープロセスが登場
レンダラープロセス初期化
エントリー HTMLsrc/vs/workbench/electron-browser/bootstrap/index.htmlの主要内容は以下の通り:
<body class="monaco-shell vs-dark" aria-label="">
<script src="preload.js"></script>
</body>
<!-- Startup via index.js -->
<script src="index.js"></script>
2 つの JS を導入。preload.jsは URL からconfigパラメータを解析し、テーマ設定に基づいてbody背景色を設定。index.jsにはロードロジックが含まれる:
function main() {
const webFrame = require('electron').webFrame;
// URL パラメータから config を解析
const args = parseURLQueryArgs();
const configuration = JSON.parse(args['config'] || '{}') || {};
// 传入された環境変数を回復
// Correctly inherit the parent's environment
assign(process.env, configuration.userEnv);
perf.importEntries(configuration.perfEntries);
// NLS 多言語設定を回復
// Get the nls configuration into the process.env as early as possible.
var nlsConfig = { availableLanguages: {} };
const config = process.env['VSCODE_NLS_CONFIG'];
if (config) {
process.env['VSCODE_NLS_CONFIG'] = config;
try {
nlsConfig = JSON.parse(config);
} catch (e) { /*noop*/ }
}
var locale = nlsConfig.availableLanguages['*'] || 'en';
if (locale === 'zh-tw') {
locale = 'zh-Hant';
} else if (locale === 'zh-cn') {
locale = 'zh-Hans';
}
window.document.documentElement.setAttribute('lang', locale);
// DevTools を有効にするか
const enableDeveloperTools = (process.env['VSCODE_DEV'] || !!configuration.extensionDevelopmentPath) && !configuration.extensionTestsPath;
const unbind = registerListeners(enableDeveloperTools);
// 拡大縮小設定
// disable pinch zoom & apply zoom level early to avoid glitches
const zoomLevel = configuration.zoomLevel;
webFrame.setVisualZoomLevelLimits(1, 1);
if (typeof zoomLevel === 'number' && zoomLevel !== 0) {
webFrame.setZoomLevel(zoomLevel);
}
// loader を初期化
// Load the loader and start loading the workbench
const loaderFilename = configuration.appRoot + '/out/vs/loader.js';
const loaderSource = require('fs').readFileSync(loaderFilename);
//!!! node require を置き換え、define 関数を提供
require('vm').runInThisContext(loaderSource, { filename: loaderFilename });
window.nodeRequire = require.__$__nodeRequire;
define('fs', ['original-fs'], function (originalFS) { return originalFS; }); // パッチされた electron fs をすべての AMD コードのために元の node fs に置き換え
window.MonacoEnvironment = {};
const onNodeCachedData = window.MonacoEnvironment.onNodeCachedData = [];
// require 設定
require.config({
baseUrl: uriFromPath(configuration.appRoot) + '/out',
'vs/nls': nlsConfig,
recordStats: !!configuration.performance,
nodeCachedDataDir: configuration.nodeCachedDataDir,
onNodeCachedData: function () { onNodeCachedData.push(arguments); },
nodeModules: [/*BUILD->INSERT_NODE_MODULES*/]
});
if (nlsConfig.pseudo) {
require(['vs/nls'], function (nlsPlugin) {
nlsPlugin.setPseudoTranslation(nlsConfig.pseudo);
});
}
// パフォーマンス設定とタイムスタンプを取得
// Perf Counters
const timers = window.MonacoEnvironment.timers = {
isInitialStartup: !!configuration.isInitialStartup,
hasAccessibilitySupport: !!configuration.accessibilitySupport,
start: configuration.perfStartTime,
appReady: configuration.perfAppReady,
windowLoad: configuration.perfWindowLoadTime,
beforeLoadWorkbenchMain: Date.now()
};
const workbenchMainClock = perf.time('loadWorkbenchMain');
// 機能モジュール JS をロード
require([
'vs/workbench/workbench.main',
'vs/nls!vs/workbench/workbench.main',
'vs/css!vs/workbench/workbench.main'
], function () {
workbenchMainClock.stop();
timers.afterLoadWorkbenchMain = Date.now();
process.lazyEnv.then(function () {
perf.mark('main/startup');
// electron-browser/main をロードし、startup() を呼び出す
require('vs/workbench/electron-browser/main')
.startup(configuration)
.done(function () {
unbind(); // workbench が実行されているので、開発者関連リスナーをバインド解除し、workbench に処理を任せる
}, function (error) {
onError(error, enableDeveloperTools);
});
});
});
}
その中で、loader の実際の役割はグローバルrequire()を置き換えdefine()を提供すること。以下の通り:
define = function () {
DefineFunc.apply(null, arguments);
};
AMDLoader.global.require = RequireFunc;
AMDLoader.global.require.__$__nodeRequire = nodeRequire;
P.S.loader はrunInThisContext()を通じて解釈実行される。API ドキュメントは以下の通り:
vm.runInThisContext()compiles code, runs it within the context of the current global and returns the result. Running code does not have access to local scope, but does have access to the current global object.
現在のglobal環境で给定コードを実行し結果を返す。eval()に類似しているが、非global変数にはアクセスできない。例えば:
let i = 1;
const result = require('vm').runInThisContext(`
// require を篡改
global.require = function(...args) {
console.log.apply(global, ['require called: '].concat(args));
}
// エラー、i is not defined
// i++;
2;
`);
require('my_module', { opts: 'opts' }); // require called: my_module Object {opts: "opts"}
require(result); // require called: 2
P.S.注意。Electron renderer process 環境でのみ上記の結果が得られる。Node REPL 環境(コマンドライン)とモジュール環境では不可。renderer process 内のrequire === global.requireであるため。其它環境のはModule.prototype._compile()が注入するもので、ローカル変数形式(module wrapper パラメータ)で存在し、vm.runInThisContext()を通じて篡改できない(もちろん、evalで行うことは可能)
最後にsrc/vs/workbench/electron-browser/main.tsのstartup()に到達:
export function startup(configuration: IWindowConfiguration): TPromise<void> {
// Open workbench
return openWorkbench(configuration);
}
function openWorkbench(configuration: IWindowConfiguration): TPromise<void> {
// ...各種 service を作成
return createAndInitializeWorkspaceService(configuration, environmentService).then(workspaceService => {
return domContentLoaded().then(() => {
// 各機能エリア UI を初期化
// Open Shell
const shell = new WorkbenchShell(document.body, {
contextService: workspaceService,
configurationService: workspaceService,
environmentService,
logService,
timerService,
storageService
}, mainServices, configuration);
shell.open();
});
});
}
WorkbenchShellの作成から正式に機能エリア UI レイアウトに入る。UI は Shell と呼ばれ、機能を担うコンテナ(「殻」)として使用される
UI レイアウト
WorkbenchShellはsrc/vs/workbench/electron-browser/shell.tsから。そのopen()メソッドの主要内容は以下の通り:
public open(): void {
// content コンテナを作成
// Controls
this.content = $('.monaco-shell-content').appendTo(this.container).getHTMLElement();
// 内容を填充
// Create Contents
this.contentsContainer = this.createContents($(this.content));
// レイアウトを計算
// Layout
this.layout();
// Listeners
this.registerListeners();
}
注意すべきはレイアウト計算部分(this.layout())。VS Code は Flex/Grid などの強力な CSS レイアウト方式を採用せず、統一して絶対レイアウト + 計算の方式で正確なピクセルレイアウトを実装:
// ref: src/vs/workbench/browser/layout.ts
public layout(options?: ILayoutOptions): void {
// Workbench
this.workbenchContainer
.position(0, 0, 0, 0, 'relative')
.size(this.workbenchSize.width, this.workbenchSize.height);
// Title Part
if (isTitlebarHidden) {
this.titlebar.getContainer().hide();
} else {
this.titlebar.getContainer().show();
}
// Editor Part and Panel part
this.editor.getContainer().size(editorSize.width, editorSize.height);
this.panel.getContainer().size(panelDimension.width, panelDimension.height);
// Activity Bar Part
this.activitybar.getContainer().size(null, activityBarSize.height);
// Sidebar Part
this.sidebar.getContainer().size(sidebarSize.width, sidebarSize.height);
// Statusbar Part
this.statusbar.getContainer().position(this.workbenchSize.height - this.statusbarHeight);
// Quick open
this.quickopen.layout(this.workbenchSize);
// Sashes
this.sashXOne.layout();
// Part レイアウトに伝播
this.titlebar.layout(new Dimension(this.workbenchSize.width, this.titlebarHeight));
this.editor.layout(new Dimension(editorSize.width, editorSize.height));
this.sidebar.layout(sidebarSize);
this.panel.layout(panelDimension);
this.activitybar.layout(activityBarSize);
// Context View に伝播
this.contextViewService.layout();
}
2 つのことを行う:
-
各機能エリアの位置とサイズを計算(XXX Part)
-
各機能エリアがさらに内容レイアウトを計算(Propagate to Part Layouts)
P.S.ほとんどのレイアウト計算は JS で完了し、一部はcalc()を使用
機能サービス連携
WorkbenchShell作成時に传入された各種 service は最後にworkbench作成に使用:
// ref: src/vs/workbench/electron-browser/shell.ts
private createContents(parent: Builder): Builder {
// サービスを持つ Instantiation service
const [instantiationService, serviceCollection] = this.initServiceCollection(parent.getHTMLElement());
// workbench を作成
// Workbench
this.workbench = instantiationService.createInstance(Workbench, parent.getHTMLElement(), workbenchContainer.getHTMLElement(), this.configuration, serviceCollection, this.lifecycleService);
try {
this.workbench.startup().done(startupInfos => this.onWorkbenchStarted(startupInfos, instantiationService));
} catch (error) {/*...*/}
}
各部分機能が依存するサポートサービスをworkbenchに渡し、その後startup()を呼び出す:
public startup(): TPromise<IWorkbenchStartedInfo> {
// UI と連携する具体的な機能 service を作成し、serviceCollection に追加
// Services
this.initServices();
// service 依存関係を注入
// Contexts
this.messagesVisibleContext = MessagesVisibleContext.bindTo(this.contextKeyService);
this.editorsVisibleContext = EditorsVisibleContext.bindTo(this.contextKeyService);
this.inZenMode = InZenModeContext.bindTo(this.contextKeyService);
this.sideBarVisibleContext = SidebarVisibleContext.bindTo(this.contextKeyService);
}
作成するserviceは 2 種類に分かれる。依存関係があるものとないもの:
private initServices(): void {
// 依存なし、直接 new して collection に追加
// Services we contribute
serviceCollection.set(IPartService, this);
// Clipboard
serviceCollection.set(IClipboardService, new ClipboardService());
// 依存あり、instantiationService で依存関係を処理してから collection に追加
// Status bar
this.statusbarPart = this.instantiationService.createInstance(StatusbarPart, Identifiers.STATUSBAR_PART);
serviceCollection.set(IStatusbarService, this.statusbarPart);
// List
serviceCollection.set(IListService, this.instantiationService.createInstance(ListService));
}
instantiationService.createInstanceは依存関係を自動処理できる。非常に興味深い:
private _createInstance<T>(desc: SyncDescriptor<T>, args: any[]): T {
// arguments defined by service decorators
let serviceDependencies = _util.getServiceDependencies(desc.ctor).sort((a, b) => a.index - b.index);
// now create the instance
const argArray = [desc.ctor];
argArray.push(...staticArgs);
argArray.push(...serviceArgs);
return <T>create.apply(null, argArray);
}
依存関係を自動処理できる秘密(_util.getServiceDependencies)はここ:
export const DI_DEPENDENCIES = '$di$dependencies';
export function getServiceDependencies(ctor: any): { id: ServiceIdentifier<any>, index: number, optional: boolean }[] {
return ctor[DI_DEPENDENCIES] || [];
}
Classに静的変数$di$dependenciesを掛け、依存関係を格納。例えば:
// ref: src/vs/editor/browser/services/codeEditorService.ts
export const ICodeEditorService = createDecorator<ICodeEditorService>('codeEditorService');
createDecorator()はservice依存関係を宣言するためのツール関数:
export function createDecorator<T>(serviceId: string): { (...args: any[]): void; type: T; } {
const id = <any>function (target: Function, key: string, index: number): any {
if (arguments.length !== 3) {
throw new Error(' @IServiceName-decorator can only be used to decorate a parameter');
}
storeServiceDependency(id, target, index, false);
};
return id;
}
function storeServiceDependency(id: Function, target: Function, index: number, optional: boolean): void {
if (target[_util.DI_TARGET] === target) {
target[_util.DI_DEPENDENCIES].push({ id, index, optional });
} else {
target[_util.DI_DEPENDENCIES] = [{ id, index, optional }];
target[_util.DI_TARGET] = target;
}
}
至此、全体の起動フローが明確になった:
-
まず Electron CLI を通じてエントリー JS をロード
-
エントリー JS を実行してメインプロセス初期化プロセスに入り、最後に BrowserWindow を作成しエントリー HTML をロード
-
エントリー HTML が依存 JS をロードしてレンダラープロセス初期化プロセスを開始。2 つに分かれる:
-
機能エリアインターフェースを組み立て
-
機能エリアインターフェースに対応する機能サービスを作成
-
参考資料
-
vscode ソースコード剖析:常に同じことをする人がいるもので、幸いにも巨人の肩はますます高くなっている
-
Hacking Node require:
runInThisContext hack requireに関する困惑の答え
コメントはまだありません