Коллаборативные системы
Collaborative systems and Conflict-Free Replicated Data Types
Максим Климишин
Head of Software Architecture at Takeoff Technologies
29 октября 2017 г.
Максим Климишин Коллаборативные системы 1 / 48
Работа
Head of Software Architecture, Takeoff Technologies, 2017
CTO @ ZAKAZ.UA and CartFresh, 2012
Team Lead @ oDesk (now Upwork), 2010
Project Coordinator @ 42 Coffee Cups, 2009
Максим Климишин Коллаборативные системы 2 / 48
Организация
co-organizer of PapersWeLove Kyiv
co-founder of KyivJS
co-founder of LvivJS
co-organizer of PyCon Ukraine
co-organizer of Hotcode
judge at UA Web Challenge
Максим Климишин Коллаборативные системы 3 / 48
Takeoff Technologies
eGrocery: Grocery Delivery startup with robotic warehouse
Максим Климишин Коллаборативные системы 4 / 48
Distributed systems & Groupware systems
Онлайн игры
Календарь, контакты, напоминалки
Софт для конференций
Редактирование онлнай несколькими людьми
Групповые чаты
Максим Климишин Коллаборативные системы 5 / 48
Распределенные приложения
Использование на разных устройствах в разное время
Одновременное использование на разных устройствах
Работа офлайн
Максим Климишин Коллаборативные системы 6 / 48
Data and Consistency
Максим Климишин Коллаборативные системы 7 / 48
Serializability
serial schedule i.e. sequential with no operations overlap in time
Solution of a Problem in Concurrent Programming Control (E.W. Dijkstra,
1965)
A Quorum-based Commit Protocol (Dale Skeen, 1982)
Максим Климишин Коллаборативные системы 8 / 48
Concurrency fundamentals
divergence или расхождение: ∪n
i=1o1
i ̸= ∪m
j=1o2
j
causality-violations или нарушение порядка операций:
o1 → o3
intention-violations или нарушение намерений: o1||o2
Максим Климишин Коллаборативные системы 9 / 48
Что приходит в голову
Locking
Transactions
Tentative Transactions
Single Active Participant
Dependency Detection
Reversible Execution
Максим Климишин Коллаборативные системы 10 / 48
Good luck with that
The Ultimate Guide to Becoming Your Best Self
Максим Климишин Коллаборативные системы 11 / 48
Consistency Models
Strong Consistency
Weak Consistency
Read Your Writes
Eventual Consistency
Strong Eventual Consistency
Максим Климишин Коллаборативные системы 12 / 48
Replication Types
Pessimistic
Optimistic or Eventual Consistency
Максим Климишин Коллаборативные системы 13 / 48
Concurrency Control in Distributed Systems
1989 by Ellis and Gibbs/GROVE editor
Операции должны быть коммутативными, чтобы удовлетворить
convergence property:
op1 ⊙ op2 = op2 ⊙ op1
Максим Климишин Коллаборативные системы 14 / 48
Кто использует OT?
Wave took 2 years to write and if we rewrote it today, it
would take almost as long to write a second time
on ShareJS
Максим Климишин Коллаборативные системы 15 / 48
Proving correctness of transformation functions in
collaborative editing systems
2006 Gerald Oster et al
Доказал что все алгоритмы некорректные (с
контрпримерами), упс
Предложил TTF (Tombstone Transformation Functions)
Максим Климишин Коллаборативные системы 16 / 48
Operation Transformations on Google Scholar
1989-2006
Максим Климишин Коллаборативные системы 17 / 48
OP DOH
Максим Климишин Коллаборативные системы 18 / 48
Conflict-Free Replicated Data Types
CRDTs
Максим Климишин Коллаборативные системы 19 / 48
Ограничения сети
Задержки
Drop пакетов
Порядок
Дубликаты
Максим Климишин Коллаборативные системы 20 / 48
Conflict-free replicated data types (CRDT)
2011 Shapiro et al
коммутативность – ∀x, y : x ⊙ y = y ⊙ x
ассоциативность – x ⊙ (y ⊙ z) = (x ⊙ y) ⊙ z
идемпотентность – x ⊙ x = x
Максим Климишин Коллаборативные системы 21 / 48
Strong Eventual Consistency
C = [c1, ..., cn], ∀i, j : ci = cj ⇒ si ≡ sj
Максим Климишин Коллаборативные системы 22 / 48
Счетчик
1 + 1
Максим Климишин Коллаборативные системы 23 / 48
+ идемпотентность нарушена
1 + 1 ̸= 1
Максим Климишин Коллаборативные системы 24 / 48
∪ – ништяк с сетами
1 ∪ 1 = 1
Максим Климишин Коллаборативные системы 25 / 48
Прочие свойства в семантике множеств
(1 ∪ 2) ∪ 3 = 1 ∪ (2 ∪ 3)
1 ∪ 2 = 2 ∪ 1
Максим Климишин Коллаборативные системы 26 / 48
CRDT
ci
CRDT State
(convergent)
Conflict
Resolution
Semantics
Application
access API
Si
Максим Климишин Коллаборативные системы 27 / 48
CRDT: GCounter
Growth-only counter: Interface
export class GCounter {
constructor(counters) { this.counters = counters; }
increment(id) { ... }
query() { ... }
merge(counter) { ... }
}
Максим Климишин Коллаборативные системы 28 / 48
CRDT: GCounter payload
Counter State on init
let counters = {
1: 0
2: 0
...
N: 0}
Максим Климишин Коллаборативные системы 29 / 48
CRDT: GCounter increment op
export class GCounter {
increment(id) {
return new GCounter(Object.assign({
[id]: (this.counters[id] || 0) + 1}))}
}
Максим Климишин Коллаборативные системы 30 / 48
CRDT: GCounter query op
export class GCounter {
query() {
return Object.values(this.counters)
.reduce((a, b) => a + b, 0);}
}
Максим Климишин Коллаборативные системы 31 / 48
CRDT: GCounter merge op
export class GCounter {
merge(counter) {
let sites = Object.keys(counter.counters)
.concat(Object.keys(this.counters));
return new GCounter(sites.reduce(
(merged, site) => Object.assign(
merged, {[site]: Math.max(
merged[site] || 0,
counter.counters[site] || 0)}),
this.counters));
}
}
Максим Климишин Коллаборативные системы 32 / 48
CRDT: GCounter sum it up
export class GCounter {
constructor(counters) { this.counters = counters; }
increment(id) {
return new GCounter(Object.assign({
[id]: (this.counters[id] || 0) + 1}))}
query() {
return Object.values(this.counters)
.reduce((a, b) => a + b, 0); }
merge(counter) {
let sites = Object.keys(counter.counters)
.concat(Object.keys(this.counters));
return new GCounter(sites.reduce(
(merged, site) => Object.assign(
merged, {[site]: Math.max(
merged[site] || 0,
counter.counters[site] || 0)}),
this.counters));
}
}
Максим Климишин Коллаборативные системы 33 / 48
CRDT: GCounter-test
Increment only counter
linenos function examples_GCounter() {
linenos let log = (op, c1, c2) => console.log(
linenos "[OP] " + op + ": c1=" + c1.query() + ",
linenos c2=" + c2.query());
linenos let counter1 = new GCounter({});
linenos let counter2 = new GCounter({});
linenos
linenos log("counter1", counter1, counter2);
linenos counter1 = counter1.increment(0);
linenos log("counter1++", counter1, counter2);
linenos counter2 = counter2.merge(counter1);
linenos log("counter1 U counter2", counter1, counter2);
linenos }
Максим Климишин Коллаборативные системы 34 / 48
CRDT: GCounter example output
linenos node_modules/.bin/babel-node --presets es2016,es2015 main.js
linenos [OP] counter1: c1=0, c2=0
linenos [OP] counter1++: c1=1, c2=0
linenos [OP] counter1 U counter2: c1=1, c2=1
Максим Климишин Коллаборативные системы 35 / 48
CRDT: PNCounter
Positive-Negative Counter
class PNCounter {
constructor(p, n) { this.n = n; this.p = p}
increment() { return new PNCounter(this.p.increment(), this.n); }
decrement() { return new PNCounter(this.p, this.n.increment()); }
query() { return this.p.query() - this.n.query(); }
merge(pncounter) {
return new PNCounter(
this.p.merge(pncounter.p),
this.n.merge(pncounter.n)); }
}
Максим Климишин Коллаборативные системы 36 / 48
CRDT: MVRegister
Multi-Value Register
export class MVRegister {
constructor(id, register) { this.id = id; this.register = register; }
set(value) {
return new MVRegister(
this.id,
Object.assign(this.register, {[this.id]: value}))}
query() {return Object.values(this.register); }
merge(register) {
let state = this.register[this.id] === undefined ?
{} : {[this.id]: this.register[this.id]};
return new MVRegister(this.id,
Object.assign(register.register, state))
}
}
Максим Климишин Коллаборативные системы 37 / 48
CRDT: MVRegister Test
const repr = (arr) => arr.length === 0 ? '[]' :
'["' + arr.join('", "') + '"]';
function example_mvregister() {
let log = (op, r1, r2) => console.log("[OP] " + op +
": r1=" + repr(r1.query()) +
", r2=" + repr(r2.query()));
let r1 = new MVRegister(1, {});
let r2 = new MVRegister(2, {});
log("register1", r1, r2);
r1 = r1.set("key1")
log("r1.set(key1)", r1, r2);
r2 = r2.merge(r1);
log("r1 U r2", r1, r2);
r1.set("v1");
log("r1.set(v1)", r1, r2)
r2.set("v2")
log("[CONCURRENT] r2.set(v2)", r1, r2)
r2 = r2.merge(r1);
log("[MERGED]", r1, r2);
}
Максим Климишин Коллаборативные системы 38 / 48
CRDT: MVRegister Output
[OP] register1: r1=[], r2=[]
[OP] r1.set(key1): r1=["key1"], r2=[]
[OP] r1 U r2: r1=["key1"], r2=["key1"]
[OP] r1.set(v1): r1=["v1"], r2=["key1"]
[OP] [CONCURRENT] r2.set(v2): r1=["v1"], r2=["key1", "v2"]
[OP] [MERGED]: r1=["v1", "v2"], r2=["v1", "v2"]
Максим Климишин Коллаборативные системы 39 / 48
JSON CRDT
A Conflict-Free Replicated JSON Datatype (August 15, 2017)
Максим Климишин Коллаборативные системы 40 / 48
JSON CRDT
Concurrent mutation on same key with different types
Максим Климишин Коллаборативные системы 41 / 48
CRDT Shopping Cart
2017: redesign of Shopping Cart API
Максим Климишин Коллаборативные системы 42 / 48
CRDTs: CvRDT, CmRDT
state-based and op-based replication
Рис.: State-based
Convergent Replicated Data
Type or CvRDT
Рис.: Op-based Commutative
Replicated Data Type or
CmRDT
Максим Климишин Коллаборативные системы 43 / 48
CRDT: Types
Registers, Counters, Sets
Register: LWW or Multi-Value (Dynamo or Couchdb-like)
Counter (growth-only) and Counter w/decrementing
G-Set – growth-only set
2P-Set – remove only once set (G-Set + Tombstones set)
LWW-Element-Set – vector clocks
OR-Set – unique-tagged elements and list of tags within
Tombstones set
WOOT, LOGOOT, Treedoc, RGA, LSEQ for ordered lists
Максим Климишин Коллаборативные системы 44 / 48
Инструменты
Y-js – framework for offline-first p2p shared editing on structured
data
Riak 2.0: Counters, Flags, Sets, Registers, Maps
Roshi by Soundcloud
ShareDB (and fall of Google Wave)
Swarm (and forever-in-pre-alpha tool)
OT.js (Operational Transformation for JS)
CRDT – github.com/dominictarr/crdt
replikativ.io – p2p distributed system framework
GUN framework: p2p distributed framework
Максим Климишин Коллаборативные системы 45 / 48
Cвой CRDT
+ Мало кода
+ Относительно легко дополнять
+ Предсказуемо работает
+ Надежно при любом качестве связи
+ Работа оффлайн
- Сложная ментальная модель
- Семантическое разрешение конфликтов
- Сложно оптимально подобрать параметры GC
- Реально сложно работать с большим количеством данных
(δ-mutation)
Максим Климишин Коллаборативные системы 46 / 48
У кого в проде?
Facebook
TomTom
League of Legends
SoundCloud
Bet265
RIAK Distributed Database
Максим Климишин Коллаборативные системы 47 / 48
Thanks
@maxmaxmaxmax
Максим Климишин Коллаборативные системы 48 / 48

KharkivJS 2017: Коллаборативные системы и CRDT

  • 1.
    Коллаборативные системы Collaborative systemsand Conflict-Free Replicated Data Types Максим Климишин Head of Software Architecture at Takeoff Technologies 29 октября 2017 г. Максим Климишин Коллаборативные системы 1 / 48
  • 2.
    Работа Head of SoftwareArchitecture, Takeoff Technologies, 2017 CTO @ ZAKAZ.UA and CartFresh, 2012 Team Lead @ oDesk (now Upwork), 2010 Project Coordinator @ 42 Coffee Cups, 2009 Максим Климишин Коллаборативные системы 2 / 48
  • 3.
    Организация co-organizer of PapersWeLoveKyiv co-founder of KyivJS co-founder of LvivJS co-organizer of PyCon Ukraine co-organizer of Hotcode judge at UA Web Challenge Максим Климишин Коллаборативные системы 3 / 48
  • 4.
    Takeoff Technologies eGrocery: GroceryDelivery startup with robotic warehouse Максим Климишин Коллаборативные системы 4 / 48
  • 5.
    Distributed systems &Groupware systems Онлайн игры Календарь, контакты, напоминалки Софт для конференций Редактирование онлнай несколькими людьми Групповые чаты Максим Климишин Коллаборативные системы 5 / 48
  • 6.
    Распределенные приложения Использование наразных устройствах в разное время Одновременное использование на разных устройствах Работа офлайн Максим Климишин Коллаборативные системы 6 / 48
  • 7.
    Data and Consistency МаксимКлимишин Коллаборативные системы 7 / 48
  • 8.
    Serializability serial schedule i.e.sequential with no operations overlap in time Solution of a Problem in Concurrent Programming Control (E.W. Dijkstra, 1965) A Quorum-based Commit Protocol (Dale Skeen, 1982) Максим Климишин Коллаборативные системы 8 / 48
  • 9.
    Concurrency fundamentals divergence илирасхождение: ∪n i=1o1 i ̸= ∪m j=1o2 j causality-violations или нарушение порядка операций: o1 → o3 intention-violations или нарушение намерений: o1||o2 Максим Климишин Коллаборативные системы 9 / 48
  • 10.
    Что приходит вголову Locking Transactions Tentative Transactions Single Active Participant Dependency Detection Reversible Execution Максим Климишин Коллаборативные системы 10 / 48
  • 11.
    Good luck withthat The Ultimate Guide to Becoming Your Best Self Максим Климишин Коллаборативные системы 11 / 48
  • 12.
    Consistency Models Strong Consistency WeakConsistency Read Your Writes Eventual Consistency Strong Eventual Consistency Максим Климишин Коллаборативные системы 12 / 48
  • 13.
    Replication Types Pessimistic Optimistic orEventual Consistency Максим Климишин Коллаборативные системы 13 / 48
  • 14.
    Concurrency Control inDistributed Systems 1989 by Ellis and Gibbs/GROVE editor Операции должны быть коммутативными, чтобы удовлетворить convergence property: op1 ⊙ op2 = op2 ⊙ op1 Максим Климишин Коллаборативные системы 14 / 48
  • 15.
    Кто использует OT? Wavetook 2 years to write and if we rewrote it today, it would take almost as long to write a second time on ShareJS Максим Климишин Коллаборативные системы 15 / 48
  • 16.
    Proving correctness oftransformation functions in collaborative editing systems 2006 Gerald Oster et al Доказал что все алгоритмы некорректные (с контрпримерами), упс Предложил TTF (Tombstone Transformation Functions) Максим Климишин Коллаборативные системы 16 / 48
  • 17.
    Operation Transformations onGoogle Scholar 1989-2006 Максим Климишин Коллаборативные системы 17 / 48
  • 18.
    OP DOH Максим КлимишинКоллаборативные системы 18 / 48
  • 19.
    Conflict-Free Replicated DataTypes CRDTs Максим Климишин Коллаборативные системы 19 / 48
  • 20.
  • 21.
    Conflict-free replicated datatypes (CRDT) 2011 Shapiro et al коммутативность – ∀x, y : x ⊙ y = y ⊙ x ассоциативность – x ⊙ (y ⊙ z) = (x ⊙ y) ⊙ z идемпотентность – x ⊙ x = x Максим Климишин Коллаборативные системы 21 / 48
  • 22.
    Strong Eventual Consistency C= [c1, ..., cn], ∀i, j : ci = cj ⇒ si ≡ sj Максим Климишин Коллаборативные системы 22 / 48
  • 23.
    Счетчик 1 + 1 МаксимКлимишин Коллаборативные системы 23 / 48
  • 24.
    + идемпотентность нарушена 1+ 1 ̸= 1 Максим Климишин Коллаборативные системы 24 / 48
  • 25.
    ∪ – ништякс сетами 1 ∪ 1 = 1 Максим Климишин Коллаборативные системы 25 / 48
  • 26.
    Прочие свойства всемантике множеств (1 ∪ 2) ∪ 3 = 1 ∪ (2 ∪ 3) 1 ∪ 2 = 2 ∪ 1 Максим Климишин Коллаборативные системы 26 / 48
  • 27.
    CRDT ci CRDT State (convergent) Conflict Resolution Semantics Application access API Si МаксимКлимишин Коллаборативные системы 27 / 48
  • 28.
    CRDT: GCounter Growth-only counter:Interface export class GCounter { constructor(counters) { this.counters = counters; } increment(id) { ... } query() { ... } merge(counter) { ... } } Максим Климишин Коллаборативные системы 28 / 48
  • 29.
    CRDT: GCounter payload CounterState on init let counters = { 1: 0 2: 0 ... N: 0} Максим Климишин Коллаборативные системы 29 / 48
  • 30.
    CRDT: GCounter incrementop export class GCounter { increment(id) { return new GCounter(Object.assign({ [id]: (this.counters[id] || 0) + 1}))} } Максим Климишин Коллаборативные системы 30 / 48
  • 31.
    CRDT: GCounter queryop export class GCounter { query() { return Object.values(this.counters) .reduce((a, b) => a + b, 0);} } Максим Климишин Коллаборативные системы 31 / 48
  • 32.
    CRDT: GCounter mergeop export class GCounter { merge(counter) { let sites = Object.keys(counter.counters) .concat(Object.keys(this.counters)); return new GCounter(sites.reduce( (merged, site) => Object.assign( merged, {[site]: Math.max( merged[site] || 0, counter.counters[site] || 0)}), this.counters)); } } Максим Климишин Коллаборативные системы 32 / 48
  • 33.
    CRDT: GCounter sumit up export class GCounter { constructor(counters) { this.counters = counters; } increment(id) { return new GCounter(Object.assign({ [id]: (this.counters[id] || 0) + 1}))} query() { return Object.values(this.counters) .reduce((a, b) => a + b, 0); } merge(counter) { let sites = Object.keys(counter.counters) .concat(Object.keys(this.counters)); return new GCounter(sites.reduce( (merged, site) => Object.assign( merged, {[site]: Math.max( merged[site] || 0, counter.counters[site] || 0)}), this.counters)); } } Максим Климишин Коллаборативные системы 33 / 48
  • 34.
    CRDT: GCounter-test Increment onlycounter linenos function examples_GCounter() { linenos let log = (op, c1, c2) => console.log( linenos "[OP] " + op + ": c1=" + c1.query() + ", linenos c2=" + c2.query()); linenos let counter1 = new GCounter({}); linenos let counter2 = new GCounter({}); linenos linenos log("counter1", counter1, counter2); linenos counter1 = counter1.increment(0); linenos log("counter1++", counter1, counter2); linenos counter2 = counter2.merge(counter1); linenos log("counter1 U counter2", counter1, counter2); linenos } Максим Климишин Коллаборативные системы 34 / 48
  • 35.
    CRDT: GCounter exampleoutput linenos node_modules/.bin/babel-node --presets es2016,es2015 main.js linenos [OP] counter1: c1=0, c2=0 linenos [OP] counter1++: c1=1, c2=0 linenos [OP] counter1 U counter2: c1=1, c2=1 Максим Климишин Коллаборативные системы 35 / 48
  • 36.
    CRDT: PNCounter Positive-Negative Counter classPNCounter { constructor(p, n) { this.n = n; this.p = p} increment() { return new PNCounter(this.p.increment(), this.n); } decrement() { return new PNCounter(this.p, this.n.increment()); } query() { return this.p.query() - this.n.query(); } merge(pncounter) { return new PNCounter( this.p.merge(pncounter.p), this.n.merge(pncounter.n)); } } Максим Климишин Коллаборативные системы 36 / 48
  • 37.
    CRDT: MVRegister Multi-Value Register exportclass MVRegister { constructor(id, register) { this.id = id; this.register = register; } set(value) { return new MVRegister( this.id, Object.assign(this.register, {[this.id]: value}))} query() {return Object.values(this.register); } merge(register) { let state = this.register[this.id] === undefined ? {} : {[this.id]: this.register[this.id]}; return new MVRegister(this.id, Object.assign(register.register, state)) } } Максим Климишин Коллаборативные системы 37 / 48
  • 38.
    CRDT: MVRegister Test constrepr = (arr) => arr.length === 0 ? '[]' : '["' + arr.join('", "') + '"]'; function example_mvregister() { let log = (op, r1, r2) => console.log("[OP] " + op + ": r1=" + repr(r1.query()) + ", r2=" + repr(r2.query())); let r1 = new MVRegister(1, {}); let r2 = new MVRegister(2, {}); log("register1", r1, r2); r1 = r1.set("key1") log("r1.set(key1)", r1, r2); r2 = r2.merge(r1); log("r1 U r2", r1, r2); r1.set("v1"); log("r1.set(v1)", r1, r2) r2.set("v2") log("[CONCURRENT] r2.set(v2)", r1, r2) r2 = r2.merge(r1); log("[MERGED]", r1, r2); } Максим Климишин Коллаборативные системы 38 / 48
  • 39.
    CRDT: MVRegister Output [OP]register1: r1=[], r2=[] [OP] r1.set(key1): r1=["key1"], r2=[] [OP] r1 U r2: r1=["key1"], r2=["key1"] [OP] r1.set(v1): r1=["v1"], r2=["key1"] [OP] [CONCURRENT] r2.set(v2): r1=["v1"], r2=["key1", "v2"] [OP] [MERGED]: r1=["v1", "v2"], r2=["v1", "v2"] Максим Климишин Коллаборативные системы 39 / 48
  • 40.
    JSON CRDT A Conflict-FreeReplicated JSON Datatype (August 15, 2017) Максим Климишин Коллаборативные системы 40 / 48
  • 41.
    JSON CRDT Concurrent mutationon same key with different types Максим Климишин Коллаборативные системы 41 / 48
  • 42.
    CRDT Shopping Cart 2017:redesign of Shopping Cart API Максим Климишин Коллаборативные системы 42 / 48
  • 43.
    CRDTs: CvRDT, CmRDT state-basedand op-based replication Рис.: State-based Convergent Replicated Data Type or CvRDT Рис.: Op-based Commutative Replicated Data Type or CmRDT Максим Климишин Коллаборативные системы 43 / 48
  • 44.
    CRDT: Types Registers, Counters,Sets Register: LWW or Multi-Value (Dynamo or Couchdb-like) Counter (growth-only) and Counter w/decrementing G-Set – growth-only set 2P-Set – remove only once set (G-Set + Tombstones set) LWW-Element-Set – vector clocks OR-Set – unique-tagged elements and list of tags within Tombstones set WOOT, LOGOOT, Treedoc, RGA, LSEQ for ordered lists Максим Климишин Коллаборативные системы 44 / 48
  • 45.
    Инструменты Y-js – frameworkfor offline-first p2p shared editing on structured data Riak 2.0: Counters, Flags, Sets, Registers, Maps Roshi by Soundcloud ShareDB (and fall of Google Wave) Swarm (and forever-in-pre-alpha tool) OT.js (Operational Transformation for JS) CRDT – github.com/dominictarr/crdt replikativ.io – p2p distributed system framework GUN framework: p2p distributed framework Максим Климишин Коллаборативные системы 45 / 48
  • 46.
    Cвой CRDT + Малокода + Относительно легко дополнять + Предсказуемо работает + Надежно при любом качестве связи + Работа оффлайн - Сложная ментальная модель - Семантическое разрешение конфликтов - Сложно оптимально подобрать параметры GC - Реально сложно работать с большим количеством данных (δ-mutation) Максим Климишин Коллаборативные системы 46 / 48
  • 47.
    У кого впроде? Facebook TomTom League of Legends SoundCloud Bet265 RIAK Distributed Database Максим Климишин Коллаборативные системы 47 / 48
  • 48.