在用testcontainer实现mysql单元测试的过程中,碰到了中文乱码问题。testcontainer版本:1.12.2
代码如下
@Entity
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
public interface UserDao extends JpaRepository {
List findByName(String name);
}
@SpringBootTest
@RunWith(SpringJUnit4ClassRunner.class)
public class UserDaoTest {
/*@ClassRule
public static MySQLContainer mysql = (MySQLContainer) new MySQLContainer("mysql:5.6")
.withInitScript("init.sql");
@BeforeClass
public static void init() {
System.setProperty("spring.datasource.url", mysql.getJdbcUrl());
System.setProperty("spring.datasource.driver-class-name", mysql.getDriverClassName());
System.setProperty("spring.datasource.username", mysql.getUsername());
System.setProperty("spring.datasource.password", mysql.getPassword());
}*/
@Autowired
private UserDao userDao;
@Before
public void setup() {
User user1 = new User();
user1.setName("你好");
User user2 = new User();
user2.setName("你好");
userDao.saveAll(Arrays.asList(user1, user2));
}
@Test
public void testFindByName() {
List result = userDao.findByName("你好");
Assert.assertEquals(2, result.size());
Assert.assertEquals("你好", result.get(0).getName());
}
}
properties配置
spring.datasource.url=jdbc:tc:mysql:5.6:///test?TC_INITSCRIPT=init.sql
spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.jpa.hibernate.ddl-auto=none
运行后,提示assert错误
org.junit.ComparisonFailure:
Expected :你好
Actual :??
明显是字符集问题。
testcontainer除了根据url连接docker容器外,还可以使用@Rule or @ClassRule方式连接容器。
那么方案一就出来了,MySQLContainer有withCommand方法可以设置启动容器时的命令,代码如下
public static MySQLContainer mysql = (MySQLContainer) new MySQLContainer("mysql:5.6")
.withInitScript("init.sql").withCommand("--character-set-server=utf8 --collation-server=utf8_unicode_ci");
那么,url的方式如何修改呢,百度上基本没资料,官网的文档里也没有。
在看了源码后,明白了如何修改。
ContainerDatabaseDriver在connect方法里,通过ConnectionUrl connectionUrl = ConnectionUrl.newInstance(url);
解析出url里的信息,再创建了docker容器,配置只有withTmpFs和setParameters。关键则是在setParameters
public class ContainerDatabaseDriver implements Driver {
//……
@Override
public synchronized Connection connect(String url, final Properties info) throws SQLException {
/*
The driver should return "null" if it realizes it is the wrong kind of driver to connect to the given URL.
*/
if (!acceptsURL(url)) {
return null;
}
ConnectionUrl connectionUrl = ConnectionUrl.newInstance(url);
synchronized (jdbcUrlContainerCache) {
String queryString = connectionUrl.getQueryString().orElse("");
/*
If we already have a running container for this exact connection string, we want to connect
to that rather than create a new container
*/
JdbcDatabaseContainer container = jdbcUrlContainerCache.get(connectionUrl.getUrl());
if (container == null) {
LOGGER.debug("Container not found in cache, creating new instance");
Map parameters = connectionUrl.getContainerParameters();
/*
Find a matching container type using ServiceLoader.
*/
ServiceLoader databaseContainers = ServiceLoader.load(JdbcDatabaseContainerProvider.class);
for (JdbcDatabaseContainerProvider candidateContainerType : databaseContainers) {
if (candidateContainerType.supports(connectionUrl.getDatabaseType())) {
container = candidateContainerType.newInstance(connectionUrl);
container.withTmpFs(connectionUrl.getTmpfsOptions());
delegate = container.getJdbcDriverInstance();
}
}
if (container == null) {
throw new UnsupportedOperationException("Database name " + connectionUrl.getDatabaseType() + " not supported");
}
/*
Cache the container before starting to prevent race conditions when a connection
pool is started up
*/
jdbcUrlContainerCache.put(url, container);
/*
Pass possible container-specific parameters
*/
container.setParameters(parameters);
/*
Start the container
*/
container.start();
}
/*
Create a connection using the delegated driver. The container must be ready to accept connections.
*/
Connection connection = container.createConnection(queryString);
/*
If this container has not been initialized, AND
an init script or function has been specified, use it
*/
if (!initializedContainers.contains(container.getContainerId())) {
DatabaseDelegate databaseDelegate = new JdbcDatabaseDelegate(container, queryString);
runInitScriptIfRequired(connectionUrl, databaseDelegate);
runInitFunctionIfRequired(connectionUrl, connection);
initializedContainers.add(container.getContainerId());
}
return wrapConnection(connection, container, connectionUrl);
}
}
//……
}
ConnectionUrl可以看出containerParameters是根据url里的(TC_[A-Z_]+)
匹配出来的
public class ConnectionUrl {
//……
String TC_PARAM_NAME_PATTERN = "(TC_[A-Z_]+)";
Pattern TC_PARAM_MATCHING_PATTERN = Pattern.compile(TC_PARAM_NAME_PATTERN + "=([^\\?&]+)");
public static ConnectionUrl newInstance(final String url) {
ConnectionUrl connectionUrl = new ConnectionUrl(url);
connectionUrl.parseUrl();
return connectionUrl;
}
private void parseUrl() {
/*
Extract from the JDBC connection URL:
* The database type (e.g. mysql, postgresql, ...)
* The docker tag, if provided.
* The URL query string, if provided
*/
Matcher urlMatcher = Patterns.URL_MATCHING_PATTERN.matcher(this.getUrl());
if (!urlMatcher.matches()) {
//Try for Oracle pattern
urlMatcher = Patterns.ORACLE_URL_MATCHING_PATTERN.matcher(this.getUrl());
if (!urlMatcher.matches()) {
throw new IllegalArgumentException("JDBC URL matches jdbc:tc: prefix but the database or tag name could not be identified");
}
}
databaseType = urlMatcher.group(1);
imageTag = Optional.ofNullable(urlMatcher.group(3));
//String like hostname:port/database name, which may vary based on target database.
//Clients can further parse it as needed.
dbHostString = urlMatcher.group(4);
//In case it matches to the default pattern
Matcher dbInstanceMatcher = Patterns.DB_INSTANCE_MATCHING_PATTERN.matcher(dbHostString);
if (dbInstanceMatcher.matches()) {
databaseHost = Optional.of(dbInstanceMatcher.group(1));
databasePort = Optional.ofNullable(dbInstanceMatcher.group(3)).map(value -> Integer.valueOf(value));
databaseName = Optional.of(dbInstanceMatcher.group(4));
}
queryParameters = Collections.unmodifiableMap(parseQueryParameters(Optional.ofNullable(urlMatcher.group(5)).orElse("")));
String query = queryParameters.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).collect(Collectors.joining("&"));
if (query == null || query.trim().length() == 0) {
queryString = Optional.empty();
} else {
queryString = Optional.of("?" + query);
}
containerParameters = Collections.unmodifiableMap(parseContainerParameters());
tmpfsOptions = parseTmpfsOptions(containerParameters);
initScriptPath = Optional.ofNullable(containerParameters.get("TC_INITSCRIPT"));
Matcher funcMatcher = Patterns.INITFUNCTION_MATCHING_PATTERN.matcher(this.getUrl());
if (funcMatcher.matches()) {
initFunction = Optional.of(new InitFunctionDef(funcMatcher.group(2), funcMatcher.group(4)));
}
Matcher daemonMatcher = Patterns.DAEMON_MATCHING_PATTERN.matcher(this.getUrl());
inDaemonMode = daemonMatcher.matches() && Boolean.parseBoolean(daemonMatcher.group(2));
}
private Map parseContainerParameters() {
Map results = new HashMap<>();
Matcher matcher = Patterns.TC_PARAM_MATCHING_PATTERN.matcher(this.getUrl());
while (matcher.find()) {
String key = matcher.group(1);
String value = matcher.group(2);
results.put(key, value);
}
return results;
}
//……
}
再查看container里的parameters使用的地方
protected void optionallyMapResourceParameterAsVolume(@NotNull String paramName, @NotNull String pathNameInContainer, @NotNull String defaultResource) {
String resourceName = parameters.getOrDefault(paramName, defaultResource);
if (resourceName != null) {
final MountableFile mountableFile = MountableFile.forClasspathResource(resourceName);
withCopyFileToContainer(mountableFile, pathNameInContainer);
}
}
这个方法只有一个地方在用
private static final String MY_CNF_CONFIG_OVERRIDE_PARAM_NAME = "TC_MY_CNF";
@Override
protected void configure() {
optionallyMapResourceParameterAsVolume(MY_CNF_CONFIG_OVERRIDE_PARAM_NAME, "/etc/mysql/conf.d",
"mysql-default-conf");
addExposedPort(MYSQL_PORT);
addEnv("MYSQL_DATABASE", databaseName);
addEnv("MYSQL_USER", username);
if (password != null && !password.isEmpty()) {
addEnv("MYSQL_PASSWORD", password);
addEnv("MYSQL_ROOT_PASSWORD", password);
} else if (MYSQL_ROOT_USER.equalsIgnoreCase(username)) {
addEnv("MYSQL_ALLOW_EMPTY_PASSWORD", "yes");
} else {
throw new ContainerLaunchException("Empty password can be used only with the root user");
}
setStartupAttempts(3);
}
结合optionallyMapResourceParameterAsVolume的方法,大致上可以理解这里是根据TC_MY_CNF
获取my.cnf,即mysql的配置文件的加载路径。默认的加载路径是mysql-default-conf
。
搜索该路径,可以在org.testcontainers:mysql:1.12.2里找到,路径下有个my.cnf
[mysqld]
user = mysql
datadir = /var/lib/mysql
port = 3306
#socket = /tmp/mysql.sock
skip-external-locking
key_buffer_size = 16K
max_allowed_packet = 1M
table_open_cache = 4
sort_buffer_size = 64K
read_buffer_size = 256K
read_rnd_buffer_size = 256K
net_buffer_length = 2K
skip-host-cache
skip-name-resolve
# Don't listen on a TCP/IP port at all. This can be a security enhancement,
# if all processes that need to connect to mysqld run on the same host.
# All interaction with mysqld must be made via Unix sockets or named pipes.
# Note that using this option without enabling named pipes on Windows
# (using the "enable-named-pipe" option) will render mysqld useless!
#
#skip-networking
#server-id = 1
# Uncomment the following if you want to log updates
#log-bin=mysql-bin
# binary logging format - mixed recommended
#binlog_format=mixed
# Causes updates to non-transactional engines using statement format to be
# written directly to binary log. Before using this option make sure that
# there are no dependencies between transactional and non-transactional
# tables such as in the statement INSERT INTO t_myisam SELECT * FROM
# t_innodb; otherwise, slaves may diverge from the master.
#binlog_direct_non_transactional_updates=TRUE
# Uncomment the following if you are using InnoDB tables
innodb_data_file_path = ibdata1:10M:autoextend
# You can set .._buffer_pool_size up to 50 - 80 %
# of RAM but beware of setting memory usage too high
innodb_buffer_pool_size = 16M
#innodb_additional_mem_pool_size = 2M
# Set .._log_file_size to 25 % of buffer pool size
innodb_log_file_size = 5M
innodb_log_buffer_size = 8M
innodb_flush_log_at_trx_commit = 1
innodb_lock_wait_timeout = 50
再结合下classLoader的加载顺序,方案二就出来了.
@NotNull
private static URL getClasspathResource(@NotNull final String resourcePath, @NotNull final Set classLoaders) {
final Set classLoadersToSearch = new HashSet<>(classLoaders);
// try context and system classloaders as well
classLoadersToSearch.add(Thread.currentThread().getContextClassLoader());
classLoadersToSearch.add(ClassLoader.getSystemClassLoader());
classLoadersToSearch.add(MountableFile.class.getClassLoader());
for (final ClassLoader classLoader : classLoadersToSearch) {
URL resource = classLoader.getResource(resourcePath);
if (resource != null) {
return resource;
}
// Be lenient if an absolute path was given
if (resourcePath.startsWith("/")) {
resource = classLoader.getResource(resourcePath.replaceFirst("/", ""));
if (resource != null) {
return resource;
}
}
}
throw new IllegalArgumentException("Resource with path " + resourcePath + " could not be found on any of these classloaders: " + classLoadersToSearch);
}
在classpath下指定自己的my.cnf,加上字符集设置即可。重新运行,case通过。
也可以在resources下再建个目录,避免加载错