Spring FrameworkにおけるDIのベストプラクティス: コンストラクタインジェクションの優位性

Spring Frameworkでの依存性注入には、

  • フィールドインジェクション
  • コンストラクタインジェクション
  • セッターインジェクション

の3つの方法があります。これらの方法は、オブジェクトが他のオブジェクトに依存している場合に、その依存オブジェクトを自動的に挿入するために使用されます。この中で主に使われるのはフィールドインジェクションとコンストラクタインジェクションです。

Springのコミュニティでは、フィールドインジェクションよりもコンストラクタインジェクションの方が推奨されています。なぜコンストラクタインジェクションが推奨されているのかをフィールドインジェクションのデメリットとコンストラクタインジェクションのメリットの観点から見ていきましょう。

フィールドインジェクション

フィールドインジェクションは、クラスのフィールドに直接依存性を注入する方法です。これは@Autowiredアノテーションをフィールドに適用することで行われます。例えば、

@Component
public class MyService {
    @Autowired
    private DependencyClass dependency;
}

のように書きます。

コンストラクタインジェクション

コンストラクタインジェクションは、オブジェクトのコンストラクタを通じて依存性を注入する方法です。コンストラクタに@Autowiredアノテーションを付けることで、Springが自動的に依存オブジェクトを挿入します。例えば、

@Component
public class MyService {
    private final DependencyClass dependency;

    @Autowired
    public MyService(DependencyClass dependency) {
        this.dependency = dependency;
    }
}

のように書きます。

コンストラクタインジェクションが推奨される理由

Spring Framework でコンストラクタインジェクションが推奨される理由のうち、特に理由としてふさわしいと思ったものには以下が2つがあります。

依存コンポーネントの不変性を宣言できる

前述のコード例を見てもらうとわかりますが、フィールドインジェクションではオブジェクト生成時点でフィールドの値が初期化されないため、final宣言をつけることができません。一方、コンストラクタインジェクションではオブジェクト生成時点でフィールドの値が初期化されるため、final宣言をつけることができます。

これにより依存コンポーネントがイミュータブル(不変)であることを明示することができるようになります。

実際にイミュータブルであることで誤って再初期化してしまうことを防ぐことはほとんどないと思いますが、実際に変更してしまうかどうかは別として、変更するつもりがないという設計意図をコードに表現できることが重要だと考えています。

循環依存を回避できる

コンストラクタインジェクションを使用するとコンストラクタの呼び出し時点でインジェクションされてイミュータブルになります。このとき循環依存があると、アプリケーションの起動時にエラーが発生して循環依存を検知することができます。

一方、フィールドインジェクションの場合は実際に対象のフィールドが呼び出されるまで検知できないため、循環依存の発見が遅れたり、発見自体が困難になる場合があります。

単一責任の原則に従っていないことに気づきやすくなるのか

単一責任の原則に従っていないことに気づきやすくなるというメリットを挙げているのを見かけますが、個人的にはそれほど効果がないのではないかと思っています。

まったくないとは言いませんが、Lombokを使用していると、ほぼフィールドインジェクションのような使い心地でコンストラクタインジェクションを使えてしまうので、レビューで検出する方が確実だと思います。例えば、

@Component
@RequiredArgsConstructor
public class MyService {
    private final DependencyClass dependency;
}

のように@RequiredArgsConstructorアノテーションを使用することで、コンストラクタを省略することが可能です。これ自体はボイラープレートコードを書かなくて済むというメリットがありますが、コンストラクタ自体を書かないため、依存コンポーネントが多すぎることに気づきにくいと思います。

コンストラクタインジェクションに頼るのではなく、きちんとレビュー前のセルフチェックやレビューで依存関係の複雑さや単一責任の原則に従っているのかなどをチェックすることが重要だと考えています。

単体テストコードを書きやすくなるのか

元々はコンストラクタインジェクションの方が書きやすかったと思うのですが、現在ではほとんど変わらないと思いました。

フィールドインジェクションを使ったコンポーネントの例

フィールドインジェクションを使ったコンポーネントのテストを書く例として、次のようなサービスクラスに対するテストケースを考えてみましょう。

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class SampleService {

    @Autowired
    private DummyComponent dummyComponent;

    public String doSomething() {
        return dummyComponent.doSomething();
    }
}

このサービスクラスに対するテストは以下のようになります。

package com.example.demo;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class SampleServiceTest {

    @InjectMocks
    private SampleService sampleService;

    @Mock
    private DummyComponent dummyComponent;

    @Test
    public void testDoSomething() {
        when(dummyComponent.doSomething()).thenReturn("test");

        assertEquals("test", sampleService.doSomething());
    }
}

コンストラクションインジェクションを使ったコンポーネントの例

コンストラクタインジェクションを使ったコンポーネントの例も見てみましょう。同じサービスクラスをコンストラクタインジェクションで書き換えた場合、以下のようになります。

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class SampleService {

    private final DummyComponent dummyComponent;

    @Autowired
    public SampleService(DummyComponent dummyComponent) {
        this.dummyComponent = dummyComponent;
    }

    public String doSomething() {
        return dummyComponent.doSomething();
    }
}

このサービスクラスに対するテストは以下のようになります。

package com.example.demo;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class SampleServiceTest {

    @InjectMocks
    private SampleService sampleService;

    @Mock
    private DummyComponent dummyComponent;

    @Test
    public void testDoSomething() {
        when(dummyComponent.doSomething()).thenReturn("test");

        assertEquals("test", sampleService.doSomething());
    }
}

見ての通り同じテストケースクラスでテストできます。

現状ではテストのしやすさという点ではその差はほぼないと考えて差し支えないと思います。

まとめ

依存コンポーネントに対して不変性を明示でき、循環依存を回避できるようになる点を考慮すると、コンストラクタインジェクションを積極的に使用していくことは重要だと思います。

一方で、単一責任の原則に従っているかどうか、依存関係の多いファットなコンポーネントになっていないかどうかについては、ペアプログラミング時に議論したり、セルフチェックやレビューによってチェックすることが重要だと思います。

単一責任の原則とオープン・クローズドの原則の関係性について

ここ最近、SOLID原則の「単一責任の原則」と「オープン・クローズドの原則」に関する検討を行っていて、オープン・クローズドの原則と単一責任の原則の関係性に考えていました。

具体的には、オープン・クローズドの原則に従った設計を行うと、自然と単一責任の原則に従うのではないかということです。必ずしも正しいとはいえませんが、多くの場合に成り立つのではないかと思います。

単一責任の原則とは

単一責任の原則(Single Responsibility Principle)は、オブジェクト指向プログラミングにおける設計原則の一つであり、「モジュール、クラス、関数などは、単一の機能についてのみ責任を持ち、その機能をカプセル化するべきである」というものです。

単一責任の原則のメリット

単一責任の原則を適用することで得られるメリットには以下のようなものがあります。

  • 理解しやすさの向上: 責務が明確に分かれているため、コードの理解が容易になり、保守性も向上します。
  • 変更の容易化: 特定の機能を変更する際、影響を受ける範囲が限定されるため、変更に伴うリスクが低減されます。
  • 再利用性の向上: 単一の機能に特化したモジュールは、他の箇所でも容易に再利用することができます。
  • テストの容易化: 単一の機能に特化したモジュールは、個別にテストしやすくなります。

単一責任の原則を満たすための手法

単一責任の原則を満たすための手法として以下のような手法が挙げられます。

  • クラスごとに単一の責務を割り当てる: あるクラスは、特定の機能のみを担当するように設計します。
  • メソッドを小さくする: メソッドは、単一のタスクを実行するように設計します。
  • インターフェースを使用する: 共通の機能をインターフェースで定義し、それを複数のクラスで実装します。

オープン・クローズドの原則とは

オープン・クローズドの原則(Open/Closed Principle)は、オブジェクト指向プログラミングにおける設計原則の一つであり、「ソフトウェアは、既存のコードを変更することなく、機能拡張できるようにすべきである」というものです。

オープン・クローズドの原則のメリット

オープン・クローズドの原則を適用することで得られるメリットには以下のようなものがあります。

  • 柔軟性の向上: 新機能の追加や変更を、既存のコードに手を加えることなく行うことができます。
  • 保守性の向上: 既存のコードを触らずに変更できるため、コードの保守性が向上します。
  • 再利用性の向上: 既存のコードを拡張することで、新しい機能を追加することができます。
  • テストの容易化: 既存のコードを変更しないため、テストが容易になります。

オープン・クローズドの原則を満たすための手法

オープン・クローズドの原則を満たすための手法として以下のような手法が挙げられます。

  • 抽象化を利用する: 共通の機能を抽象化し、それを継承したクラスで具体的な処理を実装します。
  • 依存関係を明確にする: クラス間の依存関係を明確にし、できるだけ疎結合な設計を目指します。
  • ポリモーフィズムを利用する: 異なるクラスで同じインターフェースを実装することで、柔軟な振る舞いを可能にします。

オープン・クローズドの原則に従って設計の例を確認する

ここからが本題です。

オープン・クローズドの原則に従うと、自然と単一責任の原則に従うことになるのではないかと考えました。もちろん、「オープン・クローズドの原則に従っている=単一責任の原則」が必ずしも成り立つわけではありませんが、多くのケースにおいては成り立つのではないかと思います。

拡張に対して開いていて、修正に対して閉じている

オープン・クローズドの原則には、「拡張に対して開いていて、修正に対して閉じている」という考え方があります。「拡張に対して開いている」とは、既存のコードを変更することなく、新しい機能を追加したり、既存の機能を拡張したりできることを意味します。一方、「修正に対して閉じている」とは、既存のコードを変更することなく、新しい機能を追加したり、既存の機能を拡張したりできることを意味します。

新しい機能を既存コードの変更をせずに追加する方法として、継承があります。抽象クラスやインターフェースを使用し、個別の実装をサブクラスで実現することで、新しい機能の追加を新しいクラスの作成で実現することができます。

また、修正に対して閉じているためには、各機能が別々のクラスに分かれているだけでなく、ひとつのメソッドに複数の処理が含まれていないようにすることが重要です。他の処理が含まれているとコードを修正した際に、修正対象ではない処理にも影響を与えている可能性があります。

この点について、具体的なコードで見ていきましょう。

オープン・クローズドの原則に従っていない設計例

例えば、図形クラスを考えてみます。

このクラスは様々な図形の面積を計算することができます。

class Shape:
def __init__(self, type: str):
self.type = type

def get_area(self) -> float:
if self.type == "circle":
return 3.1415 * self.radius * self.radius
elif self.type == "rectangle":
return self.width * self.height
elif self.type == "triangle":
return 0.5 * self.base * self.height
else:
raise ValueError(f"Unknown shape type: {self.type}")

この図形クラスでは、新しい図形を扱えるようにするためにクラス自身に手を加えなくてはなりません。上記のコードにおいては、新しい図形を扱えるようにするにはif文に条件を追加する必要があります。そのため、新しい図形を追加する度にコードの複雑度が増し、同じメソッドに修正を加えるため、デグレのリスクがつきまといます。

オープン・クローズドの原則に従っている設計例

オープン・クローズドの原則の原則に従うようにコードを修正してみましょう。

from abc import ABC, abstractmethod


class Shape(ABC):
@abstractmethod
def get_area(self) -> float:
pass


class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius

def get_area(self) -> float:
return 3.1415 * self.radius * self.radius


class Rectangle(Shape):
def __init__(self, width: float, height: float):
self.width = width
self.height = height

def get_area(self) -> float:
return self.width * self.height


class Triangle(Shape):
def __init__(self, base: float, height: float):
self.base = base
self.height = height

def get_area(self) -> float:
return 0.5 * self.base * self.height

抽象クラスShapeでインターフェースを定義し、具体的な図形はこれを継承する形で定義するようにします。どの具象クラスをインスタンス化するかによってget_areaメソッドの動作が変わります。新しい図形に対応する時には既存コードに手を加えずに新しいクラスを作成することで実現します。新たな図形の追加に対して既存の図形に対して影響を及ぼしません。

拡張(新しい図形の追加)に対して開いており(既存コードに修正を加えずに機能を追加できる)、修正(ある図形の計算方法が誤っている問題を修正)に対して閉じている(他の機能に影響を与えずに修正できる)ことがわかります。

各図形クラスは単一の図形に特化しており、get_areaメソッドは面積を返すという単一の機能を実現しているため、単一責任の原則に従っているといえます。

まとめ

成り立たないこともありますが、オープン・クローズドの原則に従うことは、単一責任の原則に従うことにもなります。これは、それぞれの原則は完全に独立しているのではなく、互いに関連していることを意味しています。一方の原則に従うことは他方の原則に従うことを意味することもあれば、トレードオフの関係にある場合もあります。必ずしも原則に従うことが正しいわけではなく、要求や仕様に応じてバランスをとることが重要です。

Docker Composeのversionが不要になっていた

かなり前からdocker-compose.yamlversionが不要になっていたようですが、macOS上ではversion is 'obsolete'のメッセージが出ていなかったので、気づきませんでした。

Windows環境でversion is 'obsolete'のメッセージが表示されるのを見かけたので、調べてみると後方互換性を維持するために残っているだけで、意味のない項目になっているようです。

この説明を読む限り、ワーニングメッセージが表示されるようですが、macOSのDocker Desktopに同梱されているDocker Composeでは特にメッセージが出ていませんでした。ただ、versionがなくても問題なく動作するので、無理に削除する必要はないものの、新たに作成する場合はversionをつけないようにした方がよさそうです。

ChromebookにインストールしたVSCodeがちらつく場合の対処方法

ChromebookにインストールしたVisual Studio CodeのExplorerなどの表示がちらついたり、何も表示されないという事象に遭遇しました。すべてのChromebookで起きるとは限りませんが、私の使用している「ASUS Chromebook Detachable CM3」で発生したため、対処法を共有します。

対処方法

結論から書くと、「--disble-gpu」オプションをつけてVisual Studio Codeを起動すると解消します。

$ code --diable-gpu

もし、カレントディレクトリをcodeコマンドで開く場合は、

$ code . --disable-gpu

とします。

毎回オプションを付けるのが面倒な場合は、エイリアスを使って.bashrcなどに

alias code="code --disble-gpu"

と定義しておくと、毎回オプションを指定しなくて済みます。

何が原因なのか

明確な記述を見つけられませんでしたが、VSCodeとGPUとの相性で発生するようです。ただし、今回のケースではGPU非搭載のマシンでGPUアクセラレーションが有効になっているのが問題だと思われます。

本事象はChromebookでのみ起きる事象ではなく、VirtualBox上でも起きるようですので、リモートデスクトップ環境を含む仮想マシンや低スペックマシンで発生する可能性があります。

また、直接的な影響かわかりませんが、ターミナルの文字描画が遅れます。コピペでは特に問題ありませんが、キーボードで文字を入力していると最後の方の文字が表示されていないことがあります。こちらについては引き続き調査していきます。

ローマ字表記が70年ぶりに改定の見通し

学校教育では訓令式ローマ字表記で教えていますが、社会に出てみるとヘボン式ローマ字で書かれている場合がほとんどです。文化審議会国語分科会の国語課題小委員会によると、実態とそぐわない状況を受けて、改定することも視野に入れて検討を進めているそうです。

ヘボン式ローマ字とは

ヘボン式ローマ字(ヘボンしきローマじ)は、日本語の音をローマ字(ラテン文字)で表記する方法の一つです。この方式は、19世紀にアメリカ合衆国の宣教師であるジェームス・カーティス・ヘボン(James Curtis Hepburn)によって考案されました。ヘボン式は、日本語の発音を英語の読み方に近い形で表記することを特徴としています。

ヘボン式ローマ字の特徴は以下のとおりです。

  • 母音の表記:「あ」は「a」、「い」は「i」、「う」は「u」、「え」は「e」、「お」は「o」と表記します。
  • 子音の表記:基本的に日本語の子音は、英語の発音に近い形でローマ字に変換されます。例えば、「か」は「ka」、「さ」は「sa」、「た」は「ta」、「ふ」は「fu」などとなります。
  • 撥音(「ん」):「ん」は、単独で「n」と表記されますが、次に続く音が「b」、「m」、「p」の場合は「m」と表記されることがあります(例:「さんぽ」→「sanpo」)。
  • 長音:長い母音は、基本的に母音を重ねて表記します(例:「おおきい」→「ookii」)。
  • 促音(小さい「っ」):促音は、次に続く子音を重ねて表記します(例:「きっぷ」→「kippu」)。

訓令式ローマ字とは


訓令式ローマ字(くんれいしきローマじ)は、日本語の音をローマ字(ラテン文字)で表記する方法の一つです。この方式は、日本政府が1946年に公式に採用したもので、ヘボン式ローマ字とは異なる特徴を持っています。

訓令式ローマ字の特徴は以下のとおりです。

  • 母音の表記:「あ」は「a」、「い」は「i」、「う」は「u」、「え」は「e」、「お」は「o」と表記します。
  • 子音の表記:一部の子音についてはヘボン式と異なり、より日本語の発音に近い形で表記されます。例えば、「し」は「si」、「ち」は「ti」、「つ」は「tu」、「ふ」は「hu」となります。
  • 撥音(「ん」):「ん」は常に「n」として表記されます。
  • 長音:長い母音は、アクセント記号を付けて表記します(例:「おおきい」→「ôkii」)。
  • 促音(小さい「っ」):促音は、次に続く子音を重ねて表記します(例:「きっぷ」→「kippu」)。

業務上はヘボン式ローマ字がメイン

最近だとかなり減ってきた印象ですが、DBのテーブル名、カラム名、プログラムの関数名などで英語に訳せない・訳しにくい場合にローマ字表記を使うことがあります。

その際のルールとして「ヘボン式ローマ字を使用すること」と明文化されているプロジェクトもありますが、そうでないプロジェクトもあります。そのせいか、すでに作成されているテーブルやプログラムを見てみると、ヘボン式と訓令式が混在した書き方になっていることもしばしばです。

これは、訓令式ローマ字に慣れているからではなく、ヘボン式ローマ字に慣れていないことが原因だと考えており、キーボード入力の癖がそのままローマ字に現れているのではないかと思います。

どう改定されるのか

新聞によって書き方は様々ですが、実態に合っていないことが改定の一因になっているため、ヘボン式ローマ字に改定されると予想されています。社会としてはほとんど影響ありませんが、教科書を改定するということになると、生徒や学生、教えている教師の間では混乱が生じるかもしれません。

ヘボン式ローマ字以外の表記方法になるというのはあまり考えにくいですが、どう検討されていくかについては今後も注目していきたいです。

[SQL]INSERT SELECTでテーブルから直接INSERTする

先日投稿した記事のテーブルを使って、商品別売上実績のレコードが必ず存在するようにしてみます。

月末処理で売上平均金額を求める前に売上のない商品について0円の商品別売上実績レコードを作成する状況を想定しています。

ここではSELECTした結果をINSERTするINSERT SELECTを使ってデータを登録します。

INSERT SELECTとは

INSERT SELECTとは、INSERTするデータをSELECTで作成する手法のことです。例えば、テーブルAにテーブルBから抽出したレコードをそのままINSERTする場合、以下のように書きます。

INSERT INTO A
SELECT
  *
FROM
  B

この例では、テーブルAとテーブルBは同じカラムを持っており、テーブルBから全件抽出してそのままテーブルAにINSERTしています。このやり方はバックアップ対象と同じカラムを持つバックアップ用のテーブルを作成して、そこにテックアップ対象のレコードを全件抽出しておく、というときによく使います。

SELECT時に対象のカラムと行を絞り込む

今回実現したいのは、SELECTするテーブルとINSERTするテーブルのカラムは異なっています。これはSELECTするカラムをINSERTするカラムに合わせることで対応します。

もう一つ、すべてのカラムを抽出するのではなく、すでにINSERTするテーブルに存在している商品コードはINSERTする必要がない(INSERTすると一意制約違反となる)ため、商品テーブルにあって商品別売上実績テーブルにない商品コードだけINSERTするように対象レコードを絞り込みます。

作成したSQLは以下のようになります。

INSERT INTO sales_by_product
SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

INSERTするテーブルのカラムと同じカラムを抽出する

SELECTではINSERTするテーブルと同じカラムを抽出します。

SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1

INSERT先の商品別売上実績テーブルはproduct_codetotal_sales_amountを持っているため、これらのカラムだけになるように抽出しています。product_codeproductsテーブルのカラムを使用し、total_sales_amountは今回やりたいことに合わせてデフォルト値の0を設定するようにしています。0 total_sales_amountのように固定値を直接記述することができるかどうかは使用しているRDBMSによって異なりますので注意してください。

INSERTするテーブルに存在していないレコードのみ抽出する

少しややこしいですが、NOT EXISTSを使ってsales_by_productに存在しない商品コードのみ抽出するようにします。

FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

SELECTしているproducts T1NOT EXISTS内のSELECT文のsales_by_product T2product_codeで結合することで、T1T2双方に存在するsales_by_productが抽出できます。ということは、T1NOT EXISTS以外に絞り込み条件を持たないので、すべてのT1のレコード(すべての商品)について、それぞれのレコードに対応するT2(商品別売上実績)を抽出してみて、それが存在しなかったら(NOT EXISTS)、SELECTされるということになります。

NOT EXISTS内のSELECTしている列が1となっていますが、これは行が抽出できていること自体にしか意味がないので、無駄なデータを抽出しないために適当な1という値を指定しているだけです。'x'とか3とかでも問題ありませんし、データ量のことを気にしないのであれば*でも構いません。

INSERTする前にSELECTだけを実行してみる

実際にINSERTをする前にINSERT部分以外のSELECT文単独で実行結果を確認しておきます。実務においてもまずはINSERTしたい形と同じかをSELECTだけで確認し、それが確認できたらINSERTするのが安全です。

SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

まずは、INSERT前の商品別売上実績テーブルを確認してみます。

SELECT * FROM sales_by_product;

=>
S001,50
S003,250
S004,350
S006,450

商品コードS002S005が欠損していることがわかります。これが売上合計金額0で抽出できればINSERTしたい内容と一致します。

SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

=>
S002,0
S005,0

問題なさそうです。

では実際にINSERTまでやって、再度全件抽出してみます。

INSERT INTO sales_by_product
SELECT
    T1.product_code,
    0 total_sales_amount
FROM
    products T1
WHERE
    NOT EXISTS (
        SELECT
            1
        FROM
            sales_by_product T2
        WHERE
            T1.product_code = T2.product_code
    );

SELECT * FROM sales_by_product;

=>
S001,50
S002,0
S003,250
S004,350
S005,0
S006,450

商品コードS002S005が売上合計金額0で登録されていることが確認できました。

トランザクション機能を使って確認後にコミットする

今回は自動トランザクションでINSERTしたら自動コミットされるようにしています。

実務では手動トランザクションで明示的にコミットするまではDBにコミットされないようにしておくことで、INSERT後の確認でOKになるまでは作業のやり直しができるようにしておくことが重要です。

まとめ

INSERT SELECTを使ってあるテーブルから直接INSERTする方法について確認しました。バッチなどでのバルクインサートやテーブルのバックアップ・リストアなどINSERT SELECTを行うシーンは意外とあります。

INSERTしたい結果を抽出できるSELECT文を書いて、その前にINSERT INTO テーブル名を付けるということを覚えておけば、それほど悩むこともないと思います。細かい点を抜きにすればUPDATE SELECTとは異なり、RDBMSによって構文が変わるということもないので、どれかのRDBMSでやり方を覚えておけば他のRDBMSでもほぼそのまま適用できます。

[SQL]AVG関数の集計対象にNULLの値は含まれるのか?

データベーススペシャリスト試験の過去問で気になった問題があったので実機で試すことにしました。

現場ではこのような状況にならないようにSQLを書くので気にしたこともありませんでしたが、AVG関数の対象にNULLが含まれている場合、それは分子・分母から除外されて計算されます。

この点について実際にSQLを実行しながら確認していきます。

環境構築

動作確認用のMySQLのコンテナを作成します。

$ docker run -d --name test-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password mysql:8.3

コンソールまたはGUIツールで接続し、データベースを作成します。

CREATE DATABASE test;
USE test;

今回、動作確認はすべてDataGripで行っていますが、その他のツールを使用しても結果は変わりません。

商品テーブルと商品別売上実績テーブルを作成する

問題文自体は記載しませんが、令和4年秋期 データベーススペシャリスト試験 午前IIの問7の問題となります。手元に問題集がある方はそちらを参照してください。

概要を簡単に説明すると、商品テーブルと商品別売上実績テーブルが提示され、それらに対して問題文で与えられたSQLを実行するとどのような結果が返ってくるかを問う問題です。

AVG関数やGROUP BYの働き、左外部結合LEFT OUTER JOINによる結合についての理解度を問う問題となっています。

問題文と同様の商品テーブルと商品別売上実績テーブルを作成します。

-- 商品テーブル
CREATE TABLE products (
    product_code VARCHAR(4), -- 商品コード
    product_name VARCHAR(255), -- 商品名
    product_rank CHAR(1), -- 商品ランク
    PRIMARY KEY (product_code)
);

商品テーブルには商品を一意に特定する商品コードに加え、商品を格付けするための商品ランクというカラムがあることがわかります。

-- 商品別売上実績テーブル
CREATE TABLE sales_by_product (
    product_code VARCHAR(4), -- 商品コード
    total_sales_amount integer, -- 売上合計金額
    PRIMARY KEY (product_code)
);

商品別売上実績には商品ごとに売上合計金額が格納されていることがわかります。この問題ではこの売上合計金額から売上平均金額を求めています。 

次に問題文と同じデータをインサートします。

-- 商品テーブル
INSERT INTO products VALUES ('S001', 'PPP', 'A');
INSERT INTO products VALUES ('S002', 'QQQ', 'A');
INSERT INTO products VALUES ('S003', 'RRR', 'A');
INSERT INTO products VALUES ('S004', 'SSS', 'B');
INSERT INTO products VALUES ('S005', 'TTT', 'C');
INSERT INTO products VALUES ('S006', 'UUU', 'C');

-- 商品別売上実績テーブル
INSERT INTO sales_by_product VALUES ('S001', 50);
INSERT INTO sales_by_product VALUES ('S003', 250);
INSERT INTO sales_by_product VALUES ('S004', 350);
INSERT INTO sales_by_product VALUES ('S006', 450);

登録したデータを確認しておきます。

まずは商品テーブルです。

SELECT * FROM products;

=>
S001,PPP,A
S002,QQQ,A
S003,RRR,A
S004,SSS,B
S005,TTT,C
S006,UUU,C

次に商品別売上実績テーブルです。すべての商品について売上実績があるというわけではないことがわかります。

SELECT * FROM sales_by_product;

=>
S001,50
S003,250
S004,350
S006,450

問題のSQLを確認する

まずは問題文のSQLを確認します。

SELECT
    AVG(T2.total_sales_amount) AS `売上平均金額`
FROM
    products T1
    LEFT OUTER JOIN sales_by_product T2
    ON
        T1.product_code = T2.product_code
WHERE
    T1.product_rank = 'A'
GROUP BY
    T1.product_rank
;

商品ランクごとに売上合計金額の平均を取得し、商品ランクがAのレコードのみ表示しています。

結果を確認するまえに、商品ランクがAのレコードを集計せずに抽出するとどうなるか確認してみます。

SELECT
    T1.product_code AS `商品コード`,
    T1.product_name AS `商品名`,
    T2.total_sales_amount AS `売上合計金額`
FROM
    products T1
    LEFT OUTER JOIN sales_by_product T2
    ON
        T1.product_code = T2.product_code
WHERE
    T1.product_rank = 'A'
;

=>
S001,PPP,50
S002,QQQ,
S003,RRR,250

上記の実行すると、商品ランクAの商品コードS002の売上合計金額がNULLになっていることがわかります。

このSQLで抽出された3レコードについてAVG関数を適用したときに、分子は50+250=300であることははっきりしていますが、分母は2でしょうか?3でしょうか?

その答えを確認するために問題文のSQLを実行してみます。

SELECT
    AVG(T2.total_sales_amount) AS `売上平均金額`
FROM
    products T1
    LEFT OUTER JOIN sales_by_product T2
    ON
        T1.product_code = T2.product_code
WHERE
    T1.product_rank = 'A'
GROUP BY
    T1.product_rank
;

=> 150.0000

実行結果をみると、150となっているので、2で割っていることがわかります。すなわち、NULLでない値について平均を取得していることがわかります。

COUNT関数の動作を確認する

もし、AVG関数を使わずにSUM関数とCOUNT関数を使うとどうなるのでしょうか?

COUNT関数について確認してみましょう。

COUNT(*)COUNT(T1.product_code)COUNT(T2.product_code)COUNT(T2.total_sales_amount)のそれぞれの結果を確認してみます。説明の都合上、COUNT(*)を最後に掲載します。

COUNT(T1.product_code)の結果

COUNT(T1.product_code)でカウントしてみると、3が返ってきます。

SELECT
    COUNT(T1.product_code) AS `商品数`
FROM
    products T1
    LEFT OUTER JOIN sales_by_product T2
    ON
        T1.product_code = T2.product_code
WHERE
    T1.product_rank = 'A'
;

=> 3

これは特に問題ないと思います。

COUNT(T2.product_code)の結果

次にCOUNT(T2.product_code)でカウントしてみます。

SELECT
    COUNT(T2.product_code) AS `売上実績数`
FROM
    products T1
    LEFT OUTER JOIN sales_by_product T2
    ON
        T1.product_code = T2.product_code
WHERE
    T1.product_rank = 'A'
;

=> 2

すると、2が返ってきます。商品コードS002に対応する商品別売上実績テーブルのレコードがないため、2とカウントされます。

COUNT(T2.total_sales_amount)の結果

COUNT(T2.total_sales_amount)についても確認します。

SELECT
    COUNT(T2.total_sales_amount) AS `売上実績数`
FROM
    products T1
    LEFT OUTER JOIN sales_by_product T2
    ON
        T1.product_code = T2.product_code
WHERE
    T1.product_rank = 'A'
;

=> 2

これも2と返ってきます。

COUNT(*)の結果

最後にCOUNT(*)でカウントしてみます。

SELECT
    COUNT(*) AS `レコード数`
FROM
    products T1
    LEFT OUTER JOIN sales_by_product T2
    ON
        T1.product_code = T2.product_code
WHERE
    T1.product_rank = 'A'
;

=> 3

これは駆動表である商品テーブルのレコード数が返ってきます。COUNT関数を使うとき、特に意識せずにCOUNT(*)を使いがちですが、何をカウントしたいのか明確にしないと意図しない結果が得られる場合がある点に注意が必要です。

そもそも売上平均金額はいくらなのか?

問題文で問われている内容から離れて、そもそも「売上平均金額」はいくらと計算されるのが正しいのでしょうか?

商品コードS002の売上合計金額を0と考えるなら、(50+0+250)÷3=100と計算されるのが正しいと思います。そう考えると、このデータの場合、NULLを含むカラムに対して安易にAVG関数を使うのは悪手のように思います。

100と計算されるにはどうすればいいでしょうか?

商品別売上実績のレコードが必ず存在するようにする

商品別売上実績に売上のない商品のレコードが存在しないことが問題ですので、すべての商品のレコードが存在する(売上実績がなければ売上合計金額は0になる)ようにするのが解決方法となります。

必ず存在するようにデータが登録されていれば、LEFT OUTER JOINではなくINNER JOINで結合できるようになりますが、テスト環境のようなデータが十分にメンテナンスされていない環境では正しく取得できない場合がある点に気をつける必要があります。

NVL関数を使用してNULL0とみなす

今回の問題文のテーブルおよびデータで運用するなら、NVL関数を使ってNULL0として計算対象に含める方法がよいでしょう。MySQLにはNVL関数がないため、IFNULL関数を使います。

SELECT
    AVG(IFNULL(T2.total_sales_amount, 0)) AS `売上平均金額`
FROM
    products T1
    LEFT OUTER JOIN sales_by_product T2
    ON
        T1.product_code = T2.product_code
WHERE
    T1.product_rank = 'A'
GROUP BY
    T1.product_rank
;

=> 100.0000

AVG関数に渡す前にNVL関数(IFNULL関数)でNULL0に変換することで売上合計金額÷ランク別商品数で平均を求めることができるようになります。

まとめ

NULLを含むAVG関数およびCOUNT関数のふるまいについて確認しました。

「平均」と一口に言ってもNULLを集計に含めるのかどうかによって計算結果が変わります。

  • 「平均」の計算方法についての正しい仕様を確認する
  • 結合先が必ず存在するのか存在しないことがあるのかを確認する
  • 集計にNULLが含まれることがあるのであれば、NULLを含む場合の集計が正しいことを確認する

といった基本的な点をしっかりと確認していくことが重要です。

LaravelにTailwind CSSをインストールする

基本的には公式の手順に従っていきます。公式の手順とは異なり、sail環境で実行するため、すべてのコマンドにsailがついています。また、Viteを使います。

Tailwind CSSをインストールする

すでにnpm installしてある状態で、次のコマンドを実行します。

$ sail npm install -D tailwindcss postcss autoprefixer
$ sail npx tailwindcss init -p 

コマンドを実行すると、tailwind.config.jspostcss.config.jsが作成されます。

テンプレートのパスを設定する

tailwind.config.jsを編集してテンプレートファイルのパスを追加します。

修正前のtailwind.config.jsは次のようになっています。

/** @type {import('tailwindcss').Config} */
export default {
  content: [],
  theme: {
    extend: {},
  },
  plugins: [],
}

contentを次のように編集します。

/** @type {import('tailwindcss').Config} */
export default {
  content: [
      "./resources/**/*.blade.php",
      "./resources/**/*.js",
      "./resources/**/*.vue",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

公式の手順どおりに編集していますが、Vue.jsを使わないのであれば最後の1行は不要です。

CSSファイルにTailwindディレクティブを追加する

@tailwindディレクティブをresources/css/app.cssに追加します。

@tailwind base;
@tailwind components;
@tailwind utilities;

ビルドプロセスを開始する

すでにnpm run devを実行中の場合は一度停止してから再度実行してください。

$ sail npm run dev

これでインストール作業は完了し、Tailwind CSSが使用可能な状態になります。

Tailwind CSSを使ってみる

では実際に使ってみましょう。

welcome.blade.phpを修正して機能しているかを確認します。

まずは、<head>タグに@vite('resources/css/app.css')を埋め込みます。

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
      ...
      @vite('resources/css/app.css')
      ...
    </head>

これで使用する準備が整いましたが、welcome.blade.phpの場合、すでに<style>タグでTailwind CSSが埋め込まれているので、これをコメントアウトしておきます。

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        ...
        <!-- Styles -->
        <!-- style>
            /* ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com */ ...
        </style -->
  </head>

この修正は他のBladeファイルでは不要です。

では実際に使ってみます。

公式サイトの手順にあった<h1>タグを記述します。

        <h1 class="text-3xl font-bold underline">
            Hello World!
        </h1>

画面を表示してみて、

のように表示され、デベロッパーツール上でも

のようにapp.cssに定義されたTailwind CSSのクラスが適用されていることが確認できたら正常にインストールできています。

RStudioのグラフの日本語が文字化けするのを解消する

RStudioのグラフが文字化けする場合、raggパッケージを使用することで文字化けが解消できます。

raggパッケージをインストールする

raggパッケージをインストールするにはConsoleで次のコマンドを実行します。

> install.packages("ragg")

Graphics DeviceのBackendをAGGに変更する

Tools > Global OptionsでOptionsダイアログを表示し、GeneralメニューのGraphicsタブを選択します。

Graphics Device > BackendがAGGになっていなければAGGに変更し、OKボタンをクリックしてください。

再度、グラフを表示すると、文字化け(豆腐表示)が解消されていることが確認できます。

まとめ

一度設定すれば以降再設定する必要はありませんが、RやRStudioのバージョンアップに伴うインストールを行った場合は再度設定する必要があるかもしれません。

わたしも以前設定したはずですが、RとRStudioを上書きインストールしたあとに発生したので、バージョンアップ時には再設定が必要なのではないかと思います。

Biomeを使ってLintとFormatをおこなう

フロンドエンド向け垂直統合ツールチェーンRomeが開発終了となり、Rome開発チームのひとりが作成したのがBiomeです。

Rustで構築されたBiomeは既存のFormatter、Linterと比べ非常に高速に動作するように設計されています。設定も非常に簡単でエディタの拡張機能・プラグインも提供されていることから今後のデファクトスタンダードになりそうな予感があります。

本記事ではBiomeのインストールから基本的な使い方、エディタの統合について解説します。

Biomeをインストール

Biomeはグローバルインストールすることは可能ですが推奨されていないため、プロジェクトにインストールします。また、バージョン範囲演算子を使用しないでインストールすることを強く推奨しています。

$ mkdir biome-example
$ cd biome-example
$ npm init -y
$ npm install --save-dev --save-exact @biomejs/biome
$ npx @biomejs/biome init

Node.jsをインストールせずにBiomeを使用したい場合は、Homebrewなどを使ってインストールするスタンドアロン実行形式での利用も可能ですが、本記事では割愛します。

インストール後に初期化をおこなうとbiome.jsonが生成されます。

{
  "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true
    }
  }
}

基本的な設定は済んでいるので、すぐに使い始められます。

Biomeの使い方

Biomeの基本的な使い方を見ていきます。

Formatter

Prettierのようにソースコードを整形してくれます。指定したディレクトリ内のファイルに対してソースコードの整形をおこなうには次のようなコマンドを実行します。

$ npx @biomejs/biome format src --write

このコマンドではsrcディレクトリを指定してフォーマットを行っていますが、単一ファイルやワイルドカード指定も可能です。

Linter

こちらはESLintの置き換えになります。次のようなコマンドを実行します。

$ npx @biomejs/biome lint src

こちらも単一ファイルの指定やワイルドカードでの指定が可能です。

Formatter+Lint+α

前述のFormatterとLinterに加えて、インポート文の再構成をまとめて実行するには次のようなコマンドを実行します。

$ npx @biomejs/biome check --apply src

通常はこのコマンドを使うことになると思います。

package.jsonにスクリプトを設定する

コマンドが少し長いのでpackage.jsonにスクリプトとして定義しておきましょう。

{
  "name": "biome-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "format": "biome format src --write",
    "lint": "biome lint src",
    "check": "biome check --apply src"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@biomejs/biome": "1.5.3"
  }
}

Visual Studio Codeの拡張機能を使う

開発時に都度コマンド入力するのは手間ですので、エディタ向けの拡張機能を利用するのがよいでしょう。

まずはVisual Studio Codeの拡張機能を使ってみます。

settings.jsonに以下の設定を追加します。

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[javascript]": {
    "editor.defaultFormatter": "biomejs.biome"
  },
  "editor.codeActionsOnSave": {
    "quickfix.biome": true,
    "source.organizeImports.biome": true
  }
}

以下の設定で、保存時にFormatterが実行されるように設定しています。

  "editor.formatOnSave": true,

デフォルトのFormatterはPrettierにしています。

  "editor.defaultFormatter": "esbenp.prettier-vscode",

言語モードがJavaScriptの場合、FormatterはBiomeになるよう設定しています。

  "[javascript]": {
    "editor.defaultFormatter": "biomejs.biome"
  },

保存時のアクションに、 Biomeによるフォーマット("quickfix.biome": true)とインポート文の再構成("source.organizeImports.biome": true)をおこなうように設定します。

この設定を行うことで、ファイルの保存時にソースコードの整形とインポート文の再構成をおこなってくれるようになります。

WebStormのプラグインを使う

IntelliJ向けのプラグインも提供されていますので、WebStormで使ってみます。

こちらは⌥⇧ ⌘ L or Ctrl+Alt+Lでファイルの整形ダイアログを表示して必要なオプションにチェックを入れて実行ボタンを押します。

まとめ

Biomeのインストール方法からエディタとの統合までを確認しました。 1つのパッケージで導入できてデフォルトで利用可能となっているだけでなく、エディタとの統合もサポートされているため、小さく始めて大きく育てるには非常に適したツールチェーンだといえます。

これまではPrettierとESLintを使っていましたが今後はBiomeを使ってみようと思います。

Laravel Sail+PHPStormで開発環境を構築する

Laravelで作りたいアプリケーションがあるため、macOS上で開発するための環境を構築します。

Laravel Sailでインストールする

macOS上でDocker Desktopを導入している場合、もっとも簡単に開発環境を構築できる手段になると思います。

作成したいアプリケーションがexample-appの場合、次のようにコマンドを入力します。

$ curl -s "https://laravel.build/example-app" | bash

最後にパスワードが求められますので入力してください。

初期設定を行う

PHPStormで開発を行うため、アプリケーションを開いた後必要な初期設定を行います。

まずはコンテナを起動します。

$ ./vendor/bin/sail up

コンテナが起動している状態で、設定をひらきます。

PHP言語レベル、CLIインタープリターを設定します。

今回はLaravel 10.xがサポートしているバージョンのうち、8.3を使用していますので、PHP言語レベルは8.3を指定しています。

また、CLIインタープリターはローカルのPHPではなく、Dockerコンテナー上のPHPを使用することで、ローカルにPHPをインストールしなくて済みます。CLIインタープリターの右の…ボタンをクリックし、CLIインタープリターダイアログの左上の+ボタンをクリックし、「From Docker, Vagrant, VM, WSL, Remote…」をクリックしてください。

リモートPHPインタープリターの構成ダイアログで、Dockerを選択し、

サーバーの右ある新規…ボタンをクリックし、

OKボタンをクリックしてください。

OKボタンをクリックすると設定は完了します。

デバッグの設定を行う

次にデバッグの設定を行います。docker-compose.ymlを確認すると、SAIL_XDEBUG_MODE環境変数がない場合、デバッグモードがOFFになっています。

        environment:
            WWWUSER: '${WWWUSER}'
            LARAVEL_SAIL: 1
            XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
            XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
            IGNITION_LOCAL_SITES_PATH: '${PWD}'

docker-compose.ymlを修正するのではなく、.envファイルにSAIL_XDEBUG_MODEを追加します。

SAIL_XDEBUG_MODE=debug

また、Settings…>PHP>サーバーの「パスマッピングを使用する」のチェックボックスを外しておきます。

これがあると以下のような警告が表示され、ブレークポイントで止まりません。

環境変数を反映するためにいったんDockerコンテナーを停止して起動しなおします。

動作確認のためにroutes/web.phpにデバッグを設定し、

デバッグを有効化(虫のマークをクリックして以下のようにします)

http://localhostにアクセスすると、以下のようなダイアログが表示されます。

このまま進めると、ブレークポイントで止まります。

これで環境構築は完了です。

まとめ

プロジェクトの作成から初期設定、デバッグの設定までを行いました。デバッグについては一応動作していますが、若干動作が怪しいので、今後の開発で調整するかもしれません。

Gitで初回コミットを取り消す(git resetでエラーになる場合)

リポジトリを新規作成し、コミットしたところで.gitignoreファイルを作り忘れていることに気づくことはないでしょうか?

このような場合、直前のコミットを取り消そうとすると次のようなエラーが表示されます。

$ git reset --soft HEAD~1
fatal: ambiguous argument 'HEAD~1': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'

このエラーは、取り消したいコミットの一つ前のコミットHEAD~1が存在しないことが原因で起きています。

初回コミットを取り消したい場合の方法について解説します。

ここで解説する方法の中には非常に強力な操作が含まれています。使い方を誤ると復旧困難な状態に陥る可能性がありますので、十分に注意してください。

もっとも簡単な方法

もっとも簡単に初回コミットを取り消す方法は、.gitディレクトリを削除することです。当然ながらgit initからやり直しになりますが、初回コミット時という状況に限定するのであれば、この方法がもっとも簡単かつおすすめです

$ rm -rf .git

初回コミットを取り消すコマンド

では、.gitディレクトリを削除せずに初回コミットを取り消す方法はないのでしょうか?

もちろん初回コミットを取り消すコマンドはあります。次のコマンドを実行することで、初回コミットを取り消すことができます。

$ git update-ref -d HEAD

取り消し後、変更はステージングエリアに残ります。ステージングエリアにある変更をワーキングディレクトリに戻す場合は、

$ git reset

で、ステージングエリアにあるすべての変更をワーキングディレクトリに戻すか、

$ git reset node_modules

で、特定のファイルまたはディレクトリのみをワーキングディレクトリに戻してください。

git update-ref -d HEADとはどんなコマンドなのか?

git update-ref -dは指定した参照(ref)を削除するコマンドです。ここではHEADという参照を指定していますので、HEAD参照が削除され、何もコミットしていない状態まで戻されますので、初回コミットが取り消されることになります。

git update-ref -dリポジトリの履歴を変更する非常に強力な操作です。使い方を誤ると復旧が困難な状態に陥る場合がありますので、十分に注意して使用してください。不安な場合は前述のもっとも簡単な方法を実施してください。

(おまけ)初回コミット以外のコミットの取り消す

おまけになりますが、git resetコマンドを使用した初回コミット以外のコミットを取り消す方法について確認しておきます。

取り消した変更をステージングエリアに保持

次のコマンドは、直前のコミットを取り消し、そのコミットの変更をステージングエリアに残します。

$ git reset --soft HEAD~1

取り消した変更をワーキングディレクトリに保持

次のコマンドは、直前のコミットを取り消し、その変更をワーキングディレクトリに残しますが、ステージングエリアには残しません。--mixed オプションはデフォルトなので、--mixed は省略可能です。

$ git reset --mixed HEAD~1

変更を完全に取り消し

次のコマンドは、直前のコミットを取り消し、そのコミットの変更を完全に取り消します。

$ git reset --hard HEAD~1

(おまけ)ステージングエリアの変更を取り消す

すでに説明済みですが、ステージングエリアの変更を取り消す方法についても確認します。

ステージングエリアにあるすべてのファイルやディレクトリを戻す

ステージングエリアにあるすべてのファイルやディレクトリをワーキングディレクトリに戻す場合は、次のコマンドを実行します。

$ git reset

ステージングエリアにある特定のファイルまたはディレクトリを戻す

ステージングエリアにある特定のファイルまたはディレクトリをワーキングディレクトリに戻す場合は、次のコマンドを実行します。

$ git reset node_modules

まとめ

初回コミットを取り消す方法について解説しましたが、使用タイミングを誤ると復旧不可能な事態に陥るリスクがあので注意が必要です。

ステージングエリアに上げたあとに過不足がないことをレビューすることが大切です。ステージングエリアに上げる、ステージングエリアからワーキングディレクトリに戻すという操作は安全に実施できる操作ですし、ステージングエリアと直前のコミットとの差分は、git diff --cachedまたはgit diff --stagedで比較できますので、積極的に活用してレビューを行ってください。

モバイルバージョンを終了