更新履歴:
IServiceProvider
について追記依存性の注入についての以下の記事がとても素晴らしかったです。
本投稿は上記の記事に触発されて書いた DI コンテナー、特に Unity を用いた開発にまつわる様々な事柄をとりとめもなく見ていくモノとなります。
はじめに
Unity の DI コンテナーは非常に多機能です。利用するにあたって DI コンテナーが必要なのか、オマケでついている機能が必要なのかハッキリさせる必要があります。
DI コンテナーの機能は
- オブジェクトグラフの構築
- 構築したオブジェクトの寿命管理
ですが、Unity 向けのコンテナーにはこれらに加えて
- ピュア C# クラスを Unity のライフサイクルに参加させる機能
-
[Inject]
でフィールドにインスタンスを設定する - Addressable やプレハブ等の Unity アセットのハンドリング
- その他
があります。
これらの DI コンテナーの機能を駆使して目的である「DI」を達成していく事になります。
--
※ 扱う用語については以下の通り
- DI(Dependency Injection、依存性の注入)
- 本投稿での DI はコチラ。特にコンストラクターインジェクションを指す。
- DI(Dependency Inversion)
- 制御の反転(Inversion of Control、IoC)SOLID原則の D。ココでは基本扱いません。
- DI コンテナー
コンストラクターインジェクションの重要性
まず DI コンテナーを使う際によく出てくるコンストラクターインジェクションについて、腑に落ちる解説が Google のページに書いてありました。
まとめ 依存関係インジェクションには次のような利点があります。
【中略】
リファクタリングの容易さ: 依存関係が、実装の詳細部分として見えなくなるのではなく、API サーフェスの検証可能な部分に組み込まれるため、オブジェクトの作成時またはコンパイル時に依存関係をチェックできます。
※ 強調は筆者による
DI とは関係なく常に意識するべき
API に依存関係が表れるという利点はコンストラクターインジェクションでなければ得られません。
// サービスが場所に依存しているのが明確に分かるし、そもそも依存関係を解決しないとインスタンスを作ることが出来ない
var service = new Service(new Location());
👇 こういうことは起きない
var service = new Service();
serivce.DoSomething(); // 何故かエラー
// ロケーションを設定しないとエラーが出ます!!! ちゃんとドキュメント読みましたか!?!?!!!
service.SetLocation(new Location());
当たり前すぎて「○○パターン」として語られることも無いですが、Unity から入ると MonoBehaviour
の影響でその利点に気付きにくい/コンストラクターを使わないので忘れがちです。DI コンテナーとは関係なく積極的に使いたいものです。
DI コンテナーによってオブジェクトグラフ構築が1行に出来た! とかどうでも良くて、API にしっかりと意味を持たせることが出来る点が重要です。
インターフェイスを受け取る意味
インターフェイスを受け取ろうという話も DI コンテナーと合わせて語られますが、これは DI コンテナーとは全く関係が無い IoC(Inversion of Control) を達成するための話も混ざっています。テストがしやすくなるという話も 「DI コンテナー」「オブジェクトグラフの構築」とは関係が無いです。
- https://qiita.com/getty104/items/08eab9b09b626b5306d1
- https://qiita.com/getty104/items/dc0ba5a3b10c4f5121e4
- https://qiita.com/saeki4n/items/22a276dcac9ef537ee25
インターフェイスだと足りない
個人的には「インターフェイス」を受け取る、だと足りていないと思っていて
// 👇 この中でユーザーデータベースを壊す可能性があるのは?
public Foo(IRepository repo);
public Other(IRepository repo);
public Something(IRepository repo);
コンストラクターインジェクションで受け取るインターフェイスを適切に定義することで、API の影響範囲まで表現した方が良いと思っています。
public Foo(IReadOnlyUserRepository repo);
public Other(ISaveDataRepository repo);
public Something(IUserRepository repo); // 👈 コイツがデータベース壊した
クラス名を ReadOnly○○
としたところで、インターフェイスに書き込み権限があるのなら何の保証にもなりませんから。
(ゲームだったら読み取り専用か個人情報を含むので追加のチェック体制が必要か、ぐらいで十分ですかね?)
テストどうこう/インターフェイスなら複数のバインディングを一括で出来る、ではなく依存関係に加えて必要な権限を表せるので、読み取りしか行わないのなら IReadOnlyRepository
を受け取るべきとなります。
今 MonoBehaviour
をデザインするなら
Unity 黎明期には C# にジェネリック型が無かった(ハズ)ので仕様的にしょうがなかったんだと思いますが、今なら以下のように設計するのではないでしょうか?
MonoBehaviour 2.0
class MonoBehaviour : UnityEngine.Component
{
protected MonoBehaviour() { ... }
}
class MonoBehaviour<TRequire1> : MonoBehaviour
where TRequire1 : UnityEngine.Component
{
protected MonoBehaviour(TRequire1 required1) { }
}
class MonoBehaviour<TRequire1, TRequire2> : MonoBehaviour
where TRequire1 : UnityEngine.Component
where TRequire2 : UnityEngine.Component
{
protected MonoBehaviour(TRequire1 required1, TRequire2 require2) { }
}
現在のように文章で補強するまでもなく、API からその他のコンポーネントに依存していることが読み取れます。もちろん実行することなく開発環境上でエラーの補足も可能です。
var meshFilter = new MeshFilter();
var renderer = new MeshRenderer(meshFilter);
DI コンテナーは
MeshFilter
を作ってからMeshRenderer
を作るボイラープレートコードが面倒、どうにかならないか? から始まっている説。
今の Unity の MonoBehaviour
を継承したクラスは new
でインスタンス化してはならないし、GameObject が無ければ意味を成しません。依存しているわけです。
しかし API にはそれが表れていません。ドキュメントによる補足や専用のアナライザーを VisualStudio や Rider に提供することでどうにかしています。他には依存関係を示すための RequireComponent
属性も存在します。
[RequireComponent(typeof(xxx))]
class MyBehaviour : MonoBehaviour
{
public void Awake()
{
// C# の言語機能ではなく Unity エディターによって存在が保証されている
var xxx = this.GetComponent<xxx>();
}
}
オブジェクトグラフの構築
「DI コンテナー」の機能として、オブジェクトグラフの構築があります。
この機能自体はパフォーマンスその他細かいことを考慮しなければ100行ぐらいで作れます。似たようなものを作ったことがある人もいるのではないでしょうか。
実行環境: https://dotnetfiddle.net/ これだとマッピングが出来ないので、以下の要領で実装する必要があります。 再発明しても意味はないし考慮すべき事柄が多くとにかく面倒ですが、スコープの概念とインスタンスの寿命管理、どのコンストラクターを使うか等、コツコツとやっていけばいつかは完成するでしょう。 デバッグの時だけちょっとしたものが欲しい、で上記のようなモノを作った経験がある方も多いのでは? (リゾルバーだけなら20行
using System;
using System.Reflection;
public class Program
{
// リゾルバー本体
static MethodInfo ResolveMethod = typeof(Program).GetMethod("Resolve", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
static T Resolve<T>()
{
var type = typeof(T);
var ctors = type.GetConstructors();
if (ctors.Length == 0)
throw new NotSupportedException("no .ctor");
var ctor = ctors[0];
var parameters = ctor.GetParameters();
if (parameters.Length == 0)
return Activator.CreateInstance<T>();
object[] args = new object[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
var param = parameters[i];
var m = ResolveMethod.MakeGenericMethod(param.ParameterType);
args[i] = m.Invoke(null, null);
}
return (T)Activator.CreateInstance(typeof(T), args);
}
// テスト
public static void Main()
{
Console.WriteLine(Resolve<Test>().Other.Something.Count);
Console.WriteLine(Resolve<Test>().Other.Something.Count);
Console.WriteLine(Resolve<Test>().Other.Something.Count);
}
// オブジェクトグラフ
class Test
{
public Other Other { get; }
public Test(Other other, Other other2)
{
Other = other;
Console.WriteLine("I'm Test. 2 instances are same? " + (other == other2));
}
}
class Other
{
public Something Something { get; }
public Other(Something something)
{
Something = something;
something.Count = System.Random.Shared.Next(310);
Console.WriteLine("I'm Other. something exists? " + (something != null));
}
}
class Something
{
public int Count { get; set; }
}
}
// Build() するタイミングを作って高速な検索が可能な FrozenDictionary を使う方が良い
readonly Dictionary<Type, Type> _concreteTypeByInterface = new();
void Register<TSource, TConcrete>() where TConcrete : TSource
{
if (!_concreteTypeByInterface.TryAdd(typeof(TSource), typeof(TConcrete)))
throw new Exception();
// やっとけば分岐を減らせる
_concreteTypeByInterface.TryAdd(typeof(TConcrete), typeof(TConcrete));
}
T Resolve<T>()
{
if (!_concreteTypeByInterface.TryGetValue(typeof(T), out var concreteType))
{
var t = typeof(T);
if (t.IsInterface || t.IsAbstract)
throw new Exception();
concreteType = t;
}
return ResolveCore(concreteType);
}
System
名前空間の IServiceProvider
を実装していると尚良い)
オブジェクトを組み立てる必要性
DI コンテナーを使う前に「様々なオブジェクトを集めてオブジェクトを作る」必要があるのかをハッキリさせる必要があります。
たとえばカードゲームの場合、
public class MySpecialCard : CardBase
{
public MySpecialCard(IHumanKind kind, IFireElement element, IEnumerable<IAttackEffect> attackEffects)
: base(kind, element, attackEffects) { }
public override string CardName => "すぺしゃるかーど";
public override Texture CardImage => s_textureCache ??= ...;
public override int MaxHp...
}
abstract class CardBase
{
protected IKind kind;
protected IElement element;
//...
}
このような最低限の実装で「火属性のヒューマン」という新たなカード効果を作ることも出来るでしょう。
そしてドローする際には
var card = resolver.Resolve<MySpecialCard>();
するだけで良くなりますし、各試合ごとに DI スコープを切ればリソースの破棄まで任せられます。このような場合は DI コンテナーに頼るべきです。
(DI コンテナーガチ勢ではないので良い例ではないかも)
コンテキストオブジェクト
しかし多くの場合で DI コンテナーは不要だと思っています。もしあると便利だと感じてしまっているのなら、その要因の一つは、
public class MyClass
{
public MyClass(IOption opt, IOther other...) // 👈 今後も引数が増えていく可能性あり
{
//...
}
}
引数の増減に対応したいケースだと思います。
もしこのケースに当てはまるなら、
public class MyBattleCharacter : MyCharacter
{
readonly MyBattleContext context;
public MyBattleCharacter(MyBattleContext context) : base()
{
this.context = context;
}
}
コンテキストオブジェクト(DTO)を噛ませることで、コンストラクターのシグネチャを変えずに新たな引数を追加することが出来るようになります。
sealed class MyBattleContext : ContextBase // その他の命名案: ..Args, Configuration, State, Settings, Options
{
public IInventory Inventry { get; }
public IEquipment Equipment { get; }
public IStageBuff? StageBuff { get; set; }
public IStageDebuff? StageDebuff { get; set; }
public IStageEffect? StageEffect { get; set; }
public ISomething Something { get; }
//...
public IHotfix TemporaryFixForBugIdXXX { get; } // 適当
}
これは若干雑な解決策ではありますが、個人的には DI コンテナーに「なんか良い感じに」オブジェクトを作ってもらおう、注入してもらおう、というのも同じくらい雑なやり方だと思っています。
ゲームのエントリーは単純
上記のコンテキストオブジェクトは自動的に作られるわけではないので、コンストラクターの修正は不要になったけど問題を別の場所に移しただけでしょ、という状態です。結局修正は必要になるわけです。
しかしゲームというものは、たとえアセット数やステージ数その他諸々が膨大な数であっても、その 99.99% がタイトル画面から
- ニューゲーム
- コンティニュー
どちらかを選ぶエントリーしかありません。Web サイトのようにブックマークからトップページをすっ飛ばして特定のページにエントリーしてくることを考慮する必要が無いのです。
なので new ○○Context(a, b, c...)
を様々な場所で作るようなことは無くはないでしょうが、多くても片手で収まる程度です。(コンテキストに依存する側のオブジェクトを作成する場所は多数存在する可能性が高い)
というか、そういう場所が増えてから DI コンテナーの導入を考えましょう。シッカリと型付けされていれば修正すべき箇所もばっちりきっちり見つかりますので。
細切れのインターフェイスを切り貼りしているとピンズドでココ! という場所が見つかりづらいので、適切な粒度の「型」の定義は重要です。(適切な型が存在しない場合、アプリの機能や挙動を掌握できていない可能性もあります)
数多ある int
の利用箇所から自分の求めるコードを見つけることは困難を極めます。全部読んで、確認して、そして修正漏れが発生してエンバグすることになります。細切れのインターフェイスでも同様のことが起こり得ます。
開発フェーズ
DI コンテナーの利用では、開発の段階/レイヤーも考慮する必要があると思います。
共有ライブラリ開発時にコンストラクターでテスト用のモックを受け取れるというのは良いことだと思いますが、
public MyLibrary(IService service) { }
いざ Unity に機能を組み込もうとする段階では、
class MyBehaviour : MonoBehaviour
{
// テスト済みのライブラリを使うだけだし
private MyLibrary library = MyLibrary.Instance;
}
で十分だと思います。上記の通り、ライブラリ開発時点でテスト済みな訳ですから。
この段階で必要になるテストは、
- 読み込み中なのにボタンがクリックできてしまう
- 読み込みが終わっても UI が更新されない
- リザルトが表示されない/BGMだけ流れている
- ゲームが先に進まない
- その他
各要素の繋ぎ込み、マウスクリックを伴うテスト等々、ユニットテストとは内容/実施方法自体が変わってきていると思います。
(UnityEngine
がバキバキに絡んでくるアセンブリの段階で単純なユニットテストが含まれている場合、分け方に難アリと思って間違いない)
なので、以下のような事を回避できてさえいれば十分だと思います。
class MyBehaviuor : MonoBehaviour
{
// 開発中とリリースビルドでアクセス先を変えられないのでマズい
private MyLibrary library = new MyLibrary(); // 👈 new はモチロン、引数で調整が出来るメソッドを使うのもダメ
void ○○Configure(...)
{
// ライブラリの開発段階でテストは済んでるのに注入してどうする? 初期化子で良くない?
}
}
この段階になると、テスト環境と本番環境(進行によってはどちらもモック)の切り替えが出来ることが「開発」において重要であり、しかし「アプリ/ゲーム」のコンポーネントは自身がどの環境にいるのかを知る必要はなく、また知ってはならない/依存してはならない状況です。
(なので readonly MyLibrary lib = MyLibrary.GetInstance(m_isDebugMode)
はマズい)
技術的に可能かどうかではなく、環境切り替えを達成するための手段として DI コンテナーを選択することが本当に最善なのかは十分に検討したいです。
追記: IServiceProvider
を利用する
フィールド直挿しが気になる場合は IServiceProvider
を使うことも可能です。このインターフェイスは GenericHost
や Autofac
、Unity.Microsoft.DependencyInjection
等で実装されている共通インターフェイスです。
class MyBehaviour : MonoBehaviour
{
private readonly IServiceProvider provider = ...;
void Awake()
{
this.service = this.provider.GetService<IService>();
}
}
※ 以下の通り IServiceProvider
のサービスロケーター的利用は非推奨です。非推奨の理由が分からない場合はフィールド直挿しで行きましょう。
なぜ System
名前空間にインターフェイスだけが存在するのか
IServiceProvider
はインターフェイスのみが System に存在しており、Microsoft によるデフォルト実装 Microsoft.Extensions.DependencyInjection (MS.E.DI) は追加パッケージのインストールが必要な状態で提供されています。
IObservable<T>
IObserver<T>
も同様
Rx (Reactive Extensions) の根幹をなす IObservable<T>
IObserver<T>
インターフェイスも、デフォルト実装 System.Reactive
を追加パッケージで提供する形になっています。
何故なのか
「DI」「Rx」これらのパターンに共通することは、コンポーネント間を接続する技術であるという事です。
もし共通インターフェイスが System 名前空間になかった場合、
- ○○に依存する全てのコンポーネントが特定のライブラリの使用を強制されかねない
- ○○はライブラリAを使っているが、◇◇はライブラリBを使っていて接続できない
- ライブラリは同じだが依存しているバージョンが違い API に非互換が存在する
という事態になり得ます。依存関係が外に漏れだす、感染していくという事です。
抽象に依存せよ
「DI」「Rx」のようなコンポーネント間接続のライブラリに限っては「パターン」に依存せよ。でしょうか。
(疎結合どうこうよりも依存関係や実装パターンの漏出を防ぐのことが目的です)
共通基盤ライブラリで DI パターンや DI コンテナーを使っていたとしても、利用するライブラリ、フレームワークやアプリがそれを強制されるのはマズいです。
その依存の連鎖を断ち切る方法が IServiceProvider
です。
/// <summary>内部で DI コンテナー使ってるんでインスタンスの生成にはコチラを使ってください</summary>
public IServiceProvider GetProvider() => ...;
// DI 要らんよなーという利用者側がそれを強制されない状態
var service = awesomeLibrary.GetProvider().GetService<IService>();
もしもこのインターフェイスが無かった場合、
// ライブラリAを使わずにインスタンス化してしまったんですか!?!?!?!??!?!!
var service = ライブラリCが使えって言うからライブラリBにゴリゴリに依存してて衝突するんすよ;;;;
という事もあり得ます。
内部がどうであっても境界面に露出する API が特定のライブラリではなくパターンに依存した状態であれば、ロケーター的に使って依存を断ち切るだけではなく、他のコンポーネントやライブラリとの接続/入れ替えも容易になります。
DI コンテナーは「何時でもキレイにはがせる接着剤」という状態に留め、それを維持することが肝要です。
シングルトンどうする問題
static
なクラスで十分なケースもあると思いますが、個人的には ○○.Instance
○○.Shared
○○.Default
な実装にしておいた方が後々助かると思います。(最近酷い目にあった)
参考情報
シングルトンを提供するにあたっては、インターフェイスや基底クラスを実装した具象型/継承型を、基底クラスのプロパティーから提供するという例があります。
ArrayPool<T>.Shared
はTlsOverPerCoreLockedStacksArrayPool<T>
を「ArrayPool<T>
」として返す
System.Random.Shared
はSystem.Random.ThreadSafeRandom
を「System.Random
」として返す
ただ、C# の基本ライブラリと実際のアプリ開発では事情が違うので
class ServiceLocator : IServiceProvider // 👈 将来的な DI コンテナー対応を見据えて実装しておくのもアリ
{
static IService Service => _isDebugMode ? s_Debug : s_Release;
}
みたいなものを噛ませた方が良いかもしれません。結局ロケーターですね。
しかし DI コンテナーのビルドを見てみると AsSingleton
的なものが並んでいるケースの方が多いんじゃないでしょうか? ロケーターですよねそれ。
追記: サービスロケーターにスコープの概念を加える
○○.Default
○○.Shared
パターンで実装しておいた方が後々助かるというのは、以下のような拡張が可能だからです。(≒ YAGNI 原則? 必要になった時の為に拡張できる構造にしておく)
public class MyLocator : IServiceProvider
{
// グローバルなスコープとして機能する MyLocator のインスタンス
public static MyLocator Default { get; } = new MyLocator();
// static フィールドではなく自身の管理するフィールドからインスタンスを発行する
private IService? _service;
public IService MyService => _service ??= _isDebugMode ? new...
// 必要なら親子関係を組めるようにしておく等
private readonly MyLocator? parent;
private readonly List<MyLocator> children = new();
public T Get<T>(bool searchChildren = false) where T...
}
実装すると、以下のようにスコープ(個別の MyLocator インスタンス)を使ってサービスの取得が可能になります。
// グローバルなシングルトンの取得には Default を使う
var globalSingleton = MyLocator.Default.MyService;
// 専用のスコープを作ってから取得すれば Scoped Singleton になる
var scope = new MyLocator();
var scopedSingleton = scope.MyService;
Console.WriteLine(globalSingleton == scopedSingleton); // false
これは一つのアプリ内に複数の環境が必要になるケース、例えば画面分割で対戦プレイを行うような場合に使えます。
// プレイヤー毎のスコープを作ってコンテキストに乗せて流す
var primaryPlayerEnvironment = new MyLocator();
var secondaryPlayerEnvironment = new MyLocator();
var primaryPlayer = new Player(new GameContext() { Locator = primaryPlayerEnvironment });
var secondaryPlayer = new Player(new GameContext() { Locator = secondaryPlayerEnvironment });
class GameContext()
{
// ロケーターに string Name { get; } を持たせておくとサービス取得に使われたインスタンスを特定しやすい
public MyLocator Locator { get; init; } = MyLocator.Default;
// 👇 スコープ設定を強制したくなった場合は init ではなくコンストラクターのパラメーターに変えてやる
public GameContext(MyLocator locator)
{
this.Locator = locator;
}
}
class Player()
{
readonly GameContext context;
readonly IService service;
...
public Player(GameContext context)
{
this.context = context;
this.service = context.Locator.MyService; // 流れてきたロケーターを使ってサービスを取得
...
}
}
これがあれば DI コンテナー無くても良いじゃんであり、わざわざ実装せずに DI コンテナー使えば良いじゃんでもあります。(自分でちゃんと実装すれば)機能面での差は無いので DI コンテナーへの過度な依存をどう捉えるか次第でしょう。
DI コンテナーをビルドしているメソッドはアプリや Unity シーンのエントリーポイントとして使えるハズなので、そこで行われている
Register()
をコンテキストオブジェクトの構築に置き換えれば使えるようになります。
スコープやインスタンスの寿命管理
いろいろあって纏まりませんが、
- スコープに基づいたアセット読み込み、寿命管理等に DI コンテナーを使っている。
- その機能を発火する為に依存性の注入をしている。(本来なら不要)
なら使うのを辞めるべき、とまでは言えませんが、そういう使い方だと分かるやり方にした方が良いと思います。
そういう意味では [Inject]
アトリビュートは分かりやすいですね。DI コンテナーへの依存度が上がってしまうのが気になりますが、実際依存していて無ければ動かない設計になってしまっているので、むしろ積極的に使って依存をアピールするべきまであります。
脱 Unity 依存の為の DI コンテナー
DI コンテナーの「オマケ機能」として Unity のライフサイクルにピュア C# クラスを組み込むというものがあります。
個人的には脱 Unity して DI コンテナーへの依存を高めてしまうなら本末転倒では? と思っていますが、流行りもあったのでそういうこともあるでしょう。(次のプロジェクトでもやろうとする人は居ませんよね?)
--
そういうつまらない話以外にも、Unity にはシーンの破棄に合わせてシーンに存在するオブジェクトを破棄してくれるという機能があります。しかし脱 Unity を進めると、シーンが破棄されたのにピュア C# クラスが破棄されない、という新たな問題を生み出します。
無駄な事を、、、という一方で前述のコンストラクターインジェクションの恩恵を受けるためにはピュア C# クラスである必要もあるのでもどかしい話です。
新しめの Unity では SerializeReference
の登場でピュア C# クラスを扱いやすくなりました。冒頭で紹介した記事が指摘する通り、Unity エディターはビジュアル DI コンテナーであるというのは納得感があります。
エディター拡張で SerializeReference
の対象をドロップダウンに表示するところまでは出来ているので、後はマウスオペレーション無しで自動化する手段さえあれば完璧に見えますね。
寿命を揃えたいだけなら公式 API で良い
インスタンスの寿命を Unity オブジェクトと揃えたいだけであれば、DI コンテナーを使うのではなく Unity 6 から標準となった MonoBehaviour.destroyCancellationToken
を使った方が良いと思います。(戻り値は Unity 非依存の CancellationToken
構造体)
// シーンの破棄に合わせてピュア C# オブジェクトを破棄
sceneLifetime.destroyCancellationToken.Register(this, static (obj) => ((IDisposable)obj).Dispose());
// CancellationToken は ○○Async メソッドと組み合わせなければならない、という制約はない
MyServer.Initialize(this.destroyCancellationToken); // サブモジュール/スレッドの起動・寿命設定等を行うエントリーポイント
古い Unity にもバックポートできる。
public class LifecycleBehaviour : MonoBehaviour
{
#if UNITY_2022_2_OR_NEWER == false
private CancellationTokenSource? polyfill_destroyToken;
public CancellationToken destroyCancellationToken => (polyfill_destroyToken ??= new()).Token;
#endif
public virtual void OnDestroy()
{
#if UNITY_2022_2_OR_NEWER == false
if (polyfill_destroyToken != null)
{
polyfill_destroyToken.Cancel();
polyfill_destroyToken.Dispose();
polyfill_destroyToken = null;
}
#endif
}
}
Unity 向けの DI コンテナーはスコープ管理クラスが MonoBehaviour になっているので、やっていることはほぼ変わらないはずです。
おわりに
👇 以前の投稿
--
以上です。お疲れ様でした。