Problem Statement
Design a flexible, extensible, and thread-safe logging library (similar to Log4j or Logback). The library should allow applications to log messages at various severity levels. It should support writing log messages to multiple destinations (like the system console or a file) with custom formatting (like plain text or JSON). Additionally, it should support asynchronous logging to ensure that the logging framework does not block the application's critical execution path during heavy disk I/O.
Functional Requirements
- Support log levels:
DEBUG,INFO,WARN, andERROR. - Allow filtering based on the configured log level (e.g., if the logger is set to
INFO,DEBUGlogs should be ignored). - Provide pluggable log destinations (Appenders) such as Console and File.
- Support dynamic formatting of log messages (Layouts) such as Plain Text and JSON.
- Ensure thread safety so that multiple threads can log concurrently without corrupting output or causing race conditions.
- Support asynchronous logging using an in-memory queue to delegate I/O overhead to a background thread.
Objects Required
LogLevel(Enum)LogMessage(Value Object)Layout(Interface)Appender(Interface)Logger(Core Controller)LogManager(Singleton Registry)
LogLevel Enum
We represent severity levels using an enum. The order is important here because we can assign integer values to evaluate log thresholds.
public enum LogLevel {
DEBUG(1),
INFO(2),
WARN(3),
ERROR(4);
private final int priority;
LogLevel(int priority) {
this.priority = priority;
}
public int getPriority() {
return priority;
}
}
The priority value helps us run comparative checks dynamically. The getPriority() method returns this numerical value so we can check if a logged event meets the logger's minimum active threshold.
LogMessage Class
A LogMessage object encapsulates all the contextual metadata associated with a logging event, preventing us from passing raw strings around.
import java.time.Instant;
public class LogMessage {
private final String message;
private final LogLevel level;
private final Instant timestamp;
private final String threadName;
public LogMessage(String message, LogLevel level) {
this.message = message;
this.level = level;
this.timestamp = Instant.now();
this.threadName = Thread.currentThread().getName();
}
public String getMessage() { return message; }
public LogLevel getLevel() { return level; }
public Instant getTimestamp() { return timestamp; }
public String getThreadName() { return threadName; }
}
The constructor captures the log text, severity level, instant timestamp, and the name of the calling thread. The getter methods expose this metadata cleanly to formatters.
Layout Interface & Implementations
The Layout interface determines how a LogMessage is serialized into a string. Applying this interface decouples the output format from the destination itself.
public interface Layout {
String format(LogMessage message);
}
The format() method accepts a message envelope and transforms it into a structured format. Let's write two implementations: PlainTextLayout and JsonLayout.
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
public class PlainTextLayout implements Layout {
private static final DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
@Override
public String format(LogMessage message) {
return String.format("[%s] [%s] [%s] - %s",
formatter.format(message.getTimestamp()),
message.getLevel(),
message.getThreadName(),
message.getMessage());
}
}
The overridden format() method converts the metadata into a human-readable, bracket-delimited string, formatted using local system time.
public class JsonLayout implements Layout {
@Override
public String format(LogMessage message) {
return String.format("{\"timestamp\":\"%s\",\"level\":\"%s\",\"thread\":\"%s\",\"message\":\"%s\"}",
message.getTimestamp().toString(),
message.getLevel(),
message.getThreadName(),
message.getMessage().replace("\"", "\\\""));
}
}
This version formats the payload as a JSON string, which is highly useful when piping logs to log aggregators like Elasticsearch or Splunk.
Appender Interface & Implementations
An Appender controls the write destination of the formatted logs. By creating a unified interface, we make it easy to write to standard out, local files, or network sockets.
public interface Appender {
void append(LogMessage message);
}
The append() method receives the log envelope, formats it using an internal layout, and commits it to the destination. Here is the console implementation:
public class ConsoleAppender implements Appender {
private final Layout layout;
public ConsoleAppender(Layout layout) {
this.layout = layout;
}
@Override
public synchronized void append(LogMessage message) {
System.out.println(layout.format(message));
}
}
The constructor binds a layout format, and the synchronized append() method outputs to console while ensuring that log print lines from concurrent threads do not interleave.
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
public class FileAppender implements Appender {
private final Layout layout;
private final String filePath;
public FileAppender(Layout layout, String filePath) {
this.layout = layout;
this.filePath = filePath;
}
@Override
public synchronized void append(LogMessage message) {
try (PrintWriter writer = new PrintWriter(new FileWriter(filePath, true))) {
writer.println(layout.format(message));
} catch (IOException e) {
System.err.println("Failed to write log to file: " + e.getMessage());
}
}
}
The FileAppender requires a file path. The synchronized append() method opens the file in append mode, formats the message, and flushes it, catching any file system exceptions safely.
Asynchronous Appender (Performance Optimization)
To prevent blocking application threads during slow file I/O operations, we can build a wrapper appender that uses an in-memory queue to delegate the write operations to a background daemon thread.
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class AsyncAppender implements Appender {
private final Appender targetAppender;
private final BlockingQueue<LogMessage> queue;
private final Thread workerThread;
public AsyncAppender(Appender targetAppender) {
this.targetAppender = targetAppender;
this.queue = new LinkedBlockingQueue<>(10000); // Set capacity limit
this.workerThread = new Thread(this::consumeLogs);
this.workerThread.setDaemon(true);
this.workerThread.setName("Log-Async-Worker");
this.workerThread.start();
}
@Override
public void append(LogMessage message) {
boolean offered = queue.offer(message);
if (!offered) {
System.err.println("Log queue full. Dropping log message: " + message.getMessage());
}
}
private void consumeLogs() {
try {
while (true) {
LogMessage message = queue.take(); // Blocks until log message is available
targetAppender.append(message);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
The constructor starts a daemon worker thread. The append() method adds the log message to a queue. Since it doesn't do any direct writing, this call returns instantly. The consumeLogs() method runs on a background loop, taking messages from the queue and writing them using the wrapped target appender.
Logger & LogManager (Configuration & Client Interface)
The Logger class is what applications call directly. It keeps track of the active log level and routes valid messages to all registered appenders.
import java.util.ArrayList;
import java.util.List;
public class Logger {
private final String name;
private LogLevel level;
private final List<Appender> appenders;
public Logger(String name, LogLevel level) {
this.name = name;
this.level = level;
this.appenders = new ArrayList<>();
}
public synchronized void setLevel(LogLevel level) {
this.level = level;
}
public synchronized void addAppender(Appender appender) {
appenders.add(appender);
}
public void log(LogLevel level, String message) {
if (level.getPriority() >= this.level.getPriority()) {
LogMessage logMessage = new LogMessage(message, level);
List<Appender> appendersCopy;
synchronized (this) {
appendersCopy = new ArrayList<>(appenders);
}
for (Appender appender : appendersCopy) {
appender.append(logMessage);
}
}
}
public void debug(String msg) { log(LogLevel.DEBUG, msg); }
public void info(String msg) { log(LogLevel.INFO, msg); }
public void warn(String msg) { log(LogLevel.WARN, msg); }
public void error(String msg) { log(LogLevel.ERROR, msg); }
}
The setLevel() and addAppender() methods dynamically modify the logger's rules. The primary logic lies in log(): it checks priority thresholds, snapshot-copies the appender list to allow thread-safe iterations, and calls append() on each registered appender. debug(), info(), warn(), and error() are convenient shortcuts.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class LogManager {
private static final LogManager instance = new LogManager();
private final Map<String, Logger> loggers;
private LogManager() {
loggers = new ConcurrentHashMap<>();
}
public static LogManager getInstance() {
return instance;
}
public Logger getLogger(String name) {
return loggers.computeIfAbsent(name, k -> new Logger(k, LogLevel.INFO));
}
}
The LogManager uses the Singleton pattern to coordinate global configurations. The getLogger() method retrieves or initializes a Logger instance using a thread-safe ConcurrentHashMap.
Comments
Post a Comment