Skip to content

Commit

Permalink
Introduce EnvironmentPostProcessor for R2DBC support in Cloud Sql (#772)
Browse files Browse the repository at this point in the history
This PR adds two starters for R2DBC support in Cloud SQL (one for mySQL and another for postgreSQL). It also includes 2 samples, associated integrations tests and documentation. The starters bring in spring-boot-starter-data-r2dbc which takes care of the creation of the ConnectionFactory bean.
  • Loading branch information
mpeddada1 authored Jan 24, 2022
1 parent e8b2fc0 commit 7b69799
Show file tree
Hide file tree
Showing 34 changed files with 1,055 additions and 44 deletions.
25 changes: 25 additions & 0 deletions spring-cloud-gcp-autoconfigure/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,31 @@
<scope>test</scope>
</dependency>

<!--R2DBC : mySQL connector and driver -->
<dependency>
<groupId>com.google.cloud.sql</groupId>
<artifactId>cloud-sql-connector-r2dbc-mysql</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>dev.miku</groupId>
<artifactId>r2dbc-mysql</artifactId>
<optional>true</optional>
</dependency>


<!--R2DBC : PostgreSQL connector and driver -->
<dependency>
<groupId>com.google.cloud.sql</groupId>
<artifactId>cloud-sql-connector-r2dbc-postgres</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-postgresql</artifactId>
<optional>true</optional>
</dependency>

<!-- Storage -->
<dependency>
<groupId>com.google.cloud</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,8 @@
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.PlaceholdersResolver;
import org.springframework.boot.context.properties.bind.PropertySourcesPlaceholdersResolver;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.Environment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.io.Resource;
import org.springframework.util.ClassUtils;
Expand Down Expand Up @@ -62,24 +56,8 @@ public void postProcessEnvironment(
LOGGER.info("post-processing Cloud SQL properties for + " + databaseType.name());
}

// Bind properties without resolving Secret Manager placeholders
Binder binder =
new Binder(
ConfigurationPropertySources.get(environment),
new NonSecretsManagerPropertiesPlaceholdersResolver(environment),
null,
null,
null);

String cloudSqlPropertiesPrefix =
GcpCloudSqlProperties.class.getAnnotation(ConfigurationProperties.class).value();
GcpCloudSqlProperties sqlProperties =
binder
.bind(cloudSqlPropertiesPrefix, GcpCloudSqlProperties.class)
.orElse(new GcpCloudSqlProperties());
GcpProperties gcpProperties =
binder.bind(cloudSqlPropertiesPrefix, GcpProperties.class).orElse(new GcpProperties());

PropertiesRetriever propertiesRetriever = new PropertiesRetriever(environment);
GcpCloudSqlProperties sqlProperties = propertiesRetriever.getCloudSqlProperties();
CloudSqlJdbcInfoProvider cloudSqlJdbcInfoProvider =
new DefaultCloudSqlJdbcInfoProvider(sqlProperties, databaseType);
if (LOGGER.isInfoEnabled()) {
Expand Down Expand Up @@ -108,7 +86,7 @@ public void postProcessEnvironment(
.getPropertySources()
.addFirst(new MapPropertySource("CLOUD_SQL_DATA_SOURCE_URL", primaryMap));

setCredentials(sqlProperties, gcpProperties);
setCredentials(sqlProperties, propertiesRetriever.getGcpProperties());

// support usage metrics
CoreSocketFactory.setApplicationName(
Expand Down Expand Up @@ -191,22 +169,4 @@ private void setCredentialsFileProperty(Resource credentialsLocation) {
LOGGER.info("Error reading Cloud SQL credentials file.", ioe);
}
}

private static class NonSecretsManagerPropertiesPlaceholdersResolver
implements PlaceholdersResolver {
private PlaceholdersResolver resolver;

NonSecretsManagerPropertiesPlaceholdersResolver(Environment environment) {
this.resolver = new PropertySourcesPlaceholdersResolver(environment);
}

@Override
public Object resolvePlaceholders(Object value) {
if (value.toString().contains("sm://")) {
return value;
} else {
return resolver.resolvePlaceholders(value);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public enum DatabaseType {
"jdbc:mysql://google/%s?"
+ "socketFactory=com.google.cloud.sql.mysql.SocketFactory"
+ "&cloudSqlInstance=%s",
"r2dbc:gcp:mysql://%s/%s",
"root"),

/** Postgresql constants. */
Expand All @@ -32,17 +33,25 @@ public enum DatabaseType {
"jdbc:postgresql://google/%s?"
+ "socketFactory=com.google.cloud.sql.postgres.SocketFactory"
+ "&cloudSqlInstance=%s",
"r2dbc:gcp:postgres://%s/%s",
"postgres");

private final String jdbcDriverName;

private final String jdbcUrlTemplate;

private final String r2dbcUrlTemplate;

private final String defaultUsername;

DatabaseType(String jdbcDriverName, String jdbcUrlTemplate, String defaultUsername) {
DatabaseType(
String jdbcDriverName,
String jdbcUrlTemplate,
String r2dbcUrlTemplate,
String defaultUsername) {
this.jdbcDriverName = jdbcDriverName;
this.jdbcUrlTemplate = jdbcUrlTemplate;
this.r2dbcUrlTemplate = r2dbcUrlTemplate;
this.defaultUsername = defaultUsername;
}

Expand All @@ -54,6 +63,10 @@ public String getJdbcUrlTemplate() {
return this.jdbcUrlTemplate;
}

public String getR2dbcUrlTemplate() {
return this.r2dbcUrlTemplate;
}

public String getDefaultUsername() {
return defaultUsername;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright 2021-2022 the original author or authors.
*
* 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
*
* https://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.google.cloud.spring.autoconfigure.sql;

import com.google.cloud.spring.autoconfigure.core.GcpProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.PlaceholdersResolver;
import org.springframework.boot.context.properties.bind.PropertySourcesPlaceholdersResolver;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.core.env.Environment;

/**
* Helper class to derive Cloud SQL and GCP properties from the user configuration
* (application.properties, for example).
*/
class PropertiesRetriever {

private Binder binder;

PropertiesRetriever(Environment environment) {
// Bind properties without resolving Secret Manager placeholders
this.binder =
new Binder(
ConfigurationPropertySources.get(environment),
new NonSecretsManagerPropertiesPlaceholdersResolver(environment),
null,
null,
null);
}

GcpCloudSqlProperties getCloudSqlProperties() {
String cloudSqlPropertiesPrefix =
GcpCloudSqlProperties.class.getAnnotation(ConfigurationProperties.class).value();
return this.binder
.bind(cloudSqlPropertiesPrefix, GcpCloudSqlProperties.class)
.orElse(new GcpCloudSqlProperties());
}

GcpProperties getGcpProperties() {
String gcpPropertiesPrefix =
GcpProperties.class.getAnnotation(ConfigurationProperties.class).value();
return this.binder.bind(gcpPropertiesPrefix, GcpProperties.class).orElse(new GcpProperties());
}

private static class NonSecretsManagerPropertiesPlaceholdersResolver
implements PlaceholdersResolver {
private PlaceholdersResolver resolver;

NonSecretsManagerPropertiesPlaceholdersResolver(Environment environment) {
this.resolver = new PropertySourcesPlaceholdersResolver(environment);
}

@Override
public Object resolvePlaceholders(Object value) {
if (value.toString().contains("sm://")) {
return value;
} else {
return resolver.resolvePlaceholders(value);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright 2021-2022 the original author or authors.
*
* 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
*
* https://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.google.cloud.spring.autoconfigure.sql;

import java.util.HashMap;
import java.util.Map;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;

/**
* Builds connection string for Cloud SQL through Spring R2DBC by requiring only a database and
* instance connection name.
*/
public class R2dbcCloudSqlEnvironmentPostProcessor implements EnvironmentPostProcessor {
private static final Log LOGGER = LogFactory.getLog(R2dbcCloudSqlEnvironmentPostProcessor.class);

@Override
public void postProcessEnvironment(
ConfigurableEnvironment environment, SpringApplication application) {
if (environment.getPropertySources().contains("bootstrap")) {
// Do not run in the bootstrap phase as the user configuration is not available yet
return;
}

DatabaseType databaseType = getEnabledDatabaseType(environment);
if (databaseType != null) {
PropertiesRetriever propertiesRetriever = new PropertiesRetriever(environment);
String r2dbcUrl = createUrl(databaseType, propertiesRetriever.getCloudSqlProperties());
if (LOGGER.isInfoEnabled()) {
LOGGER.info(
"Default " + databaseType.name() + " R2dbcUrl provider. Connecting to " + r2dbcUrl);
}

// Add default username as fallback when not specified
Map<String, Object> fallbackMap = new HashMap<>();
fallbackMap.put("spring.r2dbc.username", databaseType.getDefaultUsername());
environment
.getPropertySources()
.addLast(new MapPropertySource("CLOUD_SQL_R2DBC_USERNAME", fallbackMap));

Map<String, Object> primaryMap = new HashMap<>();
primaryMap.put("spring.r2dbc.url", r2dbcUrl);
environment
.getPropertySources()
.addFirst(new MapPropertySource("CLOUD_SQL_R2DBC_URL", primaryMap));
}
}

String createUrl(DatabaseType databaseType, GcpCloudSqlProperties sqlProperties) {
Assert.hasText(sqlProperties.getDatabaseName(), "A database name must be provided.");
Assert.hasText(
sqlProperties.getInstanceConnectionName(),
"An instance connection name must be provided in the format"
+ " <PROJECT_ID>:<REGION>:<INSTANCE_ID>.");

return String.format(
databaseType.getR2dbcUrlTemplate(),
sqlProperties.getInstanceConnectionName(),
sqlProperties.getDatabaseName());
}

/**
* Returns {@link DatabaseType} constant based on whether mySQL or postgreSQL R2DBC driver and
* connector dependencies are present on the classpath. Returns null if Cloud SQL is not enabled
* in Spring Cloud GCP, CredentialFactory is not present or ConnectionFactory (which is used to
* enable Spring R2DBC auto-configuration) is not present.
*
* @param environment environment to post-process
* @return database type
*/
DatabaseType getEnabledDatabaseType(ConfigurableEnvironment environment) {
if (isR2dbcEnabled(environment)
&& isOnClasspath("com.google.cloud.sql.CredentialFactory")
&& isOnClasspath("io.r2dbc.spi.ConnectionFactory")) {
if (isOnClasspath("com.google.cloud.sql.core.GcpConnectionFactoryProviderMysql")
&& isOnClasspath("dev.miku.r2dbc.mysql.MySqlConnectionFactoryProvider")) {
return DatabaseType.MYSQL;
} else if (isOnClasspath("com.google.cloud.sql.core.GcpConnectionFactoryProviderPostgres")
&& isOnClasspath("io.r2dbc.postgresql.PostgresqlConnectionFactoryProvider")) {
return DatabaseType.POSTGRESQL;
}
}
return null;
}

private boolean isOnClasspath(String className) {
return ClassUtils.isPresent(className, null);
}

private boolean isR2dbcEnabled(ConfigurableEnvironment environment) {
return Boolean.parseBoolean(environment.getProperty("spring.cloud.gcp.sql.enabled", "true"))
&& Boolean.parseBoolean(
environment.getProperty("spring.cloud.gcp.sql.r2dbc.enabled", "true"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ com.google.cloud.spring.autoconfigure.secretmanager.GcpSecretManagerBootstrapCon

org.springframework.boot.env.EnvironmentPostProcessor=\
com.google.cloud.spring.autoconfigure.sql.CloudSqlEnvironmentPostProcessor,\
com.google.cloud.spring.autoconfigure.sql.R2dbcCloudSqlEnvironmentPostProcessor,\
com.google.cloud.spring.autoconfigure.secretmanager.GcpSecretManagerEnvironmentPostProcessor
Loading

0 comments on commit 7b69799

Please sign in to comment.