Help us understand the problem. What is going on with this article?

JDBCを利用する場合、NLSパラメータはJavaのロケールに影響される。

実行環境

本記事を書くにあたって、利用した主なソフトウェアのバージョンは次の通りです。なおOracle Databaseの構築にあたってはDockerおよびOracle公式のDocker Imageを利用しています。

software version, edition
Oracle Database 12c Oracle Database 12c Standard Edition Release 12.2.0.1.0 - 64bit Production
ojdbc8.jar (12c JDBC Driver) Oracle 12.2.0.1.0 JDBC 4.2 compiled with javac 1.8.0_91 on Tue_Dec_13_06:08:31_PST_2016
javac javac 11.0.4
java openjdk version "11.0.4" 2019-07-16

概要

Oracle DatabaseのNLSパラメータはさまざまな設定方法があるため、「どこの設定値がどう影響しているのかわからん!」ということが起きるのですが、Oracle Databaseとの接続にJDBCドライバを利用している場合、クライアントとなるJavaのロケールによりNLSパラメータが変わる場合があります。

「Databaseインストレーション・ガイドfor Linux - クライアント接続の言語およびロケール・プリファレンスの設定」より抜粋:

Oracle Databaseへの接続にOracle JDBCを使用するJavaアプリケーションでは、NLS_LANGを使用しません。かわりにOracle JDBCでは、アプリケーションを実行しているJava VMのデフォルトのロケールをOracle Databaseのlanguageとterritoryの設定にマップします。

実験

実験してみましょう。以下はJDBCを利用し、NLSパラメータの現在の設定値を格納しているV$NLS_PARAMETERSの内容をすべて出力するJavaアプリケーションです。

Main1.java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public class Main1 {
    public static void main(String[] args) {
        String url = "jdbc:oracle:thin:@//192.168.99.100:1521/ORCLPDB1";
        String user = "dev1";
        String password = "password";

        try (Connection c = DriverManager.getConnection(url, user, password)) {
            Statement stmt = c.createStatement();
            ResultSet rs = stmt.executeQuery("SELECT * FROM V$NLS_PARAMETERS ORDER BY PARAMETER");
            while (rs.next()) {
                String parameter = rs.getString("PARAMETER");
                String value = rs.getString("VALUE");
                System.out.printf("%s = %s%n", parameter, value);
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

これをJavaのロケールを変更して実行してみましょう。まずは日本語。

$ java -Duser.language=ja -Duser.country=JP -cp .:../lib/ojdbc8.jar Main1
NLS_CALENDAR = GREGORIAN
NLS_CHARACTERSET = AL32UTF8
NLS_COMP = BINARY
NLS_CURRENCY = ¥
NLS_DATE_FORMAT = RR-MM-DD
NLS_DATE_LANGUAGE = JAPANESE
NLS_DUAL_CURRENCY = \
NLS_ISO_CURRENCY = JAPAN
NLS_LANGUAGE = JAPANESE
NLS_LENGTH_SEMANTICS = BYTE
NLS_NCHAR_CHARACTERSET = AL16UTF16
NLS_NCHAR_CONV_EXCP = FALSE
NLS_NUMERIC_CHARACTERS = .,
NLS_SORT = BINARY
NLS_TERRITORY = JAPAN
NLS_TIMESTAMP_FORMAT = RR-MM-DD HH24:MI:SSXFF
NLS_TIMESTAMP_TZ_FORMAT = RR-MM-DD HH24:MI:SSXFF TZR
NLS_TIME_FORMAT = HH24:MI:SSXFF
NLS_TIME_TZ_FORMAT = HH24:MI:SSXFF TZR

次は英語。

$ java -Duser.language=en -Duser.country=US -cp .:../lib/ojdbc8.jar Main1
NLS_CALENDAR = GREGORIAN
NLS_CHARACTERSET = AL32UTF8
NLS_COMP = BINARY
NLS_CURRENCY = $
NLS_DATE_FORMAT = DD-MON-RR
NLS_DATE_LANGUAGE = AMERICAN
NLS_DUAL_CURRENCY = $
NLS_ISO_CURRENCY = AMERICA
NLS_LANGUAGE = AMERICAN
NLS_LENGTH_SEMANTICS = BYTE
NLS_NCHAR_CHARACTERSET = AL16UTF16
NLS_NCHAR_CONV_EXCP = FALSE
NLS_NUMERIC_CHARACTERS = .,
NLS_SORT = BINARY
NLS_TERRITORY = AMERICA
NLS_TIMESTAMP_FORMAT = DD-MON-RR HH.MI.SSXFF AM
NLS_TIMESTAMP_TZ_FORMAT = DD-MON-RR HH.MI.SSXFF AM TZR
NLS_TIME_FORMAT = HH.MI.SSXFF AM
NLS_TIME_TZ_FORMAT = HH.MI.SSXFF AM TZR

Oracle Database側の設定をまったく変更していないにも関わらず、クライアント側のロケールによってNLSパラメータが変わってしまうことがわかりました。

何が恐ろしいのか

このふるまいの何が恐ろしいかというと、以下のようなことが起きかねないということにあります。

  • Windows PCでは正常に稼働したモジュールがLinuxサーバでは正常に稼働しない。
  • 開発機と本番機でアプリケーションサーバのロケール設定が違うため、NLSパラメータにも違いが生じて、結果として同じJavaモジュールが開発機と本番機で動作に違いが出る

要はJavaアプリケーションが環境によって、違う振る舞いを起こす可能性があるということです。経験値豊かなJavaプログラマであれば、DBMSやロケールに依存しないプログラミングを心掛けるとは思いますが、そうもいかない現実があるわけですね(´・ω・`)

具体例: 文字列型からTIMESTAMP型への暗黙の型変換

このふるまいにより、問題が発生するより具体的な例を示して終わりとします。

以下のようなusersというテーブルがあるとします。このテーブルはユーザの名前nameとレコードの更新時間updated_atをそれぞれ有しているとしましょう (よくある構成ですね)

CREATE TABLE users (
  name VARCHAR2(256 CHAR),
  updated_at TIMESTAMP
)

以下のMain2.javaはこのusersテーブルにデータを挿入するJavaアプリケーションです。

Main2.java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class Main2 {
    public static void main(String[] args) {
        String url = "jdbc:oracle:thin:@//192.168.99.100:1521/ORCLPDB1";
        String user = "dev1";
        String password = "password";

        try (Connection c = DriverManager.getConnection(url, user, password)) {
            PreparedStatement pstmt = c.prepareStatement("INSERT INTO users (name, updated_at) VALUES (?, ?)");
            pstmt.setString(1, "nekoTheShadow");
            pstmt.setString(2, "20200117");
            int count = pstmt.executeUpdate();

            System.out.printf("PreparedStatement::executeUpdate = %d%n", count);
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

ここで着目してほしいのは、TIMESTAMP型のカラムであるupdated_atに文字列型の値("20200117")を挿入しようとしていることです。この場合はOracle Databaseの「暗黙の型変換」機能によって、文字列型からTIMESTAMP型へ返還されます。そして、どのように「暗黙の型変換」されるのかについては、NLSパラメータに強く依存します。

では、このMain2.javaをロケールを変えて実行してみましょう。まずは日本語。

$ java -Duser.language=ja -Duser.country=JP -cp .:../lib/ojdbc8.jar Main2
PreparedStatement::executeUpdate = 1

ただしくデータが挿入されたようです。次に英語で実行します。

$ java -Duser.language=en -Duser.country=US -cp .:../lib/ojdbc8.jar Main2
java.sql.SQLDataException: ORA-01843: not a valid month

        at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:494)
        at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:446)
        at oracle.jdbc.driver.T4C8Oall.processError(T4C8Oall.java:1054)
        at oracle.jdbc.driver.T4CTTIfun.receive(T4CTTIfun.java:623)
        at oracle.jdbc.driver.T4CTTIfun.doRPC(T4CTTIfun.java:252)
        at oracle.jdbc.driver.T4C8Oall.doOALL(T4C8Oall.java:612)
        at oracle.jdbc.driver.T4CPreparedStatement.doOall8(T4CPreparedStatement.java:226)
        at oracle.jdbc.driver.T4CPreparedStatement.doOall8(T4CPreparedStatement.java:59)
        at oracle.jdbc.driver.T4CPreparedStatement.executeForRows(T4CPreparedStatement.java:910)
        at oracle.jdbc.driver.OracleStatement.doExecuteWithTimeout(OracleStatement.java:1119)
        at oracle.jdbc.driver.OraclePreparedStatement.executeInternal(OraclePreparedStatement.java:3780)
        at oracle.jdbc.driver.T4CPreparedStatement.executeInternal(T4CPreparedStatement.java:1343)
        at oracle.jdbc.driver.OraclePreparedStatement.executeLargeUpdate(OraclePreparedStatement.java:3865)
        at oracle.jdbc.driver.OraclePreparedStatement.executeUpdate(OraclePreparedStatement.java:3845)
        at oracle.jdbc.driver.OraclePreparedStatementWrapper.executeUpdate(OraclePreparedStatementWrapper.java:1061)
        at Main2.main(Main2.java:16)
Caused by: Error : 1843, Position : 51, Sql = INSERT INTO users (name, updated_at) VALUES (:1 , :2 ), OriginalSql = INSERT INTO users (name, updated_at) VALUES (?, ?), Error Msg = ORA-01843: not a valid month

        at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:498)
        ... 15 more

ロケール=日本の場合は想定通り動作したにもかかわらず、ロケール=英語に変えたとたん、Exceptionを投げる結果になりました。しかも、Exception内容がかなりわかりにくいというか、少なくともロケールに端を発した問題であるとは一目ではわかりません。なお、解決策としては、暗黙の型変換をしないこと、もしくは、JavaのDate型を利用することにつきます。

neko_the_shadow
IT業界の片隅でひっそり生きるシステムエンジニアです(´・ω・`)
https://nekotheshadow.github.io/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした