Making Swift code extensible through plugins
When writing shared abstractions, libraries and other kinds of code that’s intended to be used by multiple developers or different parts of a system, it’s often quite tricky to decide what the exact scope of that code’s functionality should be.
With a very narrow feature set the code might not be able to accomplish what we need it to, and if it has too many features there’s a substantial risk that it becomes huge, messy and hard to maintain — by attempting to cover too much ground and by taking on too many responsibilities.
This week, let’s continue exploring the topic of configurable types that was covered earlier this year — by taking a look at how setting up a plugin-based architecture can help us keep a library or piece of functionality as narrow and as small as possible, while still enabling it to be extended and tailored for more specific use cases.
Starting out simple
There’s definitely an argument to be made that most of the code that we write should ideally start out as simple as possible. Making things too generic and flexible from the start often leads to over-complicated implementations, and APIs that might never end up being used in practice.
Let’s say that we've started building an ImageLoader
class for loading images over the network, and that in order to follow that philosophy of not making things too complicated, we’ve simply made our new class act as a relatively thin wrapper around an existing Networking
protocol — like this:
class ImageLoader {
typealias Handler = (Result<UIImage, Error>) -> Void
private let networking: Networking
init(networking: Networking) {
self.networking = networking
}
func loadImage(from url: URL,
then handler: @escaping Handler) {
let request = Request(url: url, method: .get)
networking.perform(request) { result in
switch result {
case .success(let data):
guard let image = UIImage(data: data) else {
handler(.failure(ImageError.invalidData))
return
}
handler(.success(image))
case .failure(let error):
handler(.failure(error))
}
}
}
}
The above implementation might be simple, but it’ll probably be enough as long as our requirements stay simple as well. However, as our project evolves, that situation might change. We might also decide that we want to share the above class with other developers, or between different apps, which could make its requirements grow increasingly more complex as a result.
For example, some use cases might require certain requests to be authenticated, or we might want to add support for displaying placeholder images if the user’s network connection was lost, and so on.
While we could, of course, decide to let our ImageLoader
take on all of those new responsibilities (and have it grow in scope and complexity as a result) — let’s instead take a look at how we could enable external code to be injected into it, and how that code could implement those kind of features in a very flexible way.
Plugins
Like the name implies, a plugin is a piece of code that can be plugged into another type or system in order to modify its functionality. While plugins can take on many different shapes and forms, let’s again start out simple and say that a plugin will, in this case, simply be a function — that takes a value of a given type as input and returns a new version of it as output:
typealias Plugin<T> = (T) -> T
With just that simple type alias, we could now start defining many different kinds of plugins and modifiers. For example, here’s how we might set up a plugin function that adds a watermark to any image that was passed into it:
let watermarkingPlugin: Plugin<UIImage> = { image in
let renderer = UIGraphicsImageRenderer(size: image.size)
return renderer.image { context in
context.draw(image)
context.drawWatermark(forImageSize: image.size)
}
}
However, being able to define plugins is not enough — we also need a way for them to actually interact with the type that they’re for. To make that happen, let’s start by implementing a simple collection type that’ll let us keep track of all plugins that were added for a certain value and use case, and to also apply them to a given value as well:
struct PluginCollection<Value> {
private var plugins = [Plugin<Value>]()
mutating func add(_ plugin: @escaping Plugin<Value>) {
plugins.append(plugin)
}
func apply(to value: Value) -> Value {
plugins.reduce(value) { value, plugin in
plugin(value)
}
}
}
The reason we keep the underlying array of plugins private is that we don’t want to allow any external code to freely modify it. Using the above setup, the only type of mutation that’s allowed is to add new plugins to the collection.
With the above infrastructure in place, let’s start enabling plugins to be attached to our ImageLoader
from before. The first thing we’ll do is to define two PluginCollection
properties — one for plugins that’ll be called before we start performing a request, and one for those that wish to modify the result of loading an image:
class ImageLoader {
...
var preProcessingPlugins = PluginCollection<Request>()
var postProcessingPlugins = PluginCollection<Result<UIImage, Error>>()
...
}
Next, let’s invoke each set of plugins as part of our image loading process. Since our collection of preProcessingPlugins
will be capable of modifying each request before it’s sent, let’s call it when initializing our Request
value — like this:
let request = preProcessingPlugins.apply(
to: Request(url: url, method: .get)
)
On the other end, our collection of postProcessingPlugins
should be called after a request finished loading — enabling each of those plugins to perform post-processing on the loaded image (or the resulting error). Using variable shadowing, we’ll wrap the handler
that was passed into our image loading function, in order to inject our plugin logic:
let handler: Handler = { [postProcessingPlugins] result in
handler(postProcessingPlugins.apply(to: result))
}
If we now update ImageLoader
with the above two pieces of code, our loadImage
method will end up looking like this:
class ImageLoader {
...
func loadImage(from url: URL,
then handler: @escaping Handler) {
let request = preProcessingPlugins.apply(
to: Request(url: url, method: .get)
)
let handler: Handler = { [postProcessingPlugins] result in
handler(postProcessingPlugins.apply(to: result))
}
networking.perform(request) { result in
switch result {
case .success(let data):
guard let image = UIImage(data: data) else {
handler(.failure(ImageError.invalidData))
return
}
handler(.success(image))
case .failure(let error):
handler(.failure(error))
}
}
}
}
In the grand scheme of things, the modifications that we just made to ImageLoader
were really minor, but they still enable a ton of new flexibility and power. For example, besides the watermarking plugin we took a look at earlier, we could use our new plugin system to conditionally authenticate each request if the user of our app has logged in:
imageLoader.preProcessingPlugins.add { [loginController] request in
// Don't authenticate external API calls
guard request.url.isInternalAPIURL else {
return request
}
guard let accessToken = loginController.session?.accessToken else {
return request
}
return request.addingHeader(
named: "Authorization",
value: "Bearer \(accessToken)"
)
}
When it comes to post-processing, we could now easily inject a placeholder image in case a request failed with an offline error:
imageLoader.postProcessingPlugins.add { result in
switch result {
case .success:
return result
case .failure(let error) where error.isOfflineError:
return .success(.makePlaceholder())
case .failure:
return result
}
}
Above we’re using Swift’s powerful pattern matching capabilities to match all failures with errors that were caused by our app being offline. To learn more about those capabilities, check out “Pattern matching in Swift”.
The benefit of building the above functionality as plugins, rather than implementing it within ImageLoader
itself, is that our system becomes a lot more modular and flexible overall. Our image loader doesn’t need to know anything about access tokens, but can still support authenticated requests, and we could keep defining new kinds of plugins to do all sorts of both pre- and post-processing — all using a very simple, closure-based abstraction.
Multiple flavors of the same pattern
Like with most patterns and techniques, there are multiple ways that a plugin-style architecture could be implemented — and many different scales at which such a setup could be deployed. However, regardless of which abstraction that’s chosen and to what extent we end up using such a plugin system, the goal remains the same — to enable functionality to be decoupled, and for specific overrides to be injected, rather than requiring one single type to cover all possible use cases.
Let’s take a look at two examples from the open source world, starting with the Core Animation-based game engine Imagine Engine — which uses plugins to enable various game components and logic to be defined in a completely decoupled manner. Since these plugins require multiple APIs, they’re modeled as a protocol, rather than as a closure type:
public protocol Plugin: AnyObject {
associatedtype Object: AnyObject
func activate(for object: Object, in game: Game)
func deactivate()
}
Just like our ImageLoader
plugin system from before, the above protocol enables all sorts of plugins to be used to modify the game engine’s various objects. For example, here’s how a plugin could be implemented to have a scene’s camera follow the movement of any actor — such as a player or an enemy:
class FollowActorPlugin: Plugin {
private let actor: Actor
init(actor: Actor) {
self.actor = actor
}
func activate(for camera: Camera, in game: Game) {
actor.events.moved.addObserver(camera) { camera, actor in
camera.position = actor.position
}
}
}
The above code also shows the observer pattern in action, which was covered in the two-part article “Observers in Swift”.
Finally, let’s take a look at a third “flavor” of plugins, which can be found in the Markdown parser Ink, which enables Markdown-formatted strings to be converted into HTML. When using that library, the plugin-like Modifier
type can be used to implement various modifiers that are plugged into the Markdown parsing process — for example in order to add a heading on top of each code block within an article:
let modifier = Modifier(target: .codeBlocks) { html, markdown in
return "<h3>Sample code:</h3>" + html
}
Fun meta fact: Ink was used to generate the very article that you’re reading right now.
Acting as a sort of hybrid between the protocol-oriented approach found in Imagine Engine, and the simpler closure-based approach that we built in this article, Ink’s Modifier
type uses a Target
enum to enable the library to decide in which context to execute each plugin closure — and looks like this:
public struct Modifier {
public typealias Input = (html: String, markdown: Substring)
public typealias Closure = (Input) -> String
public var target: Target
public var closure: Closure
public init(target: Target, closure: @escaping Closure) {
self.target = target
self.closure = closure
}
}
Each of the above examples enable their libraries to remain focused on their core set of tasks, rather than having to include explicit APIs for all sorts of functionality — which in turns enables the users of those libraries to more freely customize how they behave, and to implement new functionality without having to make any modifications to the library itself.
Conclusion
When deployed in the right contexts, plugin architectures can be incredibly powerful — and can let us unlock new capabilities for both external users, and for our internal implementations as well. Adding plugin support to a library or type can not only act as an “escape hatch” that lets users implement missing APIs and features themselves, but can also help prevent a project from growing too much in both scope and complexity.
However, plugins are not always appropriate, and they do come with their own set of trade-offs as well. One risk with using a plugin-heavy approach is that the overall system could become too fragmented and distributed — potentially making certain issues harder to debug, or making it more time-consuming to get an overview of the system. Whether or not those trade-offs are worth it will, like always, depend on the sort of system that we’re looking to build.
What do you think? Have you ever added plugin support to a library or system, or have you perhaps benefited from one of your dependencies supporting plugins? Let me know — along with your questions, comments and feedback — either on Twitter or via email.
Thanks for reading! 🚀