All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.tinylog.writers.JdbcWriter Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2017 Martin Winandy
 *
 * 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 org.tinylog.writers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;

import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.sql.DataSource;

import org.tinylog.Level;
import org.tinylog.core.LogEntry;
import org.tinylog.core.LogEntryValue;
import org.tinylog.pattern.FormatPatternParser;
import org.tinylog.pattern.Token;
import org.tinylog.provider.InternalLogger;

/**
 * Writer for inserting log entries into an SQL database table.
 */
public final class JdbcWriter extends AbstractWriter {

	private static final String FIELD_PREFIX = "field.";
	private static final long MAX_BATCH_SIZE = 100;
	private static final long MIN_RETRY_INTERVAL = 1000;

	private final String url;
	private final String user;
	private final String password;
	private final boolean reconnect;
	private final boolean batch;

	private final Object mutex;
	private final String sql;
	private final List tokens;
	private final List entries;

	private Connection connection;
	private PreparedStatement statement;
	private long lostCount;
	private long reconnectTimestamp;

	/**
	 * @throws NamingException
	 *             Data source cannot be found
	 * @throws SQLException
	 *             Database connection cannot be established
	 */
	public JdbcWriter() throws NamingException, SQLException {
		this(Collections.emptyMap());
	}

	/**
	 * @param properties
	 *            Configuration for writer
	 *
	 * @throws NamingException
	 *             Data source cannot be found
	 * @throws SQLException
	 *             Database connection cannot be established
	 */
	public JdbcWriter(final Map properties) throws NamingException, SQLException {
		super(properties);

		url = getUrl();
		user = getStringValue("user");
		password = getStringValue("password");
		reconnect = getBooleanValue("reconnect");
		batch = getBooleanValue("batch");

		mutex = getBooleanValue("writingthread") ? null : new Object();

		entries = new ArrayList();
		connection = connect(url, user, password);
		sql = renderSql(properties, connection.getMetaData().getIdentifierQuoteString());
		statement = connection.prepareStatement(sql);
		tokens = createTokens(properties);
	}

	@Override
	public Collection getRequiredLogEntryValues() {
		Collection values = EnumSet.noneOf(LogEntryValue.class);
		for (Token token : tokens) {
			values.addAll(token.getRequiredLogEntryValues());
		}
		return values;
	}

	@Override
	public void write(final LogEntry logEntry) throws SQLException {
		if (mutex == null) {
			doWrite(logEntry);
		} else {
			synchronized (mutex) {
				doWrite(logEntry);
			}
		}
	}

	@Override
	public void flush() throws SQLException {
		if (batch) {
			if (mutex == null) {
				doFlush();
			} else {
				synchronized (mutex) {
					doFlush();
				}
			}
		}
	}

	@Override
	public void close() throws SQLException {
		if (mutex == null) {
			doClose();
		} else {
			synchronized (mutex) {
				doClose();
			}
		}
	}

	/**
	 * Unsynchronized method for inserting a log entry.
	 *
	 * @param logEntry
	 *            Log entry to insert
	 *
	 * @throws SQLException
	 *             Database access failed
	 */
	private void doWrite(final LogEntry logEntry) throws SQLException {
		if (checkConnection()) {
			if (batch) {
				entries.add(logEntry);
			}

			try {
				applyLogEntry(logEntry);
			} catch (SQLException ex) {
				resetConnection();
				throw ex;
			}

			try {
				if (batch) {
					statement.addBatch();
					if (entries.size() >= MAX_BATCH_SIZE) {
						statement.executeBatch();
						entries.clear();
					}
				} else {
					statement.executeUpdate();
				}
			} catch (SQLException ex) {
				resetConnection();
				throw ex;
			}
		} else if (batch && entries.size() < MAX_BATCH_SIZE) {
			entries.add(logEntry);
		} else {
			lostCount += 1;
		}
	}

	/**
	 * Unsynchronized method for flushing all cached batch insert statements.
	 *
	 * @throws SQLException
	 *             Database access failed
	 */
	private void doFlush() throws SQLException {
		if (entries.size() > 0) {
			try {
				statement.executeBatch();
				entries.clear();
			} catch (SQLException ex) {
				resetConnection();
				throw ex;
			}
		}
	}

	/**
	 * Unsynchronized method for closing database connection.
	 *
	 * @throws SQLException
	 *             Database access failed
	 */
	private void doClose() throws SQLException {
		try {
			if (batch) {
				doFlush();
			}
		} finally {
			if (!entries.isEmpty()) {
				lostCount += entries.size();
			}

			if (lostCount > 0) {
				InternalLogger.log(Level.ERROR, "Lost log entries due to broken database connection: " + lostCount);
			}

			if (connection != null) {
				connection.close();
			}
		}
	}

	/**
	 * Checks if database connection is opened. Regular attempts are made to reestablish a broken database connection.
	 *
	 * @return {@code true} if database connection is opened, otherwise {@code false}
	 */
	private boolean checkConnection() {
		if (connection == null) {
			if (System.currentTimeMillis() >= reconnectTimestamp) {
				long start = System.currentTimeMillis();
				try {
					connection = connect(url, user, password);
					statement = connection.prepareStatement(sql);

					if (!entries.isEmpty()) {
						for (LogEntry entry : entries) {
							applyLogEntry(entry);
							statement.addBatch();
						}
						statement.executeBatch();
						entries.clear();
					}

					if (lostCount > 0) {
						InternalLogger.log(Level.ERROR, "Lost log entries due to broken database connection: " + lostCount);
						lostCount = 0;
					}

					return true;
				} catch (NamingException ex) {
					long now = System.currentTimeMillis();
					reconnectTimestamp = now + Math.max(MIN_RETRY_INTERVAL, (now - start) * 2);
					closeConnectionSilently();
					return false;
				} catch (SQLException ex) {
					long now = System.currentTimeMillis();
					reconnectTimestamp = now + Math.max(MIN_RETRY_INTERVAL, (now - start) * 2);
					closeConnectionSilently();
					return false;
				}
			} else {
				return false;
			}
		} else {
			return true;
		}
	}

	/**
	 * Resets the database connection after an error, if automatic reconnection is enabled.
	 */
	private void resetConnection() {
		if (reconnect) {
			closeConnectionSilently();
			statement = null;
			lostCount = batch ? 0 : 1;
			reconnectTimestamp = 0;
		}
	}

	/**
	 * Closes the opened database connection without throwing any exceptions.
	 */
	private void closeConnectionSilently() {
		if (connection != null) {
			try {
				try {
					connection.close();
				} catch (SQLException ex) {
					// Ignore
				}
			} finally {
				connection = null;
			}
		}
	}

	/**
	 * Applies a log entry to the current {@link PreparedStatement}.
	 *
	 * @param logEntry Log entry to apply
	 * @throws SQLException Failed to apply the passed log entry
	 */
	private void applyLogEntry(final LogEntry logEntry) throws SQLException {
		for (int i = 0; i < tokens.size(); ++i) {
			tokens.get(i).apply(logEntry, statement, i + 1);
		}
	}

	/**
	 * Establishes the connection to the database.
	 *
	 * @param url
	 *            JDBC or data source URL
	 * @param user
	 *            Username for login (can be {@code null} if no login is required)
	 * @param password
	 *            Password for login (can be {@code null} if no login is required)
	 * @return Connection to the database
	 *
	 * @throws NamingException
	 *             Requested data source cannot be found
	 * @throws SQLException
	 *             Failed to connect to database
	 */
	private static Connection connect(final String url, final String user, final String password) throws NamingException, SQLException {
		if (url.toLowerCase(Locale.ROOT).startsWith("java:")) {
			DataSource source = (DataSource) new InitialContext().lookup(url);
			if (user == null) {
				return source.getConnection();
			} else {
				return source.getConnection(user, password);
			}
		} else {
			if (user == null) {
				return DriverManager.getConnection(url);
			} else {
				return DriverManager.getConnection(url, user, password);
			}
		}
	}

	/**
	 * Extracts the URL to database or data source from configuration.
	 *
	 * @return Connection URL
	 *
	 * @throws IllegalArgumentException
	 *             URL is not defined in configuration
	 */
	private String getUrl() {
		String url = getStringValue("url");
		if (url == null) {
			throw new IllegalArgumentException("URL is missing for JDBC writer");
		} else {
			return url;
		}
	}

	/**
	 * Extracts the database table name from configuration.
	 *
	 * @param properties
	 *            Configuration for writer
	 * @return Name of database table
	 *
	 * @throws IllegalArgumentException
	 *             Table is not defined in configuration
	 */
	private static String getTable(final Map properties) {
		String table = properties.get("table");
		if (table == null) {
			throw new IllegalArgumentException("Name of database table is missing for JDBC writer");
		} else {
			return table;
		}
	}

	/**
	 * Generates an insert SQL statement for the configured table and its fields.
	 *
	 * @param properties
	 *            Properties that contains the configured table and fields
	 * @param quote
	 *            Character for quoting identifiers (can be a space if the database doesn't support quote characters)
	 * @return SQL statement for {@link PreparedStatement}
	 *
	 * @throws SQLException
	 *             Table or field names contain illegal characters
	 */
	private static String renderSql(final Map properties, final String quote) throws SQLException {
		StringBuilder builder = new StringBuilder();
		builder.append("INSERT INTO ");
		if (properties.get("schema") != null) {
			append(builder, properties.get("schema"), quote);
			builder.append(".");
		}
		append(builder, getTable(properties), quote);
		builder.append(" (");

		int count = 0;

		for (Entry entry : properties.entrySet()) {
			String key = entry.getKey();
			if (key.toLowerCase(Locale.ROOT).startsWith(FIELD_PREFIX)) {
				String column = key.substring(FIELD_PREFIX.length());

				if (count++ != 0) {
					builder.append(", ");
				}

				append(builder, column, quote);
			}
		}

		builder.append(") VALUES (");

		for (int i = 0; i < count; ++i) {
			if (i > 0) {
				builder.append(", ?");
			} else {
				builder.append("?");
			}
		}

		builder.append(")");

		return builder.toString();
	}

	/**
	 * Appends a database identifier securely to a builder that is building a SQL statement.
	 *
	 * @param builder
	 *            String builder that is building a SQL statement
	 * @param identifier
	 *            Identifier to add
	 * @param quote
	 *            Character for quoting the identifier (can be a space if the database doesn't support quote characters)
	 *
	 * @throws SQLException
	 *             Identifier contains an illegal character
	 */
	private static void append(final StringBuilder builder, final String identifier, final String quote) throws SQLException {
		if (identifier.indexOf('\n') >= 0 || identifier.indexOf('\r') >= 0) {
			throw new SQLException("Identifier contains line breaks: " + identifier);
		} else if (" ".equals(quote)) {
			for (int i = 0; i < identifier.length(); ++i) {
				char c = identifier.charAt(i);
				if (!Character.isLetterOrDigit(c) && c != '_' && c != '@' && c != '$' && c != '#') {
					throw new SQLException("Illegal identifier: " + identifier);
				}
			}
			builder.append(identifier);
		} else {
			builder.append(quote).append(identifier.replace(quote, quote + quote)).append(quote);
		}
	}

	/**
	 * Creates tokens for all configured fields.
	 *
	 * @param properties
	 *            Properties that contains the configured fields
	 * @return Tokens for filling a {@link PreparedStatement}
	 */
	private static List createTokens(final Map properties) {
		FormatPatternParser parser = new FormatPatternParser(properties.get("exception"));

		List tokens = new ArrayList();
		for (Entry entry : properties.entrySet()) {
			if (entry.getKey().toLowerCase(Locale.ROOT).startsWith(FIELD_PREFIX)) {
				tokens.add(parser.parse(entry.getValue()));
			}
		}
		return tokens;
	}

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy