SwiftNIO Redis Client

(Nathan Harris) #1

NIORedis: Client for Redis server built on NIO

Introduction

This package includes two modules: NIORedis and Redis, which provide clients that handle connection to, authorizing, and executing commands against a Redis server.

NIORedis provides channel handlers for encoding / decoding between Swift native types and Redis' Serialization Protocol (RESP).

Redis is an abstraction layer that wraps NIORedis to be callback based with DispatchQueue.

Motivation

Implementations of Redis connections have decayed as newer capabilities of the Swift stdlib, SwiftNIO, and Swift itself have developed.

As part of the initiative of trying to push the ecosystem to be centered around SwiftNIO, a framework-agnostic driver on Redis can provide an easier time for feature development on Redis.

Proposed Solution

A barebones implementation is available at mordil/nio-redis.

The following are already implemented, with unit tests:

This package is a re-implementation of vapor/redis stripped down to only build on SwiftNIO to be framework agnostic.

Much of this was inspired by the NIOPostgres pitch.

Detailed Solution

NOTE: This this is written against SwiftNIO 2.0, and as such requires Swift 5.0!

This is to take advantage of the Result type in the Redis module,
and to stay ahead of development of the next version of SwiftNIO.

NIORedis

Most use of this library will be focused on a NIORedisConnection type that works explicitly in a SwiftNIO EventLoop context - with
return values all being EventLoopFuture.

import NIORedis

let elg = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let redis = NIORedis(executionModel: .eventLoopGroup(elg))

// connections

// passing a value to `password` will automatically authenticate with Redis before resolving the connection
let connection = try redis.makeConnection(
    hostname: "localhost", // this is the default
    port: 6379, // this is the default
    password: "MY_PASS" // default is `nil`
).wait()
print(connection) // NIORedisConnection

// convienence methods for commands

let result = try connection.set("my_key", to: "some value")
    .then {
        return connection.get("my_key")
    }.wait()
print(result) // Optional("some value")

// raw commands

let keyCount = try connection.command("DEL", [RedisData(bulk: "my_key")])
    .thenThrowing { res in
        guard case let .integer(count) else {
            // throw Error
        }
        return count
    }.wait()
print(keyCount) // 1

let pipelineResults = try connection.makePipeline()
    .enqueue(command: "SET", arguments: ["my_key", 3])
    .enqueue(command: "INCR", arguments: ["my_key"])
    .execute()
    .wait()
print(pipelineResults) // [ .simpleString("OK"), .integer(4) ]

// cleanup 

connection.close()
try redis.terminate()
try elg.syncShutdownGracefully()

RedisData & RedisDataConvertible

This is a 1:1 mapping enum of the RESP types: Simple String, Bulk String, Array, Integer and Error.

Conforming to RedisDataConvertible allows Swift types to more easily convert between RedisData and native types.

Array, Data, Float, Double, FixedWidthInteger, String, and of course RedisData all conform in this package.

A ByteToMessageDecoder and MessageToByteEncoder are used for the conversion process on connections.

NIORedisConnection

This class uses a ChannelInboundHandler that handles the actual process of sending and receiving commands.

It retains a queue of promises to resolve when messages have been received.

To support pipelining, a user should create a NIORedisPipeline with makePipeline().

NIORedisPipeline

A NIORedisPipeline is a small abstraction that buffers an array of complete messages as RedisData, and executes them in sequence after a user has invoked execute().

It returns an EventLoopFuture<[RedisData]> with the results of all commands executed - unless one errors.

Redis

To support contexts where someone either doesn't want to work in a SwiftNIO context, the Redis module provides a callback-based interface that wraps all of NIORedis.

A Redis instance manages a NIORedis object under the hood, with RedisConnection doing the same for NIORedisConnection.

Redis Module NIORedis Module
Redis NIORedis
RedisConnection NIORedisConnection
RedisPipeline NIORedisPipeline
import Redis

let redis = Redis(threadCount: 1) // default is 1

// connections

// passing a value to `password` will automatically authenticate with Redis before resolving the connection
redis.makeConnection(
    hostname: "localhost", // this is the default
    port: 6379, // this is the default
    password: "MY_PASS", // default is `nil`
    queue: DispatchQueue(label: "com.MyPackage.redis") // default is `.main`
) { result in
    switch result {
    case .success(let conn):
        showCommands(on: conn)
    case .failure(let error):
        fatalError("Could not create RedisConnection!")
    }
}

// convenience methods for commands

func showCommands(on conn: RedisConnection) {
    conn.get("my_key") { result in
        switch result {
        case .success(let value):
            // use value, which is String?
        case .failure(let error):
            // do something on error
        }
    }
}

// cleanup is handled by deinit blocks
1 Like
Jan 10th, 2019
[Discussion] Server Metrics API
February 7th, 2019
(Johannes Weiss) #2

Thanks @Mordil for sending this proposal, this looks like a great start. Redis is very important and fits really well with the goals of the SSWG.

Also having NIORedis and Redis as separate modules (where Redis uses NIORedis) feels like a really good fit for the side-side Swift ecosystem.

Personally I'm not super keen on the design of the NIORedis module: The only NIO-things it exposes are the EventLoopFuture/EventLoopPromise types. One of the key design points in NIO is that it makes things composeable through the ChannelPipeline. But if I see this correctly, there's no way for me to create a NIO Channel and add the NIORedis handlers to the ChannelPipeline.

As a super dumb and contrived example, let's imagine I would like to implement a library that implements a 'kitten store' on top of some data store. So we would have our main entity

struct Kitten {
    var name: String
    ... // some properties
}

and a few 'kitten store requests' which allow the user to save and receive kittens from the data store

enum KittenStoreRequest {
    case saveKitten(Kitten)
    case receiveKitten(name: String)
}

after each command, the kitten store would reply with some response

enum KittenStoreResponse {
    case ok(Kitten)
    case error(Error)
}

Now, as an implementation detail, the kitten store could be implemented with Redis, and if I choose NIO to implement it all, I'd expect to be able to do this:

let channel = ClientBootstrap(group: group)
                 .channelInitializer { channel in
                     channel.pipeline.add(RedisDecoder()).then {
                         channel.pipeline.add(RedisEncoder())
                     }.then {
                         channel.pipeline.add(KittenStoreOnRedisHandler())
                     }
                 }.connect(...)

channel.then { channel in
    channel.write(KittenStoreRequest.saveKitten(Kitten(name: "Lisa", ...)), promise: nil)
    channel.write(KittenStoreRequest.saveKitten(Kitten(name: "Bart", ...)), promise: nil)
    channel.writeAndFlush(KittenStoreRequest.saveKitten(Kitten(name: "Foobar", ...)))
}

The KittenStoreOnRedisHandler would probably be a ChannelDuplexHandler that transforms KittenStoreRequests into the required Redis commands to store/retrieve a Redis-encoded kitten from the store. KittenStoreOnRedisHandler would be more than a encoder/decoder as we might require multiple Redis commands to fully execute one KittenStoreRequest.

I do realise that most people are not direct NIO users and therefore a fully abstracted interface to Redis (like your Redis module) is absolutely necessary but I do think we should offer a proper NIO interface which uses the ChannelPipeline as the composition mechanism. That would allow writing libraries that use Redis as an underlying implementation detail to use all the NIO features for composition.

That's also why I believe that NIORedis had to reimplement functionality that NIO already provides. For example NIORedisPipeline has a way to enqueue outgoing messages (enqueue) and a way to flush them (execute). NIO implements this already with a way to enqueue messages (write) and a way to flush them (flush).

In other words: I think the low-level parts of a Redis library should provide all the 'bricks' which are necessary to compose a NIO application which means pretty much all of it would be ChannelHandlers and some extra convenience.

Does that make sense? And do you see the value in being able to use NIO's ChannelPipeline as the composition mechanism? Also please don't get me wrong, I think a lot of the design works well but I think we could implement (and expose) more of the functionality with the tools that NIO offers rather than needing to reimplement many of them.

2 Likes
(Nathan Harris) #3

@johannesweiss Thanks for the feedback!

I think it's a fundamental question that I haven't necessarily solved yet personally - what is the purpose of any of these driver libraries?

On one hand, it could be a library of composeable building blocks like you mentioned, where the library provides an Encoder, Decoder, etc., with baseline implementations for creating and using connections out of the box - but it's still a library that additional libraries can build or mix & match as they see fit.

Or on the other hand, it can be a straight "OEM" library out of the box that does those parts for you, and you have a choice of NIORedis to work in a NIO context with Futures and EventLoops, or Redis if you want a more familiar workflow of callbacks.

I don't think they're mutually exclusive, and I think a blended option is possible where all of NIORedis is publicly available, but it provides much out of the box for you with Redis being a DispatchQueue abstraction from NIO.

Part of that was me learning NIO as I was re-implementing things (I was learning Redis under the hood at the same time as NIO :joy:) It's a personal TODO to streamline enqueue for both {X}Pipeline and RedisMessenger to work directly with NIO more than a [RedisData] queue.

1 Like
(Johannes Weiss) #4

That's a very good point and I think you're right. I guess my only wish then would be that the "OEM library" parts would be as slim as possible. Because it would be most annoying if say the mid-level API (NIO futures but "OEM library"-style) would contain useful logic that a low-level (NIO ChannelPipeline) user can't use.

You're doing a great job :+1:. Very happy if we can get those changes in to make it useful directly with NIO but also ship a more ready-to-go version that's less composable but easier to use.

Also please don't hesitate reaching out to us if you want to bounce off some ideas on how to express a certain concept with NIO in a natural way. We NIO devs are reachable in the NIO area of the forums and many of the community's Discord/Slack channels.

(Tanner) #5

I think this is a great pitch and I like the connection API. I also agree with @johannesweiss about exposing the underlying ChannelHandlers so that users can take advantage of the hard work done there.

Some notes I have:

I would avoid having a type named NIORedis since that is the module name. I've run into annoying issues in the past trying to disambiguate type names when this is the case. I think having a static connect method on the NIORedisConnection or just naming the type something different like NIORedisDatabase would be better.

I think connection.close() might need to return a future. Maybe not, but this is worth looking into.

Should executionModel: .eventLoopGroup be named something like unowned or unmanaged? Something that makes it more clear that you need to shutdown the event loop manually even though you are calling redis.terminate() would be nice. Here is an example of how I'm doing that for Vapor 4's HTTP client.

I'm not a big fan of the Redis* and NIORedis* differentiation. This conflates being higher level with being Dispatch based. There are things such as connection pooling, Codable support, etc that belong in a higher level package, but that should not be tied to Dispatch. As a side note, you run the risk of having the Redis team taking issue with the package / module having an identical name. See a Trademark Violation issue on Vapor's SQLite package. (We're renaming it to SQLiteKit). I'd suggest something like DispatchRedis* as a prefix for the Dispatch-based extensions or wrappers. Then something like RedisKit for the higher level package.

I've also noticed that both my pitch and @Helge_Hess1's IRC package use the module name NIOFoo but then name types Foo*. Take NIOIRC.IRCChannelChandler or NIOPostgres.PostgresConnection for example.

(Nathan Harris) #6

I don't see this hurting. Filed

I was jumping between this, and I ultimately agree that it provides a stronger indication of expected usage. Filed

These are great points, and I'll jump in to look at them. Filed

1 Like
(Helge Heß) #7

Actually the protocol module should be called NIORESP because that is the name of the protocol ...

1 Like
(Nathan Harris) #8

I disagree on the module name for two primary reasons:

  1. Communicating using the RESP protocol is technically an implementation detail - albeit a very obvious one given half of the API revolves around it. If, for some reason, Redis decided to implement a new way to communicate, would we want to rename the module around it?

Now, a good counter-point would be "create a new package for the new protocol", but I personally still don't like the entire package being around "RESP" when that's a few trees in the forest. The library is for connecting to Redis, using SwiftNIO.

  1. Discoverability by those who aren't familiar with the low-level details of Redis. If I saw an import NIORESP statement and I have no idea what RESP is - how would I know that it's related to Redis?

That being said, I did change all references to RedisData to be RESPValue (taking inspiration from Noze.IO's implementation) as that more properly identifies the type and case values.

(Helge Heß) #9

You misunderstand me, IMO there should be 3+ modules:

  1. NIORESP (channel handler for decoding the RESP protocol), as shown over here: swift-nio-redis
  2. Redis (a client module), as shown over here: swift-nio-redis-client
  3. RedisServer (a server), as shown over here: Redi/S

(and yes I know that the naming I propose is not followed here, it was a mistake :-) )

Depending on what your take on this group is, I do not think that 2. or 3. should be provided as part of the effort. Stuff like that is provided by frameworks like Kitura or Prefect, integrated into their overall API vision. (and obviously I wouldn't want two RESP imps for server and client, thought pulling in a small client might be OK).

swift-nio-redis-client is a good example on how that can look (NIOIRC/IRC the same). That client library is not much more but assembling NIO and swift-nio-redis into a Node.js-Redis-client look-a-like.
The only thing it additionally does/should-do is connection maintenance (pooling, retries etc), and I would really like to see something generic for that in the NIO core, or some extra, shared NIO module.

It is probably unlikely that people can find consensus on how a client library should look like over here. That is a rather opinionated (and probably often an "integrated") thing. The modules provided here should provide the building blocks for higher level frameworks which can be shared among them.
But that is just my opinion :woman_shrugging:

(Helge Heß) #10

Note that my approach to this is to call protocol implementations NIO, e.g. NIORESP or NIOIRC following the pattern of NIO itself (NIOHTTP etc).
Depending on the framework my actual client library module would probably be named just Redis or maybe RedisClient, w/o the NIO, but that already is a very opinionated decision :slight_smile:

(Nathan Harris) #11

Ah, there was misunderstanding going on.

I personally do not find much value (as an end-user) in having a library of just building blocks when there is an obvious place for a default implementation.

In regards to a Redis server, that's definitely up for others to decide to build on top of this library.

Ultimately my goal and expectation for a driver is: It allows me to build higher level features without needing to re-implement low-level details; but if I choose to, I should be able to use it right out of the box.

I don't, personally, see a good justification in saying that you should use a library or framework that's built on top of the building blocks simply because no implementation was provided.

In my opening post I mentioned that the decision to use NIORedis vs. something higher, should be "do I want to know about all this NIO stuff, and do conversions between RESPValue all the time" NOT "Do I want to implement my own client library or not".

So, in the 2nd revision, I've combined what you described as 1 & 2 - there are publicly available building blocks for you to make your own client, with the possibility of deriving from default implementations, but you can use the base implementation out of the gate.

(Helge Heß) #12

I absolutely agree, the end user is going to use a framework bringing those parts together in line with framework conventions.

(Nathan Harris) #13

All, I've made some heavy revisions based on feedback so far and have pushed publicly.

You can see the changes on master (or by pulling the 0.2.0 pre-release tagged version): mordil/nio-redis

For a diff, you can see the pull request

Here is an overview of the changes:

Renaming

  • Redis module (Dispatch abstraction) has been changed to DispatchRedis
  • RedisData and references to it have been changed to RESPValue or just RESP
  • NIORedis (the class) has been renamed to RedisDriver
  • Other NIO* types have lost the NIO prefix
  • RedisMessenger is now RedisCommandHandler
  • NIORedis.ExecutionModel is now RedisDriver.ThreadOwnershipModel

Public Interface

  • RESPDecoder and RESPEncoder are now public, as well as their respective parsing methods.
  • RedisCommandHandler is now an open class
  • isRunning and isClosed properties are now immutable and public
  • RedisPipeline (formerly NIORedisPipeline) now has a count property of how many commands have been queued.

Implementation

  • RedisCommandHandler (formerly RedisMessenger) has been completely re-written as a ChannelDuplexHandler that works more through protocol method invocations by NIO than before - while still providing a way to queue callbacks to receive Redis command responses
  • RedisPipeline has been completely re-written to more directly work with NIO Channel by creating a new construct RedisCommandContext that is used by RedisCommandHandler
(Nathan Harris) #14

One thing that I still haven't quite figured out - and am open to ideas - is how to easily support both SwiftNIO contexts (EventLoopFutures) and Dispatch (callbacks) without duplicating code or having both mixed in code completion tools.

As brought up in the NIOPostgres Pitch the ideal is that when working with NIORedis you see code completion with functions that return EventLoopFuture while DispatchRedis has callback parameters.

When working in one context, the noise of the other should be removed.

My pass at this was to use NIORedis under the hood of DispatchRedis (names are in flux)

public final class Redis {
    private let driver: RedisDriver

    deinit { try? driver.terminate() }

    public init(threadCount: Int = 1) { /* initialize RedisDriver */ }

    public func makeConnection(
        hostname: String,
        port: Int, password: String?,
        queue: DispatchQueue, 
        _: @escaping (Result<RedisConnection, Error>) -> Void)

But this breaks down of being able to allow "arbitrary" initialization for RedisConnection, as it needs the NIORedis.RedisConnection to act as a "driver". Users will now be made aware of NIORedis as an implementation detail.