项目作者: dotchain

项目描述 :
分布式数据与操作转换/转换同步
高级语言: Go
项目地址: git://github.com/dotchain/dot.git
创建时间: 2017-12-18T01:08:12Z
项目社区:https://github.com/dotchain/dot

开源协议:MIT License

下载


DOT

Status
GoDoc
codecov
Go Report Card

The DOT project is a blend of operational
transformation
,
CmRDT,
persistent/immutable
datastructures

and reactive
stream processing.

This is an implementation of distributed data synchronization of rich
custom data structures with conflict-free merging.

Status

This is very close to v1 release. The ES6 version
interoperates well right now but outstanding short-term issues have
more to do with consistency of the API surface than features:

  • ~The ES6 version has a simpler polling-based Network API that seems worth adopting here.~ Adopted
  • ~The ES6 branch/undo integration also feels a lot simpler.~ Adopted
  • The ES6 version prefers replace() instead of update().
  • Nullable value types (i.e typed Nil values vs change.Nil vs nil) seems confusing.

Features

  1. Small, well tested mutations and immutable persistent values
  2. Support for rich user-defined types, not just collaborative text
  3. Streams and Git-like branching, merging support
  4. Simple network support (Gob serialization) and storage support
  5. Strong references support that are automatically updated with changes
  6. Rich builtin undo support for any type and mutation
  7. Folding (committed changes on top of uncommitted changes)
  8. Support for CmRDT types (see crdt)

An interoperable ES6 version is available on dotchain/dotjs with a TODO MVC demo of it here

Contents

  1. Status
  2. Features
  3. CRDTs
  4. TODO Example
    1. Server
    2. Types
    3. Type registration
    4. Code generation
    5. Toggling Complete
    6. Changing description
    7. Adding Todos
    8. Client connection
    9. Running the demo
    10. In browser demo
  5. How it all works
    1. Applying changes
    2. Applying changes with streams
    3. Composition of changes
    4. Convergence
    5. Convergence using streams
    6. Revert and undo
    7. Folding
    8. Branching of streams
    9. References
    10. Network synchronization and server
  6. Broad Issues
  7. Contributing

CRDTs

Much of the framework can support operation-based CRDT changes which
simply appear as commutative operations (and so the merge operation is
trivial). A set of types built this way is available in the
crdt
folder.

TODO Example

The standard TODO-MVC example demonstrates the features of
collaborative (eventually consistent) distributed data structures.

Server

The DOT backend is essentially a simple log store. All mutations to
the application state are represented as a sequence of operations
and written in append-only fashion onto the log. The following
snippet shows how to start a web server (though it does not include
authentication or CORs for example).

```go example.global

func Server() {
// import net/http
// import github.com/dotchain/dot

  1. // uses a local-file backed bolt DB backend
  2. http.Handle("/dot/", dot.BoltServer("file.bolt"))
  3. http.ListenAndServe(":8080", nil)

}

  1. The example above uses the
  2. [Bolt](http://godoc.org/github.com/dotchain/dot/ops/bolt)
  3. backend for the actual storage of the operations. There is also a
  4. [Postgres](http://godoc.org/github.com/dotchain/dot/ops/pg) backend
  5. available.
  6. Note that the server above has no real reference to any application
  7. logic: it simply accepts operations and writes them out in a
  8. guaranteed order broadcasting these to all the clients.
  9. ### Types
  10. A TODO MVC app consists of only two core types: `Todo` and `TodoList`:
  11. ```go example.global
  12. // Todo tracks a single todo item
  13. type Todo struct {
  14. Complete bool
  15. Description string
  16. }
  17. // TodoList tracks a collection of todo items
  18. type TodoList []Todo

Type registration

To use the types across the network, they have to be registered with
the codec (which will be
sjson
in this example)

```go example.global
// import github.com/dotchain/dot/ops/nw

func init() {
nw.Register(Todo{})
nw.Register(TodoList{})
}

  1. ### Code generation
  2. For use with **DOT**, these types need to be augmented with standard
  3. methods of the [Value](https://godoc.org/github.com/dotchain/dot/changes#Value)
  4. interface (or in the case of lists like `TodoList`, also implement the
  5. [Collection](https://godoc.org/github.com/dotchain/dot/changes#Collection)
  6. interface).
  7. These interfaces are essentially the ability to take changes of the
  8. form **replace a sub field** or **replace items in the array** and
  9. calculate the result of applying them. They are mostly boilerplate
  10. and so can be autogenerated easily via the
  11. [dotc](https://godoc.org/github.com/dotchain/dot/x/dotc) package. See
  12. [code generation](codegen.md) for augmenting the above type
  13. information.
  14. The code generation not only implements these two interfaces, it also
  15. produces a new **Stream** type for **Todo** and **TodoList**. A
  16. stream type is like a linked list with the `Value` field being the
  17. underlying value and **Next()** returning the next entry in the stream
  18. (in case the value was modified). And **Latest** returns the
  19. last entry in the stream at that point. Also, each stream type
  20. implements mutation methods to easily modify the value associated with
  21. a stream.
  22. What makes the streams interesting is that two different modifications
  23. from the same state cause the **Latest** of both to be the same with
  24. the effect of both *merged*. (This is done using the magic of
  25. operational transformations)
  26. ### Toggling Complete
  27. The code to toggle the `Complete` field of a particular todo item
  28. looks like the following:
  29. ```go example.global
  30. func Toggle(t *TodoListStream, index int) {
  31. // TodoListStream.Item() is generated code. It returns
  32. // a stream of the n'th element of the slice so that
  33. // particular stream can be modified. When that stream is
  34. // modified, the effect is automatically merged into the
  35. // parent (and available via .Next of the parent stream)
  36. todoStream := t.Item(index)
  37. // TodoStream.Complete is generated code. It returns a stream
  38. // for the Todo.Complete field so that it can be modified. As
  39. // with slices above, mutations on the field's stream are
  40. // reflected on the struct stream (via .Next or .Latest())
  41. completeStream := todoStream.Complete()
  42. // completeStream is of type streams.Bool. All streams
  43. // implement the simple Update(newValue) method that replaces
  44. // the current value with a new value.
  45. completeStream.Update(!completeStream.Value)
  46. }

Note that the function does not return any value here but the updates
can be fetched by calling .Latest() on any of the corresponding
streams. If a single stream instance has multiple edits, the
Latest() value is the merged value of all those edits.

Changing description

The code for changing the Description field is similar. The string
Description field in Todo maps to a streams.S16 stream. This
implements an Update() method like all streams.

But to make things interesting, lets look at splicing rather
than replacing the whole string. Splicing is taking a subsequence of
the string at a particular position and replacing it with the provided
value. It captures insert, delete and replace in one operation.

This probably better mimics what text editors do and a benefit of such
high granularity edits is that when two users edit the same text, the
edits will merge quite cleanly so
long as they don’t directly touch the same characters.

```go example.global
func SpliceDescription(t *TodoListStream, index, offset, count int, replacement string) {
// TodoListStream.Item() is generated code. It returns
// a stream of the n’th element of the slice so that
// particular stream can be modified. When that stream is
// modified, the effect is automatically merged into the
// parent (and available via .Next of the parent stream)
todoStream := t.Item(index)

  1. // TodoStream.Description is generated code. It returns a
  2. // stream for the Todo.Description field so that it can be
  3. // modified. As with slices above, mutations on the field's
  4. // stream are reflected on the struct stream (via .Next or
  5. // .Latest())
  6. // TodoStream.Description() returns streams.S16 type
  7. descStream := todoStream.Description()
  8. // streams.S16 implements Splice(offset, removeCount, replacement)
  9. descStream.Splice(offset, count, replacement)

}

  1. ### Adding Todos
  2. Adding a Todo is relatively simple as well:
  3. ```go example.global
  4. func AddTodo(t *TodoListStream, todo Todo) {
  5. // All slice streams implement Splice(offset, removeCount, replacement)
  6. t.Splice(len(t.Value), 0, todo)
  7. }

The use of Splice in this example should hint that (just like
strings) collections support insertion/deletion at arbitrary points within
via the Splice method. In addition to supporting this, collections also
support the Move(offset, count, distance) method to move some items
around within the collection

Client connection

Setting up the client requires connecting to the URL where the server
is hosted. In addition, the code below illustrates how sessions
could be saved and restarted if needed.

```go example.global
// import time
// import sync
// import github.com/dotchain/dot

var Lock sync.Mutex
func Client(stop chan struct{}, render func(*TodoListStream)) {
url := “http://localhost:8080/dot/
session, todos := SavedSession()
s, store := session.NonBlockingStream(url, nil)
defer store.Close()

  1. todosStream := &TodoListStream{Stream: s, Value: todos}
  2. ticker := time.NewTicker(500*time.Millisecond)
  3. changed := true
  4. for {
  5. if changed {
  6. render(todosStream)
  7. }
  8. select {
  9. case <- stop:
  10. return
  11. case <- ticker.C:
  12. }
  13. Lock.Lock()
  14. s.Push()
  15. s.Pull()
  16. next := todosStream.Latest()
  17. changed = next != todosStream
  18. todosStream, s = next, next.Stream
  19. Lock.Unlock()
  20. }
  21. SaveSession(session, todosStream.Value)

}

func SaveSession(s *dot.Session, todos TodoList) {
// this is not yet implemented. if it were, then
// this value should be persisted locally and returned
// by the call to savedSession
}

func SavedSession() (s *dot.Session, todos TodoList) {
// this is not yet implemented. return default values
return dot.NewSession(), nil
}

  1. ### Running the demo
  2. The TODO MVC demo is in the
  3. [example](https://github.com/dotchain/dot/tree/master/example)
  4. folder.
  5. The snippets in this markdown file can be used to generate the
  6. **todo.go** file and then auto-generate the "generated.go" file:
  7. ```sh
  8. $ go get github.com/tvastar/test/cmd/testmd
  9. $ testmd -pkg example -o examples/todo.go README.md
  10. $ testmd -pkg main codegen.md > examples/generated.go

The server can then be started by:

  1. $ go run server.go

The client can then be started by:

  1. $ go run client.go

The provide client.go stub file simply appends a task every 10
seconds.

In browser demo

The fuss project has demos of a
TODO-MVC app built on top of this framework using
gopherjs. In particular, the
collab
folder illustrates how simple the code is to make something work
collaboratively (the rest of the code base is not even aware of
whether things are collaborative).

How it all works

There are values, changes and streams.

  1. Values implement the
    Value
    interface. If the value represents a collection, it also implements
    the
    Collection
    interface.
  2. Changes represent mutations to values that can be merged. If
    two independent changes are made to the same value, they can be merged
    so that the A + merged(B) = B + merged(A). This is represented by
    the Change
    interface. The
    changes package
    implements the core changes with composition that allow richer changes
    to be implemented.
  3. Streams represent a sequence of changes to a value, except it
    is convergent — if multiple writers modify a value, they each get
    a separate stream instance that only reflects their local change but
    following the Next chain will guarantee that all versions end up with
    the same final value.

Applying changes

The following example illustrates how to edit a string with values and
changes

```golang dot_test.Example_applyingChanges
// import fmt
// import github.com/dotchain/dot/changes
// import github.com/dotchain/dot/changes/types

  1. // S8 is DOT-compatible string type with UTF8 string indices
  2. initial := types.S8("hello")
  3. append := changes.Splice{
  4. Offset: len("hello"), // end of "hello"
  5. Before: types.S8(""), // nothing to remove
  6. After: types.S8(" world"), // insert " world"
  7. }
  8. // apply the change
  9. updated := initial.Apply(nil, append)
  10. fmt.Println(updated)
  11. // Output: hello world
  1. ### Applying changes with streams
  2. A less verbose *stream* based version (preferred) would look like so:
  3. ```golang dot_test.Example_applyingChangesUsingStreams
  4. // import fmt
  5. // import github.com/dotchain/dot/streams
  6. initial := &streams.S8{Stream: streams.New(), Value: "hello"}
  7. updated := initial.Splice(5, 0, " world")
  8. fmt.Println(updated.Value)
  9. // Output: hello world

The changes
package implements the core changes: Splice, Move and
Replace. The logical model for these changes is to treat all
values as either being like arrays or like maps. The actual
underlying datatype can be different as long as the array/map
semantics is implemented.

Composition of changes

Changes can be composed together. A simple form of composition is
just a set of changes:

```golang dot_test.Example_changesetComposition
// import fmt
// import github.com/dotchain/dot/changes
// import github.com/dotchain/dot/changes/types

  1. initial := types.S8("hello")
  2. // append " world" => "hello world"
  3. append1 := changes.Splice{
  4. Offset: len("hello"),
  5. Before: types.S8(""),
  6. After: types.S8(" world"),
  7. }
  8. // append "." => "hello world."
  9. append2 := changes.Splice{
  10. Offset: len("hello world"),
  11. Before: types.S8(""),
  12. After: types.S8("."),
  13. }
  14. // now combine the two appends and apply
  15. both := changes.ChangeSet{append1, append2}
  16. updated := initial.Apply(nil, both)
  17. fmt.Println(updated)
  18. // Output: hello world.
  1. Another form of composition is modifying a sub-element such as an
  2. array element or a dictionary path:
  3. ```golang dot_test.Example_pathComposition
  4. // import fmt
  5. // import github.com/dotchain/dot/changes
  6. // import github.com/dotchain/dot/changes/types
  7. // types.A is a generic array type and types.M is a map type
  8. initial := types.A{types.M{"hello": types.S8("world")}}
  9. // replace "world" with "world!"
  10. replace := changes.Replace{Before: types.S8("world"), After: types.S8("world!")}
  11. // replace "world" with "world!" of initial[0]["hello"]
  12. path := []interface{}{0, "hello"}
  13. c := changes.PathChange{Path: path, Change: replace}
  14. updated := initial.Apply(nil, c)
  15. fmt.Println(updated)
  16. // Output: [map[hello:world!]]

Convergence

The core property of all changes is the ability to guarantee
convergence when two mutations are attempted on the same state:

```golang dot_test.Example_convergence
// import fmt
// import github.com/dotchain/dot/changes
// import github.com/dotchain/dot/changes/types

  1. initial := types.S8("hello")
  2. // two changes: append " world" and delete "lo"
  3. insert := changes.Splice{Offset: 5, Before: types.S8(""), After: types.S8(" world")}
  4. remove := changes.Splice{Offset: 3, Before: types.S8("lo"), After: types.S8("")}
  5. // two versions derived from initial
  6. inserted := initial.Apply(nil, insert)
  7. removed := initial.Apply(nil, remove)
  8. // merge the changes
  9. removex, insertx := insert.Merge(remove)
  10. // converge by applying the above
  11. final1 := inserted.Apply(nil, removex)
  12. final2 := removed.Apply(nil, insertx)
  13. fmt.Println(final1, final1 == final2)
  14. // Output: hel world true
  1. ### Convergence using streams
  2. The same convergence example is a lot easier to read with streams:
  3. ```golang dot_test.Example_convergenceUsingStreams
  4. // import fmt
  5. // import github.com/dotchain/dot/streams
  6. initial := streams.S8{Stream: streams.New(), Value: "hello"}
  7. // two changes: append " world" and delete "lo"
  8. s1 := initial.Splice(5, 0, " world")
  9. s2 := initial.Splice(3, len("lo"), "")
  10. // streams automatically merge because they are both
  11. // based on initial
  12. s1 = s1.Latest()
  13. s2 = s2.Latest()
  14. fmt.Println(s1.Value, s1.Value == s2.Value)
  15. // Output: hel world true

The ability to merge two independent changes done to the same
initial state is the basis for the eventual convergence of the data
structures. The
changes package
has fairly intensive tests to cover the change types defined there,
both individually and in composition.

Revert and undo

All the predefined types of changes in DOT (see
changes) are
carefully designed so that every change can be inverted easily without
reference to the underlying value. For example,
changes.Replace
has both the Before and After fields instead of just keeping
the After. This allows the reverse to be computed quite easily by
swapping the two fields. This does generally incur additional storage
expenses but the tradeoff is that code gets much simpler to work
with.

In particular, it is possible to build generic
undo support
quite easily and naturally. The following example shows both Undo
and Redo being invoked from an undo stack.

```go dot_test.Example_undoStreams
// import fmt
// import github.com/dotchain/dot/streams
// import github.com/dotchain/dot/changes
// import github.com/dotchain/dot/changes/types
// import github.com/dotchain/dot/streams/undo

  1. // create master, undoable child and the undo stack itself
  2. master := &streams.S16{Stream: streams.New(), Value: "hello"}
  3. s := undo.New(master.Stream)
  4. undoableChild := &streams.S16{Stream: s, Value: master.Value}
  5. // change hello => Hello
  6. undoableChild = undoableChild.Splice(0, len("h"), "H")
  7. fmt.Println(undoableChild.Value)
  8. // for kicks, update master hello => hello$ as if it came
  9. // from the server
  10. master.Splice(len("hello"), 0, "$")
  11. // now undo this via the stack
  12. s.Undo()
  13. // now undoableChild should be hello$
  14. undoableChild = undoableChild.Latest()
  15. fmt.Println(undoableChild.Value)
  16. // now redo the last operation to get Hello$
  17. s.Redo()
  18. undoableChild = undoableChild.Latest()
  19. fmt.Println(undoableChild.Value)
  20. // Output:
  21. // Hello
  22. // hello$
  23. // Hello$
  1. ### Folding
  2. In the case of editors, folding refers to a piece of text that has
  3. been hidden away. The difficulty with implementing this in a
  4. collaborative setting is that as external edits come in, the fold has
  5. to be maintained.
  6. The design of DOT allows for an elegant way to achieve this: consider
  7. the "folding" as a local change (replacing the folded region with
  8. say "..."). This local change is never meant to be sent out. All
  9. changes to the unfolded and folded versions can be proxied quite
  10. nicely without much app involvement:
  11. ```go dot_test.Example_folding
  12. // import fmt
  13. // import github.com/dotchain/dot/streams
  14. // import github.com/dotchain/dot/changes
  15. // import github.com/dotchain/dot/changes/types
  16. // import github.com/dotchain/dot/x/fold
  17. // create master, folded child and the folding itself
  18. master := &streams.S16{Stream: streams.New(), Value: "hello world!"}
  19. foldChange := changes.Splice{
  20. Offset: len("hello"),
  21. Before: types.S16(" world"),
  22. After: types.S16("..."),
  23. }
  24. foldedStream := fold.New(foldChange, master.Stream)
  25. folded := &streams.S16{Stream: foldedStream, Value :"hello...!"}
  26. // folded: hello...! => Hello...!!!
  27. folded = folded.Splice(0, len("h"), "H")
  28. folded = folded.Splice(len("Hello...!"), 0, "!!")
  29. fmt.Println(folded.Value)
  30. // master: hello world => hullo world
  31. master = master.Splice(len("h"), len("e"), "u")
  32. fmt.Println(master.Value)
  33. // now folded = Hullo...!!!
  34. fmt.Println(folded.Latest().Value)
  35. // master = Hullo world!!!
  36. fmt.Println(master.Latest().Value)
  37. // Output:
  38. // Hello...!!!
  39. // hullo world!
  40. // Hullo...!!!
  41. // Hullo world!!!

Branching of streams

Streams in DOT can also be branched a la Git. Changes made in
branches do not affect the master or vice-versa — until one of Pull
or Push are called.

```golang dot_test.Example_branching
// import fmt
// import github.com/dotchain/dot/streams
// import github.com/dotchain/dot/changes
// import github.com/dotchain/dot/changes/types

  1. // local is a branch of master
  2. master := &streams.S16{Stream: streams.New(), Value: "hello"}
  3. local := &streams.S16{Stream: streams.Branch(master.Stream), Value: master.Value}
  4. // edit locally: hello => hallo
  5. local.Splice(len("h"), len("e"), "a")
  6. // changes will not be reflected on master yet
  7. fmt.Println(master.Latest().Value)
  8. // push local changes up to master now
  9. local.Stream.Push()
  10. // now master = hallo
  11. fmt.Println(master.Latest().Value)
  12. // Output:
  13. // hello
  14. // hallo

```

There are other neat benefits to the branching model: it provides a
fine grained control for pulling changes from the network on demand
and suspending it as well as providing a way for making local
changes.

References

There are two broad cases where a JSON-like structure is not quite
enough.

  1. Editors often need to track the cursor or selection which can be
    thought of as offsets in the editor text. When changes happen to the
    text, for example, the offset would need to be updated.
  2. Objects often need to refer to other parts of the JSON-tree. For
    example, one can represent a graph using the array, map primitives
    with the addition of references. When changes happen, these too would
    need to be updated.

The refs package
implements a set of types that help work with these. In particular,
it defines a
Container
value that allows elements within to refer to other elements.

Network synchronization and server

DOT uses a fairly simple backend
Store
interface: an append-only dumb log. The
Bolt and
Postgres
implementations are quite simple and other data backends can be
easily added.

See Server and Client connection for
sample server and client applications. Note that the journal approach
used implies that the journal size only increases and so clients will
eventually take a while to rebuild their state from the journal. The
client API allows snapshotting state to make the rebuilds faster.
There is no server support for snapshots though it is possible to
build one rather easily

Broad Issues

  1. changes.Context/changes.Meta are not fully integrated
  2. ~gob-encoding makes it harder to deal with other languages but JSON
    encodindg wont work with interfaces.~
    • Added sjson encoding as a portable (if verbose) format.
    • The ES6 dotjs package uses this as the native format.
  3. Cross-object merging and persisted branches need more platform support
    • Snapshots are somewhat related to this as well.
  4. Full rich-text support with collaborative cursors still needs work
    with references and reference containers.
  5. Code generation can infer types from regular go declarations
  6. Snapshots and transient states need some sugar.

Contributing

Please see CONTRIBUTING.md.