The Decorator Pattern in Go
When I was writing my RSS notification plugin for my chat bot, walter, I ran into a problem when displaying notifications to users. It was litered with HTML.
This was a good opportunity to use the Decorator pattern, considering I already had an interface defined too.
Overview
Let’s take a step back and give a quick summary of what the RSS module wants to achieve. The RSS plugin was created to notify users of new RSS feed items. Users may subscribe to a feed in a channel with specified keywords. For example,
pseudo client
#chat
<gh0st> !subscribe darknet_diaries security,privacy,crypto
<bot> Subscribed to darknet_diaries with keywords: security,privacy,crypto
darknet_diaries
is a RSS feed that was added by a bot administrator. In this
case, it links to Darknet Diaries’ RSS feed here: https://feeds.megaphone.fm/darknetdiaries
Notice that the user is subscribing in a specific channel, #chat.
A subscription is unique to a user, channel, and RSS feed. This means that you
can subscribe again to darknet_diaries
in a different channel, but not again in
#chat.
Every thirty minutes, the bot will parse all the feeds, then find all the subscriptions for each feed, and then start keyword matching. It will pluck out all the Items that match the user’s subscription, and create a pending notification. Once finished, it will send all notifications to the appropriate channels.
There’s some more nuance to it, but not important here. (limiting notifications per channel, ignoring already seen Items, etc)
Code!
With a rough understanding of the core concept of the plugin, let’s dive into some code.
At some point in the process I laid out earlier, we need to download a Feed’s URL and parse it into a more friendly API.
To do this, I created a Parser
interface:
// Parser downloads a Feed.Url and translates it to a ParsedFeed to
// be checked by a Subscription.
type Parser interface {
ParseURL(string) (*ParsedFeed, error)
}
A Parser
returns a ParsedFeed
struct, a Domain Entity created to separate
the domain from external libraries.
By creating the internal ParsedFeed
struct and
Parser
interface, we’re able to separate the core from third party
packages, the outside world.
This enables testing to be isolated. By defining an interface, I can then implement a StubParser
as a dummy in my testing. Then have it return the expected ParsedFeed in the test case.
If the tests had to actually http download a feed url, too many things could go wrong.
Good architecture leads to good tests.
This Parser
is used by the Processor
, the struct that actually goes through
and processes user subscriptions:
func NewProcessor(repo Repository, parser Parser) *Processor {
return &Processor{
repository: repo,
parser: parser,
}
}
In this case, I used the gofeed library as the main implementation. It worked great, but when trying to display a simple Notification as a string:
type MinimalFormatter struct {
}
func (m MinimalFormatter) Format(n Notification) string {
i := n.Item
return fmt.Sprintf(
"%s - %s : %s",
i.Title, i.Link, strings.Join(n.Users, ","),
)
}
Long anchor tags, spans, etc would pollute the output. Not legible output.
The Decorator
Decorator is a structural design pattern that lets you attach new behaviors to objects by placing these objects inside special wrapper objects that contain the behaviors. 1
Let’s look at a simple example of a Decorator first:
type Formatter interface {
Format(str) string
}
type MyDecorator struct {
wrappee Formatter
}
func NewMyDecorator(formatter Formatter) *MyDecorator {
return &MyDecorator{wrappee: formatter}
}
type (b *MyDecorator) Format(str string) string {
// I can do whatever I want before,
strings.Trim(str)
str := b.wrappee.Format(str)
// Or after I call the wrappee!
// No yelling, only questions?
str = strings.Replace(str, "!", "?")
return str
}
MyDecorator accepts an existing instance of a Formatter, and then applies its own logic before, and after the wrappee call. This allows you to extend and build layers of behavior while having organized, decoupled code. Pretty nifty.
After some digging around, I found the bluemonday package. Exactly what I needed. All I had to do was create a StripHTML Decorator, and this problem would be solved!
The StripHTML Parser
Decorator:
strip_html.go
package decorators
import (
"html"
"reflect"
"github.com/microcosm-cc/bluemonday"
"github.com/dean-martin/walter/mods/rss"
)
type cleanHtml struct {
// The "wrappee".
BaseParser rss.Parser
}
// Create a new StripHtml Parser that wraps around another Parser.
func StripHtml(p rss.Parser) rss.Parser {
return &cleanHtml{BaseParser: p}
}
func (s *cleanHtml) ParseURL(url string) (*rss.ParsedFeed, error) {
// Call the BaseParser with the given URL and get its ParsedFeed.
feed, err := s.BaseParser.ParseURL(url)
// If it failed, just return that error, nothing for us to clean.
if err != nil {
return feed, err
}
// Strip the base struct fields.
stripHtml(feed)
// Then all its Items.
for _, i := range feed.Items {
stripHtml(i)
}
return feed, nil
}
// Use reflection to find all the string fields on the struct,
// and then HTML escape & sanitize them.
func stripHtml(any interface{}) {
p := bluemonday.StripTagsPolicy()
r := reflect.ValueOf(any).Elem()
for i := 0; i < r.NumField(); i++ {
f := r.Field(i)
if f.Kind() == reflect.String {
f.SetString(
html.UnescapeString(p.Sanitize(f.String()))
)
}
}
}
Bringing it all together
// ...
rss_plugin.New(rss.Context{
// Create a new gofeed Parser as the base, and then
// wrap a StripHtml Parser around it.
Parser: decorators.StripHtml(gofeed.New()),
Formatter: rss.MinimalFormatter{},
Repository: sqlite.NewRssRepository(db),
}),
Wrapping Up (Pun Intended)
The Decorator pattern, is a simple, but effective pattern to layering behaviors. It abides to the Open-Close principle, adding new functionality by extending exisiting software, instead of changing it. As well as organizing behavior.
Some pitfalls however, in the Pros and Cons section here are worth mentioning.
Most notably:
- It’s difficult to remove an item, or change the order of the stack.
- Possible ugly initial setup.
Thanks for reading.
-
The above quote is excerpted from refactoring.guru ↩︎