Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions 04-formats/collaborative-excel/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
run:
go run ./cmd/collaborative-excel

build:
go build -o bin/collaborative-excel ./cmd/collaborative-excel

test:
go test ./...

clean:
rm -rf bin/
127 changes: 127 additions & 0 deletions 04-formats/collaborative-excel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Collaborative Excel

Проект для курса «Децентрализованные системы»: распределённая таблица с редактированием в реальном времени.

## Описание

`Collaborative Excel` — это децентрализованное приложение для совместной работы с таблицей:
- пользователь редактирует ячейки в UI;
- изменения рассылаются другим узлам напрямую через P2P-сеть;
- конфликты разрешаются детерминированно по правилу `LWW` (last-write-wins).

Проект реализован на Go с UI-библиотекой `Fyne`.

## Цель проекта

- показать, что совместная работа может работать без единого сервера;
- продемонстрировать распределённую синхронизацию состояний между узлами;
- разрешать конкурентные изменения через единый детерминированный механизм.

## Ключевые возможности (MVP)

- Один лист в книге.
- Табличный интерфейс для просмотра и редактирования ячеек.
- P2P-синхронизация изменений между узлами.
- Конфликт-резолвер `LWW`.
- Локальное хранение журнала/состояния (по умолчанию опционально).
- Автопоиск peers в локальной сети (mDNS), с возможным bootstrap для подключения из внешней сети.

## Архитектура

### Модули

- `cmd/` — точка входа приложения.
- `internal/ui/` — интерфейс на `Fyne`: таблица, редактирование, UI-события.
- `internal/core/` — модель таблицы и валидация операций.
- `internal/sync/` — P2P-коммуникация и обмен сообщениями.
- `internal/storage/` — локальное хранение состояния (Bolt/Badger).
- `internal/conflict/` — логика `LWW`.

### Как это работает

1. Пользователь редактирует ячейку в UI.
2. Формируется операция:
- `SetCellOperation{cell="A1", value, clock, actorID}`.
3. Операция применяется локально и публикуется в сеть.
4. Удалённые узлы применяют операцию тем же правилом.
5. При конфликте для одной ячейки используется порядок:
- больший `clock` выигрывает;
- при равенстве больше `actorID` по лексикографическому сравнению.

## Разделение ответственности

- **UI (`Fyne`)**: рендер ячеек, ввод пользователя, локальное отображение.
- **Core**: структура книги/ячеек, бизнес-правила.
- **Sync**: сеть и доставка сообщений.
- **Storage**: сериализация и восстановление состояния.

## Разрешение конфликтов (LWW)

Используется правило `last-write-wins` (LWW):

- каждая операция содержит `Lamport`-счетчик (или логический `clock`);
- у каждой ячейки хранится последняя применённая операция;
- при конфликте выбирается операция с большим `clock`, либо с большим `actorID` при равенстве.

Схема детерминирована и подходит для учебного MVP.

## Технологии

- **Go 1.22+**
- **Fyne** (UI)
- **libp2p** (P2P-сеть, pubsub)
- **BoltDB / Badger** (локальная персистентность по выбору)
- **JSON/protobuf** (формат обмена операциями)

## Установка и запуск

### Требования

- Go 1.22+
- make (опционально)

### Запуск из исходников

```bash
go mod tidy
go run ./cmd/collaborative-excel
```

### Сборка

```bash
go build -o bin/collaborative-excel ./cmd/collaborative-excel
```

## Makefile

Рекомендуемый набор целей:

```make
run:
go run ./cmd/collaborative-excel

build:
go build -o bin/collaborative-excel ./cmd/collaborative-excel

test:
go test ./...

clean:
rm -rf bin/
```

## Ограничения MVP

- один лист;
- без формул;
- без undo/redo и расширенной истории изменений;
- упрощённая модель конфликтов (LWW).

## Дальнейшее развитие

- поддержка формул;
- несколько листов;
- полный журнал изменений и undo;
- более сложная модель консистентности (CRDT) для семантически строгого слияния;
- расширенная авторизация peers и доступ по ролям.
129 changes: 129 additions & 0 deletions 04-formats/collaborative-excel/cmd/collaborative-excel/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package main

import (
"context"
"flag"
"fmt"
"strings"
"time"

"collaborative-excel/internal/core"
collabsync "collaborative-excel/internal/sync"
"collaborative-excel/internal/ui"
)

func main() {
listenAddr := flag.String("listen", "/ip4/127.0.0.1/tcp/0", "libp2p listen address")
bootstrap := flag.String("bootstrap", "", "comma separated bootstrap peers")
name := flag.String("name", "", "node identifier")
shareMode := flag.Bool("share", false, "print a ready-to-copy bootstrap command and exit")
rows := flag.Int("rows", 20, "sheet rows")
cols := flag.Int("cols", 12, "sheet columns")
flag.Parse()

actorID := strings.TrimSpace(*name)
if actorID == "" {
actorID = time.Now().Format("150405.000")
}

ctx := context.Background()
service := core.NewService(actorID)
updates := service.Subscribe()

networkCfg := collabsync.Config{
ListenAddress: *listenAddr,
Bootstrap: splitPeers(*bootstrap),
TopicName: "collaborative-excel",
}

networkClient, err := collabsync.NewClient(ctx, networkCfg)
if err != nil {
fmt.Printf("sync disabled: %v\n", err)
} else {
defer networkClient.Close()
}

if networkClient != nil {
fmt.Printf("peer=%s\n", actorID)
fmt.Printf("libp2p_peer=%s\n", networkClient.LocalPeerID())
for _, addr := range networkClient.LocalAddresses() {
fmt.Printf("addr=%s\n", addr)
}
}
if *shareMode {
if networkClient == nil {
fmt.Println("share unavailable: sync disabled")
} else {
shareArg := strings.Join(buildShareAddresses(networkClient.LocalAddresses()), ",")
fmt.Printf("share=%q\n", shareArg)
fmt.Printf("share_cmd=go run ./cmd/collaborative-excel -rows %d -cols %d -listen \"/ip4/0.0.0.0/tcp/0\" -bootstrap %q\n", *rows, *cols, shareArg)
}
}

if networkClient != nil {
go func() {
for update := range networkClient.Incoming() {
fmt.Printf(
"sync: inbound op cell=%s actor=%s clock=%d value=%q\n",
update.CellID,
update.ActorID,
update.Clock,
update.Value,
)
service.RemoteUpdate(update)
}
}()
}

publishLocal := func(op core.SetCellOp) {
if networkClient == nil {
return
}
if err := networkClient.Publish(op); err != nil {
fmt.Printf("sync: failed to publish op: %v\n", err)
return
}
fmt.Printf(
"sync: published op cell=%s actor=%s clock=%d value=%q\n",
op.CellID,
op.ActorID,
op.Clock,
op.Value,
)
}

ui.Run(service, *rows, *cols, publishLocal, updates)
}

func splitPeers(raw string) []string {
fields := strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == '\n' || r == '\r' || r == '\t' || r == ' '
})
clean := make([]string, 0, len(fields))
for _, part := range fields {
if trimmed := strings.TrimSpace(part); trimmed != "" {
clean = append(clean, trimmed)
}
}
return clean
}

func buildShareAddresses(addresses []string) []string {
filtered := make([]string, 0, len(addresses))
for _, addr := range addresses {
trimmed := strings.TrimSpace(addr)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, "/ip4/127.") ||
strings.Contains(trimmed, "/ip6/::1") ||
strings.Contains(trimmed, "/ip4/0.0.0.0") {
continue
}
filtered = append(filtered, trimmed)
}
if len(filtered) == 0 {
filtered = append(filtered, addresses...)
}
return filtered
}
Loading