Eğitim Teknolojisinde Gerçek Zamanlı İşbirliği: CRDT ve WebSocket
Google Dokümanlar, dokümanlar üzerinde eşzamanlı ortak çalışmanın mümkün olduğunu kanıtladı. Notion, Figma ve Miro bu paradigmayı sonsuz notalara, tasarımlara ve beyaz tahtalara kadar genişletti. Artık Eğitim Teknolojisi platformları aynı deneyimi işbirlikçi öğrenmeye de getirmek istiyor: Aynı belge, kod veya egzersiz üzerinde gerçek zamanlı çalışan öğrenciler, diğer insanların imleçlerini görmek, anlık değişiklikler, çatışma olmadan.
Temel sorun, dağıtılmış rekabet: iki öğrenci olduğunda aynı metni aynı anda farklı makinelerden düzenliyorlar; hangi düzenleme kazanır? Basitçe "son yazma-kazanır" kabul edilemez sonuçlar doğurur (kayıp değişiklikler). İki standart çözüm vardır: Operasyonel Dönüşüm (OT) (Google Dokümanlar tarafından kullanılır) e Çatışmasız Çoğaltılmış Veri Türleri (CRDT). 2024-2025'te CRDT'ler üretim olgunluğuna ulaştı ve basitlikleri sayesinde yeni uygulamalar için fiili standart kavramsal ve matematiksel sağlamlık.
Bu yazıda EdTech için gerçek zamanlı bir işbirliği sistemi oluşturacağız: Yjs (en popüler CRDT), WebSocket ile işbirliğine dayalı belge düzenleme senkronizasyon için ve öğrenme için belirli özellikler gibi paylaşılan ek açıklamalar, adlandırılmış imleçler ve dizili yorumlar.
Bu Makalede Neler Öğreneceksiniz?
- CRDT ve OT: farklar, avantajlar ve ne zaman seçileceği
- Yjs: iç yapı, veri türleri ve Y.Doc
- Y-websocket ile WebSocket senkronizasyon sunucusu
- Varlık: adlandırılmış imleçler, seçimler ve çevrimiçi kullanıcılar
- Kalıcılık: CRDT durumunu kaydetme ve yükleme
- Eğitici ek açıklamalar: yorumlar, öne çıkanlar ve tartışma konuları
- CRDT ile önce çevrimdışı: yeniden bağlanıldığında otomatik senkronizasyon
- Ölçeklendirme: Redis pub/sub ile tek sunucudan kümeye
1. CRDT ve Operasyonel Dönüşüm
Operasyonel Dönüşüm (OT) işlemleri uygulamadan önce dönüştürün eşzamanlı değişiklikleri hesaba katmak için. İyi çalışıyor ancak merkezi bir sunucu gerektiriyor dönüşümleri koordine eden: Google Dokümanlar bu mimariyi bir sunucuyla kullanır merkezi dönüşüm.
CRDT sorunu kökten farklı bir şekilde çözüyor: veri yapıları matematiksel olarak tasarlanmışlardır birleştirme-Çakışma olmadan mümkün. Operasyonlar değişmeli (ne olursa olsun aynı sonuç siparişten) e güçsüz (aynı işlemi iki kez uygulayın sonucu değiştirmez). Bu, merkezi bir koordinatör ihtiyacını ortadan kaldırır ve otomatik senkronizasyonla çevrimdışı çalışmaya izin verir.
OT ve CRDT karşılaştırması
| bekliyorum | OT (Google Dokümanlar) | CRDT (Yjs, Otomatik Birleştirme) |
|---|---|---|
| Merkez koordinatörü | Gerekli | Gerekli değil |
| Çevrimdışı destek | Sınırlı | Yerli |
| Uygulama karmaşıklığı | Yüksek | Medya (kitaplıklarla birlikte) |
| Matematiksel garantiler | Bu uygulamaya bağlıdır | Resmi olarak test edildi |
| Bellek yükü | Bas | Orta (silinen öğeler için mezar taşı) |
| Yetişkin kütüphaneleri | ShareDB, OT.js | Yjs, Otomatik Birleştirme, Kola |
| Ölçeklenebilirlik | Dikey (merkezi sunucu) | Yatay (P2P veya çoklu sunucu) |
| Evlat Edinme 2025 | Eski (mevcut sistemler) | Yeni sistemler için standartlar |
2. Yjs: Eğitim Teknolojisi için CRDT
Evet ve 2025'in en olgun, benimsenmiş CRDT kütüphanesi.
Tüm önemli editörler için bağlamalar sunar (ProseMirror, TipTap, CodeMirror, Monaco),
WebSocket, WebRTC ve IndexedDB desteği ve basit bir API tabanlı
Y.Doc (ortak çalışma belgesi) ve onun paylaşılan veri türleri.
Yjs e'nin temel birimi Y.Doc: içeren bir belge
Paylaşılan veri yapılarının hiyerarşisi. Bu verilerde değişiklikler geliyor
otomatik olarak serileştirildi ikili güncellemeler ellerinden geldiğince kompakt
tüm akranlara gönderilmeli ve herhangi bir çakışma olmaksızın herhangi bir sırayla uygulanmalıdır.
// Frontend: src/collaboration/collaborative-editor.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
import { IndexeddbPersistence } from 'y-indexeddb';
interface CollabUser {
name: string;
color: string;
avatar: string;
studentId: string;
}
export class CollaborativeEditorSession {
private doc: Y.Doc;
private wsProvider: WebsocketProvider;
private dbProvider: IndexeddbPersistence;
private text: Y.Text;
private annotations: Y.Array<any>;
private comments: Y.Map<any>;
constructor(
private readonly documentId: string,
private readonly user: CollabUser,
private readonly wsUrl: string,
) {
// Crea il documento collaborativo
this.doc = new Y.Doc();
// Strutture dati condivise
this.text = this.doc.getText('content');
this.annotations = this.doc.getArray('annotations');
this.comments = this.doc.getMap('comments');
}
async initialize(): Promise<void> {
// Persistenza locale con IndexedDB (supporto offline)
this.dbProvider = new IndexeddbPersistence(
`edtech-doc-${this.documentId}`,
this.doc,
);
await new Promise<void>((resolve) => {
this.dbProvider.on('synced', () => {
console.log('Documento caricato da IndexedDB');
resolve();
});
});
// Connessione WebSocket per sync real-time
this.wsProvider = new WebsocketProvider(
this.wsUrl,
`doc-${this.documentId}`,
this.doc,
{
connect: true,
awareness: this.createAwareness(),
},
);
this.wsProvider.on('status', (event: { status: string }) => {
console.log('WebSocket status:', event.status);
});
// Gestione presenza: cursori degli altri utenti
this.setupPresence();
}
private createAwareness() {
// Awareness e il meccanismo Yjs per stato temporaneo (presenza, cursori)
// NON fa parte del documento persistente, solo stato effimero
return {
getLocalState: () => ({
user: this.user,
cursor: null, // Aggiornato durante l'editing
selection: null,
lastSeen: Date.now(),
}),
};
}
private setupPresence(): void {
const awareness = this.wsProvider.awareness;
// Imposta stato locale dell'utente
awareness.setLocalStateField('user', this.user);
// Ascolta cambiamenti nella presenza degli altri utenti
awareness.on('change', () => {
const states = Array.from(awareness.getStates().entries());
const onlineUsers = states
.filter(([clientId]) => clientId !== this.doc.clientID)
.map(([, state]) => state.user)
.filter(Boolean);
this.onUsersChanged(onlineUsers);
});
}
updateCursor(position: number, selection: { from: number; to: number } | null): void {
this.wsProvider.awareness.setLocalStateField('cursor', position);
this.wsProvider.awareness.setLocalStateField('selection', selection);
}
addAnnotation(annotation: {
from: number;
to: number;
type: 'highlight' | 'underline' | 'comment';
comment?: string;
authorId: string;
}): void {
this.doc.transact(() => {
this.annotations.push([{
id: crypto.randomUUID(),
...annotation,
createdAt: Date.now(),
}]);
});
}
addComment(commentId: string, comment: {
text: string;
authorId: string;
authorName: string;
replyTo?: string;
}): void {
this.doc.transact(() => {
const thread = this.comments.get(commentId) || [];
this.comments.set(commentId, [...thread, {
id: crypto.randomUUID(),
...comment,
timestamp: Date.now(),
}]);
});
}
getAnnotations(): any[] {
return this.annotations.toArray();
}
getComments(): Map<string, any[]> {
return new Map(Array.from(this.comments.entries()));
}
protected onUsersChanged(users: CollabUser[]): void {
// Override in componente per aggiornare UI
console.log('Utenti online:', users.map(u => u.name));
}
destroy(): void {
this.wsProvider?.destroy();
this.dbProvider?.destroy();
this.doc?.destroy();
}
}
3. WebSocket Senkronizasyon Sunucusu
WebSocket Yjs sunucusu şunlardan sorumludur: istemcilerden güncellemeleri toplamak,
bunları aynı belgeye bağlı tüm eşlere dağıtın ve durumu sürdürün
Yeni istemcilerin bağlanması için belgenin. hadi kullanalım y-websocket
Temel olarak kimlik doğrulama ve kiracı başına yetkilendirme ile bunu genişletiyoruz
ve Redis/PostgreSQL'de kalıcılık.
# server/collab_server.py
import asyncio
import json
import logging
from typing import Dict, Set, Optional
from dataclasses import dataclass, field
import websockets
from websockets.server import WebSocketServerProtocol
logger = logging.getLogger(__name__)
@dataclass
class DocumentRoom:
document_id: str
clients: Set[WebSocketServerProtocol] = field(default_factory=set)
awareness_states: Dict[int, dict] = field(default_factory=dict)
# Ultimo stato persistito del documento (aggiornamenti Yjs binari)
persisted_state: Optional[bytes] = None
class YjsWebSocketServer:
"""
Server WebSocket per sincronizzazione Yjs multi-room.
Ogni 'room' corrisponde a un documento collaborativo.
"""
MESSAGE_SYNC = 0
MESSAGE_AWARENESS = 1
MESSAGE_AUTH = 2
def __init__(
self,
redis_client,
db,
auth_service,
port: int = 8080,
):
self.redis = redis_client
self.db = db
self.auth = auth_service
self.port = port
# rooms: document_id -> DocumentRoom
self.rooms: Dict[str, DocumentRoom] = {}
async def handle_client(self, websocket: WebSocketServerProtocol, path: str):
"""
Handler principale per ogni connessione WebSocket.
path: /collab/{document_id}
"""
# Estrai document_id dal path
try:
document_id = path.split("/collab/")[1]
except (IndexError, AttributeError):
await websocket.close(4001, "Invalid path")
return
# Autenticazione: primo messaggio deve essere il token
try:
auth_msg = await asyncio.wait_for(websocket.recv(), timeout=5.0)
auth_data = json.loads(auth_msg)
user = await self.auth.verify_token(auth_data.get("token", ""))
if not user:
await websocket.close(4003, "Unauthorized")
return
# Verifica accesso al documento
if not await self.auth.can_access_document(user.id, document_id):
await websocket.close(4003, "Forbidden")
return
except asyncio.TimeoutError:
await websocket.close(4002, "Auth timeout")
return
# Unisci alla room del documento
room = self._get_or_create_room(document_id)
room.clients.add(websocket)
try:
# Invia stato corrente al nuovo client (sync iniziale)
await self._send_initial_state(websocket, document_id, room)
# Loop messaggi
async for message in websocket:
await self._handle_message(websocket, room, message, user)
except websockets.ConnectionClosed:
pass
finally:
room.clients.discard(websocket)
room.awareness_states.pop(id(websocket), None)
# Notifica agli altri client che questo utente e andato offline
await self._broadcast_awareness(room, exclude=websocket)
if not room.clients:
# Ultima persone nella room: persisti e rimuovi
await self._persist_document(document_id, room)
del self.rooms[document_id]
def _get_or_create_room(self, document_id: str) -> DocumentRoom:
if document_id not in self.rooms:
self.rooms[document_id] = DocumentRoom(document_id=document_id)
return self.rooms[document_id]
async def _send_initial_state(
self,
websocket: WebSocketServerProtocol,
document_id: str,
room: DocumentRoom,
) -> None:
"""Invia lo stato corrente del documento al nuovo client."""
# Carica da Redis (cache) o PostgreSQL
state = room.persisted_state or await self._load_document(document_id)
if state:
# Messaggio di sync: tipo 0 = SYNC_STEP1 in protocollo Yjs
await websocket.send(
bytes([self.MESSAGE_SYNC, 0]) + state
)
# Invia stati di awareness degli altri utenti
if room.awareness_states:
awareness_payload = json.dumps({
"clients": room.awareness_states
}).encode()
await websocket.send(bytes([self.MESSAGE_AWARENESS]) + awareness_payload)
async def _handle_message(
self,
websocket: WebSocketServerProtocol,
room: DocumentRoom,
message: bytes,
user,
) -> None:
"""Processa un messaggio dal client e lo propaga."""
if not isinstance(message, bytes) or not message:
return
msg_type = message[0]
payload = message[1:]
if msg_type == self.MESSAGE_SYNC:
# Aggiornamento documento: broadcast a tutti gli altri client
await self._broadcast(room, message, exclude=websocket)
# Aggiorna stato in memoria e schedula persistenza
await self._update_document_state(room, payload)
elif msg_type == self.MESSAGE_AWARENESS:
# Aggiornamento presenza: cursori, selezioni, utenti online
client_id = id(websocket)
room.awareness_states[client_id] = {
"user": user.to_dict(),
"payload": payload.decode("utf-8", errors="replace"),
}
await self._broadcast_awareness(room, exclude=None)
async def _broadcast(
self,
room: DocumentRoom,
message: bytes,
exclude: Optional[WebSocketServerProtocol] = None,
) -> None:
"""Invia un messaggio a tutti i client nella room eccetto il mittente."""
dead_clients = set()
for client in room.clients:
if client == exclude:
continue
try:
await client.send(message)
except websockets.ConnectionClosed:
dead_clients.add(client)
room.clients -= dead_clients
async def _broadcast_awareness(self, room: DocumentRoom, exclude=None) -> None:
awareness_payload = json.dumps({
"clients": {
str(cid): state
for cid, state in room.awareness_states.items()
}
}).encode()
message = bytes([self.MESSAGE_AWARENESS]) + awareness_payload
await self._broadcast(room, message, exclude=exclude)
async def _load_document(self, document_id: str) -> Optional[bytes]:
"""Carica documento da Redis o PostgreSQL."""
# Prima prova Redis
cached = await self.redis.get(f"yjsdoc:{document_id}")
if cached:
return cached
# Fallback su PostgreSQL
result = await self.db.execute(
"SELECT yjs_state FROM collaborative_documents WHERE id = :did",
{"did": document_id},
)
row = result.fetchone()
if row and row[0]:
state = bytes(row[0])
await self.redis.setex(f"yjsdoc:{document_id}", 3600, state)
return state
return None
async def _persist_document(self, document_id: str, room: DocumentRoom) -> None:
if not room.persisted_state:
return
await self.db.execute(
"""INSERT INTO collaborative_documents (id, yjs_state, updated_at)
VALUES (:did, :state, NOW())
ON CONFLICT (id) DO UPDATE
SET yjs_state = :state, updated_at = NOW()""",
{"did": document_id, "state": room.persisted_state},
)
await self.db.commit()
await self.redis.setex(f"yjsdoc:{document_id}", 3600, room.persisted_state)
async def _update_document_state(self, room: DocumentRoom, update: bytes) -> None:
"""Applica un update al documento in memoria."""
# In produzione usa una libreria Yjs lato server (y-py) per merge corretto
# Qui semplificato: conserva l'ultimo update ricevuto
room.persisted_state = update
async def start(self):
async with websockets.serve(self.handle_client, "0.0.0.0", self.port):
logger.info(f"Yjs WebSocket server in ascolto su porta {self.port}")
await asyncio.Future() # Loop infinito
4. Ölçeklendirme: Tek Sunucudan Kümeye
Tek bir WebSocket sunucusu binlerce eşzamanlı belgeyi işleyemez. Yatay olarak ölçeklendirmek için şunu kullanırız: Redis Pub/Sub mesaj otobüsü olarak sunucular arasında: Sunucu A'daki bir istemci bir güncelleme gönderdiğinde, Sunucu A bunu yayınlar Redis'te ve Sunucu B (aynı belgeye bağlı başka istemcileri olan) onu alır ve müşterilerine iletir.
# server/collab_cluster.py
import asyncio
import json
from typing import Dict, Optional
import redis.asyncio as aioredis
class ClusteredYjsServer:
"""
Estensione del server Yjs per deployment in cluster.
Usa Redis Pub/Sub per sincronizzare i nodi del cluster.
"""
def __init__(self, base_server: "YjsWebSocketServer", redis_url: str):
self.base = base_server
self.redis_url = redis_url
self.pub_redis: Optional[aioredis.Redis] = None
self.sub_redis: Optional[aioredis.Redis] = None
async def start_cluster_sync(self):
"""Avvia la sincronizzazione tra nodi del cluster via Redis."""
self.pub_redis = await aioredis.from_url(self.redis_url)
self.sub_redis = await aioredis.from_url(self.redis_url)
pubsub = self.sub_redis.pubsub()
await pubsub.psubscribe("yjs:*") # Subscribe a tutti i canali documento
asyncio.create_task(self._listen_cluster_messages(pubsub))
logger.info("Cluster sync avviato via Redis Pub/Sub")
async def _listen_cluster_messages(self, pubsub):
"""Ricevi e distribuisci messaggi dagli altri nodi del cluster."""
async for message in pubsub.listen():
if message["type"] not in ("message", "pmessage"):
continue
channel = message["channel"].decode()
document_id = channel.split("yjs:")[-1]
if document_id not in self.base.rooms:
continue # Questo nodo non ha client per questo documento
data = message["data"]
room = self.base.rooms[document_id]
# Propaga ai client locali (escludi il mittente tramite source_node)
source_node = json.loads(data[:36]) if len(data) > 36 else {}
payload = data[36:]
await self.base._broadcast(room, payload, exclude=None)
async def publish_update(self, document_id: str, update: bytes, node_id: str) -> None:
"""Pubblica un update a tutti i nodi del cluster."""
channel = f"yjs:{document_id}"
# Prefissa con il node_id per evitare loop
message = json.dumps({"node": node_id}).encode()[:36].ljust(36) + update
await self.pub_redis.publish(channel, message)
5. Eğitimsel Özellikler: Ek Açıklamalar ve Konular
Eğitim Teknolojisi platformlarının işbirliğine dayalı düzenlemeye ek olarak işlevselliğe de ihtiyacı var öğrenmenin özellikleri: ek açıklamalar (vurgular ve notlar metnin bazı kısımlarında), tartışma konusu (sorular ve cevaplar belirli adımlara bağlı), e inceleme modu (öğretmen doğrudan değiştirmeden öğrencinin çalışmasına ilişkin yorumlar).
// Frontend: src/collaboration/annotations.service.ts
import * as Y from 'yjs';
import { Injectable } from '@angular/core';
export interface Annotation {
id: string;
from: number;
to: number;
type: 'highlight' | 'underline' | 'comment' | 'correction';
color?: string;
authorId: string;
authorName: string;
text?: string;
timestamp: number;
resolved?: boolean;
}
export interface CommentThread {
id: string;
annotationId: string;
replies: CommentReply[];
resolved: boolean;
}
export interface CommentReply {
id: string;
text: string;
authorId: string;
authorName: string;
timestamp: number;
isTeacher: boolean;
}
@Injectable({ providedIn: 'root' })
export class AnnotationsService {
private doc: Y.Doc | null = null;
private annotations: Y.Array<Annotation> | null = null;
private threads: Y.Map<CommentThread> | null = null;
initialize(doc: Y.Doc): void {
this.doc = doc;
this.annotations = doc.getArray<Annotation>('annotations');
this.threads = doc.getMap<CommentThread>('threads');
}
addAnnotation(annotation: Omit<Annotation, 'id' | 'timestamp'>): string {
if (!this.annotations || !this.doc) throw new Error('Non inizializzato');
const id = crypto.randomUUID();
const fullAnnotation: Annotation = {
...annotation,
id,
timestamp: Date.now(),
};
this.doc.transact(() => {
this.annotations!.push([fullAnnotation]);
// Crea thread vuoto se e un commento
if (annotation.type === 'comment') {
this.threads!.set(id, {
id,
annotationId: id,
replies: [],
resolved: false,
});
}
});
return id;
}
addReply(threadId: string, reply: Omit<CommentReply, 'id' | 'timestamp'>): void {
if (!this.threads || !this.doc) return;
const thread = this.threads.get(threadId);
if (!thread) return;
const updatedThread: CommentThread = {
...thread,
replies: [...thread.replies, {
...reply,
id: crypto.randomUUID(),
timestamp: Date.now(),
}],
};
this.doc.transact(() => {
this.threads!.set(threadId, updatedThread);
});
}
resolveThread(threadId: string): void {
if (!this.threads || !this.doc) return;
const thread = this.threads.get(threadId);
if (!thread) return;
this.doc.transact(() => {
this.threads!.set(threadId, { ...thread, resolved: true });
// Segna l'annotazione come risolta
const annotations = this.annotations!.toArray();
const idx = annotations.findIndex(a => a.id === thread.annotationId);
if (idx !== -1) {
this.annotations!.delete(idx, 1);
this.annotations!.insert(idx, [{ ...annotations[idx], resolved: true }]);
}
});
}
getAnnotations(): Annotation[] {
return this.annotations?.toArray() ?? [];
}
getThread(threadId: string): CommentThread | undefined {
return this.threads?.get(threadId);
}
onAnnotationsChange(callback: (annotations: Annotation[]) => void): () => void {
if (!this.annotations) return () => {};
const handler = () => callback(this.getAnnotations());
this.annotations.observe(handler);
return () => this.annotations?.unobserve(handler);
}
}
Kaçınılması Gereken Anti-Desenler
- CRDT olmadan paylaşılan değişken durum: Kilitli bir paylaşılan değişken ve yüksek kilitlenme riski olan bir anti-model kullanın. Daima CRDT veya OT kullanın.
- Filtresiz yayın: Her tuş vuruşunu her müşteriye göndermek pahalıdır. Güncellemeleri göndermeden önce istemci tarafında geri dönmeyi (50-100 ms) uygulayın.
- Kalıcı Farkındalık: Varlık durumu (imleçler, çevrimiçi kullanıcılar) kalıcı değil, geçicidir. Ana belgeye kaydetmeyin.
- Çöp toplama yok: Yjs, silinen öğeler için "mezar taşı" biriktirir. Bellek kullanımını düşük tutmak için periyodik çöp toplamayı etkinleştirin.
- Kalp atışı olmayan WebSocket: Boşta kalan bağlantılar proxy'ler/yük dengeleyiciler tarafından kapatılır. Her 30 saniyede bir ping/pong uygulayın.
- WebSocket'te kimlik doğrulama yok: WebSocket'ler standart HTTP kimlik doğrulamasını atlar. Bağlanırken daima jetonunuzu doğrulayın.
Sonuçlar ve Sonraki Adımlar
EdTech için eksiksiz bir gerçek zamanlı işbirliği sistemi oluşturduk: Paylaşılan belgelerin çakışmasız yönetimi için Yjs ile CRDT, WebSocket gerçek zamanlı senkronizasyon için, imleçlerin ve çevrimiçi kullanıcıların varlığı, Belirli eğitimsel özelliklere yönelik ek açıklamalar ve tartışma konuları, ve ölçeklenebilirlik için Redis Pub/Sub ile küme mimarisi.
CRDT + çevrimdışı öncelikli kombinasyon bu sistemi özellikle uygun hale getirir bağlantının her zaman garanti edilemediği eğitim bağlamları için: öğrenciler çevrimdışı çalışmaya devam edebilirler ve değişiklikleri senkronize edilecektir bağlantı tekrar kullanılabilir hale geldiğinde otomatik olarak.
Bir sonraki yazımızda bu konuyu ele alacağız çevrimdışı öncelikli mimari EdTech mobil uygulamaları için: IndexedDB, Hizmet Çalışanları, senkronizasyon stratejileri ve Bağlantı olmadan bile öğrenmeyi garanti eden aşamalı geliştirme.
EdTech Mühendislik Serisi
- Ölçeklenebilir LMS Mimarisi: Çok Kiracılı Model
- Uyarlanabilir Öğrenme Algoritmaları: Teoriden Üretime
- Eğitim için Video Yayını: WebRTC vs HLS vs DASH
- Yapay Zeka Gözetleme Sistemleri: Bilgisayarlı Görme ile Öncelik Gizlilik
- LLM'de Kişiselleştirilmiş Öğretmen: Bilgi Temellendirme için RAG
- Oyunlaştırma Motoru: Mimari ve Durum Makinesi
- Öğrenme Analitiği: xAPI ve Kafka ile Veri Hattı
- Eğitim Teknolojisinde Gerçek Zamanlı İşbirliği: CRDT ve WebSocket (bu makale)
- Mobil Öncelikli Eğitim Teknolojisi: Çevrimdışı Öncelikli Mimari
- Çok Kiracılı İçerik Yönetimi: Sürüm Oluşturma ve SCORM







