Upgrade to Pro — share decks privately, control downloads, hide ads and more …

メルカリ アッテを支えるオートマトン/monadic-mealy-machine

Orakaro
October 10, 2017

メルカリ アッテを支えるオートマトン/monadic-mealy-machine

「メルカリアッテ」アプリのリニューアルの一つとして会員登録フローを実装し直しました。Facebook、メルカリ連携など色々な機能が登場し複雑なフローに変更した上で、仕様の調整も頻繁に行われ、柔軟な設計と実装手法が求められていました。

本トークにはその問題を解決するために状態管理技術のステートマシン(オートマトン)を活かし、柔軟性の高い且つテストしやすい実装をどのように実現したかの話を紹介します。

デモ:https://github.com/orakaro/MonadicMealyMachine

Orakaro

October 10, 2017
Tweet

More Decks by Orakaro

Other Decks in Technology

Transcript

  1. ϝϧΧϦ ΞοςΛࢧ͑Δ
    ΦʔτϚτϯ
    Vu Nhat Minh / @orakaro
    Souzoh iOS Talk

    View Slide

  2. Mercari Atte
    Scala / Swift Engineer / @orakaro

    View Slide

  3. Ξος͕औΓ૊ΜͰ͍Δ͜ͱ
    • ϑϧRxSwift
    • Driverʹੵۃతʹஔ͖׵͑Δ
    • ViewModelͷΠϯϓοτɺΞ΢τϓοτԽ
    • ܧঝΑΓίϯϙδγϣϯ
    • ΦʔτϚτϯ

    View Slide

  4. εςʔτϚγϯͷ͓͞Β͍
    • ༗ݶΦʔτϚτϯ
    • ભҠؔ਺ͷΈ (s, a) -> s
    • Mealy Machine
    • ભҠؔ਺ (s, a) -> s
    • ग़ྗؔ਺ (s, a) -> b
    • Moore Machine
    • ભҠؔ਺ (s, a) -> s
    • ग़ྗؔ਺ s -> b

    View Slide

  5. ༗ݶΦʔτϚτϯ
    • ༗ݶΦʔτϚτϯ
    • ભҠؔ਺ͷΈ (s, a) -> s
    • Mealy Machine
    • ભҠؔ਺ (s, a) -> s
    • ग़ྗؔ਺ (s, a) -> b
    • Moore Machine
    • ભҠؔ਺ (s, a) -> s
    • ग़ྗؔ਺ s -> b
    ঢ়ଶΛ؅ཧ͍ͨ࣌͠ʹ׆༻͢΂͖

    View Slide

  6. Binary Gap
    A binary gap within a positive integer N is any maximal sequence
    of consecutive zeros that is surrounded by ones at both ends in the
    binary representation of N.
    Ex: 9 = 1001 → 2

    View Slide

  7. A binary gap within a positive integer N is any maximal sequence
    of consecutive zeros that is surrounded by ones at both ends in the
    binary representation of N.
    Ex: 9 = 1001 → 2
    Started Zero
    One
    Binary Gap

    View Slide

  8. Visual Format Language
    class func constraints(withVisualFormat format: String,
    options opts: NSLayoutFormatOptions = [],
    metrics: [String : Any]?,
    views: [String : Any]) -> [NSLayoutConstraint]
    "H:|-15-[iconImageView(30)]-[appNameLabel]-[skipButton]-15-|"

    View Slide

  9. Visual Format Language
    class func constraints(withVisualFormat format: String,
    options opts: NSLayoutFormatOptions = [],
    metrics: [String : Any]?,
    views: [String : Any]) -> [NSLayoutConstraint]
    "H:|-15-[iconImageView(30)]-[appNameLabel]-[skipButton]-15-|"
    Direction Start Line Number Label
    [ ] ( )
    End

    View Slide

  10. ݈શͳίʔυϕʔεͷͨΊʹ
    ڊେif elseΛʢͰ͖Ε͹ʣॻ͔ͳ͍
    άϩʔόϧม਺ΛʢͰ͖Ε͹ʣ࢖Θͳ͍
    ؔ਺ ΛʢͰ͖Δ͚ͩʣ७ਮؔ਺ʹ͍ͨ͠

    View Slide

  11. ձһొ࿥ϑϩʔ

    ʁ ʁ
    ʁ
    ʁ
    ʁ

    View Slide

  12. ձһొ࿥ϑϩʔ
    Emailొ࿥͍ͤͨ͞ Facebookೝূ͍ͤͨ͞
    ϝϧΧϦొ࿥ࡁΈͳΒϫϯΫϦοΫ
    ύεϫʔυ࠶ઃఆ
    ϩάΠϯ͍ͤͨ͞

    View Slide

  13. ձһొ࿥ϑϩʔ

    View Slide

  14. ొ࿥ঢ়ଶ൑ఆ

    View Slide

  15. Facebookೝূ

    View Slide

  16. ϝʔϧϩάΠϯ

    View Slide

  17. ձһొ࿥ʢΠϝʔδʣ
    Root
    Register
    Root
    Profile
    Register
    Start With
    Mercari
    AtteLogin
    Facebook
    Email
    Login
    Register With
    Mercari
    Confirm
    SMS
    Home
    PassCode
    Email
    SignUp
    Reset
    Password

    View Slide


  18. Root
    Register
    Root
    Profile
    Register
    Start With
    Mercari
    AtteLogin
    Facebook
    Email
    Login
    Register With
    Mercari
    Confirm
    SMS
    Home
    PassCode
    Email
    SignUp
    Reset
    Password

    View Slide

  19. ભҠϩδοΫ͸υϝΠϯ஌ࣝ
    Ϟσϧ૚Ͱ؅ཧ͢΂͖
    MVC / MVP / MVVM

    View Slide

  20. ΋͸΍֤ը໘͕
    εςʔτΛදݱ͍ͯ͠Δͷ͡Ό

    View Slide

  21. ΠϕϯτʹΑͬͯભҠ
    Root
    Register
    Root
    Atte Login
    Start With
    Mercari
    Atte Resource
    Mercari Resource

    View Slide

  22. RegistrationStateViewController
    protocol RegistrationStateViewController {
    var state: RegistrationState { get }
    func nextViewController(event: RegistrationEvent) -> UIViewController?
    }
    extension RegistrationStateViewController {
    func nextViewController(event: RegistrationEvent) -> UIViewController? {
    let machine = AutomatonManager.registrationMachine
    guard let state = machine.transition(from: state, by: event) else { return nil }
    switch state {
    //...
    }
    }
    ΦʔτϚτϯ

    View Slide

  23. TransitionΛఆٛ
    struct Transition {
    let from: S
    let to: S
    let by: E
    }
    from to
    by
    State State
    Event

    View Slide

  24. State MachineΛ࣮૷
    class Automaton {
    var routes: [S: [E: S]] = [:]
    init(initialState: S, transitions: [Transition]) {
    for t in transitions {
    addRoute(t)
    }
    }
    private func addRoute(_ t: Transition) {
    var dict = routes[t.from] ?? [:]
    dict[t.by] = t.to
    routes[t.from] = dict
    }
    func transition(from: S, by: E) -> S? {
    guard let next = routes[from].flatMap({ $0[by] })
    else {
    return nil
    }
    return next
    }
    }

    View Slide

  25. State MachineΛ࣮૷
    class Automaton {
    var routes: [S: [E: S]] = [:]
    init(initialState: S, transitions: [Transition]) {
    for t in transitions {
    addRoute(t)
    }
    }
    private func addRoute(_ t: Transition) {
    var dict = routes[t.from] ?? [:]
    dict[t.by] = t.to
    routes[t.from] = dict
    }
    func transition(from: S, by: E) -> S? {
    guard let next = routes[from].flatMap({ $0[by] })
    else {
    return nil
    }
    return next
    }
    }
    ࣙॻܕΛ࢖ͬͯભҠΛ؅ཧ͢Δ

    View Slide

  26. εςʔτͱΠϕϯτΛఆٛ
    enum RegistrationState {
    case root
    case registerRoot
    case profileRegister
    case startWithMercari
    case emailLogin
    case atteLogin
    case home
    }
    enum RegistrationEvent: Hashable {
    case showRegisterRoot(loginMode: RootViewController.LoginMode?)
    case registerWithFacebook(profile: FacebookProfile?)
    case startWithMercari(resource: MercariIdResource?, tokenPair: TokenPair?, showsCloseButton: Bool)
    case loginWithEmail(showsCancelButton: Bool)
    case loginWithAtte(resource: MercariIdResource?, tokenPair: TokenPair?)
    case loginAndShowHome(tokenPair: TokenPair?, userId: Int64, hasMercariItem: Bool)
    }

    View Slide

  27. ભҠάϥϑΛఆٛ
    let registrationGraph: [Transition] = [
    Transition(from: .root, to: .registerRoot, by: DefaultEvent.showRegisterRoot),
    Transition(from: .registerRoot, to: .profileRegister, by: DefaultEvent.registerWithFacebook),
    Transition(from: .registerRoot, to: .startWithMercari, by: DefaultEvent.startWithMercari),
    Transition(from: .registerRoot, to: .emailLogin, by: DefaultEvent.loginWithEmail),
    Transition(from: .registerRoot, to: .atteLogin, by: DefaultEvent.loginWithAtte),
    Transition(from: .registerRoot, to: .home, by: DefaultEvent.loginAndShowHome),
    ]
    let registrationMachine = Automaton(
    initialState: .root,
    transitions: registrationGraph
    )

    View Slide

  28. ભҠϩδοΫ͚ͩςετͰ͖Δ
    describe("ΞϓϦىಈ࣌τʔΫϯ͕ଘࡏ͠ͳ͍") {
    it("ϝʔϧͰ৽نొ࿥͕Ͱ͖Δ") {
    self.tryRoute(events: [
    DefaultEvent.showRegisterRoot,
    DefaultEvent.registerWithEmail,
    DefaultEvent.showProfileRegister,
    DefaultEvent.confirmSMS,
    DefaultEvent.loginAndShowHome
    ])
    XCTAssert(self.currentState == .home)
    }
    }
    //...
    private func tryRoute(events: [RegistrationEvent]) {
    for event in events {
    self.currentState.map { state in
    self.currentState = self.machine.transition(from: state, by: event)
    }
    }
    }

    View Slide

  29. View Slide

  30. ·ͩ໰୊͕͋Δ

    View Slide

  31. RegistrationStateViewController
    protocol RegistrationStateViewController {
    var state: RegistrationState { get }
    func nextViewController(event: RegistrationEvent) -> UIViewController?
    }
    extension RegistrationStateViewController {
    func nextViewController(event: RegistrationEvent) -> UIViewController? {
    let machine = AutomatonManager.registrationMachine
    guard let state = machine.transition(from: state, by: event) else { return nil }
    switch state {
    //...
    }
    }
    ͜͜ͰViewControllerΛੜ੒͢Δͷʁ

    View Slide

  32. RegistrationStateViewController
    switch state {
    case .root:
    return RootViewController.make(withDependency: ())
    case .registerRoot:
    return RegisterRootViewController.make(withDependency: ())
    case .profileRegister :
    switch event {
    case .registerWithFacebook(.some(let profile)):
    return ProfileRegisterViewController.make(withDependency: .init(facebookProfile: profile))
    default: return nil
    }
    case .startWithMercari:
    switch event {
    case .startWithMercari(.some(let resource), .some(let token)):
    return StartWithMercariViewController.make(withDependency: .init(resource: resource, token: token))
    default: return nil
    }
    case .emailLogin:
    return EmailLoginViewController.make(withDependency: ())
    case .atteLogin:
    switch event {
    case .loginWithAtte(.some(let resource), .some(let token)):
    return AtteLoginViewController.make(withDependency: .init(resource: resource, token: token))
    default: return nil
    }
    case .home:
    switch event {
    case .loginAndShowHome(.some(let token), let userId):
    return HomeViewController.make(withDependency: .init(token: token, userId: userId))
    default: return nil
    }
    default: return nil
    }
    ڊେ if elseͱେͯ͠มΘΜͳ͍…

    View Slide

  33. ભҠάϥϑΛఆٛ
    let registrationGraph: [Transition] = [
    Transition(from: .root, to: .registerRoot, by: DefaultEvent.showRegisterRoot),
    Transition(from: .registerRoot, to: .profileRegister, by: DefaultEvent.registerWithFacebook),
    Transition(from: .registerRoot, to: .startWithMercari, by: DefaultEvent.startWithMercari),
    Transition(from: .registerRoot, to: .emailLogin, by: DefaultEvent.loginWithEmail),
    Transition(from: .registerRoot, to: .atteLogin, by: DefaultEvent.loginWithAtte),
    Transition(from: .registerRoot, to: .home, by: DefaultEvent.loginAndShowHome),
    ]
    let registrationMachine = Automaton(
    initialState: .root,
    transitions: registrationGraph
    )
    DefaultEvent !?

    View Slide

  34. DefaultEvent ͬͯԿʁ
    • Swift 3 ͷassociated value enumʹ͸σϑΥϧτ஋ઃఆͰ͖ͳ͍
    • Acceptedͷεςʔλε͕ͩSwift4ʹೖ͍ͬͯͳ͍ Proposal: SE-0155
    struct DefaultEvent {
    static let showRegisterRoot: RegistrationEvent = .showRegisterRoot()
    static let registerWithFacebook: RegistrationEvent = .registerWithFacebook(profile: nil)
    static let startWithMercari: RegistrationEvent = .startWithMercari(resource: nil, token: nil)
    static let loginWithEmail: RegistrationEvent = .loginWithEmail()
    static let loginWithAtte: RegistrationEvent = .loginWithAtte(resource: nil, token: nil)
    static let loginAndShowHome: RegistrationEvent = .loginAndShowHome(token: nil, userId: 0)
    }

    View Slide

  35. enumͷassociated value͕optionalܕ
    enum RegistrationEvent: Hashable {
    case showRegisterRoot(loginMode: RootViewController.LoginMode?)
    case registerWithFacebook(profile: FacebookProfile?)
    case startWithMercari(resource: MercariIdResource?, tokenPair: TokenPair?, showsCloseButton: Bool)
    case loginWithEmail(showsCancelButton: Bool)
    case loginWithAtte(resource: MercariIdResource?, tokenPair: TokenPair?)
    case loginAndShowHome(tokenPair: TokenPair?, userId: Int64, hasMercariItem: Bool)
    }
    Optional

    View Slide

  36. ΦʔτϚτϯ + FP

    View Slide

  37. ભҠؔ਺ (aka Reducer)
    • ༗ݶΦʔτϚτϯ
    • ભҠؔ਺ͷΈ (s, a) -> s
    • Mealy Machine
    • ભҠؔ਺ (s, a) -> s
    • ग़ྗؔ਺ (s, a) -> b
    • Moore Machine
    • ભҠؔ਺ (s, a) -> s
    • ग़ྗؔ਺ s -> b

    View Slide

  38. ભҠؔ਺ (s, a) -> s
    ग़ྗؔ਺ (s, a) -> b
    Mealy Machine / Moore Machine
    ભҠؔ਺ (s, a) -> (s, b)
    ભҠؔ਺ (s, a) -> s
    ग़ྗؔ਺ s -> b
    ભҠؔ਺ s -> (a -> s, b)

    View Slide

  39. ભҠؔ਺ (s, a) -> s
    ग़ྗؔ਺ (s, a) -> b
    Mealy Machine vs Elm
    ભҠؔ਺ (s, a) -> (s, b)
    Elm Architecture
    fun update(state: State, action: Action): Pair

    View Slide

  40. ભҠؔ਺ (s, a) -> s
    ग़ྗؔ਺ (s, a) -> b
    Mealy Machine
    ભҠؔ਺ (s, a) -> (s, b)
    ભҠؔ਺ a -> s -> (s, b)

    View Slide

  41. ભҠؔ਺ (s, a) -> s
    ग़ྗؔ਺ (s, a) -> b
    Mealy Machine
    ભҠؔ਺ (s, a) -> (s, b)
    ભҠؔ਺ a -> s -> (s, b)
    Stateful Computation !!

    View Slide

  42. ঢ়ଶ෇͖ͷܭࢉʢStateful Computationʣ
    ঢ়ଶ෇͖ͷܭࢉͱ͸ɺ͋Δঢ়ଶΛऔͬͯɺߋ৽͞Εͨঢ়ଶͱҰॹʹܭࢉ݁ՌΛ
    ฦؔ͢਺ͱͯ͠දݱͰ͖Δɿ s -> (s, a)
    class State {
    private let run: (S) -> (S, A)
    init(f: @escaping (S) -> (S, A)) {
    self.run = f
    }
    func run(s: S) -> (S, A) {
    return self.run(s)
    }
    }

    View Slide

  43. εςʔτϞφυ
    ঢ়ଶ෇͖ͷܭࢉͱ͸ɺ͋Δঢ়ଶΛऔͬͯɺߋ৽͞Εͨঢ়ଶͱҰॹʹܭࢉ݁ՌΛ
    ฦؔ͢਺ͱͯ͠දݱͰ͖Δɿ s -> (s, a)
    extension State {
    func map(g: @escaping (A) -> B) -> State {
    return State { s in
    let (s1, val) = self.run(s: s)
    return (s1, g(val))
    }
    }
    func flatMap(g: @escaping (A) -> State) -> State {
    return State { s in
    let (s1, val) = self.run(s: s)
    return g(val).run(s: s1)
    }
    }
    }

    View Slide

  44. Ϟφυ = flatmappable ܕ
    Image credit: Functors, Applicatives, And Monads In Pictures

    View Slide

  45. Swift ͷϞφυ
    Maybe Monad
    Either Monad
    Reader Monad
    Observable Monad
    State Monad
    I/O Monad

    Swift Optional
    antitypical/Result
    Statically typed Dependency Injection
    Signal / Observable
    Mealy Machine

    View Slide

  46. ભҠؔ਺ (s, a) -> s
    ग़ྗؔ਺ (s, a) -> b
    Mealy Machine
    ભҠؔ਺ (s, a) -> (s, b)
    ભҠؔ਺ a -> s -> (s, b)
    ભҠؔ਺ a -> State[s, b]
    εςʔτϞφυ

    View Slide

  47. MonadicAutomaton
    class MonadicAutomaton {
    typealias T = (A) -> State
    private var f : T
    init(f: @escaping T) {
    self.f = f
    }
    func transition(from: S, by: A) -> (S, B) {
    return f(by).run(s: from)
    }
    }
    ɹભҠؔ਺ A -> State[S, B]
    A:ΠϯϓοτܕɺB: Ξ΢τϓοτܕ
    A = ΠϕϯτenumɺB = UIViewController

    View Slide

  48. εςʔτͱΠϕϯτΛఆٛ
    enum RState {
    case any
    case root
    case registerRoot
    case profileRegister
    case startWithMercari
    case emailLogin
    case atteLogin
    case home
    }
    enum REvent {
    case showRegisterRoot
    case registerWithFacebook(profile: FacebookProfile)
    case startWithMercari(resource: MercariIdResource, token: String)
    case loginWithEmail
    case loginWithAtte(resource: MercariIdResource, token: String)
    case loginAndShowHome(token: String, userId: Int64)
    }
    OptionalͰ͸ͳ͍ʂʂ

    View Slide

  49. ભҠάϥϑΛఆٛ
    let transitionFunc: (REvent) -> State = { event in
    switch event {
    case .registerWithFacebook(let profile):
    let vc = ProfileRegisterViewController.make(withDependency: .init(facebookProfile: profile))
    return State { s in
    let s1: RState = s == .registerRoot ? .profileRegister : .any
    return (s1, vc)
    //...
    }
    }
    let registrationMachine = MonadicAutomaton(f : transitionFunc)
    A S B
    A
    S B

    View Slide

  50. ΦʔτϚτϯ + FRP

    View Slide

  51. ΦʔτϚτϯ + FRP
    • ը໘಺ͷUIมԽ΋εςʔτʹදݱ͢Δ
    • ΠϕϯτΛετϦʔϜʹද͢
    • ReactiveSwift
    • inamiy/ReactiveAutomaton
    • RxSwift
    • kzaher/RxFeedback

    View Slide

  52. ·ͱΊ
    • ΦʔτϚτϯͷԠ༻
    • ༗ݶΦʔτϚτϯ
    • Mealy Machine
    • Moore Machine
    • ঢ়ଶ؅ཧʹ༏ΕΔ
    • ؆୯ͳભҠॲཧͳΒFSM
    • Mealy Machine = State Monad

    View Slide

  53. https://github.com/orakaro/MonadicMealyMachine
    Twitter / Github: @orakaro
    Q&A

    View Slide