多人/終端 文件共同編輯器的實現-python版

一. 前言

本文探討多人在線文件編輯器的實現,主要借助PyQt5來進行圖形化界面實現。借助消息中間件activemq來進行消息的訂閱和轉發,使用MQTT作為網絡協議。
activemq原理:服務器端創建一個唯一訂閱號,發送者可以向這個訂閱號中發東西,然后接受者(即訂閱了這個訂閱號的人)都會收到這個訂閱號發出來的消息。以此來完成消息的推送。服務器其實是一個消息中轉站。

二. 開發環境

Windows10
activemq5.16.1
python3.8+PyQt5+cryptography+paho-mqtt

三. 具體過程

1.Windows10下環境配置

  1. 到官網https://activemq.apache.org/components/classic/download/下載apache-activemq-5.16.1并解壓

    官網下載

  2. 命令行輸入:

 - cd [activemq_install_dir]
 - bin\activemq start

啟動成功:


啟動成功后的顯示
  1. 使用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.最終效果

Demo

四. 總結與討論

  • 由于采用mq進行訂閱和轉發,所以必須要有至少一個broker在后臺運行進行轉發
  • 沒有采用加鎖的設計,在互斥性方面還有提升的空間
  • 界面方面可以添加一些按鈕之類提升用戶友好型
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,572評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,071評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,409評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,569評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,360評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,895評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,979評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,123評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,643評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,559評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,742評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,250評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,981評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,363評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,622評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,354評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,707評論 2 370

推薦閱讀更多精彩內容