Logging is one of the rare cases when you could probably justify having a singleton, but with Swift Protocol Extension you don't need to.
Let's integrate Logging in a way that:
- Doesn't cause 3rd party dependencies to leak across your codebase
- Hides the existence of singleton from the codebase
- Supports writing fully testable code
- Ability to suppress logs from specific modules or classes
Interface
Protocol oriented programming is a very useful technique for Swift source, we can create protocols and provide default implementations for whole or specific parts of it.
Let's declare our interfaces first:
public enum LogTag: String {
case Observable
case Model
case ViewModel
case View
}
public enum LogLevel: Int {
case Verbose = 0
case Debug = 1
case Info = 2
case Warning = 3
case Error = 4
}
public protocol Loggable {
var defaultLoggingTag: LogTag { get }
func log(level: LogLevel, @autoclosure _ message: () -> Any, _ path: String, _ function: String, line: Int)
func log(level: LogLevel, tag: LogTag, @autoclosure message: () -> Any, _ path: String, _ function: String, line: Int)
}
LogTag
is an enumeration declaring the specific module in our application.LogLevel
is enumeration for the severity of the log.log
is a function that we will use for logging, one version will use underlying typedefaultLoggingTag
and the other one allows us to provide it explicitly.
Default implementation
We want to provide default implementation for our logging function, and only require the users of our protocol to provide defaultLoggingTag
:
public extension Loggable {
func log(level: LogLevel, @autoclosure _ message: () -> Any, _ path: String = #file, _ function: String = #function, line: Int = #line) {
log(level, tag: defaultLoggingTag, message: message, path, function, line: line)
}
func log(level: LogLevel, tag: LogTag, @autoclosure message: () -> Any, _ path: String = #file, _ function: String = #function, line: Int = #line) {
Logger.sharedInstance.log(level, tag: tag, className: String(self.dynamicType), message: message, path, function, line: line)
}
}
Logger singleton
We need to provide implementation of Logger
that our protocol extension is supposed to use:
protocol LoggerType {
func log(level: LogLevel, tag: LogTag, className: String, @autoclosure message: () -> Any, _ path: String, _ function: String, line: Int)
}
final class Logger {
internal var activeLogger: LoggerType?
internal var disabledSymbols = Set<String>()
private(set) static var sharedInstance = Logger()
/// Overrides shared instance, useful for testing
static func setSharedInstance(logger: Logger) {
sharedInstance = logger
}
func setupLogger(logger: LoggerType) {
assert(activeLogger == nil, "Changing logger is disallowed to maintain consistency")
activeLogger = logger
}
func ignoreClass(type: AnyClass) {
disabledSymbols.insert(String(type))
}
func ignoreTag(tag: LogTag) {
disabledSymbols.insert(tag.rawValue)
}
func log(level: LogLevel, tag: LogTag, className: String, @autoclosure message: () -> Any, _ path: String, _ function: String, line: Int) {
guard logAllowed(tag, className: className) else { return }
activeLogger?.log(level, tag: tag, className: className, message: message, path, function, line: line)
}
private func logAllowed(tag: LogTag, className: String) -> Bool {
return !disabledSymbols.contains(className) && !disabledSymbols.contains(tag.rawValue)
}
}
Our Logger
instance is only responsible for log suppressing symbols, as this is functionality often missing from external loggers.
It doesn't log message anywhere directly, instead, it forwards it to specific LoggerType
implementation:
- We can use any 3rd party logger solution we want, and changing it, later on, requires no changes in the codebase, other than the adapter itself.
- We can provide a fake logger for writing tests:
final class LoggerStub: LoggerType {
var prototype: LogMessagePrototype?
var flushTimeout: Int64?
func log(level: LogLevel, tag: LogTag, className: String, @autoclosure message: () -> Any, _ path: String, _ function: String, line: Int) {
prototype = LogMessagePrototype(level: level, tag: tag, className: className, message: "\(message())", path: path, function: function, line: line)
}
func flush(secondTimeout: Int64) {
flushTimeout = secondTimeout
}
}
let setup: () -> (sut: Logger, stub: LoggerStub) = {
let sut = Logger()
let stub = LoggerStub()
sut.setupLogger(stub)
Logger.setSharedInstance(sut)
return (sut, stub)
}
func testLoggableLogWithDefaultTagArgumentIsForwarded() {
let (_, stub) = setup()
let expected = LogMessagePrototype(level: .Warning, tag: defaultLoggingTag, className: String(self.dynamicType), message: "Test", path: "path", function: "function", line: 66)
let actual: LogMessagePrototype? = {
self.log(expected.level, expected.message, expected.path, expected.function, line: expected.line)
return stub.prototype
}()
XCTAssertEqual(expected, actual)
}
func testIgnoredTagsAreHonored() {
let (sut, stub) = setup()
let expected: LogMessagePrototype? = nil
let actual: LogMessagePrototype? = {
sut.ignoreTag(.Model)
sut.log(.Info, tag: .Model, className: "", message: "", "", "", line: 0)
return stub.prototype
}()
XCTAssertEqual(expected, actual)
}
Note: LogMessagePrototype
is just a struct containing all params the function accepts.
If you are working on a Model layer in your app, and you don't care about UI logs, you can create a user breakpoint on the configuration point and do:
A simple implementation that fulfills all of our initial requirements. It's also an example of how you can create testable protocol extensions.