diff --git a/clickhouse-native-jdbc/src/main/java/com/github/housepower/client/NativeClient.java b/clickhouse-native-jdbc/src/main/java/com/github/housepower/client/NativeClient.java index e6f4a5f4..27da6776 100644 --- a/clickhouse-native-jdbc/src/main/java/com/github/housepower/client/NativeClient.java +++ b/clickhouse-native-jdbc/src/main/java/com/github/housepower/client/NativeClient.java @@ -45,8 +45,12 @@ public class NativeClient { private static final Logger LOG = LoggerFactory.getLogger(NativeClient.class); public static NativeClient connect(ClickHouseConfig configure) throws SQLException { + return connect(configure.host(), configure.port(), configure); + } + + public static NativeClient connect(String host, int port, ClickHouseConfig configure) throws SQLException { try { - SocketAddress endpoint = new InetSocketAddress(configure.host(), configure.port()); + SocketAddress endpoint = new InetSocketAddress(host, port); // TODO support proxy Socket socket = new Socket(); socket.setTcpNoDelay(true); diff --git a/clickhouse-native-jdbc/src/main/java/com/github/housepower/jdbc/ClickHouseConnection.java b/clickhouse-native-jdbc/src/main/java/com/github/housepower/jdbc/ClickHouseConnection.java index 1d5665ae..df671fff 100644 --- a/clickhouse-native-jdbc/src/main/java/com/github/housepower/jdbc/ClickHouseConnection.java +++ b/clickhouse-native-jdbc/src/main/java/com/github/housepower/jdbc/ClickHouseConnection.java @@ -33,7 +33,17 @@ import javax.annotation.Nullable; import java.net.InetSocketAddress; -import java.sql.*; +import java.sql.Array; +import java.sql.ClientInfoStatus; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLClientInfoException; +import java.sql.SQLException; +import java.sql.SQLWarning; +import java.sql.Statement; +import java.sql.Struct; import java.time.Duration; import java.time.ZoneId; import java.util.HashMap; @@ -45,6 +55,8 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static com.github.housepower.jdbc.ClickhouseJdbcUrlParser.PORT_DELIMITER; + public class ClickHouseConnection implements SQLConnection { private static final Logger LOG = LoggerFactory.getLogger(ClickHouseConnection.class); @@ -305,7 +317,43 @@ public static ClickHouseConnection createClickHouseConnection(ClickHouseConfig c } private static NativeContext createNativeContext(ClickHouseConfig configure) throws SQLException { - NativeClient nativeClient = NativeClient.connect(configure); + if (configure.hosts().size() == 1) { + NativeClient nativeClient = NativeClient.connect(configure); + return new NativeContext(clientContext(nativeClient, configure), serverContext(nativeClient, configure), nativeClient); + } + + return createFailoverNativeContext(configure); + } + + private static NativeContext createFailoverNativeContext(ClickHouseConfig configure) throws SQLException { + NativeClient nativeClient = null; + SQLException lastException = null; + + int tryIndex = 0; + do { + String hostAndPort = configure.hosts().get(tryIndex); + String[] hostAndPortSplit = hostAndPort.split(PORT_DELIMITER, 2); + String host = hostAndPortSplit[0]; + int port; + + if (hostAndPortSplit.length == 2) { + port = Integer.parseInt(hostAndPortSplit[1]); + } else { + port = configure.port(); + } + + try { + nativeClient = NativeClient.connect(host, port, configure); + } catch (SQLException e) { + lastException = e; + } + tryIndex++; + } while (nativeClient == null && tryIndex < configure.hosts().size()); + + if (nativeClient == null) { + throw lastException; + } + return new NativeContext(clientContext(nativeClient, configure), serverContext(nativeClient, configure), nativeClient); } diff --git a/clickhouse-native-jdbc/src/main/java/com/github/housepower/jdbc/ClickhouseJdbcUrlParser.java b/clickhouse-native-jdbc/src/main/java/com/github/housepower/jdbc/ClickhouseJdbcUrlParser.java index a6f04fdd..51b5ee4b 100644 --- a/clickhouse-native-jdbc/src/main/java/com/github/housepower/jdbc/ClickhouseJdbcUrlParser.java +++ b/clickhouse-native-jdbc/src/main/java/com/github/housepower/jdbc/ClickhouseJdbcUrlParser.java @@ -15,15 +15,17 @@ package com.github.housepower.jdbc; import com.github.housepower.exception.InvalidValueException; -import com.github.housepower.misc.Validate; -import com.github.housepower.settings.SettingKey; import com.github.housepower.log.Logger; import com.github.housepower.log.LoggerFactory; +import com.github.housepower.misc.Validate; +import com.github.housepower.settings.SettingKey; import java.io.Serializable; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.*; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.StringTokenizer; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -32,92 +34,65 @@ public class ClickhouseJdbcUrlParser { public static final String CLICKHOUSE_PREFIX = "clickhouse:"; public static final String JDBC_CLICKHOUSE_PREFIX = JDBC_PREFIX + CLICKHOUSE_PREFIX; - public static final Pattern DB_PATH_PATTERN = Pattern.compile("/([a-zA-Z0-9_]+)"); - public static final Pattern HOST_PORT_PATH_PATTERN = Pattern.compile("//(?[^/:\\s]+)(:(?\\d+))?"); + public static final String HOST_DELIMITER = ","; + public static final String PORT_DELIMITER = ":"; + + /** + * Jdbc Url sames like: + * '//[host1][:port1],[host2][:port2],[host3][:port3]]...[/[database]][?propertyName1=propertyValue1[&propertyName2=propertyValue2]...]' + * + * Default_port is used when port does not exist. + */ + public static final Pattern CONNECTION_PATTERN = Pattern.compile("//(?([^/?:,\\s]+(:\\d+)?)(,[^/?:,\\s]+(:\\d+)?)*)" // hosts: required; starts with "//" followed by any char except "/", "?" + + "(?:/(?([a-zA-Z0-9_]+)))?" // database: optional; starts with "/", and then followed by any char except "?" + + "(?:\\?(?.*))?"); // properties: optional; starts with "?", and then followed by any char private static final Logger LOG = LoggerFactory.getLogger(ClickhouseJdbcUrlParser.class); public static Map parseJdbcUrl(String jdbcUrl) { - try { - URI uri = new URI(jdbcUrl.substring(JDBC_PREFIX.length())); - String host = parseHost(jdbcUrl); - Integer port = parsePort(jdbcUrl); - String database = parseDatabase(jdbcUrl); - Map settings = new HashMap<>(); - settings.put(SettingKey.host, host); - settings.put(SettingKey.port, port); - settings.put(SettingKey.database, database); - settings.putAll(extractQueryParameters(uri.getQuery())); - - return settings; - } catch (URISyntaxException ex) { - throw new InvalidValueException(ex); + String uri = jdbcUrl.substring(JDBC_CLICKHOUSE_PREFIX.length()); + Matcher matcher = CONNECTION_PATTERN.matcher(uri); + if (!matcher.matches()) { + throw new InvalidValueException("Connection is not support"); } - } - public static Map parseProperties(Properties properties) { Map settings = new HashMap<>(); - for (String name : properties.stringPropertyNames()) { - String value = properties.getProperty(name); + String hosts = matcher.group("hosts"); + String database = matcher.group("database"); + String properties = matcher.group("properties"); - parseSetting(settings, name, value); - } + if (hosts.contains(HOST_DELIMITER)) { // multi-host + settings.put(SettingKey.host, hosts); + } else { // standard-host + String[] hostAndPort = hosts.split(PORT_DELIMITER, 2); - return settings; - } + settings.put(SettingKey.host, hostAndPort[0]); - private static String parseDatabase(String jdbcUrl) throws URISyntaxException { - URI uri = new URI(jdbcUrl.substring(JDBC_PREFIX.length())); - String database = uri.getPath(); - if (database != null && !database.isEmpty()) { - Matcher m = DB_PATH_PATTERN.matcher(database); - if (m.matches()) { - database = m.group(1); - } else { - throw new URISyntaxException("wrong database name path: '" + database + "'", jdbcUrl); + if (hostAndPort.length == 2) { + if (Integer.parseInt(hostAndPort[1]) == 8123) { + LOG.warn("8123 is default HTTP port, you may connect with error protocol!"); + } + settings.put(SettingKey.port, Integer.parseInt(hostAndPort[1])); } } - if (database != null && database.isEmpty()) { - database = "default"; - } - return database; - } - private static String parseHost(String jdbcUrl) throws URISyntaxException { - String uriStr = jdbcUrl.substring(JDBC_PREFIX.length()); - URI uri = new URI(uriStr); - String host = uri.getHost(); - if (host == null || host.isEmpty()) { - Matcher m = HOST_PORT_PATH_PATTERN.matcher(uriStr); - if (m.find()) { - host = m.group("host"); - } else { - throw new URISyntaxException("No valid host was found", jdbcUrl); - } - } - return host; + settings.put(SettingKey.database, database); + settings.putAll(extractQueryParameters(properties)); + + return settings; } - private static int parsePort(String jdbcUrl) { - String uriStr = jdbcUrl.substring(JDBC_PREFIX.length()); - URI uri; - try { - uri = new URI(uriStr); - } catch (Exception ex) { - throw new InvalidValueException(ex); - } - int port = uri.getPort(); - if (port <= -1) { - Matcher m = HOST_PORT_PATH_PATTERN.matcher(uriStr); - if (m.find() && m.group("port") != null) { - port = Integer.parseInt(m.group("port")); - } - } - if (port == 8123) { - LOG.warn("8123 is default HTTP port, you may connect with error protocol!"); + public static Map parseProperties(Properties properties) { + Map settings = new HashMap<>(); + + for (String name : properties.stringPropertyNames()) { + String value = properties.getProperty(name); + + parseSetting(settings, name, value); } - return port; + + return settings; } public static Map extractQueryParameters(String queryParameters) { diff --git a/clickhouse-native-jdbc/src/main/java/com/github/housepower/settings/ClickHouseConfig.java b/clickhouse-native-jdbc/src/main/java/com/github/housepower/settings/ClickHouseConfig.java index e04156d1..cece71ec 100644 --- a/clickhouse-native-jdbc/src/main/java/com/github/housepower/settings/ClickHouseConfig.java +++ b/clickhouse-native-jdbc/src/main/java/com/github/housepower/settings/ClickHouseConfig.java @@ -22,15 +22,21 @@ import java.io.Serializable; import java.nio.charset.Charset; import java.time.Duration; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; +import static com.github.housepower.jdbc.ClickhouseJdbcUrlParser.HOST_DELIMITER; + @Immutable public class ClickHouseConfig implements Serializable { + private final String host; + private final List hosts; private final int port; private final String database; private final String user; @@ -46,6 +52,7 @@ private ClickHouseConfig(String host, int port, String database, String user, St Duration queryTimeout, Duration connectTimeout, boolean tcpKeepAlive, String charset, String clientName, Map settings) { this.host = host; + this.hosts = Arrays.asList(host.split(HOST_DELIMITER)); this.port = port; this.database = database; this.user = user; @@ -62,6 +69,10 @@ public String host() { return this.host; } + public List hosts() { + return this.hosts; + } + public int port() { return this.port; } @@ -96,7 +107,13 @@ public String clientName() { public String jdbcUrl() { StringBuilder builder = new StringBuilder(ClickhouseJdbcUrlParser.JDBC_CLICKHOUSE_PREFIX) - .append("//").append(host).append(":").append(port).append("/").append(database) + .append("//").append(host); + + if (hosts.size() == 1) { + builder.append(":").append(port); + } + + builder.append("/").append(database) .append("?").append(SettingKey.query_timeout.name()).append("=").append(queryTimeout.getSeconds()) .append("&").append(SettingKey.connect_timeout.name()).append("=").append(connectTimeout.getSeconds()) .append("&").append(SettingKey.charset.name()).append("=").append(charset) diff --git a/clickhouse-native-jdbc/src/test/java/com/github/housepower/jdbc/ConnectionParamITest.java b/clickhouse-native-jdbc/src/test/java/com/github/housepower/jdbc/ConnectionParamITest.java index 19c5e91a..6b8bb56e 100644 --- a/clickhouse-native-jdbc/src/test/java/com/github/housepower/jdbc/ConnectionParamITest.java +++ b/clickhouse-native-jdbc/src/test/java/com/github/housepower/jdbc/ConnectionParamITest.java @@ -14,6 +14,7 @@ package com.github.housepower.jdbc; +import com.github.housepower.exception.InvalidValueException; import com.github.housepower.settings.ClickHouseConfig; import com.github.housepower.settings.SettingKey; import org.junit.jupiter.api.BeforeEach; @@ -28,6 +29,7 @@ import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -38,6 +40,70 @@ public void init() throws SQLException { resetDriverManager(); } + @Test + public void connectionPatternTest() { + String[] jdbcFalse = new String[]{ + "//ck1:", + "//,ck1", + "//ck1,ck2,", + "//ck1:,ck2", + "//ck1,ck2/" + }; + + for (String jdbc : jdbcFalse) { + assertFalse(ClickhouseJdbcUrlParser.CONNECTION_PATTERN.matcher(jdbc).matches()); + } + + String[] jdbcTrue = new String[]{ + "//ch1?max_rows_to_read=1&connect_timeout=10", + "//ch1/default?max_rows_to_read=1&connect_timeout=10", + "//ch1:1234?max_rows_to_read=1&connect_timeout=10", + "//ch1,ch2?max_rows_to_read=1&connect_timeout=10", + "//ch1,ch2:1234?max_rows_to_read=1&connect_timeout=10", + "//ch1:1234,ch2:1234?max_rows_to_read=1&connect_timeout=10", + "//ch1:1234,ch2:1234/default?max_rows_to_read=1&connect_timeout=10", + "//ch1:1234,ch2:1234,ch3:2222/default?max_rows_to_read=1&connect_timeout=10", + "//ch1:1234,ch2,ch3/default?max_rows_to_read=1&connect_timeout=10" + }; + + for (String jdbc : jdbcTrue) { + ClickHouseConfig cfg = ClickHouseConfig.Builder.builder() + .withJdbcUrl(ClickhouseJdbcUrlParser.JDBC_CLICKHOUSE_PREFIX + jdbc) + .build(); + + if (jdbc.contains("?")) { + assertEquals(cfg.connectTimeout().getSeconds(), 10); + assertEquals((Long) cfg.settings().get(SettingKey.max_rows_to_read), 1L); + } + + if (jdbc.contains("/")) { + assertEquals("default", cfg.database()); + } + + if (jdbc.contains(",")) { + assertEquals(jdbc.split(",").length, cfg.hosts().size()); + } + } + + String[] fullJdbc = new String[]{ + "//ch1:9000/default?query_timeout=1&connect_timeout=10&charset=UTF-8&client_name=test&tcp_keep_alive=true", + "//ch1:9001/default?query_timeout=1&connect_timeout=10&charset=UTF-8&client_name=test&tcp_keep_alive=true", + "//ch1,ch2:9001/default?query_timeout=1&connect_timeout=10&charset=UTF-8&client_name=test&tcp_keep_alive=false", + "//ch1:9001,ch2:9002/default?query_timeout=1&connect_timeout=10&charset=UTF-8&client_name=test&tcp_keep_alive=false" + }; + + for (String jdbc : fullJdbc) { + jdbc = ClickhouseJdbcUrlParser.JDBC_CLICKHOUSE_PREFIX + jdbc; + ClickHouseConfig cfg = ClickHouseConfig.Builder.builder() + .withJdbcUrl(jdbc) + .build(); + + assertEquals(jdbc, cfg.jdbcUrl()); + } + + } + + @Test public void successfullyMaxRowsToRead() { assertThrows(SQLException.class, () -> { @@ -115,6 +181,36 @@ public void successfullyHostNameWithCustomPort() { assertEquals(Duration.ofSeconds(50), config.connectTimeout()); } + @Test + public void successfullyFailoverHostNameWithCustomPort() { + String url = "jdbc:clickhouse://my_clickhouse_sever_host_name1:1940,my_clickhouse_sever_host_name2:1941/system?min_insert_block_size_rows=1000&connect_timeout=50"; + ClickHouseConfig config = ClickHouseConfig.Builder.builder().withJdbcUrl(url).build(); + assertEquals("my_clickhouse_sever_host_name1:1940,my_clickhouse_sever_host_name2:1941", config.host()); + assertEquals(2, config.hosts().size()); + assertEquals(9000, config.port()); + assertEquals("system", config.database()); + assertEquals(1000L, config.settings().get(SettingKey.min_insert_block_size_rows)); + assertEquals(Duration.ofSeconds(50), config.connectTimeout()); + } + + @Test + public void successfullyFailoverHostNameWithDefaultPort() { + String url = "jdbc:clickhouse://my_clickhouse_sever_host_name1,my_clickhouse_sever_host_name2/system?min_insert_block_size_rows=1000&connect_timeout=50"; + ClickHouseConfig config = ClickHouseConfig.Builder.builder().withJdbcUrl(url).build(); + assertEquals("my_clickhouse_sever_host_name1,my_clickhouse_sever_host_name2", config.host()); + assertEquals(2, config.hosts().size()); + assertEquals(9000, config.port()); + assertEquals("system", config.database()); + assertEquals(1000L, config.settings().get(SettingKey.min_insert_block_size_rows)); + assertEquals(Duration.ofSeconds(50), config.connectTimeout()); + } + + @Test + public void successWrongUrlParser() { + String url = "jdbc:clickhouse://127.0.0. 1/system?min_insert_block_size_rows=1000&connect_timeout=50"; + assertThrows(InvalidValueException.class, () -> ClickHouseConfig.Builder.builder().withJdbcUrl(url).build()); + } + @Test public void successWithQueryId() throws Exception { String queryId = UUID.randomUUID().toString(); diff --git a/clickhouse-native-jdbc/src/test/java/com/github/housepower/jdbc/FailoverClickhouseConnectionITest.java b/clickhouse-native-jdbc/src/test/java/com/github/housepower/jdbc/FailoverClickhouseConnectionITest.java new file mode 100644 index 00000000..887dd453 --- /dev/null +++ b/clickhouse-native-jdbc/src/test/java/com/github/housepower/jdbc/FailoverClickhouseConnectionITest.java @@ -0,0 +1,178 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.github.housepower.jdbc; + +import com.github.housepower.log.Logger; +import com.github.housepower.log.LoggerFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.ClickHouseContainer; +import org.testcontainers.junit.jupiter.Container; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class FailoverClickhouseConnectionITest extends AbstractITest { + private static final Logger LOG = LoggerFactory.getLogger(FailoverClickhouseConnectionITest.class); + + protected static String HA_HOST; + protected static int HA_PORT; + + @Container + public static ClickHouseContainer containerHA = (ClickHouseContainer) new ClickHouseContainer(CLICKHOUSE_IMAGE) + .withEnv("CLICKHOUSE_USER", CLICKHOUSE_USER) + .withEnv("CLICKHOUSE_PASSWORD", CLICKHOUSE_PASSWORD) + .withEnv("CLICKHOUSE_DB", CLICKHOUSE_DB); + + + @BeforeEach + public void reset() throws SQLException { + resetDriverManager(); + container.start(); + containerHA.start(); + + CK_PORT = container.getMappedPort(ClickHouseContainer.NATIVE_PORT); + HA_HOST = containerHA.getHost(); + HA_PORT = containerHA.getMappedPort(ClickHouseContainer.NATIVE_PORT); + LOG.info("Port1 {}, Port2 {}", CK_PORT, HA_PORT); + } + + @Test + public void testClickhouseDownBeforeConnect() throws Exception { + String haHost = String.format(Locale.ROOT, "%s:%s,%s:%s", CK_HOST, CK_PORT, HA_HOST, HA_PORT); + + container.stop(); + try (Connection connection = DriverManager + .getConnection(String.format(Locale.ROOT, "jdbc:clickhouse://%s/default", haHost)) + ) { + withStatement(connection, stmt -> { + ResultSet rs = stmt.executeQuery("select count() from system.tables"); + + if (rs.next()) { + assertTrue(rs.getLong(1) > 0); + } + }); + } + } + + @Test + public void testClickhouseDownBeforeStatement() throws Exception { + String haHost = String.format(Locale.ROOT, "%s:%s,%s:%s", CK_HOST, CK_PORT, HA_HOST, HA_PORT); + + try (Connection connection = DriverManager + .getConnection(String.format(Locale.ROOT, "jdbc:clickhouse://%s/default", haHost)) + ) { + container.stop(); + withStatement(connection, stmt -> { + ResultSet rs = stmt.executeQuery("select count() from system.tables"); + + if (rs.next()) { + assertTrue(rs.getLong(1) > 0); + } + }); + } + } + + @Test + public void testClickhouseDownBeforePrepareStatement() throws Exception { + String haHost = String.format(Locale.ROOT, "%s:%s,%s:%s", CK_HOST, CK_PORT, HA_HOST, HA_PORT); + + try (Connection connection = DriverManager + .getConnection(String.format(Locale.ROOT, "jdbc:clickhouse://%s/default", haHost)) + ) { + container.stop(); + withPreparedStatement(connection, "select count() from system.tables", stmt -> { + ResultSet rs = stmt.executeQuery(); + + if (rs.next()) { + assertTrue(rs.getLong(1) > 0); + } + }); + } + } + + @Test + public void testClickhouseDownBeforeExecute() throws Exception { + String haHost = String.format(Locale.ROOT, "%s:%s,%s:%s", CK_HOST, CK_PORT, HA_HOST, HA_PORT); + + try (Connection connection = DriverManager + .getConnection(String.format(Locale.ROOT, "jdbc:clickhouse://%s/default", haHost)) + ) { + withStatement(connection, stmt -> { + container.stop(); + ResultSet rs = stmt.executeQuery("select count() from system.tables"); + + if (rs.next()) { + assertTrue(rs.getLong(1) > 0); + } + }); + } + } + + @Test + public void testClickhouseDownBeforeAndAfterConnect() { + String haHost = String.format(Locale.ROOT, "%s:%s,%s:%s", CK_HOST, CK_PORT, HA_HOST, HA_PORT); + + Exception ex = null; + container.stop(); + try (Connection connection = DriverManager + .getConnection(String.format(Locale.ROOT, "jdbc:clickhouse://%s/default?query_id=xxx", haHost)) + ) { + containerHA.stop(); + withStatement(connection, stmt -> { + ResultSet rs = stmt.executeQuery("select count() from system.tables"); + + if (rs.next()) { + assertTrue(rs.getLong(1) > 0); + } + }); + } catch (Exception e) { + ex = e; + } + + assertNotNull(ex); + } + + @Test + public void testClickhouseAllDownBeforeConnect() throws Exception { + String haHost = String.format(Locale.ROOT, "%s:%s,%s", CK_HOST, CK_PORT, HA_HOST); + + Exception ex = null; + container.stop(); + containerHA.stop(); + try (Connection connection = DriverManager + .getConnection(String.format(Locale.ROOT, "jdbc:clickhouse://%s/default", haHost)) + ) { + withStatement(connection, stmt -> { + + ResultSet rs = stmt.executeQuery("select count() from system.tables"); + + if (rs.next()) { + assertTrue(rs.getLong(1) > 0); + } + }); + } catch (Exception e) { + ex = e; + } + + assertNotNull(ex); + } +}