JPAは重い、MyBatisは大げさに感じる。でも素のSQLは安全に書きたい。そんなときに一番手軽なのが、Spring標準の JdbcTemplate です。

この記事では、queryForObject での単一行取得から、RowMapper での複数行マッピング、update / batchUpdate での更新、NamedParameterJdbcTemplate での名前付きバインドまで、コピーしてすぐ使える形で一通り見ていきます。

JdbcTemplateとは何か

JdbcTemplate は、JDBCの定型処理をまるごと肩代わりしてくれるSpring標準のクラスです。接続の取得とクローズ、PreparedStatement の生成、SQLException から DataAccessException への変換といった、毎回書くと面倒な部分を全部やってくれます。

おかげで私たちは「どんなSQLを投げて、結果をどう受け取るか」だけに集中できます。ORMを入れるほどではない小さめのプロジェクトや、生SQLで細かくチューニングしたい場面での第一選択になりますよね。

JPAやMyBatisとの使い分けが気になる場合は、MyBatisとJPAの比較記事も参考にしてください。ここでは選定の話は脇に置いて、実装に集中します。

依存関係とBeanの注入

まず spring-boot-starter-jdbc を追加します。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

あとはDBドライバ(PostgreSQLなど)を入れて、接続情報を書くだけです。

spring.datasource.url=jdbc:postgresql://localhost:5432/app
spring.datasource.username=app
spring.datasource.password=secret

これで DataSource が自動構成され、JdbcTemplateNamedParameterJdbcTemplate がBeanとして登録されます。あとはコンストラクタで注入するだけです。

@Repository
public class UserRepository {

    private final JdbcTemplate jdbcTemplate;

    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
}

queryForObjectで単一値・単一行を取得する

件数のようなスカラ値は、queryForObject に戻り値の型を渡すだけで取れます。

public int countAll() {
    return jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM users", Integer.class);
}

主キーで1件のオブジェクトを取るときは、第2引数に RowMapper、そのあとにプレースホルダ ? へ渡す値を並べます。

public User findById(long id) {
    return jdbcTemplate.queryForObject(
            "SELECT id, user_name, email FROM users WHERE id = ?",
            (rs, rowNum) -> new User(
                    rs.getLong("id"),
                    rs.getString("user_name"),
                    rs.getString("email")),
            id);
}

ここで注意したいのが、queryForObject は結果が0件だと EmptyResultDataAccessException を投げる点です。「見つからなければnull」を期待していると落ちます。存在しないケースを普通に扱いたいなら、query でListを受けて先頭を返す形にしておくと安全です。

public Optional<User> findByEmail(String email) {
    List<User> list = jdbcTemplate.query(
            "SELECT id, user_name, email FROM users WHERE email = ?",
            userRowMapper, email);
    return list.stream().findFirst();
}

queryとRowMapperで複数行を取得する

複数行は query(sql, rowMapper) で受け取ります。RowMapper はResultSetの1行を1オブジェクトに変換する関数だと思えばOKです。

public List<User> findAll() {
    return jdbcTemplate.query(
            "SELECT id, user_name, email FROM users ORDER BY id",
            (rs, rowNum) -> new User(
                    rs.getLong("id"),
                    rs.getString("user_name"),
                    rs.getString("email")));
}

同じマッピングを何度も使うなら、RowMapper を定数として切り出しておくと重複が消えて読みやすくなります。

private static final RowMapper<User> userRowMapper = (rs, rowNum) -> new User(
        rs.getLong("id"),
        rs.getString("user_name"),
        rs.getString("email"));

BeanPropertyRowMapperでマッピングを自動化する

カラム名とプロパティ名が素直に対応しているなら、BeanPropertyRowMapper で手書きを省けます。

public List<User> findAll() {
    return jdbcTemplate.query(
            "SELECT id, user_name, email FROM users ORDER BY id",
            new BeanPropertyRowMapper<>(User.class));
}

これは user_name のようなスネークケースを userName のキャメルケースへ自動変換してくれます。ただし対応するsetter(または引数コンストラクタ)が必要で、SELECT * に頼るとテーブル変更で壊れやすいので、カラムは明示するのがおすすめです。集計列などは AS でプロパティ名に合わせておきましょう。

updateでINSERT・UPDATE・DELETEを実行する

更新系は全部 update です。戻り値は影響を受けた行数なので、削除や更新の成否判定に使えます。

public int insert(User user) {
    return jdbcTemplate.update(
            "INSERT INTO users (user_name, email) VALUES (?, ?)",
            user.getUserName(), user.getEmail());
}

public int deleteById(long id) {
    return jdbcTemplate.update("DELETE FROM users WHERE id = ?", id);
}

値は必ず ? プレースホルダ経由で渡します。"... WHERE email = '" + email + "'" のような文字列連結は、SQLインジェクションの入口になるので絶対に避けてください。プレースホルダを使えば値は常にパラメータとして扱われ、SQL構文として解釈されることがありません。安全のためだけでなく、型変換やエスケープも任せられて楽です。

NamedParameterJdbcTemplateで名前付きパラメータを使う

パラメータが増えてくると、? の順番を数えるのが辛くなります。そこで :name 形式が使える NamedParameterJdbcTemplate の出番です。

private final NamedParameterJdbcTemplate namedTemplate;

public int update(User user) {
    String sql = "UPDATE users SET user_name = :name, email = :email WHERE id = :id";
    var params = new MapSqlParameterSource()
            .addValue("name", user.getUserName())
            .addValue("email", user.getEmail())
            .addValue("id", user.getId());
    return namedTemplate.update(sql, params);
}

エンティティのプロパティ名とパラメータ名が一致しているなら、BeanPropertySqlParameterSource で一括バインドできてさらに簡潔です。

public int insert(User user) {
    String sql = "INSERT INTO users (user_name, email) VALUES (:userName, :email)";
    return namedTemplate.update(sql, new BeanPropertySqlParameterSource(user));
}

batchUpdateで一括INSERT・UPDATEを行う

大量のデータを入れるとき、ループで update を1件ずつ回すと往復回数ぶん遅くなります。batchUpdate を使えばまとめて送信できて、目に見えて速くなります。

public void insertAll(List<User> users) {
    List<Object[]> batch = users.stream()
            .map(u -> new Object[]{u.getUserName(), u.getEmail()})
            .toList();
    jdbcTemplate.batchUpdate(
            "INSERT INTO users (user_name, email) VALUES (?, ?)", batch);
}

名前付き版なら SqlParameterSource[] を渡します。件数がまとまるとき(初期データ投入や取り込み処理など)に効いてくるので、覚えておくと便利です。

自動採番キーを取得する

INSERTで採番された主キーを取りたいときは、GeneratedKeyHolder を使います。

public long insertAndReturnId(User user) {
    KeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcTemplate.update(con -> {
        var ps = con.prepareStatement(
                "INSERT INTO users (user_name, email) VALUES (?, ?)",
                new String[]{"id"});
        ps.setString(1, user.getUserName());
        ps.setString(2, user.getEmail());
        return ps;
    }, keyHolder);
    return keyHolder.getKey().longValue();
}

もう少し書き味を軽くしたいなら SimpleJdbcInsert が便利で、usingGeneratedKeyColumns("id") を指定するとキーを返してくれます。単純なINSERTなら SimpleJdbcInsert、SQLを自分で持ちたいなら KeyHolder、くらいの目安で選べば十分です。

@Transactionalとの連携

複数の更新を1つのトランザクションにまとめたいときは、Serviceメソッドに @Transactional を付けるだけです。JdbcTemplate はSpring管理のトランザクションに自動で乗ります。

@Service
public class UserService {

    private final UserRepository repository;

    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void register(User user, Profile profile) {
        long id = repository.insertAndReturnId(user);
        repository.insertProfile(id, profile);
    }
}

デフォルトでは RuntimeException などの非検査例外が投げられるとロールバックされます。途中で例外が起きれば両方のINSERTが取り消されるので、中途半端な状態が残りません。伝播レベルなどの細かい挙動はトランザクション管理の記事にまとめています。

まとめ

JdbcTemplate のAPIは、取得と更新でシンプルに対応が分かれています。

  • 取得は queryForObject(単一)と query + RowMapper(複数)
  • 更新は update(単発)と batchUpdate(一括)
  • パラメータが多いなら NamedParameterJdbcTemplate で名前付きに

これだけ押さえれば、ORMを入れなくても素のSQLで一通りのCRUDが書けます。次のステップとして、JPAのクエリメソッドが気になればJPAクエリメソッドの記事、参照系の負荷分散に踏み込むなら複数データソースの読み書き分離もあわせてどうぞ。