Skip to main content

No such thing as perfect code

· 6 min read
Pär Dahlman
Backend Engineer

The date for the next major release of RawRabbit is drawing nearer. It is more than a year ago since I decided to implement a .NET Core1 client for RabbitMq. During this time, I've discovered some differences between developing a library and an application.

Application code is your code

Applications are often created with a clear purpose and one or a few well defined user cases. There is often no sleep lost over how a repository is wired up or exactly when the connection to the database is established. Bugs can be identified and patched within the development cycle without any external dependencies.

There are of course exceptions to this, but I think most of you can partly agree or think of an application you've worked on where this is true.

Library code is everyone else's

Application uses libraries to do things for them. They rely on the library to work for scenarios defined long after the library is shipped.

A bug in an third party library can have devastating consequences for a project. I know times when I've looked through the source code of open source projects, trying to figure out what's going on - instead of making progress on the project I'm on.

Bugs are not the only thing that can slow you down. Assumptions made when creating a library can be just as problematic.

Bugs are not the only thing that can slow you down. Assumptions made when creating a library can be just as problematic. Here's an example. If you want to publish or consume a message over RabbitMq, you need a connection. RawRabbit tries to establish a connection when it is instantiated, so that it can verify that the provided configuration is correct. This means that if something goes wrong the client can throw a clear exception right when the application starts up, which in turn can be used to indicate that something went wrong with a deployment.

Makes sense, right? Well, it turns out that there are scenarios2 where it makes sense to delay the connection or add some sort of retry policy. Controlled consume concurrency is another example of something that was not supported, but important at times.

I didn't foresee that, how can this be handled?

Dependency Injection is not enough

RawRabbit, like other libs, offers a way to register internal services that will be used for the client

public void ConfigureServices(IServiceCollection services)
{
services
.AddRawRabbit(
ioc => ioc
.AddSingleton(LoggingFactory.ApplicationLogger)
.AddSingleton<IInternalService, CustomService>())
.AddMvc();
}

That's great for scenarios like the one I described. The problem can be solved by register a home rolled IChannelFactory. However, a realistic scenario is that a custom implementation of an internal service is a copy of the default implementation together with a relative small portion of custom code. The custom implementations becomes a snapshot of the default implementation. It is time consuming to keep a custom implementation aligned with latest defaults. It is even easier to forget to update it, or make mistakes when working with internal aspects that might be unfamiliar to the developer.

Expect the unexpected

I realized that there is no way to predict all the user cases for a client like RawRabbit. Instead, I've tried to ensure that it is easy to customize the behavior if needed. Here are some details of how I did this.

Optional options and reasonable defaults

RawRabbit's uses a middleware architecture, where each middleware has a corresponding options class. A middleware like BasicPublishMiddleware has an optional constructor argument BasicPublishOptions. All options classes follow the same pattern and looks something like this

public class BasicPublishOptions
{
public Func<IPipeContext, string> RoutingKeyFunc { get; set; }
public Func<IPipeContext, IModel> ChannelFunc { get; set; }
public Func<IPipeContext, string> ExchangeNameFunc { get; set; }
public Func<IPipeContext, bool> MandatoryFunc { get; set; }
public Func<IPipeContext, IBasicProperties> BasicPropsFunc { get; set; }
public Func<IPipeContext, byte[]> BodyFunc { get; set; }
}

Each func reflects some aspect of the middleware. The routing key, for example, will be retrieved by calling the RoutingKeyFunc3. This allows the caller to slightly change the behavior of the middleware by supplying different options. The client leverage this when performing an RPC request.

.Use<BasicPublishMiddleware>(new BasicPublishOptions
{
ExchangeNameFunc = c => c.GetRequestConfiguration()?.Request.Exchange.Name,
RoutingKeyFunc = c => c.GetRequestConfiguration()?.Request.RoutingKey,
ChannelFunc = c => c.Get<IBasicConsumer>(PipeKey.Consumer)?.Model
})

The funcs are assigned to fields when constructing the middleware. If no value is provided, the func will fallback to "reasonable" defaults.

Virtual protected methods

The funcs from the options object are not called in (the main method) InvokeAsync. Each func is instead invoked in a separate method that is marked protected virtual. The actual code for retrieving the routing key looks like this

protected virtual string GetRoutingKey(IPipeContext context)
{
var routingKey = RoutingKeyFunc(context);
if (routingKey == null)
{
_logger.LogWarning("No routing key found in the Pipe context.");
}
return routingKey;
}

If necessary, a custom implementation can be created by inheriting from the middleware and override relevant methods. It might be overkill for something like the routing key, but might be very useful for things like channel management.

Splitting everything up into really small methods also has a nice side effect, the code get really easy to follow

public override Task InvokeAsync(IPipeContext context, CancellationToken token)
{
var channel = GetOrCreateChannel(context);
var exchangeName = GetExchangeName(context);
var routingKey = GetRoutingKey(context);
var mandatory = GetMandatoryOptions(context);
var basicProps = GetBasicProps(context);
var body = GetMessageBody(context);

ExclusiveExecute(channel, c => c.BasicPublish(
exchange: exchangeName,
routingKey: routingKey,
mandatory: mandatory,
basicProperties: basicProps,
body: body
), token);

return Next.InvokeAsync(context, token);
}

Make it easy to tweak

It is even possible to remove or replace certain middlewares of an existing pipe. That means that a custom middleware can be registered in an predefined pipe, like the pipe used for publishing

var customPipe = PublishMessageExtension.PublishPipeAction + (pipe => pipe
.Replace<BasicPublishMiddleware, CustomPublishMiddleware>();

The code above creates a custom pipe that is identical to the official publish pipe, that will be kept updated together with the lib.

Footnotes

Footnotes

  1. I really wonder how much Microsoft payed for that domain.

  2. Like working with containers, as described in this issue

  3. With the provided IPipeContext from the middleware base class