Date post: | 23-Feb-2017 |
Category: |
Software |
Upload: | globallogic-ukraine |
View: | 777 times |
Download: | 1 times |
Functional Programming & Event Sourcing
A pair made in heaven
twitter: @rabbitonweb, email: [email protected]
Functional Programming
● no assignment statements
● no assignment statements● no variables
● no assignment statements● no variables ● once given a value, never
change
● no assignment statements● no variables ● once given a value, never
change● no side-effects at all
● no assignment statements● no variables ● once given a value, never
change● no side-effects at all
“The functional programmer sounds rather like a mediæval monk,
“The functional programmer sounds rather like a mediæval monk, denying himself the pleasures of life
“The functional programmer sounds rather like a mediæval monk, denying himself the pleasures of life in the hope that it will make him virtuous.”
Functional Programming
case class User(id: Long, fn: String, ln: String)
case class User(id: Long, fn: String, ln: String)
class Cache { def check(id: Long): Option[User] = ??? }
case class User(id: Long, fn: String, ln: String)
class Cache { def check(id: Long): Option[User] = ??? }
case class UserRepo(cache: Cache) {
def retrieve(id: Long): User = ???
}
case class User(id: Long, fn: String, ln: String)
class Cache { def check(id: Long): Option[User] = ??? }
case class UserRepo(cache: Cache) {
def retrieve(id: Long): User = ???
}
class UserFinder(cache: Cache, repo: UserRepo) {
}
case class User(id: Long, fn: String, ln: String)
class Cache { def check(id: Long): Option[User] = ??? }
case class UserRepo(cache: Cache) {
def retrieve(id: Long): User = ???
}
class UserFinder(cache: Cache, repo: UserRepo) {
def findUser(id: Long): User = {
}
}
case class User(id: Long, fn: String, ln: String)
class Cache { def check(id: Long): Option[User] = ??? }
case class UserRepo(cache: Cache) {
def retrieve(id: Long): User = ???
}
class UserFinder(cache: Cache, repo: UserRepo) {
def findUser(id: Long): User = {
val maybeUser: Option[User] = cache.check(id)
}
}
case class User(id: Long, fn: String, ln: String)
class Cache { def check(id: Long): Option[User] = ??? }
case class UserRepo(cache: Cache) {
def retrieve(id: Long): User = ???
}
class UserFinder(cache: Cache, repo: UserRepo) {
def findUser(id: Long): User = {
val maybeUser: Option[User] = cache.check(id)
if (maybeUser.isDefined) {
maybeUser.get
}
}
}
case class User(id: Long, fn: String, ln: String)
class Cache { def check(id: Long): Option[User] = ??? }
case class UserRepo(cache: Cache) {
def retrieve(id: Long): User = ???
}
class UserFinder(cache: Cache, repo: UserRepo) {
def findUser(id: Long): User = {
val maybeUser: Option[User] = cache.check(id)
if (maybeUser.isDefined) {
maybeUser.get
} else {
val user: User = repo.retrieve(id)
cache.insert(user.id, user)
}
}
}
No concept of ‘time’
f(input): output
input f(input): output
input f(input): output output
input f(input): output output g(input): output
input f(input): output output g(input): output output
input f(input): output output g(input): output output
h = g o f
input f(input): output output g(input): output output
Modularity & composition
h = g o f
input f(input): output output g(input): output output
/** * This function returns a reversed list * @param list A list to be reversed * @return A reversed list */public List<T> reverse(List<t> list) { ??? }
/** * This function returns a reversed list * @param list A list to be reversed * @return A reversed list */public List<T> reverse(List<t> list) { ??? }
/** * This function returns a reversed list * @param list A list to be reversedpublic List<T> reverse(List<t> list) { ??? }
/** * This function returns a reversed listpublic List<T> reverse(List<t> list) { ??? }
/**public List<T> reverse(List<t> list) { ??? }
public List<T> reverse(List<t> list) { ??? }
public List<T> reverse(List<t> list) { return list.sort();}
def smdfknmsdfp[A](a: A): A = ???
def smdfknmsdfp[A](a: A): A = a
def identity[A](a: A): A = a
def smdfknmsdfp[A](a: A): A = a
def smdfknmsdfp(a: Int): Int = ???
def smdfknmsdfp(a: Int): Int = a = a + 10 = 10
“Why Functional Programming Matters”J. Hughes
http://comjnl.oxfordjournals.org/content/32/2/98.full.pdf
“Why Functional Programming Matters”J. Hughes, Nov. 1988
http://comjnl.oxfordjournals.org/content/32/2/98.full.pdf
Soft introduction
“Why Functional Programming Matters”J. Hughes, Nov. 1988
http://comjnl.oxfordjournals.org/content/32/2/98.full.pdf
“Program Design by Calculation”J.N. Oliveira
http://www4.di.uminho.pt/~jno/ps/pdbc_part.pdf
“Program Design by Calculation”J.N. Oliveira, Draft
http://www4.di.uminho.pt/~jno/ps/pdbc_part.pdf
Patterns in FP World
“Program Design by Calculation”J.N. Oliveira, Draft
http://www4.di.uminho.pt/~jno/ps/pdbc_part.pdf
Patterns in FP World Math Matters
“Program Design by Calculation”J.N. Oliveira, Draft
http://www4.di.uminho.pt/~jno/ps/pdbc_part.pdf
“A lengthy approach to Haskell fundamentals”
http://www.davesquared.net/2012/05/lengthy-approach-to-haskell.html
Functional Programming
Functional Programming
How to do something useful?
How to do something useful?
case class User(id: Long, fn: String, ln: String)
class Cache { def check(id: Long): Option[User] = ??? }
case class UserRepo(cache: Cache) {
def retrieve(id: Long): User = ???
}
class UserFinder(cache: Cache, repo: UserRepo) {
def findUser(id: Long): User = {
val maybeUser: Option[User] = cache.check(id)
if (maybeUser.isDefined) {
maybeUser.get
} else {
val user: User = repo.retrieve(id)
cache.insert(user.id, user)
}
}
}
case class User(id: Long, fn: String, ln: String)
case class User(id: Long, fn: String, ln: String)
class Cache {}
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long)(cache: Cache): (Cache, Option[User]) = ...
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long)(cache: Cache): (Cache, Option[User]) = ...
def retrieve(id: Long)(cache: Cache): (Cache, User) = ...
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long)(cache: Cache): (Cache, Option[User]) = ...
def retrieve(id: Long)(cache: Cache): (Cache, User) = ...
def findUser(id: Long)(cache: Cache): (Cache, User) = {
}
}
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long)(cache: Cache): (Cache, Option[User]) = ...
def retrieve(id: Long)(cache: Cache): (Cache, User) = ...
def findUser(id: Long)(cache: Cache): (Cache, User) = {
val (c, mu) = check(id)(cache)
}
}
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long)(cache: Cache): (Cache, Option[User]) = ...
def retrieve(id: Long)(cache: Cache): (Cache, User) = ...
def findUser(id: Long)(cache: Cache): (Cache, User) = {
val (c, mu) = check(id)(cache)
mu match {
case Some(u) => (c, u)
}
}
}
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long)(cache: Cache): (Cache, Option[User]) = ...
def retrieve(id: Long)(cache: Cache): (Cache, User) = ...
def findUser(id: Long)(cache: Cache): (Cache, User) = {
val (c, mu) = check(id)(cache)
mu match {
case Some(u) => (c, u)
case None => retrieve(id)(c)
}
}
}
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long)(cache: Cache): (Cache, Option[User]) = ...
def retrieve(id: Long)(cache: Cache): (Cache, User) = ...
def findUser(id: Long)(cache: Cache): (Cache, User) = {
val (c, mu) = check(id)(cache)
mu match {
case Some(u) => (c, u)
case None => retrieve(id)(c)
}
}
}
S => (S, A)
State[S, A]S => (S, A)
State[S, A]S => (S, A)
.run(S)
State[S, A]S => (S, A)
.map(A => B): State[S, B]
State[S, A]S => (S, A)
.flatMap(A => State[S, B]): State[S,B]
object State {
def apply[S, A] (f: S => (S,A)): State[S, A] =
}
object State {
def apply[S, A] (f: S => (S,A)): State[S, A] =
new State[S, A] {
def run(s: S) = f(s)
}
}
object State {
def apply[S, A] (f: S => (S,A)): State[S, A] =
new State[S, A] {
def run(s: S) = f(s)
}
}
def check(id: String) =
object State {
def apply[S, A] (f: S => (S,A)): State[S, A] =
new State[S, A] {
def run(s: S) = f(s)
}
}
def check(id: String) =
(c: Cache) => (c, c.get(id))
object State {
def apply[S, A] (f: S => (S,A)): State[S, A] =
new State[S, A] {
def run(s: S) = f(s)
}
}
def check(id: String) = State[Cache, Option[User]].apply {
(c: Cache) => (c, c.get(id))
}
object State {
def apply[S, A] (f: S => (S,A)): State[S, A] =
new State[S, A] {
def run(s: S) = f(s(
}
}
def check(id: String) = State[Cache, Option[User]]{
(c: Cache) => (c, c.get(id))
}
trait State[S, +A] {
}
trait State[S, +A] {
def run(initial: S): (S, A)
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State {
}
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State { s0 =>
(_, _ )
}
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State { s0 =>
(_, f(a))
}
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State { s0 =>
val (s, a) = run(s0)
(_, f(a))
}
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State { s0 =>
val (s, a) = run(s0)
(s, f(a))
}
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State { s0 =>
val (s, a) = run(s0)
(s, f(a))
}
}
def flatMap[B](f: A => State[S,B]): State[S, B] =
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State { s0 =>
val (s, a) = run(s0)
(s, f(a))
}
}
def flatMap[B](f: A => State[S,B]): State[S, B] =
f(a)
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State { s0 =>
val (s, a) = run(s0)
(s, f(a))
}
}
def flatMap[B](f: A => State[S,B]): State[S, B] =
val (s, a) = run(s0)
f(a)
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State { s0 =>
val (s, a) = run(s0)
(s, f(a))
}
}
def flatMap[B](f: A => State[S,B]): State[S, B] =
State { s0 =>
val (s, a) = run(s0)
f(a)
}
}
}
trait State[S, +A] {
def run(initial: S): (S, A)
def map[B](f: A => B): State[S, B] =
State { s0 =>
val (s, a) = run(s0)
(s, f(a))
}
}
def flatMap[B](f: A => State[S,B]): State[S, B] =
State { s0 =>
val (s, a) = run(s0)
f(a).run(s)
}
}
}
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long)(cache: Cache): (Cache, Option[User]) = ???
def retrieve(id: Long)(cache: Cache): (Cache, User) = ???
def findUser(id: Long)(cache: Cache): (Cache, User) = {
val (c, mu) = check(id)(cache)
mu match {
case Some(u) => (c, u)
case None => retrieve(id)(c)
}
}
}
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long)(cache: Cache): (Cache, Option[User]) = ???
def retrieve(id: Long)(cache: Cache): (Cache, User) = ???
def findUser(id: Long)(cache: Cache): (Cache, User) = {
val (c, mu) = check(id)(cache)
mu match {
case Some(u) => (c, u)
case None => retrieve(id)(c)
}
}
}
case class User(id: Long, fn: String, ln: String)
class Cache {}
def check(id: Long): State[Cache, Option[User]] = ???
def retrieve(id: Long): State[Cache, User] = ???
def findUser(id: Long): State[Cache, User] = {
for {
maybeUser <- check(id)
user <- maybeUser match {
case Some(u) => State { c => (c, u)}
case None => retrieve(id)
}
} yield (user)
}
Event Sourcing
Event Sourcing driven by business
Bloggers Conf App
Bloggers Conf App
● Can create an account
Bloggers Conf App
● Can create an account● List all bloggers already using the app
Bloggers Conf App
● Can create an account● List all bloggers already using the app● Mark/unmark other blogger as a friend
Bloggers Conf App
● Can create an account● List all bloggers already using the app● Mark/unmark other blogger as a friend● Mark/unmark other blogger as an enemy
Bloggers Conf App
● Can create an account● List all bloggers already using the app● Mark/unmark other blogger as a friend● Mark/unmark other blogger as an enemy● Deactivate its account
Bloggers Conf App
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
Bloggers Conf App
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
Friends
id friend_id
3 1
Bloggers Conf App
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
Friends
id friend_id
3 1
Enemies
id enemy_id
3 2
Bloggers Conf App
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
Friends
id friend_id
3 1
Enemies
id enemy_id
3 2
The Structure
Structure is not that important
Structure is not that important
Changes more often than behaviour
Start thinking about facts occurring
Start thinking about facts occurring
Derive structure from them
Blogger Account Created (id=3)
Blogger Account Created (id=3)
Befriended Blogger id=1
Blogger Account Created (id=3)
Befriended Blogger id=1
Made Enemy of Blogger id=2
Blogger Account Created (id=3)
Befriended Blogger id=1
Made Enemy of Blogger id=2
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
Friends
id friend_id
Enemies
id enemy_id
Blogger Account Created (id=3)
Befriended Blogger id=1
Made Enemy of Blogger id=2
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
Friends
id friend_id
Enemies
id enemy_id
Blogger Account Created (id=3)
Befriended Blogger id=1
Made Enemy of Blogger id=2
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
Friends
id friend_id
3 1
Enemies
id enemy_id
Blogger Account Created (id=3)
Befriended Blogger id=1
Made Enemy of Blogger id=2
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
Friends
id friend_id
3 1
Enemies
id enemy_id
3 2
Blogger Account Created (id=3)
Befriended Blogger id=1
Made Enemy of Blogger id=2
Blogger Account Created (id=3)
Befriended Blogger id=1
Made Enemy of Blogger id=2
Befriended Blogger id=2
Unfriended Blogger id=2
Blogger Account Created (id=3)
Befriended Blogger id=1
Made Enemy of Blogger id=2
Event Sourcing driven by business
Event Sourcing The only model that does not lose data
“Enemy of my enemy is my friend”
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
Friends
id friend_id
3 1
Enemies
id enemy_id
3 2
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
4 Tomasz Młynarski T
Friends
id friend_id
3 1
Enemies
id enemy_id
3 2
4 2
Bloggers
id first_name last_name active
1 Jan Kowalski T
2 Krystian Nowak T
3 Malgorzata Kucharska T
4 Tomasz Młynarski T
5 Monika Jagoda T
Friends
id friend_id
3 1
Enemies
id enemy_id
3 2
4 2
2 5
id = 3
id = 2
id = 1
id = 4
id = 5
id = 6
id = 3
id = 2
id = 1
id = 4
id = 5
id = 6
id = 3
id = 2
id = 1
id = 4
id = 5
id = 6
id = 3
id = 2
id = 1
id = 4
id = 5
id = 6
id = 3
id = 2
id = 1
id = 4
id = 5
id = 6
id = 3
id = 2
id = 1
id = 4
id = 5
id = 6
id = 3
id = 2
id = 1
id = 4
id = 5
id = 6
id = 3
id = 2
id = 1
id = 4
id = 5
id = 6
Benefits of Event Sourcing
Benefits of Event Sourcing● Ability to go in time and figure out exactly what have
happened
Benefits of Event Sourcing● Ability to go in time and figure out exactly what have
happened● Scientific measurements over time, compare time
periods
Benefits of Event Sourcing● Ability to go in time and figure out exactly what have
happened● Scientific measurements over time, compare time
periods● Built-in audit log
Benefits of Event Sourcing● Ability to go in time and figure out exactly what have
happened● Scientific measurements over time, compare time
periods● Built-in audit log● Enables temporal querying
Benefits of Event Sourcing● Ability to go in time and figure out exactly what have
happened● Scientific measurements over time, compare time
periods● Built-in audit log● Enables temporal querying● Fits well with machine learning
Benefits of Event Sourcing● Ability to go in time and figure out exactly what have
happened● Scientific measurements over time, compare time
periods● Built-in audit log● Enables temporal querying● Fits well with machine learning● Preserves history - question not yet asked
Benefits of Event Sourcing● Ability to go in time and figure out exactly what have
happened● Scientific measurements over time, compare time
periods● Built-in audit log● Enables temporal querying● Fits well with machine learning● Preserves history - question not yet asked● Writing regression tests is easy
Benefits of Event Sourcing● Ability to go in time and figure out exactly what have
happened● Scientific measurements over time, compare time
periods● Built-in audit log● Enables temporal querying● Fits well with machine learning● Preserves history - question not yet asked● Writing regression tests is easy● Polyglot data
Drawbacks of Event Sourcing
Drawbacks of Event Sourcing● Historical record of your bad decisions
Drawbacks of Event Sourcing● Historical record of your bad decisions● Handling event duplicates
Drawbacks of Event Sourcing● Historical record of your bad decisions● Handling event duplicates● Data eventually consistent
How to implement it?
Events vs Commands
Journal
Journal
Journal
val id = “”val firstName: String = “”val lastName: String = “”val friends: List[String] = List()
Journal
val id = “”val firstName: String = “”val lastName: String = “”val friends: List[String] = List()
Journal
val id = “”val firstName: String = “”val lastName: String = “”val friends: List[String] = List()
Journal
val id = “”val firstName: String = “”val lastName: String = “”val friends: List[String] = List()
Initialized(“1”, “Jan”, “Kowalski”
Journal
val id = “”val firstName: String = “”val lastName: String = “”val friends: List[String] = List()
Initialized(“1”, “Jan”, “Kowalski”
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List()
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List()
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List()
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List()
Befriended(“10”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List()
Befriended(“10”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“31”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“31”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“31”)
validation
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“31”)
validation
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
validation
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
validation
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
Befriended(“34”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
Befriended(“34”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
Befriended(“34”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
Befriended(“34”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”)
Befriend(“34”)
ACK
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”, “34”)
Befriend(“34”)
Journal
val id = “1”val firstName: String = “Jan”val lastName: String = “Kowalski”val friends: List[String] = List(“10”, “31”, “34”)
Let’s see some code!https://github.
com/rabbitonweb/es_cqrs_example
What we are missing?
What we are missing?
1. Read-model
What we are missing?
1. Read-model2. Validation
And that’s all folks!
Paweł Szulc
Paweł Szulchttp://rabbitonweb.com
@rabbitonwebhttps://github.com/rabbitonweb/
Paweł Szulchttp://rabbitonweb.com
@rabbitonwebhttps://github.com/rabbitonweb/http://rabbitonweb.com/spark
Thank you!