一. 前言
本文探討多人在線文件編輯器的實現,主要借助PyQt5來進行圖形化界面實現。借助消息中間件activemq來進行消息的訂閱和轉發,使用MQTT作為網絡協議。
activemq原理:服務器端創建一個唯一訂閱號,發送者可以向這個訂閱號中發東西,然后接受者(即訂閱了這個訂閱號的人)都會收到這個訂閱號發出來的消息。以此來完成消息的推送。服務器其實是一個消息中轉站。
二. 開發環境
Windows10
activemq5.16.1
python3.8+PyQt5+cryptography+paho-mqtt
三. 具體過程
1.Windows10下環境配置
-
到官網https://activemq.apache.org/components/classic/download/下載apache-activemq-5.16.1并解壓
官網下載 命令行輸入:
- cd [activemq_install_dir]
- bin\activemq start
啟動成功:
- 使用pip安裝Python擴展PyQt5+cryptography+paho-mqtt
pip/conda install即可
2.核心思路及代碼
1. 初始化界面的設定
初始化界面展示:
class Main(QMainWindow):
HOST = "127.0.0.1"
def __init__(self, parent=None):
QMainWindow.__init__(self, parent)
self.patch_stack = [] #輸入堆棧
self.author = False #設定主機
self.known_authors = [] #用戶列表
self.patch_set = [] # 存儲doc對象
#顯示文本框要求輸入
resp, ok = QInputDialog.getText(
self, "導引", "粘貼你收到的ID來加入或者不輸入來創建自己的房間"
)
#如果沒有輸入則生成一個,并設置author為True即為主機,site=0
if not resp:
self.portal_id, self.pad_name, self.fernet_key = self.generate_portal_tuple()
self.author = True
print(f"分享這個id給其他人:\n {self.portal_id}\n\n")
else:
self.pad_name, self.fernet_key = self.parse_portal_id(resp)
self.fernet = Fernet(self.fernet_key)
self.site = 0
#如果不是主機則隨機生成用戶標識號site
if not self.author:
self.site = int(random.getrandbits(32))
self.known_authors.append(self.site)
generate_portal_tuple()產生房間id具體實現方法,使用ip地址作為頭,uuid生成pad,Fernet對稱加密算法生成密鑰key,然后把它們組合起來:
#生成分享ID
def generate_portal_tuple(self, include_server=False):
pad = uuid.uuid4().hex
key = Fernet.generate_key().decode()
temp_str = ""
if include_server:
temp_str += f"{self.HOST}:"
temp_str += f"{pad}:{key}"
return base64.b64encode(temp_str.encode()).decode(), pad, key.encode()
解析方法調用decode即可:
#解析分享ID
def parse_portal_id(self, portal_id):
tup = base64.b64decode(portal_id.encode()).decode().split(":")
if len(tup) == 2:
pad, key = tup
return pad, key.encode()
elif len(tup) == 3:
server, pad, key = tup
return server, pad, key.encode()
2. 連接及topic設置
先設置一波topic用來標識各種行為,使用字典存儲。之后各種輸入,或者用戶加入退出等行為都將通過這些topic來標識轉發
self.known_authors.append(self.site)
self.mqtt_name = f"jwy/pad/{self.pad_name}"
self.subs = {
self.mqtt_name + "/aloha": self.on_topic_aloha,
self.mqtt_name + "/patch": self.on_topic_patch,
self.mqtt_name + "/authors/enter": self.on_topic_authors,
self.mqtt_name + "/authors/set": self.on_topic_authors,
self.mqtt_name + "/authors/leave": self.on_topic_authors
}
再設置連接參數及連接事件處理
#初始化客戶端,設置連接和接收消息事件
self.client = mqtt.Client()
self.client.on_connect = self.on_connect
self.client.on_message = self.on_message
self.client.connect(self.HOST, 1883, 60)
on_connect方法實現:
#重寫連接上之后的方法,訂閱各種topic,發布進入通知
def on_connect(self, client, userdata, flags, rc):
for topic in self.subs.keys():
self.client.subscribe(topic, qos=2)
self.client.publish(self.mqtt_name + "/authors/enter", str(self.site))
#如果不是主機,發送進入消息,更新用戶列表
if not self.author:
self.client.publish(self.mqtt_name + "/aloha", str(self.site))
on_message方法,接收到消息之后查詢字典調用對應topic的方法:
#接收到消息之后查詢字典調用對應topic的方法
def on_message(self, client, userdata, msg):
self.subs[msg.topic](msg.topic, msg.payload)
aloha topic上的事件,更新主機的用戶列表
#設置aloha topic上的事件
def on_topic_aloha(self, topic, payload):
if self.author:
set_dict = {
"dst": int(payload),
"authors": self.known_authors
}
print("Main author procedure: Sending known authors...")
self.client.publish(self.mqtt_name + "/authors/set", json.dumps(set_dict))
print("Main author procedure: Sending known patches...")
for patch in self.patch_set:
payload = self.fernet.encrypt(patch.encode())
self.client.publish(self.mqtt_name + "/patch", payload, qos=2)
print("Main author procedure: Done")
3. 文本編輯界面初始化
定義Editor類來進行文本的編輯,繼承QTextEdit
#文本編輯器界面
class Editor(QTextEdit):
upd_text = pyqtSignal(str) # in
change_evt = pyqtSignal(str) # out
res_state_evt = pyqtSignal(str) # out
def __init__(self, site):
self.view = QPlainTextEdit.__init__(self)
self.setFrameStyle(QFrame.NoFrame)
self.font = QFont()
self.font.setStyleHint(QFont.Monospace)
self.font.setFixedPitch(True)
self.font.setPointSize(16)
self.setFont(self.font)
self.doc = Doc()
self.doc.site = site
self.upd_text.connect(self.on_upd_text)
#鼠標點擊事件
def keyPressEvent(self, e):
cursor = self.textCursor()
#粘貼處理
if e.matches(QKeySequence.Paste) and QApplication.clipboard().text():
pos = cursor.position()
for i, c in enumerate(QApplication.clipboard().text()):
patch = self.doc.insert(pos + i, c)
self.change_evt.emit(patch)
#刪除處理
elif e.key() == Qt.Key_Backspace:
if not self.toPlainText():
return
sel_start = cursor.selectionStart()
sel_end = cursor.selectionEnd()
if sel_start == sel_end:
patch = self.doc.delete(cursor.position() - 1)
self.change_evt.emit(patch)
else:
for pos in range(sel_end, sel_start, -1):
patch = self.doc.delete(pos - 1)
self.change_evt.emit(patch)
#插入并且ctrl鍵沒被按下
elif e.key() != Qt.Key_Backspace and e.text() and e.modifiers() != Qt.ControlModifier:
sel_start = cursor.selectionStart()
sel_end = cursor.selectionEnd()
if sel_start != sel_end:
for pos in range(sel_end, sel_start, -1):
patch = self.doc.delete(pos - 1)
self.change_evt.emit(patch)
patch = self.doc.insert(sel_start, e.text())
self.change_evt.emit(patch)
self.res_state_evt.emit(json.dumps(self.doc.patch_set))
QTextEdit.keyPressEvent(self, e)
設置文本更新事件
@pyqtSlot(str)
def on_upd_text(self, patch):
self.doc.apply_patch(patch)
cursor = self.textCursor()
old_pos = cursor.position()
self.setPlainText(self.doc.text)
cursor.setPosition(old_pos)
self.setTextCursor(cursor)
apply_patch根據輸入值實現插入或刪除字符
def apply_patch(self, patch: str) -> None:
json_char = json.loads(patch)
op = json_char["op"]
if op == self.PATCH_INSERT_TOKEN:
char = Char(json_char["char"], Position(
json_char["pos"], json_char["sites"]), json_char["clock"])
self._doc.add(char)
elif op == self.PATCH_DELETE_TOKEN:
char = next(c for c in self._doc if
c.pos.pos == json_char["pos"] and
c.pos.sites == json_char["sites"] and
c.clock == json_char["clock"]
)
self._doc.remove(char)
4. json廣播實現同步
主要用json字符串來傳輸文本編輯器的改變情況,如位置,增/刪等。
#采用json字符串來接收插入消息patch topic上的監聽事件
def on_topic_patch(self, topic, payload):
if payload not in self.patch_stack:
payload_decrypted = self.fernet.decrypt(payload)
patch = json.loads(payload_decrypted)
if patch["src"] != self.site:
print(f"Received patch: {payload_decrypted.decode()}")
self.patch_stack.append(payload)
self.editor.upd_text.emit(payload_decrypted.decode())
5. 字符存儲方法
定義Doc類來存儲Char類的列表,里面包括具體字符char,用戶site和clock,同時設置存儲策略_alloc
class Doc:
PATCH_INSERT_TOKEN = "i"
PATCH_DELETE_TOKEN = "d"
def __init__(self, site: int=0) -> None:
self._site: int = site
self._strategy = RandomStrategy()
self._alloc = Allocator(self._strategy, self.site)
self._clock: int = 0
#_doc里面存儲排序字符列表,每一個Char里面都包括具體字符,用戶site和clock
self._doc: SortedList["Char"] = SortedList()
self._doc.add(Char("", Position([0], [-1]), self._clock))
self._doc.add(Char("", Position([2 ** BASE_BITS - 1], [-1]), self._clock))
插入方法處理:
def insert(self, pos: int, char: str) -> str:
self._clock += 1
p, q = self._doc[pos].pos, self._doc[pos + 1].pos
new_char = Char(char, self._alloc(p, q), self._clock)
self._doc.add(new_char)
return self._serialize(self.PATCH_INSERT_TOKEN, new_char)
存儲的文本值的獲取,一個一個讀取char
@property
def text(self) -> str:
return "".join([c.char for c in self._doc])
position位置類:
#使用樹路徑標識字符的位置
class Position:
def __init__(self, pos: List[int]=None, sites: List[int]=None, base_bits: int=0) -> None:
# Each element is part of the tree path as a var. base digit of size 2^(BASE_BITS + el. no)
self.pos = pos or []
self.sites = sites or []
self.base_bits = base_bits or BASE_BITS
#類對象實例化之前調用函數
@classmethod
def from_int(cls, pos: int, depth: int, sites: List[int], base_bits: int=0) -> "Position":
new_pos = cls(sites=sites, base_bits=base_bits)
for _depth in range(depth, 0, -1):
shift = base_bits + _depth - 1
# Extract n rightmost bits where n equals the no. of bits at depth `_depth`
new_pos.pos.insert(0, pos & (1 << shift) - 1)
pos >>= shift
return new_pos
def to_int(self, trim: int=0) -> int:
#如果平衡了
if trim:
workspace = self._ptrim(self.pos, trim)
else:
workspace = self.pos
out = 0
for _depth, i in enumerate(workspace):
# Add zeros and place `i` in them
out = (out << (self.base_bits + _depth)) | int(i)
return out
def interval_between(self, other: "Position", depth: int) -> Tuple[int, bool]:
if self.pos == other.pos and self.sites[-1] != other.sites[-1] and depth > len(self.pos):
return self.interval_at(depth), True
return other.to_int(depth) - self.to_int(depth) - 1, False
def interval_at(self, depth: int) -> int:
return 2 ** (self.base_bits + depth - 1) - 1
@staticmethod
def _ptrim(pos: List[int], depth: int) -> List[int]:
out = []
length = len(pos)
for _depth in range(depth):
if _depth < length:
out.append(pos[_depth])
else:
out.append(0)
return out
def __lt__(self, other):
# Lexical order by `position` and `site` as tie-breaker
return list(zip(self.pos, self.sites)) < list(zip(other.pos, other.sites))
def __str__(self):
return str(list(zip(self.pos, self.sites)))
設定偏移量為5,即每5個字符為一個單位,到下一行時pos+5
6. 顏色區分實現
可以預先寫幾個顏色值到COLORS里面,然后對于不同的author選取不同color,并根據輸入人來改變文字背景,每應用一個則block_pos+1
#顏色顯示,繼承QSyntaxHighlighter類
class AuthorHighlighter(QSyntaxHighlighter):
COLORS = (
(251, 222, 187),
(187, 251, 222),
(222, 251, 187),
(222, 187, 251),
(187, 222, 251)
)
NUM_COLORS = len(COLORS)
def __init__(self, parent):
QSyntaxHighlighter.__init__(self, parent)
self.parent = parent
def highlightBlock(self, text):
curr_line = self.previousBlockState() + 1
doc_line = 0
block_pos = 0
text_format = QTextCharFormat()
#循環,c為字符,a為用戶id(site)
for c, a in zip(self.parent.doc.text, self.parent.doc.authors[1:-1]):
if c in ("\n", "\r"):
doc_line += 1
continue
else:
if doc_line == curr_line:
text_format.setBackground(QBrush(self.get_author_color(a), Qt.SolidPattern))
self.setFormat(block_pos, 1, text_format)
block_pos += 1
elif doc_line > curr_line:
break
self.setCurrentBlockState(self.previousBlockState() + 1)
7.最終效果
四. 總結與討論
- 由于采用mq進行訂閱和轉發,所以必須要有至少一個broker在后臺運行進行轉發
- 沒有采用加鎖的設計,在互斥性方面還有提升的空間
- 界面方面可以添加一些按鈕之類提升用戶友好型