Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replaces CloudSqlAutoConfiguration with CloudSqlEnvironmentPostProcessor #131

Merged
merged 8 commits into from
Nov 21, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions docs/src/main/asciidoc/sql.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -59,32 +59,32 @@ In other words, properties like the SQL username, `spring.datasource.username`,
There is also some configuration specific to Google Cloud SQL:

|===
| Property name | Description | Default value
| `spring.cloud.gcp.sql.enabled` | Enables or disables Cloud SQL auto configuration | `true`
| `spring.cloud.gcp.sql.database-name` | Name of the database to connect to. |
| `spring.cloud.gcp.sql.instance-connection-name` | A string containing a Google Cloud SQL instance's project ID, region and name, each separated by a colon.
For example, `my-project-id:my-region:my-instance-name`. |
| `spring.cloud.gcp.sql.ip-types` | Allows you to specify a comma delimited list of preferred IP types for connecting to a Cloud SQL instance. Left unconfigured Cloud SQL Socket Factory will default it to `PUBLIC,PRIVATE`. See https://github.com/GoogleCloudPlatform/cloud-sql-jdbc-socket-factory#specifying-ip-types[Cloud SQL Socket Factory - Specifying IP Types] | `PUBLIC,PRIVATE`
| Property name | Description | Required | Default value
| `spring.cloud.gcp.sql.enabled` | Enables or disables Cloud SQL auto configuration | No | `true`
| `spring.cloud.gcp.sql.database-name` | Name of the database to connect to. | Yes |
| `spring.cloud.gcp.sql.instance-connection-name` | A string containing a Google Cloud SQL instance's project ID, region and name, each separated by a colon. | Yes |
For example, `my-project-id:my-region:my-instance-name`.
| `spring.cloud.gcp.sql.ip-types` | Allows you to specify a comma delimited list of preferred IP types for connecting to a Cloud SQL instance. Left unconfigured Cloud SQL Socket Factory will default it to `PUBLIC,PRIVATE`. See https://github.com/GoogleCloudPlatform/cloud-sql-jdbc-socket-factory#specifying-ip-types[Cloud SQL Socket Factory - Specifying IP Types] | No | `PUBLIC,PRIVATE`
| `spring.cloud.gcp.sql.credentials.location` | File system path to the Google OAuth2 credentials private key file.
Used to authenticate and authorize new connections to a Google Cloud SQL instance.
Used to authenticate and authorize new connections to a Google Cloud SQL instance. | No
| Default credentials provided by the Spring GCP Boot starter
| `spring.cloud.gcp.sql.credentials.encoded-key` | Base64-encoded contents of OAuth2 account private key in JSON format.
Used to authenticate and authorize new connections to a Google Cloud SQL instance.
Used to authenticate and authorize new connections to a Google Cloud SQL instance. | No
| Default credentials provided by the Spring GCP Boot starter
| `spring.datasource.username` | Database username | No | MySQL: `root`; PostgreSQL: `postgres`
| `spring.datasource.password` | Database password | No | `null`
| `spring.datasource.driver-class-name` | JDBC driver to use. | No | MySQL: `com.mysql.cj.jdbc.Driver`; PostgreSQL: `org.postgresql.Driver`
|===

NOTE: If you provide your own `spring.datasource.url`, it will be ignored, unless you disable Cloud SQL auto configuration with `spring.cloud.gcp.sql.enabled=false`.

==== `DataSource` creation flow

Based on the previous properties, the Spring Boot starter for Google Cloud SQL creates a `CloudSqlJdbcInfoProvider` object which is used to obtain an instance's JDBC URL and driver class name.
If you provide your own `CloudSqlJdbcInfoProvider` bean, it is used instead and the properties related to building the JDBC URL or driver class are ignored.
Spring Boot starter for Google Cloud SQL registers a `CloudSqlEnvironmentPostProcessor` that provides a correctly formatted `spring.datasource.url` property to the environment based on the properties defined above.
It also provides defaults for `spring.datasource.username` and `spring.datasource.driver-class-name`, which can be overridden.
The starter also configures credentials for the JDBC connection based on the properties above.

The `DataSourceProperties` object provided by Spring Boot Autoconfigure is mutated in order to use the JDBC URL and driver class names provided by `CloudSqlJdbcInfoProvider`, unless those values were provided in the properties.
It is in the `DataSourceProperties` mutation step that the credentials factory is registered in a system property to be `SqlCredentialFactory`.

`DataSource` creation is delegated to
https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-sql.html[Spring Boot].
The user properties and the properties provided by the `CloudSqlEnvironmentPostProcessor` are then used by https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-sql.html[Spring Boot] to create the `DataSource`.
You can select the type of connection pool (e.g., Tomcat, HikariCP, etc.) by https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-sql.html#boot-features-connect-to-production-database[adding their dependency to the classpath].

Using the created `DataSource` in conjunction with Spring JDBC provides you with a fully configured and operational `JdbcTemplate` object that you can use to interact with your SQL database.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/*
* Copyright 2017-2020 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.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import com.google.cloud.sql.CredentialFactory;
import com.google.cloud.sql.core.CoreSocketFactory;
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.ClassUtils;

/**
* Provides Google Cloud SQL instance connectivity through Spring JDBC by providing only a
* database and instance connection name.
*
* @author João André Martins
* @author Artem Bilan
* @author Mike Eltsufin
* @author Chengyuan Zhao
* @author Eddú Meléndez
*/
public class CloudSqlEnvironmentPostProcessor implements EnvironmentPostProcessor {
private final static String CLOUD_SQL_PROPERTIES_PREFIX = "spring.cloud.gcp.sql.";

private static final Log LOGGER =
LogFactory.getLog(CloudSqlEnvironmentPostProcessor.class);

@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
DatabaseType databaseType = getEnabledDatabaseType(environment);

if (databaseType != null) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("post-processing Cloud SQL properties for + " + databaseType.name());
}

CloudSqlJdbcInfoProvider cloudSqlJdbcInfoProvider = buildCloudSqlJdbcInfoProvider(environment, databaseType);

// configure default JDBC driver and username as fallback values when not specified
Map<String, Object> fallbackMap = new HashMap<>();
fallbackMap.put("spring.datasource.username", databaseType.getDefaultUsername());
fallbackMap.put("spring.datasource.driver-class-name", cloudSqlJdbcInfoProvider.getJdbcDriverClass());
environment.getPropertySources()
.addLast(new MapPropertySource("CLOUD_SQL_DATA_SOURCE_FALLBACK", fallbackMap));

// always set the spring.datasource.url property in the environment
Map<String, Object> primaryMap = new HashMap<>();
primaryMap.put("spring.datasource.url", cloudSqlJdbcInfoProvider.getJdbcUrl());
environment.getPropertySources()
.addFirst(new MapPropertySource("CLOUD_SQL_DATA_SOURCE_URL", primaryMap));

setCredentials(environment, application);

// support usage metrics
CoreSocketFactory.setApplicationName("spring-cloud-gcp-sql/"
meltsufin marked this conversation as resolved.
Show resolved Hide resolved
+ this.getClass().getPackage().getImplementationVersion());
}
}

private DatabaseType getEnabledDatabaseType(ConfigurableEnvironment environment) {
if (Boolean.parseBoolean(getSqlProperty(environment, "enabled", "true"))
&& isOnClasspath("javax.sql.DataSource")
&& isOnClasspath("org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType")
&& isOnClasspath("com.google.cloud.sql.CredentialFactory")) {
if (isOnClasspath("com.google.cloud.sql.mysql.SocketFactory")
&& isOnClasspath("com.mysql.cj.jdbc.Driver")) {
return DatabaseType.MYSQL;
}
else if (isOnClasspath("com.google.cloud.sql.postgres.SocketFactory")
&& isOnClasspath("org.postgresql.Driver")) {
return DatabaseType.POSTGRESQL;
}
}
return null;
}

private boolean isOnClasspath(String className) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no Spring utility we can use for this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find it. They do exactly the same thing in their @ConditionalOnClass implementation, but it's not reusable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what version you looked at but OnClassCondition uses ClassUtils#isPresent that does what this method does.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might've actually seen it somewhere else. ClassUtils#isPresent works, Thanks!

try {
ClassUtils.forName(className, null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use ClassUtils#isPresent rather.

return true;
}
catch (ClassNotFoundException ex) {
return false;
}
}

private CloudSqlJdbcInfoProvider buildCloudSqlJdbcInfoProvider(ConfigurableEnvironment environment, DatabaseType databaseType) {
CloudSqlJdbcInfoProvider cloudSqlJdbcInfoProvider = new DefaultCloudSqlJdbcInfoProvider(
getSqlProperty(environment, "database-name", null),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be documenting these properties? Or at least enumerating them in some enum or constant set?

Copy link
Member Author

@meltsufin meltsufin Nov 19, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the benefit (for the enum or constant)?
I believe these props are already documented.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have to answer the question of "did we document everything", it would be easier to look in one spot and not play hide-and-seek with string constants. But it's up to you.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. I added them.

getSqlProperty(environment, "instance-connection-name", null),
getSqlProperty(environment, "ip-types", null),
databaseType);
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Default " + databaseType.name()
+ " JdbcUrl provider. Connecting to "
+ cloudSqlJdbcInfoProvider.getJdbcUrl() + " with driver "
+ cloudSqlJdbcInfoProvider.getJdbcDriverClass());
}
return cloudSqlJdbcInfoProvider;
}

private String getSqlProperty(ConfigurableEnvironment environment, String shortName, String defaultValue) {
return environment.getProperty(CLOUD_SQL_PROPERTIES_PREFIX + shortName, defaultValue);
}

private void setCredentials(ConfigurableEnvironment environment, SpringApplication application) {
String encodedKey = getSqlProperty(environment, "credentials.encoded-key", null);
if (encodedKey != null) {
setCredentialsEncodedKeyProperty(encodedKey);
}
else {
setCredentialsFileProperty(environment, application);
}
}

private void setCredentialsEncodedKeyProperty(String encodedKey) {
System.setProperty(SqlCredentialFactory.CREDENTIAL_ENCODED_KEY_PROPERTY_NAME,
encodedKey);

System.setProperty(CredentialFactory.CREDENTIAL_FACTORY_PROPERTY,
SqlCredentialFactory.class.getName());
}

/**
* Set credentials to be used by the Google Cloud SQL socket factory.
*
* <p>The only way to pass a {@link CredentialFactory} to the socket factory is by passing a
* class name through a system property. The socket factory creates an instance of
* {@link CredentialFactory} using reflection without any arguments. Because of that, the
* credential location needs to be stored somewhere where the class can read it without
* any context. It could be possible to pass in a Spring context to
* {@link SqlCredentialFactory}, but this is a tricky solution that needs some thinking
* about.
*
* <p>If user didn't specify credentials, the socket factory already does the right thing by
* using the application default credentials by default. So we don't need to do anything.
*/
private void setCredentialsFileProperty(ConfigurableEnvironment environment, SpringApplication application) {
File credentialsLocationFile;

try {
String sqlCredentialsLocation = getSqlProperty(environment, "credentials.location", null);
String globalCredentialsLocation = environment.getProperty("spring.cloud.gcp.credentials.location", (String) null);
// First tries the SQL configuration credential.
if (sqlCredentialsLocation != null) {
credentialsLocationFile = application.getResourceLoader().getResource(sqlCredentialsLocation).getFile();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can getResource() return null?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Javadoc says it's never null, but getFile() can throw an IOException which I guess is what we want anyway.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use Resource#exists or Resource#isFile.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're fine with IOException here.

setSystemProperties(credentialsLocationFile);
}
// Then, the global credential.
else if (globalCredentialsLocation != null) {
// A resource might not be in the filesystem, but the Cloud SQL credential must.
credentialsLocationFile = application.getResourceLoader().getResource(globalCredentialsLocation).getFile();
setSystemProperties(credentialsLocationFile);
}

// Else do nothing, let sockets factory use application default credentials.

}
catch (IOException ioe) {
LOGGER.info("Error reading Cloud SQL credentials file.", ioe);
}
}

private void setSystemProperties(File credentialsLocationFile) {
// This should happen if the Spring resource isn't in the filesystem, but a URL,
// classpath file, etc.
if (credentialsLocationFile == null) {
LOGGER.info("The private key of the Google Cloud SQL credential must "
+ "be in a file on the filesystem.");
return;
}

System.setProperty(SqlCredentialFactory.CREDENTIAL_LOCATION_PROPERTY_NAME,
credentialsLocationFile.getAbsolutePath());

// If there are specified credentials, tell sockets factory to use them.
System.setProperty(CredentialFactory.CREDENTIAL_FACTORY_PROPERTY,
SqlCredentialFactory.class.getName());
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2017-2018 the original author or authors.
* Copyright 2017-2020 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.
Expand All @@ -21,29 +21,33 @@
*
* @author João André Martins
* @author Chengyuan Zhao
* @author Mike Eltsufin
*/
public enum DatabaseType {
/**
* MySQL constants.
*/
MYSQL("com.mysql.cj.jdbc.Driver", "jdbc:mysql://google/%s?"
+ "socketFactory=com.google.cloud.sql.mysql.SocketFactory"
+ "&cloudSqlInstance=%s"),
+ "&cloudSqlInstance=%s", "root"),

/**
* Postgresql constants.
*/
POSTGRESQL("org.postgresql.Driver", "jdbc:postgresql://google/%s?"
+ "socketFactory=com.google.cloud.sql.postgres.SocketFactory"
+ "&cloudSqlInstance=%s");
+ "&cloudSqlInstance=%s", "postgres");

private final String jdbcDriverName;

private final String jdbcUrlTemplate;

DatabaseType(String jdbcDriverName, String jdbcUrlTemplate) {
private final String defaultUsername;

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

public String getJdbcDriverName() {
Expand All @@ -53,4 +57,8 @@ public String getJdbcDriverName() {
public String getJdbcUrlTemplate() {
return this.jdbcUrlTemplate;
}

public String getDefaultUsername() {
return defaultUsername;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,21 @@
* @author Ray Tsang
* @author João André Martins
* @author Øystein Urdahl Hardeng
* @author Mike Eltsufin
*/
public class DefaultCloudSqlJdbcInfoProvider implements CloudSqlJdbcInfoProvider {

private final GcpCloudSqlProperties properties;

private final String databaseName;
private final String instanceConnectionName;
private final String ipTypes;
private final DatabaseType databaseType;

public DefaultCloudSqlJdbcInfoProvider(GcpCloudSqlProperties properties,
DatabaseType databaseType) {
this.properties = properties;
public DefaultCloudSqlJdbcInfoProvider(String databaseName, String instanceConnectionName, String ipTypes, DatabaseType databaseType) {
this.databaseName = databaseName;
this.instanceConnectionName = instanceConnectionName;
this.ipTypes = ipTypes;
this.databaseType = databaseType;
Assert.hasText(this.properties.getDatabaseName(), "A database name must be provided.");
Assert.hasText(properties.getInstanceConnectionName(),
Assert.hasText(this.databaseName, "A database name must be provided.");
Assert.hasText(this.instanceConnectionName,
"An instance connection name must be provided in the format <PROJECT_ID>:<REGION>:<INSTANCE_ID>.");
}

Expand All @@ -51,10 +53,10 @@ public String getJdbcDriverClass() {
@Override
public String getJdbcUrl() {
String jdbcUrl = String.format(this.databaseType.getJdbcUrlTemplate(),
this.properties.getDatabaseName(),
this.properties.getInstanceConnectionName());
if (StringUtils.hasText(properties.getIpTypes())) {
jdbcUrl = String.format(jdbcUrl + "&ipTypes=%s", properties.getIpTypes());
this.databaseName,
this.instanceConnectionName);
if (StringUtils.hasText(this.ipTypes)) {
jdbcUrl = String.format(jdbcUrl + "&ipTypes=%s", this.ipTypes);
}
return jdbcUrl;
}
Expand Down
Loading