実行環境
本記事を書くにあたって、利用した主なソフトウェアのバージョンは次の通りです。なお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アプリケーションです。
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アプリケーションです。
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
型を利用することにつきます。