A Fairy Tale of F# and Durable Functions
hackernoon.com
hackernoon.com
F# can be so readable, e.g.:
// WishList -> Async<Reservation>
let workflow (wishlist: WishList) = async {
// 1. Find matches for each wish
let! matches =
wishlist.Wishes
|> List.map findMatchingGift
|> Async.Parallel
// 2. Pick one product from the combined list of matches
let gift = pickGift (List.concat matches)
// 3. Register and return the reservation
let reservation = { Kid = wishlist.Kid; Product = gift }
do! reserve reservation
return reservation
}
view raw
durable-fsharp
Could basically be how you would write pseudocode.Side note: How do I stop sites like hackernoon blocking my back button? It's so annoying!
But the same syntax is what makes partial application work so well, which is in turn what makes the pipeline operator (|>) work so well.
Also, the syntax does a good job of encouraging you to organize code for better readability. It's a lot like lisp's function call syntax, only it somehow makes me feel incentivized to capture intermediate values in let statements instead of nesting my function calls. I'm guessing it's because there's something vaguely pleasing about getting to skip the parens.
I also like that F#'s version of do blocks requires you to explicitly state what kind of computation expression you're using.
I stuck it out with F#, though, and now I miss that syntax greatly when I have to use other languages. It's surprisingly wonderful (at least in my opinion). Or at least in languages where partial application / currying / composition is easily-available. I suspect that syntax wouldn't be very helpful with languages where that isn't the case.
I realize that for business code that that isn't a super big deal, but it becomes an issue if you want to write generic libraries; the closest thing that you can do is to exploit static-resolved types with member constraints and do explicit binds.
For example, if I wanted to have a monad transformer for Async<Option>, I could define the custom monad, but then I end up having to wrap every regular Async function manually (or do a lot of manual unboxing) to make it work. A lot of the time, my logic in the async blocks isn't specific to async, and I feel that when you're forced to write custom unboxing everywhere, that's a good way to make a lot of mistakes.
EDIT: Just an FYI, despite this complaint, I do really like F#...I find it an incredibly practical language, and one of the best of the Hindley-Milner-style functional languages.
But yeah, you'd still be stuck writing wrappers to mate any pre-existing types to the library. I'm not sure that's really even computation expressions' fault, so much as an inevitable implication of F#'s lack of any facility for ad-hoc polymorphism.
In most cases the APIs are uniform enough to where it's not a pain point.
In Haskell land I would have used something like (Monad m) => m a -> m b, and then I could throw any monad transformer into there.
inputs |> step1 |> step2 |> step3 |> etc
... and it seemed mostly doable, although I would wind up needing a layer of parameter rearrangement functions to make it read quite that cleanly. I don't know if I have a concrete opinion on how it worked out - I suppose I need to get back into doing this some more.
I suspect that if I were to try to hide an async workflow behind such an arrangement that the parameter-rearrangement layer might create a greater cost than a benefit.
But still, it's really neat that the language allows for this sort of attempt.
This can quickly become unreadable but used well it can hide some noise and meaningless variable names.
(|>) :: a -> (a -> b) -> b x |> f = apply x f
(g ∘ f)(x) = g (f(x)) // math
(g . f) x = g (f x) // haskell
g f x g f x // see how the g f x order always matches with the default application and composition notation.
you can mess with the order and "turn the order of application "inside-out" but then you have to switch modes from left-to-right and right-to-left more often. Or you can try to write in a style that prefers right-to-left. f >>> g = g . f
Works for any instance of Category. g f x
would not be the same as (g . f) x
you need the right associative function application operator $, ie. g $ f x
or simply g (f x) Elm: Haskell:
f <| g <| x == f $ g $ x -- application
f << g == f . g -- composition
x |> g |> f == x & g & f -- reverse application
g >> f == g >>> f -- reverse composition
It's nice to be able to change between composition and application by changing between | and < in the operator. I've actually done this quite a lot when refactoring code in Elm lately, there's something visual and intutive about it that I like. It makes it easier to mix left-to-right and right-to-left in the same code, while keeping it obvious what's going on.I've found that certain parts, that scan/read better as imperative steps (think chains of List.map, Maybe.map, <MonadlikeModule>.andThen etc.) often benefit from a reverse application and/or composition. Sort of to simulate the look of do notation since Elm doesn't have that. Smaller functional parts are better with regular application/composition. With the directional operators, this becomes quite ergonomical.
Probaly helps that I use (and enjoy, ymmv) a font with ligatures for these symbols.
I used to think that all these Haskell discussions were garbage. But now I get it.
It's still very inaccessible, in my opinion. But I'm working on something to help change all that.
let (|>) x f = f x fun(arg1, _, arg3)
where the "_" indicates a skipped argument. My thought being that, in return for being a little bit more syntax-heavy, you'd get several potential advantages: No need for parameter rearrangement functions, easier overloading, and the more explicit syntax might help with maintainability. (0 to 100).map(someFunc(arg1, arg2, _, arg3)) let myFunc = step1 >> step2 >> step3 >> etc
Personally, I often find point-free to be a little too terse, but occasionally it's much clearer than "regular" code."We used Azure Storage Queues to keep the whole flow asynchronous and more resilient to failures and load fluctuation."
That's not enough for me. I often encounter people building complex systems message queuing, despite failures being rare, retries being cheap and fast, and the hardware already being resilient to load.
In this article, a simple explanation of why each step actually _needs_ EDA/queues would be great - e.g. as the Product Matcher algorithm occasionally can't read a child's writing, an elf has to manually contact the child's parents to find out what they want so it can take a very long time.
I'm a bit familiar with Azure Queues and Azure functions and I'll try to provide some more context here. But short answer: Using Azure Queues here actually makes things less complicated.
An Azure function (or any function) doesn't do much in isolation. Someone has to call it, so you've got to figure out: who's calling this function, when are they calling it, and what context do they have at call-time?
General purpose functions allow a wide range of possibilities to the above questions, and it's a lot easier to shoot yourself in the foot. So many people implement their own queue-like functionality that Azure decided to abstract it away into a general purpose model that roughly says: This function is called whenever there are unprocessed items remaining in the queue, and only information that's in the queue is passed to the function.
When you buy into that model, you get a whole bunch of stuff done for you: They've added common functionality like retry logic, expiry of queue items, security/permissions/tokens and https endpoints, logging that provides visibility into failures.
It's eliminated a lot of boilerplate code and it's also an architectural model that's easily understood. I'm a big fan myself.
[also, disclaimer, I'm an MS employee, but I don't work for Azure]
I do think it does HTTP a disservice though - when you switch from HTTP to EDA you ditch the well known Uniform Interface and a lot of the benefits you get for free,(i.e. robust caching/proxying/routing, all the things a Service Mesh can do).
Instead of ditching HTTP, it would be nice to see people building async HTTP APIs, where the API responds with 201 Accepted (probably using a message queue behind the scenes) rather than the brittle systems you describe, where everything comes back under a 200.