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

# 実行環境

本記事を書くにあたって、利用した主なソフトウェアのバージョンは次の通りです。なおOracle Databaseの構築にあたってはDockerおよび[Oracle公式のDocker Image](https://github.com/oracle/docker-images)を利用しています。

|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 - クライアント接続の言語およびロケール・プリファレンスの設定」](https://docs.oracle.com/cd/E96517_01/ladbi/setting-language-preferences-for-client-connections.html#GUID-78A71337-2199-4FED-B0F1-D313F769B22C)より抜粋:

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

# 実験

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

```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アプリケーションです。

```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`型を利用することにつきます。