LoginSignup
17
8

More than 5 years have passed since last update.

Web MIDIを例にChromeにAPIを追加する手順概要を追ってみる

Last updated at Posted at 2017-12-11

はじめに

またのタイトルを「Chrome開発で必要なことは、みんなWeb MIDIが教えてくれた」。まぁ、実際にはまだまだ足りない事がいっぱいあって、Kinuko先輩やマネージャーのマイケルから色々と学びながら修業の日々です。

さて、カレンダーで記事を追っている人は、そろそろChromium/ChromeにAPIを追加してみようと考えはじめている頃かと思います。この記事ではWeb MIDIの実装を例に、ChromeにAPIを追加する手順を追ってみたいと思います。

例によって、この記事は個人によって書かれた物で、GoogleやChromium Projectの意見を代弁する物ではありません。

ちなみに、Chromium/Chromeの名前の使い分けは曖昧なところがあるのですが、ここではChromiumはProject名、ChromeはChromium Projectの成果物の1つで、Google ChromeやChromium Browserを含む事とします。これはProject内である程度合意の取れた使い分けで、ソースコード内でもChromiumという名前は使わず、Chromeで統一されています。バイナリ名もmacOS版がChromiumを使っているという例外はあるものの、基本的にはchromeが使われています。Androidでは内部的なシステムユニークでなければいけない名前空間にもchromiumを使っていますね。これらは基本的に各OSで回避すべき衝突を避けるためのものであって、それ以上の意味はありません。またBlinkと言った時はWebKitからforkしたChromeのレンダリングエンジンを指します。ディレクトリ名は未だにthird_party/WebKitですが、近々blinkにリネームされそうな雰囲気です。

記事中、ソースコードへのリンクも多く入れてしまったのですが、考えてみればソースコードの変化は激しく、すぐにリンク切れが起きそう……。気づいた方は編集リクエストを投げて頂けると助かります。あるいは特定のリビジョンにリンクを固定すべきか。

予備知識

Web MIDI API

まずはWeb MIDIのベースとなるMIDI規格について触れなければなりません。MIDIとは電子楽器向けの通信規格です。古い音楽フォーマットの1つとして誤解している人は、藤本健の"DTMステーション"『今さら聞けない、「MIDIって何?」「MIDIって古いの?」』を読んでみましょう。

音楽制作のベースとなる通信規格で、ハードウェアのみならず、ソフトウェア音源もMIDIを核としたプラグイン規格に則って作られています。古いパソコンユーザーが知っているのはSMF(Standard MIDI File)ですね。これは通信データにタイミング情報を載せてダンプした物で、古く流通していたものはGM音源と呼ばれる共通規格の楽器を演奏するデータのダンプでした。最近ではオーケストラで演奏されるような映画音楽もMIDI上で映像と同期した演奏データを作り込み、最終工程でMIDIに合わせてオーケストラで演奏し直す、という手順をとるそうです。

Web MIDI APIはこのMIDI規格に則った通信をJavaScriptから行うAPIです。

  • 接続されているMIDI規格対応デバイスを列挙する
  • Hotplugにより抜き差しされたデバイスを検知・通知する
  • 入力デバイスから信号を受信する
  • 出力デバイスから信号を送信する

といった機能があります。デバイスの接続方法はLegacyなMIDI固有のケーブルによる接続、USBやBluetoothによる接続、あるいはソフトウェアによる擬似デバイスの接続と多岐にわたります。これらの機能のサポートにより、

  • 音楽制作ソフト
  • 電子楽器の管理ツール(音色管理、ファームウェアアップデート)
  • 鍵盤など電子楽器からの入力を扱う

といった事が可能となります。実際、YAMAHAのSoundmondoのような音色共有SNSであったり、MarshallのCODEシリーズのようにブラウザからファームウェアのアップデートを行い機能の追加ができる製品も発売されています。novationから発売されているcircuitやpeakといった最近の機器はcomponentsというサイトからデバイスの管理ができるようになっています。SoundtrapAudiotoolはブラウザから利用できるDAWの代表例です。

詳しくは公式の仕様、あるいはg200kgさんによる日本語訳を見て下さい。

MIDI自体が古い規格である事もあり、USBやBluetoothといったデバイス系APIと比べてもAPIサーフェスは比較的シンプルです。が、実際の作業工程を見てもらえると、JavaScriptに向けたAPIの実装は全体のほんの一部なんですね。そういう意味でも全体の流れを見るのには適した例題なのではないかと思います。

マルチプロセスアーキテクチャ

Web MIDI APIのようなOSの機能を利用するAPIはChromeのマルチプロセスアーキテクチャについて理解する必要があります。このあたりは@amiq11
さんがChromiumのプロセス構成とWorker/SharedWorker/ServiceWorkerのうごきで説明しているので、既にみなさん理解している物として進めていきます。

またデバイス系のAPIについて語る上では、このプロセス分離で実現するサンドボックスや、パーミッション認証の仕組み、入出力データの検証などがセキュリティ上重要となってきます。

加えて通信遅延が問題となるAPIについてはスレッドモデルについても特殊なデザインが採用されており、メインスレッド等の制約の緩いスレッドは経由せず、I/Oスレッドと呼ばれる、同期的に重い処理、ブロックする可能性のあるシステムコールの呼び出しなどを一切禁止したリアルタイム性の強いスレッド上でコードを組んでいく必要があります。

APIからOSまでの道のり

JavaScriptのAPIをそのままOSのAPIに変換するだけなら話は簡単なのですが、ブラウザに実装する場合、そう簡単にはいきません。

その理由の1つはサンドボックスにあります。@amiq11さんも説明しているように、レンダリングエンジンやJavaScriptエンジンの動作するレンダラープロセスにはほとんど全てのOSが提供するシステムコールの呼び出しが許されていません。可能なのは事前に確立された通信経路を使ってブラウザに処理を依頼し、応答を得ることくらいです。Chromeはその速度を売りの1つとしてはいますが、別の売りの1つであるセキュリティのために、速度に関してデザイン上とても大きなハンディキャップを負っています。もっともSafariやEdgeも同様のデザインを採用するようになったため、今となっては大きな負い目ではありません。一方でFirefoxはRustを使って安全に開発するという選択を取り差別化を謀っています。

もう1つの理由は多重化です。macOSのCore MIDIなどでは、一度に複数のアプリケーションが単一のデバイスに対して同時にアクセスする事が可能となっています。これはCore MIDIレベルでマルチプレクサを実現しているためです。一方でWindowsなどはデバイスは排他アクセスしかできないため、Chrome内でマルチプレクサを実現する必要があります。

midimux.png

ざっくり多重化の様子を模式したのが上図になります。ブラウザプロセス内にOSとのやりとりを代表するMIDI Serviceが実装されており、各レンダラーとの通信を受けるMIDI Hostがレンダラーの数だけ存在します。ここでMIDI Serviceに対して1:Nの多重化が必要となります。一方でレンダラー側からMIDI Hostと通信するために1対1で存在するのがMIDI Message Filterです。JavaScriptからWeb MIDI APIを呼び出すとMIDIAccessと呼ばれるオブジェクトが得られ、このオブジェクト経由でデバイスの情報取得や通信を行います。オブジェクトは同一フレーム(≒HTMLファイル)内でのみ参照可能なため、iframe内などでは個別のMIDIAccessが作られます。また、同一フレーム内から複数のAPI呼び出しを行えば、複数のMIDIAccessが得られます。これらMIDiAccessはMIDI Message Filterにより多重化されブラウザプロセスと通信します。ここでも1:Nの多重化が行われています。

MIDI HostとMIDI Message Filterは破線と薄い文字で書かれています。Web MIDI APIは数年前に実装され、当時はChrome IPCという仕組みを使ってブラウザ・レンダラー間の通信を行っていました。現在ではMojoと呼ばれる仕組みを使って実装します。この場合、MIDI HostやMIDI Message Filterといったブリッジは不要となり、例えばMIDIAccessから直接MIDI Serviceに接続する事が可能となります。また、MIDI Serviceの実装についてもデスクトップではC++、AndroidではJavaで実装する、などといった事も可能となります。Chrome IPCではC++で受けなければなりませんでした。一方で現在のようにレンダラー毎に多重化する層を導入するか否かはデザイン上の選択になります。MIDIの場合、デバイスからJavaScriptに流れるデータはマルチキャストになるため、同一レンダラーへの通信はまとめた方が効率は良いのですが、通信量は多くはないため、コードの複雑化とのトレードオフで考えると不要でしょうか。Web MIDI APIのMojo化は来年前半に行う予定です。

このように多数のクライアントが複数のプロセスで独立して動作しており、加えてデバイスの抜き差しといったHotplugは人手により任意のタイミングで発生します。これら並列に発生する各種イベントに対して整合性を取りながら、I/Oスレッド上の制約の中でいかに安全にMIDI Serviceを実装するのかが大きな課題となります。

実装

概要

実装時に作ったdesign doc(いわゆる実装仕様書)の図を使って概要を説明したいと思います。
mididesign.png

先程の絵ではプロセスが左右でわかれていましたが、この図では上下に分かれています。色使いも統一されておらず、申し訳ありません。

JavaScriptに公開するAPIは図の青い部分になります。APIを利用する際には、まずは左側のパスを経由してブラウザプロセスに権限を要求します。許可を得た後は、右側のパスを経由してブラウザプロセスを経由したMIDIデバイスの制御を行います。最初の図で示した多重化のパスはこちら側の通信経路の模式図でした。また、権限の管理とデバイスの管理は、同じブラウザプロセスで行われているのですが、実際の処理を行っているブロックと動作しているスレッドが異なります。そこで双方から共通してアクセス可能なChildProcessSecurityPolicyというモジュールで権限を二重管理しています。

各実装について詳しく見ていきましょう。

JavaScriptに公開するAPIの実装

最近実装された多くのAPIはWebKit/Source/modules/というパスに個別のディレクトリを掘って実装されています。実装の独立性を保つため、レンダリングエンジンの本体であるWebKit/Source/core/側からmodules以下を参照する事は許されていません。つまりmodules以下の実装は、本体側のコードに影響を与えることなく無効化する事が可能です。

Web MIDI APIに関するファイルはWebKit/Source/modules/webmidi以下にあります。

ここで最初に注目したいのが.idlという拡張子のファイル群です。例えば

MIDIAccess.idl
// https://webaudio.github.io/web-midi-api/#MIDIAccess

[
    ActiveScriptWrappable
] interface MIDIAccess : EventTarget {
    readonly attribute MIDIInputMap inputs;
    readonly attribute MIDIOutputMap outputs;

    readonly attribute boolean sysexEnabled;

    attribute EventHandler onstatechange;
};

こんなファイル。これはWeb IDLと呼ばれる記法に沿ったインタフェース記述で、JavaScriptのAPIについての仕様を読んだ事のある人には見慣れた記法かもしれません。これは……一応、将来のためってなるのかな、言語(ここではJavaScript)に依存せずにAPIを定義するための標準記法になります。Blinkの中では仕様に書かれたIDLをこのように入力として用意する事で、C++側の実装をv8にJavaScriptのインタフェースとして見せるためのbindingを自動生成しています。

仕様側のIDL定義は以下のようになっており、必ずしも完全に同一ではありません。Blinkの実装に必要な追加要素をアノテートしていますが、徐々に必要となるアノテーションも減ってきました。

interface MIDIAccess : EventTarget {
    readonly attribute MIDIInputMap  inputs;
    readonly attribute MIDIOutputMap outputs;
             attribute EventHandler  onstatechange;
    readonly attribute boolean       sysexEnabled;
};

IDLに対応する実装はMIDIAccess.hとMIDIAccess.cppのように同名のファイル名を用いて、同名のクラス名で実装します。ちなみにファイル名の命名規則はWebKit時代の物を踏襲していますが、GoogleやBlink外のChromeコードではmidi_access.ccとなるような命名規則を使っています。Blinkは今まで、インデントスタイル(4タブ→2タブなど)、変数命名規則(m_foo→foo_)、関数命名規則(doSomething→DoSomething)などの大規模チェンジが入っています。将来的にはファイル名命名規則も変わる可能性がありますし、既にプロセス境界に近い部分では両スタイルが混在し始めています。

v8 bindingとC++の実装の間は広い意味でのduck typingになっていて、bindingが呼び出すインタフェースを正しく実装していればbindingのコンパイルとBlinkのリンクが通り、相互に正しく接続されます。間違っていればコンパイルかリンクが通りません。

同ディレクトリのBUILD.gnに追加したファイルを列挙し、modules/BUILD.gnにdepsを追加すれば、ダミーのインタフェース追加などは簡単にできますので、お手軽にBlink改造した感がお楽しみ頂けるのではないでしょうか。

APIのエントリポイントとしてnavigatorなどが利用される事が多いですが、このように既存のインタフェースに何か追加する必要がある場合には、partial interfaceという定義を使います。例えばWeb MIDIでは以下のファイルで利用しています。

NavigatorWebMIDI.idl
// https://webaudio.github.io/web-midi-api/#requestMIDIAccess

[
  ImplementedAs=NavigatorWebMIDI
] partial interface Navigator {
  [
    CallWith = ScriptState, MeasureAs = RequestMIDIAccess_ObscuredByFootprinting
  ] Promise
  requestMIDIAccess(optional MIDIOptions options);
};

この例ではpartial interface Navigatorと定義する事で、NavigatorインタフェースにrequestMIDIAccessというメソッドを追加しています。
ImplementedAsで指定した名前のファイルで追加した部分の実装を行います。

ちないみにOriginTrialやruntimeフラグをオンにした時のみ利用できるAPIは、IDLファイルにアノテーションを加え、runtime_enabled_features.json5で設定を定義する事で実現できるようになっています。例えばVRDisplay.idlはOriginTrialで管理されていますね。

ブラウザプロセスへのパーミッションの要求

midipermo.png

最初の図で左側のパスになります。この図に書かれたBlink Web API→MIDIDispatcherHostの経路は既にMojo化が済んでおり、Content API越しに叩いているContent Settingsは現在ではPermissionServiceの一部として実装されています。Content APIはBlinkをレンダリングエンジンとして組み込む際のAPI境界で、この先の実装は例えばElectronやOperaなどでは別の物に差し替わっています。Mojo化した最新の実装では、Blink内のMIDIAccessor周辺から直接PermissionServiceへ問い合わせを行っています。

midipermi.png

JavaScript側のインタフェースとPermissionServiceまでへの道のりは変わっていません。この図で書かれたMIDIAccessorからPermissionServiceへの問い合わせを行っています。

PermissionServiceでは各種設定を調べた上で必要があればポップアップでユーザーに許可を問い合わせます。Chromeではエンタープライズ向けのPolicy設定、既存のユーザーによるサイトごとの許可情報を調べた上で(下図)、必要ならパーミッションの問い合わせを行うポップアップを表示します。
midics.png
これらの処理はブラウザプロセスのメインスレッドで行われます。与えた許可について今後通信経路のI/Oスレッドから検証できるよう、ChildProcessSecurityPolicyと呼ばれる場所に許可を与えたプロセスの情報を保管します。

パーミッションの管理

パーミッション管理のバックエンドとポップアップの表示はchrome/browser/content_settings/以下の実装されています。また、パーミッション管理のUI部分はchrome/browser/ui/です。それぞれ独自のシステムを理解する必要があるのですが、自分が追加してからだいぶ時間も経過し、その後は各チームにメンテ・改良を続けてもらっているため、現状についてはあまり詳しくありません。当時書いた呼び出しフローをまとめた図があったので流用しておきます。

midiperms.png

UIとして実装しなければならないのは以下の通り。当時はOS毎に実装しなければならない部分も多く、わりと手間がかかりました。今はmacOSとAndroid向けに特殊なコードが必要なくらいでしょうか。macOS向けのコードも近々不要になり、Desktop向け、mobile向けのUIをそれぞれ1つずつ組めば良くなるはずです。

Infobar / Permission Bubble

midiinfobar.png

現在はmacOSとAndroidのみで利用されているInfobar形式のパーミッションの問い合わせ。表示全体が下にシフトするために操作ミスを誘発しやすく、デスクトップ向け一般にはPermission Bubbleと呼ばれる新UIが用いられています。URLの右側の☆アイコンの近くにポップアップを開き問い合わせる方式です。

利用中を示すアイコン

midiicon.png

API利用中に表示されます(ただし、Web MIDIの場合は特権的な処理を要求するsysexフラグを指定した場合のみです)。アイコンをクリックする事で設定のクリアや詳細な設定を行うUIへ移動できます。新しいPermission Bubbleはこれと似たようなUIです。

サイト設定

midisite.png

知っている人はもしかしたら少ないのかもしれませんが、URLの左側のアイコンをクリックする事で、現在のページに対するパーミッションの設定が一括して変更可能です。

設定

midisetting.png

そしてChromeの設定から辿り着ける全体のパーミッションの設定UI。現行バージョンではマテリアルデザイン化が進み、UIもガラリと変わってしまっていますが、このUIから内部実装ではcontent settingsと言われているパーミッション設定のデータベースへ更新がかけられます。

という事で、パーミッションを取るAPIは、UI実装という意味でも非常に面倒なのがわかります。

ブラウザプロセスとの通信

midiio.png
さて、権限を獲得したレンダラーはブラウザプロセスに対して通信の要求を開始します。ブラウザプロセス内のMIDIServiceに接続すると、接続中のデバイス一覧が送られてきます。またデバイスの抜き差しのタイミングでもデバイスの状態変化情報が送られてきます。これらの情報を適切に管理する事で、ブラウザプロセスとレンダラープロセスで認識しているデバイスの一覧に一貫性を持たせます。

獲得した権限についてはChildProcessSecurityPolicyを通して通信時に常に確認が行われます。これはChromeがセキュリティーデザイン上、レンダラープロセスを一切信用していない事に依ります。攻撃が成立し陥落したレンダラープロセスから偽りの虚偽申請があったとしても、ここで権限違反を検出し、該当レンダラーを強制終了しています。

MIDIAccessorからMIDIMessageFilterを経由してブラウザプロセスへ依頼、ブラウザプロセスではレンダラー毎に用意されたMIDIHostがメッセージを受け、MIDIServiceへ処理を要求します。現在はこのChrome IPCベースの経路を利用していますが、近い将来にはMIDIAccessorから直接MIDIServiceへ依頼するようになるはずです。これから新しいAPIを作る人も、これら旧ブリッジを経由する事無く、直接Mojoのサービスとして機能を提供する事ができます。

ブラウザプロセス内のサービス

MIDIを扱うサービスの本体です。各OS毎に実装され、共通のインタフェースを上位層に提供します。複数プロセスから並列に飛び込んでくるリクエスト、そしてユーザーの物理的なデバイス抜き差しによって発生するイベント。Chromeのデザイン上の制約を満たすよう、低遅延かつ安全にサービスを実装する事はかなり難易度の高い作業です。と言い訳をするわけでもないのですが、かつて何度かセキュリティーバグを仕込んでしまっています。

I/Oスレッドでサービスを動かす難しさについてはWeb MIDI backend dynamic instantiationのdesign docを読むと理解できるかもしれません。サービスの停止・再開をするだけでも非常に多くの事を考える必要があります。

デバイスを制御するためのAPIの多くはOS内部でブロックする可能性があるためI/Oスレッドで直接呼び出す事は禁止されています。そのため専用スレッドを立てて、別スレッドで実際のデバイス制御を行います。また入力データについてはOSが内部でスレッドを作ってコールバックを呼ぶケースも少なくありません。一方でサービスの停止・終了はI/Oスレッドから要求されます。この時にサービスのデストラクタでワーカースレッドの停止を待つことも許されていないのです。唯一の例外はブラウザ全体のシャットダウン時で、その時だけはスレッド終了を待つことが許されています。そのため、通常のサービス停止時にはワーカースレッドは止めずに、終了処理はサービスのインスタンスとは切り離して遅延実行する事になります。また、再度サービスが起動される時には、遅延実行される終了処理との競合も考えなければいけません。

midisvc.png

新しいモデルでは、これらスレッド処理の難しさをなるべくプラットフォーム非依存分に分離しつつ、プラットフォーム依存分ではI/Oスレッドから要求を受けて自身のワーカースレッドで簡潔かつ安全に非同期処理できるよう工夫されています。

例えばTaskServiceでは、クラスのインスタンスに紐付けたタスクを別スレッドに投げる事ができますが、投げたタスクの処理中にはインスタンスの紐付けの解除は完了せず、紐付け解除後にはタスクは実行されずに破棄されます。

またAndroidに関しては、これらブラウザプロセスサービスの全体がNDKで実装されています。MIDIService内で動くMIDIManagerからJava側のコードに返り、AndroidのAPIを叩きます。Mojo化が進んだ際にはJavaでサービスを再実装する事もあるかもしれませんが、現在その計画はありません。

セキュリティーについて

パーミッションを取るAPIで面倒なのはUIだけではありません。セキュリティーに関しても何重にも防護壁を張り巡らせる必要があります。

Blink内でのメッセージ検証

一応メッセージの妥当性のチェックはあります。が、これはAPIとして不正データを受け取った時にエラー通知するためだけの物であって、セキュリティー的にはあまり意味を持ちません。Chromeは、基本的にはレンダラー上で攻撃が成立し、悪意のあるコードに制御を乗っ取られても平常運転を続けるようデザインされています。つまりレンダラー内のチェックは全て無効化される可能性のあるもの、と考えられています。

レンダラープロセス内では、通常のシステムコールは全て失敗し、何か意味のある行為を行う際には、ブラウザプロセスにプロトコルに従って処理を依頼するしかありません。よって、乗っ取りによりできる行為は限られており、レンダラー内でのチェックをバイパスしてブラウザに処理を依頼するか、ブラウザプロセスのプロトコル処理部のバグを突くシーケンスを投げつけるくらいの事しかできません。

レンダラープロセスのSanityチェック

ブラウザプロセスでは、ChildProcessSecurityPolicyを使い、権限を越えたアクセスを常時監視しています。またレンダラー・ブラウザ間の通信プロトコルに違反するような通信があった場合にも、即座にレンダラープロセスを強制停止します。多くの通信は適切な引数をメモリ上から探し当てて再利用しないと通信が継続できないため、トライ&エラーでセキュリティーホールを突くことが非常に難しくなっています。

ブラウザプロセスは、このような例外的な通信を正しく遮断できる事を確認するため、メッセージ通信路に乱数ベースの不正データ・不正シーケンスを送り込み、正しく強制終了するかどうかの負荷試験が常時行われています。

ブラウザプロセスでのメッセージ検証

ブラウザプロセスではレンダラーからのメッセージ、デバイスからのメッセージ全てを検証し、OS含めソフトウェアがミスハンドルする可能性の少ない簡潔な表現に変換しています。MIDIに詳しい人向けに説明するとランニングステータスは全て展開し、メッセージ内に割り込んだリアルタイムメッセージも括りだします。

最近ではこのメッセージの検証コードに対しても乱数ベースの負荷試験が導入されていますので、不正なシーケンスでデバイスを攻撃する事も難しくなっています。

KILLスイッチ

実は、最後の手段としてAPIを完全無効化するための秘密のスイッチもあります。

Google Chromeの場合、A/Bテストのようなフィールドトライアルを行うための仕組みとしてサーバ経由で一部の設定を微妙にコントロールしています。例えば最近私が行っているテストでは、バックグラウンドタブのフレームから同時に出るネットワークリクエストの最大数を絞っているのですが、その最大数をユーザーグループごとに変え、性能に対して統計的に優位な結果が得られるかを調べています。レポートの送信を許可していれば、各種設定におけるI/OやCPU負荷、UXに影響する評価値が匿名で送信され、その結果を元にパフォーマンスチューニングに関する決断がなされます。

同じ仕組みを用いて、例えばWeb MIDIの新しい動作モードのローンチを行っています。サーバ経由で対象ユーザの比率を増やしていき、問題があれば即座に動作モードを元に戻すことで、再アップデートを要求せず、新しい機能の安定性を確認しながらローンチできます。最近はmacOS向けの新モードをローンチするにあたり、macOSが一定の確率でエラーを返し始めるという問題が発覚し、すぐにターンダウンしました。また新しく致命的なセキュリティー問題が見つかった場合には、特定の機能をまるごと緊急停止させる事も可能です。一時的に互換性を失うことになるのですが、ブラウザのセキュリティーに起因する問題は、それよりも深刻だという事です。

まとめ

Web MIDI APIを例に、ChromeにAPIを追加する際に必要となる修正を一通り眺めてみました。APIによってはこれ以外にも以下のような処理が必要になるかもしれません。

  • DevToolsからのサポート
  • chrome://midi-internal といった開発者向けページの用意

インターナルページについては個人的には欲しいと思っているのですが、おそらくMojo化が落ち着いた後かと思います。

また、この記事では触れていませんがWeb MIDIでも実装している物として

  • UMA(性能評価などの参考となる匿名レポート)
  • 性能解析の参考となるchrome://tracing向けトレースポイントの設置

などがあります。

17
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
8