LoginSignup
74
84

More than 5 years have passed since last update.

JavaEE使い方メモ(JTA)

Last updated at Posted at 2015-09-27

環境構築

コード

環境

OS

Windows 7

AP サーバー

GlassFish 4.1 Open Source Edition (一部 Wildfly 9.0.1 使用)

Java

1.8.0_60

DB サーバー

MySQL 5.5.28, for Win64 (x86)

JTA とは

Java Transaction API の略。

Java で トランザクションマネージャ を扱うための各種 API を定義した仕様。

トランザクションマネージャとは、分散トランザクションの管理を行うサービス(ミドルウェア)で、 Java EE サーバーだと EJB コンテナがその役割を担っている。

DTP

Distributed Transaction Processing の略(直訳するなら、分散トランザクション処理?)。

X/Open という組織が策定した、分散トランザクションについての標準規格。JTA が扱う分散トランザクションも、この DTP に準拠している。
X/Open は UNIX に関する技術の標準化を行う団体だったが、 1996 年に Open Software Foundation と合併して、現在は The Open Group という組織になっている。

DTP で登場するソフトウェア

DTP では、分散トランザクションに登場するソフトウェアとして、以下のものを挙げている。

リソースマネージャ

トランザクション機能を備えたリソースを管理するソフトウェア。
リレーショナルデータベース製品や、メッセージ指向ミドルウェアなどがこれにあたる。

トランザクションマネージャ

複数のリソースマネージャを管理し、分散トランザクションを制御するソフトウェア。

アプリケーション

トランザクションを利用するソフトウェア。

2フェーズコミット

分散トランザクションを実現するための具体的なプロトコル。

トランザクションのコミットを2回のフェーズに分けて行う。

第1フェーズでは、各リソースマネージャに現在のトランザクションがコミット可能かどうかを確認する。
全てのリソースマネージャがコミット可能であると返事をしたら、第2フェーズとして各リソースマネージャにコミットの指示が飛ぶ。

どれか1つでもコミット不可の返事をしたリソースマネージャが存在した場合は、全てのリソースマネージャにロールバックの指示が飛ぶ。

こうすることで、分散トランザクションでの原子性の担保が実現されている。

より細かい話は 分散トランザクションに挑戦しよう! を参照のこと。

DTP では、このプロトコルを実現するための具体的なインターフェースが定義されている。

インターフェース

DTP では、大きく以下の2つのインターフェースが定義されている。

TX インターフェース

トランザクションマネージャが、アプリケーションに対して公開するインターフェース。

XA インターフェース

リソースマネージャが、トランザクションマネージャに対して公開するインターフェース。

JTA が定義するインターフェース

JTA では、以下の3つのインターフェースが定義されている。

javax.transaction.TransactionManager

トランザクションマネージャが、 アプリケーション・サーバー に対して公開するインターフェース。

EJB がサポートしているコンテナ管理のトランザクション(CMT)は、この TransactionManager を使って実現されている。

javax.transaction.UserTransaction

トランザクションマネージャが、 アプリケーション に対して公開するインターフェース。

トランザクションの開始・終了をコンテナに任せないとき(BMT)は、この UserTransaction を EJB などにインジェクションして明示的にトランザクション境界を定義する。

javax.transaction.xa.XAResource

リソースマネージャが、トランザクションマネージャに対して公開するインターフェース。

トランザクションマネージャは、この XAResource に定義されたメソッドを利用して、 DTP で定義された分散トランザクションを実現している。

XAResource の実装は、各リソースマネージャが用意する。
例えば、 MySQL の場合は JDBC ドライバの中に XAResource を実装したクラスが含まれている(com.mysql.jdbc.jdbc2.optional.MysqlXAConnection)。

下図は、JTA の仕様書に描かれている図で、各インターフェースとソフトウェアの関連を把握するのに丁度いい図になっている。

jta.JPG

グローバルトランザクションとローカルトランザクション

JTA の設定のときなどに目にする、これらの言葉について。

グローバルトランザクション

分散トランザクションのこと。

トランザクションの対象となるリソースマネージャが複数存在するときに利用する。

ローカルトランザクション

リソースマネージャが提供するトランザクションの API をそのまま利用すること。
JDBC なら、 java.sql.Connectioncommit() メソッドとかを直接利用することになる。

トランザクションの対象となるリソースマネージャが1つしか存在しないときに利用する。

GlassFish で分散トランザクションを使用する

JTA を使うということは、すなわち分散トランザクションを使うことになるので、「明示的に分散トランザクションを使用する」といった設定は必要ない。

強いて言うなら、データソースの登録で XA を実装したクラスを指定しないといけない、という点くらい。

MySQL を使って XA に対応した2つのデータソースを登録する。
JDBC ドライバはあらかじめインストールしておくこと

データソースの登録

コネクションプールの登録

  • 管理コンソールを開き、 [Resources] > [JDBC] > [JDBC Connection Pools] を選択する。
  • [New...] をクリック。
  • 以下入力。
    • [Pool Name] に適当な名前を入れる。
    • [Resource Type] で javax.sql.XADataSource を選択。
    • [Database Driver Vendor] で MySql を選択。

jta.JPG

  • [Next] をクリックして、次のページで以下のプロパティを設定。
    • Username, Password, ServerName, DatabaseName
  • 同じ要領で、名前違いのデータソースをもう1つ登録する。

JDBC リソースの登録

  • 管理コンソールから、 [Resources] > [JDBC] > [JDBC Resources] を選択する。
  • [New...] をクリック。
  • 以下入力。
    • [JNDI Name] に適当な名前を入れる。
    • [Pool Name] で、先ほど登録したコネクションプールを選択。

jta.JPG

  • [OK] をクリック。

データベース

jta.JPG

こんなテーブルを用意しておく。

実装+動作確認

HelloEjb.java
package sample.jta.ejb;

import java.sql.Connection;
import java.sql.PreparedStatement;
import javax.annotation.Resource;
import javax.ejb.Stateless;
import javax.sql.DataSource;
import javax.transaction.TransactionSynchronizationRegistry;

@Stateless
public class HelloEjb {

    @Resource(lookup = "jdbc/mysql_global_1") // ★データソースを注入
    private DataSource ds1;

    @Resource(lookup = "jdbc/mysql_global_2") // ★データソースを注入
    private DataSource ds2;

    @Resource
    private TransactionSynchronizationRegistry tx;

    public void execute() {
        this.insert(this.ds1, "hoge");
        this.insert(this.ds2, "fuga");

        System.out.println("txKey = " + tx.getTransactionKey());
    }

    private void insert(DataSource ds, String value) {
        // ★ TEST_TABLE にレコードを INSERT
        try (Connection con = ds.getConnection();
            PreparedStatement ps = con.prepareStatement("INSERT INTO TEST_TABLE (VALUE) VALUES (?)");
            ) {

            ps.setString(1, value);
            ps.executeUpdate();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

HelloEjb#execute() を実行するように外部からアクセスする。

GlassFishコンソール出力
情報:   txKey = JavaEETransactionImpl: txId=21 nonXAResource=null jtsTx=com.sun.jts.jta.TransactionImpl@c3161dfc ...]

実行後のテーブル

jta.JPG

複数のデータソースを、1つの EJB (トランザクション)の中で利用できている。

非 XA なデータソースを登録するとどうなる?

GlassFish に限らず、 AP サーバーのデータソースには、 XA を実装していないデータソースも登録できる。

JTA は分散トランザクションのための API なので、 XA に対応していないデータソースは利用できなさそうだが、実際は登録できるし、そのまま JTA 経由で利用もできてしまう。

何が起こっているのか調べてみる。

データソースを登録する

先ほどと同じ要領で、非 XA なデータソースを登録する。

jta.JPG

実装

NonXADataSourceEjb.java
package sample.jta.ejb;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import javax.annotation.Resource;
import javax.ejb.Stateless;
import javax.sql.DataSource;
import javax.transaction.TransactionSynchronizationRegistry;

@Stateless
public class NonXADataSourceEjb {

    @Resource(lookup = "jdbc/mysql_local_1")
    private DataSource ds;

    @Resource
    private TransactionSynchronizationRegistry tx;

    public void execute() throws Exception {
        try (Connection con = ds.getConnection()) { // ★ ds を JTA に参加させる
            Object key = tx.getTransactionKey();
            System.out.println("txKey = " + key);

            // ★ TransactionKey が持つ nonXAResource の具体的なクラス名を確認する
            Object nonXAResource = this.getFieldValue(key, "nonXAResource");
            System.out.println("nonXAResource.class = " + nonXAResource.getClass());
        }
    }

    public Object getFieldValue(Object obj, String fieldName) throws Exception {
        if (obj == null) return null;

        Class<?> clazz = obj.getClass();
        try {
            Field field = clazz.getDeclaredField(fieldName);
            field.setAccessible(true);
            Object result = field.get(obj);
            field.setAccessible(false);
            return result;
        } catch (NoSuchFieldException e) {
            return null;
        }
    }
}

非 XA なデータソースを JTA 経由で利用すると、 nonXAResource といういかにもそれっぽいインスタンスが生成されているようなので、その値をリフレクションを使って無理やり取得している。

GlassFishコンソール出力
情報:   txKey = JavaEETransactionImpl: txId=39 nonXAResource=715 jtsTx=null ...]
情報:   nonXAResource.class = class com.sun.enterprise.resource.ResourceHandle

nonXAResource の実体は com.sun.enterprise.resource.ResourceHandle というクラスらしい。

このクラスのコードは以下になる。

GC: ResourceHandle - com.sun.enterprise.resource.ResourceHandle (.java) - GrepCode Class Source

ResourceHandle.java
public class ResourceHandle implements
        com.sun.appserv.connectors.internal.api.ResourceHandle, TransactionalResource {

    ...

    private XAResource xares;

    ...

よく見ると、中に XAResource が入っている。

このフィールドの実体を調べてみると、 com.sun.enterprise.resource.ConnectorXAResource というクラスだった。
コードは以下。

GC: ConnectorXAResource - com.sun.enterprise.resource.ConnectorXAResource (.java) - GrepCode Class Source

このクラスの、 commit() メソッドを覗いてみる。

ConnectorXAResource.java
    public void commit(Xid xid, boolean onePhase) throws XAException {
        try {
            ResourceHandle handle = getResourceHandle();
            ManagedConnection mc = (ManagedConnection) handle.getResource();
            mc.getLocalTransaction().commit(); // ★ getLocalTransaction() してる!
        } catch (Exception ex) {
            handleResourceException(ex);
        }finally{
            resetAssociation();
        }
    }

ConnectorXAResourceXAResource を実装しているものの、実はローカルトランザクションを使っているっぽい。

つまり、データソースが XA に対応していなかったとしても、内部でラップして XAResource に擬似的に対応させることで、 JTA 経由で利用できるようになっているのだろう。

一応 Wildfly でも同じように確認したが、やはり非 XA データソースは、内部でローカルトランザクションを使うクラスにラップされて利用されていた。

複数のローカルトランザクションを使うとどうなる?

MultiNonXADataSourceEjb.java
package sample.jta.ejb;

import javax.annotation.Resource;
import javax.ejb.Stateless;
import javax.sql.DataSource;

@Stateless
public class MultiNonXADataSourceEjb {

    @Resource(lookup = "jdbc/mysql_local_1")
    private DataSource ds1;

    @Resource(lookup = "jdbc/mysql_local_2")
    private DataSource ds2;

    public void execute() throws Exception {
        try (Connection c1 = ds1.getConnection();
             Connection c2 = ds2.getConnection()) {}
    }
}
GlassFishコンソール出力
情報:   ejb = MultiNonXADataSourceEjb, method = execute
重大:   RAR5029:Unexpected exception while registering component 
java.lang.IllegalStateException: Local transaction already has 1 non-XA Resource: cannot add more resources.  
    at com.sun.enterprise.transaction.JavaEETransactionManagerSimplified.enlistResource(JavaEETransactionManagerSimplified.java:345)
        ...

警告:   RAR7132: Unable to enlist the resource in transaction. Returned resource to pool. Pool name: [ mysql_local_2 ]
警告:   RAR5117 : Failed to obtain/create connection from connection pool [ mysql_local_2 ]. Reason : com.sun.appserv.connectors.internal.api.PoolingException: java.lang.IllegalStateException: Local transaction already has 1 non-XA Resource: cannot add more resources.  
警告:   RAR5114 : Error allocating connection : [Error in allocating a connection. Cause: java.lang.IllegalStateException: Local transaction already has 1 non-XA Resource: cannot add more resources.  ]
重大:   java.lang.reflect.InvocationTargetException
       ...

ds2 を JTA に参加させようとした時点で例外がスローされた。

Local transaction already has 1 non-XA Resource: cannot add more resources. とあるので、非 XA なデータソースを複数使うことはできないようになっている。

データベースコネクションが JTA に参加するタイミング

説明を省いていたが、データベースコネクションが JTA の管理下に置かれるタイミングは、上記の例からも分かる通り DataSource#getConnection() を実行したタイミングになる。

ただし、 JTA の仕様的にそうなのか、 GlassFish の実装的にそうなのかは分からない。

アノテーション

JTA1.2 (Java EE 7) から追加されたアノテーションに、 @Transactional@TransactionScoped がある。

これらのアノテーションを利用することで、 CDI 管理ビーンでコンテナ管理のトランザクションが利用できるようになる。

@Transactional

このアノテーションで CDI 管理ビーンをアノテートすることで、 EJB と同じように宣言的なトランザクションが使えるようになる。

基本

package sample.jta.ejb;

import java.sql.Connection;
import javax.annotation.Resource;
import javax.ejb.Stateless;
import javax.inject.Inject;
import javax.sql.DataSource;
import javax.transaction.TransactionSynchronizationRegistry;
import sample.jta.cdi.TransactionalBean;

@Stateless
public class TransactionalEjb {

    @Resource(lookup = "jdbc/mysql_local_1")
    private DataSource ds;

    @Resource
    private TransactionSynchronizationRegistry tx;

    @Inject
    private TransactionalBean bean;

    public void execute() throws Exception {
        try (Connection con = ds.getConnection()) {
            System.out.println("TransactionalEjb : txKey=" + tx.getTransactionKey());
            this.bean.requreisNew();
            this.bean.required();
        }
    }
}
TransactionalBean.java
package sample.jta.cdi;

import java.sql.Connection;
import javax.annotation.Resource;
import javax.enterprise.context.ApplicationScoped;
import javax.sql.DataSource;
import javax.transaction.TransactionSynchronizationRegistry;
import javax.transaction.Transactional;

@ApplicationScoped
public class TransactionalBean {

    @Resource(lookup = "jdbc/mysql_local_1")
    private DataSource ds;

    @Resource
    private TransactionSynchronizationRegistry tx;

    @Transactional
    public void required() throws Exception {
        try (Connection con = ds.getConnection()) {
            System.out.println("required() : txKey=" + tx.getTransactionKey());
        }
    }

    @Transactional(Transactional.TxType.REQUIRES_NEW)
    public void requreisNew() throws Exception {
        try (Connection con = ds.getConnection()) {
            System.out.println("requreisNew() : txKey=" + tx.getTransactionKey());
        }
    }
}
GlassFishコンソール出力
情報:   TransactionalEjb : txKey=JavaEETransactionImpl: txId=52 nonXAResource=827 jtsTx=null ...]

情報:   requreisNew() : txKey=JavaEETransactionImpl: txId=53 nonXAResource=null jtsTx=null localTxStatus=0 syncs=[]

情報:   required() : txKey=JavaEETransactionImpl: txId=52 nonXAResource=827 jtsTx=null ...]
  • @Transactional で CDI 管理ビーンのメソッド(クラス)をアノテートすることで、トランザクション境界を設けることができる。
  • アノテーションの引数で、トランザクションの開始・終了をどうするかの制御ができる。
    • 列挙型の TxType を使用する。
    • それぞれの意味は、 EJB の場合 と同じ。

例外発生時の制御

※以下のコードは、最初 GlassFish 4.1 と Payara 4.1.153 で動作検証したのですが、どうも仕様通りに動いてくれなかったので Wildfly 9.0.1 で動作検証しました。

追記

コメント にあるとおり、バグだったようで今後修正されるようです。

基本

TransactionalExceptionBean.java
package sample.jta.ejb;

import javax.ejb.Stateless;
import javax.inject.Inject;
import sample.jta.cdi.TransactionalExceptionBean;

@Stateless
public class TransactionalExceptionEjb {

    @Inject
    private TransactionalExceptionBean bean;

    public void execute() {
        this.executeQuietly(this.bean::insertAndThrowException);
        this.executeQuietly(this.bean::insertAndThrowRuntimeException);
    }

    private void executeQuietly(ThrowableRunnable tr) {
        try {
            tr.execute();
        } catch (Exception ex) {
            System.out.println("[" + ex.getClass().getSimpleName() + "] " + ex.getMessage());
        }
    }

    @FunctionalInterface
    private static interface ThrowableRunnable {
        void execute() throws Exception;
    }
}
TransactionalExceptionBean.java
package sample.jta.cdi;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import javax.annotation.Resource;
import javax.enterprise.context.ApplicationScoped;
import javax.sql.DataSource;
import javax.transaction.Transactional;

@ApplicationScoped
@Transactional(Transactional.TxType.REQUIRES_NEW)
public class TransactionalExceptionBean {

    @Resource(lookup = "java:/jdbc/mysql_test")
    private DataSource ds;

    public void insertAndThrowException() throws Exception {
        this.insert(this.ds, "insertAndThrowException");
        throw new Exception("test exception");
    }

    public void insertAndThrowRuntimeException() throws Exception {
        this.insert(this.ds, "insertAndThrowRuntimeException");
        throw new RuntimeException("test runtime exception");
    }

    private void insert(DataSource ds, String value) throws SQLException {
        try (Connection con = ds.getConnection();
            PreparedStatement ps = con.prepareStatement("INSERT INTO TEST_TABLE (VALUE) VALUES (?)");
            ) {

            ps.setString(1, value);
            ps.executeUpdate();
        }
    }
}
Wildflyコンソール出力
22:14:29,546 INFO  [stdout] (default task-2) [Exception] test exception
22:14:29,552 INFO  [stdout] (default task-2) [RuntimeException] test runtime exception

実行後のデータベース

jta.JPG

  • チェック例外がスローされた場合、トランザクションはロールバックされない
  • 非チェック例外がスローされた場合、トランザクションはロールバックされる。

この辺の挙動は、 EJB の場合と同じ。

ロールバックする(しない)例外を限定する

TransactionalExceptionBean.java
package sample.jta.cdi;

...
import java.util.function.Supplier;

@ApplicationScoped
@Transactional(Transactional.TxType.REQUIRES_NEW)
public class TransactionalExceptionBean {

    @Resource(lookup = "java:/jdbc/mysql_test")
    private DataSource ds;

    ...

    @Transactional(
        value=Transactional.TxType.REQUIRES_NEW,
        rollbackOn=IOException.class,
        dontRollbackOn=NullPointerException.class
    )
    public void insertAndThrowAnyException(Supplier<? extends Exception> exceptionProducer) throws Exception {
        Exception e = exceptionProducer.get();
        this.insert(this.ds, "insertAndThrowAnyException(" + e.getClass().getSimpleName() + ")");
        throw e;
    }

    ...
}
TransactionalExceptionEjb.java
package sample.jta.ejb;

import java.io.IOException;
...

@Stateless
public class TransactionalExceptionEjb {

    @Inject
    private TransactionalExceptionBean bean;

    public void execute() {
        ...

        // RuntimeException
        this.executeQuietly(() -> this.bean.insertAndThrowAnyException(NullPointerException::new));
        this.executeQuietly(() -> this.bean.insertAndThrowAnyException(IllegalStateException::new));

        // Exception
        this.executeQuietly(() -> this.bean.insertAndThrowAnyException(NoSuchMethodException::new));
        this.executeQuietly(() -> this.bean.insertAndThrowAnyException(IOException::new));
    }

    ...
}
Wildflyコンソール出力
22:30:30,120 INFO  [stdout] (default task-4) [NullPointerException] null
22:30:30,127 INFO  [stdout] (default task-4) [IllegalStateException] null
22:30:30,132 INFO  [stdout] (default task-4) [NoSuchMethodException] null
22:30:30,137 INFO  [stdout] (default task-4) [IOException] null

実行後のデータベース

jta.JPG

  • rollbackOn で、チェック例外の中でもロールバックする例外を指定できる。
    • IOException はチェック例外なので、デフォルトではロールバックされずデータベースに登録される。
    • しかし、 rollbackOn で指定しているので、ロールバックされてデータベースには登録されていない。
  • dontRollbackOn で、非チェック例外の中でもロールバックしない例外を指定できる。
    • NullPointerException は非チェック例外なので、デフォルトではロールバックされてデータベースには登録されない。
    • しかし、 dontRollbackOn で指定しているので、ロールバックされずにデータベースに登録されている。

GlassFish での動作

GlassFish で動かすと、 RuntimeException をスローしてもロールバックしてくれませんでした。
代わりに、 RollbackException がスローされるという現象が発生します。

挙動的には、 GlassFish4.1をなおしてみた - 見習いプログラミング日記 こちらの記事に描かれているものと同じ感じでした。
issue は reolved だけど、もしかして例外のセットだけ直して、 rollbackonly → commit の即死コンボは直ってないのかなぁ。。。

@TransactionScoped

TransactionScopedBean.java
package sample.jta.cdi;

import java.io.Serializable;
import javax.transaction.TransactionScoped;

@TransactionScoped
public class TransactionScopedBean implements Serializable {

    @Override
    public String toString() {
        return "TransactionScopedBean {hash=" + this.hashCode() + "}";
    }
}
TransactionScopedEjb.java
package sample.jta.ejb;

import javax.annotation.Resource;
import javax.ejb.Stateless;
import javax.inject.Inject;
import javax.transaction.TransactionSynchronizationRegistry;
import sample.jta.cdi.TransactionScopedBean;

@Stateless
public class TransactionScopedEjb {

    @Inject
    private TransactionScopedBean bean;

    @Resource
    private TransactionSynchronizationRegistry tx;

    @Inject
    private RequiredEjb requiredEjb;

    @Inject
    private RequiresNewEjb requiresNewEjb;

    public void execute() {
        System.out.println("[TestTransactionScopedEjb]");
        System.out.println("txKey=" + this.tx.getTransactionKey());
        System.out.println("bean=" + this.bean);

        this.requiresNewEjb.execute();
        this.requiredEjb.execute();
    }
}
RequiredEjb.java
package sample.jta.ejb;

import javax.annotation.Resource;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.inject.Inject;
import javax.transaction.TransactionSynchronizationRegistry;
import sample.jta.cdi.TransactionScopedBean;

@Stateless
@TransactionAttribute(TransactionAttributeType.REQUIRED)
public class RequiredEjb {

    @Inject
    private TransactionScopedBean bean;

    @Resource
    private TransactionSynchronizationRegistry tx;

    public void execute() {
        System.out.println("[RequiredEjb]");
        System.out.println("txKey=" + this.tx.getTransactionKey());
        System.out.println("bean=" + this.bean);
    }
}
RequiresNewEjb.java
package sample.jta.ejb;

import javax.annotation.Resource;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.inject.Inject;
import javax.transaction.TransactionSynchronizationRegistry;
import sample.jta.cdi.TransactionScopedBean;

@Stateless
@TransactionAttribute(TransactionAttributeType.REQUIRES_NEW)
public class RequiresNewEjb {

    @Inject
    private TransactionScopedBean bean;

    @Resource
    private TransactionSynchronizationRegistry tx;

    public void execute() {
        System.out.println("[RequiresNewEjb]");
        System.out.println("txKey=" + this.tx.getTransactionKey());
        System.out.println("bean=" + this.bean);
    }
}
GlassFishコンソール出力
情報:   [TestTransactionScopedEjb]
情報:   txKey=JavaEETransactionImpl: txId=1 ...]
情報:   bean=TransactionScopedBean {hash=1815003275}

情報:   [RequiresNewEjb]
情報:   txKey=JavaEETransactionImpl: txId=2 ...]
情報:   bean=TransactionScopedBean {hash=2009619742}

情報:   [RequiredEjb]
情報:   txKey=JavaEETransactionImpl: txId=1 ...]
情報:   bean=TransactionScopedBean {hash=1815003275}
  • txIdTransactionScopedBean のハッシュ値の関係に注目。
  • @TransactionScoped でアノテートすると、トランザクションごとにインスタンスが生成される。

参考

74
84
13

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
74
84