20242115 实验四《Python程序设计》实验报告

20242115 2024-2025-2 《Python程序设计》实验四报告

课程:《Python程序设计》
班级: 2421
姓名: 叶抒宇
学号:20242115
实验教师:王志强
实验日期:2025年5月14日
必修/选修: 公选课

1. 实验内容

Python综合实践,综合运用所学知识编写一个应用程序。
应用程序初步设计为:

  • 一个英语笔记管理系统,科用于管理英语笔记;
  • 创建服务端和客户端,服务端在特定端口监听多个客户请求,并提供上面的英语笔记管理系统服务。

实验将在个人计算机上测试本地模式运行情况,在华为云服务器和个人计算机上测试客户端/服务端(C/S)模式运行情况。

2. 实验过程及结果

2.1 需求分析与设计

该程序需要实现以下功能:

  • 使用SQLite3数据库对笔记内容进行存储
  • 设计笔记实体,运用面向对象的方式对笔记内容进行组织与管理
  • 编写交互控制程序,实现与用户的交互,其中包括菜单交互和命令交互两种方式
  • 编写命令解析模块,使用正则表达式进行命令解析
  • 使用Python文件读写读入图片文件,作为笔记的附件存入数据库
  • 使用Python Pillow模块将图片数据进行显示
  • 编写单词库模块,方便对笔记中的单词进行整理和复习,并可添加额外单词
  • 实现单词查询功能,利用Python爬虫技术从剑桥大学词典网站获取单词读音、中英文释义和例句
  • 利用SQLite3数据库实现单词查询结果缓存功能
  • 实现背单词功能,通过监听键盘按键实现交互
  • 利用socket技术实现远程连接,将程序分为服务端和客户端
  • 服务端在本地版基础上,重定向标准输入输出(stdio),从而复用本地笔记管理系统的代码,即实现远程输入输出
  • 客户端使用socket流来接收输出并发送输入,实现用户与服务端的交互
  • 使用base64编码来传输图片数据
  • 服务端与客户端之间的所有通信使用国产加密算法SM4进行加密,并通过Diffie-Hellman算法进行密钥交换
  • 服务端使用多线程技术处理多客户端连接的情况

2.2 实验过程

2.2.1 Entry实体

Entry类对一个笔记条目进行封装,包括ID、类型、标题、内容、附加图片。使用了面向对象编程(OOP) 的思想。

该类的具体设计如下:

EntryTypes = ("", "单词", "语法", "词句积累", "其他")

class Entry:
    def __init__(self,
                 entryId: int = None,
                 type: int = None,
                 title: str = None,
                 content: str = None,
                 extra: bytes = b''):
        self.entryId = entryId
        self.type = type
        self.title = title
        self.content = content
        self.extra = extra

    def getEntryId(self):
        return self.entryId
    
    def getType(self):
        return self.type

    def getTitle(self):
        return self.title
    
    def getContent(self):
        return self.content
    
    def getExtra(self):
        return self.extra
    
    def setEntryId(self, entryId):
        self.entryId = entryId

    def setType(self, type):
        self.type = type

    def setTitle(self, title):
        self.title = title

    def setContent(self, content):
        self.content = content

    def setExtra(self, extra):
        self.extra = extra

2.2.2 DAO层

DAO层实现对数据库操作的封装,实现了数据库的初始化和对Entry和单词数据的增、删、改、查。

其中,数据库采用了Python自带的SQLite3数据库。设计了note和wordbank两张数据表。

note数据表:

CREATE TABLE IF NOT EXISTS note (
                   id INTEGER PRIMARY KEY AUTOINCREMENT,
                   type INTEGER NOT NULL,
                   title TEXT,
                   content TEXT,
                   extra BLOB
                   )

wordbank数据表:

CREATE TABLE IF NOT EXISTS wordbank (
                   id INTEGER PRIMARY KEY AUTOINCREMENT,
                   word TEXT NOT NULL,
                   content TEXT
                   )

DAO层具体实现如下:

import json
import sqlite3
from Entry import *

conn = None

def __getCursor() -> sqlite3.Cursor:
    global conn
    conn = sqlite3.connect("note.db")
    conn.row_factory = sqlite3.Row
    return conn.cursor()

def __getClosedConn():
    global conn
    conn.commit()
    conn.close()

def addNote(entry: Entry) -> int:
    cursor = __getCursor()
    cursor.execute('INSERT INTO note (type, title, content, extra) VALUES(?, ?, ?, ?)', (entry.type, entry.title, entry.content, sqlite3.Binary(entry.extra)))
    __getClosedConn()
    return cursor.rowcount

def initNotebook():
    cursor = __getCursor()
    cursor.execute("""
CREATE TABLE IF NOT EXISTS note (
                   id INTEGER PRIMARY KEY AUTOINCREMENT,
                   type INTEGER NOT NULL,
                   title TEXT,
                   content TEXT,
                   extra BLOB
                   )
""")
    __getClosedConn()

def getNoteById(entryId: int) -> Entry:
    cursor = __getCursor()
    cursor.execute("SELECT * FROM note WHERE id=?",(entryId,))
    row = cursor.fetchone()
    __getClosedConn()
    if row:
        entry = Entry(row["id"], row["type"], row["title"], row["content"], row["extra"])
        return entry
    return None

def getAllNotes() -> list[Entry]:
    cursor = __getCursor()
    cursor.execute("SELECT * FROM note")
    result = []
    rows = cursor.fetchall()
    __getClosedConn()
    if not rows:
        return result
    for row in rows:
        result.append(Entry(row["id"], row["type"], row["title"], row["content"], row["extra"]))
    return result

def deleteNoteById(entryId: int) -> int:
    cursor = __getCursor()
    cursor.execute("DELETE FROM note WHERE id=?", (entryId,))
    result = cursor.rowcount
    __getClosedConn()
    return result

def updateNoteById(entry: Entry) -> int:
    cursor = __getCursor()
    cursor.execute("UPDATE note SET type=?, title=?, content=?, extra=? WHERE id=?", (entry.type, entry.title, entry.content, sqlite3.Binary(entry.extra), entry.entryId))
    result = cursor.rowcount
    __getClosedConn()
    return result

def initWordbank():
    cursor = __getCursor()
    cursor.execute("""
CREATE TABLE IF NOT EXISTS wordbank (
                   id INTEGER PRIMARY KEY AUTOINCREMENT,
                   word TEXT NOT NULL,
                   content TEXT
                   )
""")
    __getClosedConn()

def addWord(word: str, content: dict):
    cursor = __getCursor()
    cursor.execute('INSERT INTO wordbank (word, content) VALUES(?, ?)', (word, json.dumps(content)))
    __getClosedConn()
    return cursor.rowcount

def getWordByWord(word: str) -> dict:
    cursor = __getCursor()
    cursor.execute("SELECT * FROM wordbank WHERE word=?",(word,))
    row = cursor.fetchone()
    __getClosedConn()
    if row:
        content = json.loads(row["content"])
        return {"id": row["id"], "word": row["word"], "content": content}
    return None

def getWordById(wordId: str) -> dict:
    cursor = __getCursor()
    cursor.execute("SELECT * FROM wordbank WHERE id=?",(wordId,))
    row = cursor.fetchone()
    __getClosedConn()
    if row:
        content = json.loads(row["content"])
        return {"id": row["id"], "word": row["word"], "content": content}
    return None

def getAllWords() -> list[dict]:
    cursor = __getCursor()
    cursor.execute("SELECT * FROM wordbank")
    result = []
    rows = cursor.fetchall()
    __getClosedConn()
    if not rows:
        return result
    for row in rows:
        content = json.loads(row["content"])
        result.append({"id": row["id"], "word": row["word"], "content": content})
    return result

def deleteWordById(wordId: int) -> int:
    cursor = __getCursor()
    cursor.execute("DELETE FROM wordbank WHERE id=?", (wordId,))
    result = cursor.rowcount
    __getClosedConn()
    return result

def updateWordById(wordId: int, word: str, content: dict) -> int:
    cursor = __getCursor()
    cursor.execute("UPDATE wordbank SET word=?, content=? WHERE id=?", (word, json.dumps(content), wordId))
    result = cursor.rowcount
    __getClosedConn()
    return result

def dropWordbank():
    cursor = __getCursor()
    cursor.execute("DROP TABLE wordbank")
    __getClosedConn()

def getWordsCount() -> int:
    cursor = __getCursor()
    cursor.execute("SELECT COUNT(*) FROM wordbank")
    row = cursor.fetchone()
    __getClosedConn()
    return row[0]

2.2.3 Service层

Service层对数据处理逻辑进行封装,并暴露接口供上层调用。上层调用者主要为交互控制程序controller和单词库模块wordbank。

Service层具体实现如下:

import dao
from Entry import *
from wordQuery import queryWord

def initNotebook():
    dao.initNotebook()

def addNote(entry: Entry) -> int:
    return dao.addNote(entry)

def deleteNoteById(entryId: int) -> int:
    return dao.deleteNoteById(entryId)

def updateNoteById(entry: Entry) -> int:
    return dao.updateNoteById(entry)

def getNoteById(entryId: int) -> Entry:
    return dao.getNoteById(entryId)

def getAllNotes() -> list[Entry]:
    return dao.getAllNotes()

def initWordbank():
    dao.initWordbank()

def getWord(word: str, forceRefresh: bool = False) -> dict:
    item = dao.getWordByWord(word)
    if not forceRefresh:
        if item:
            print("[getWord] Hit.")
            return item["content"]
    print(f"[getWord] Querying '{word}'...")
    content = queryWord(word)
    if forceRefresh and item:
        result = dao.updateWordById(item["id"], word, content)
    else:
        result = dao.addWord(word, content)
    if result > 0:
        print("[getWord] Saved!")
    else:
        print("[getWord] Database error!")
    return content

def getAllWords() -> list[dict]:
    return dao.getAllWords()

def dropWordbank():
    dao.dropWordbank()

def deleteWordById(wordId: int) -> int:
    return dao.deleteWordById(wordId)

def getWordsCount() -> int:
    return dao.getWordsCount()

2.2.4 交互控制程序controller

controller用于控制与用户的交互流程,是本套系统在本地模式下运行时的入口点。交互共有两种模式,一种是菜单模式,另一种是命令模式,用户可自由在两种模式之间切换。程序开始运行时,默认进入菜单模式。

其中,菜单模式的实现如下:

import service
from Entry import *
from common import cmdParser, fileSelector, showImage
from wordbank import wordbank, printWord

def printNote(entry: Entry, query: bool = False):
    print("===============NOTE===============")
    print("编号:", entry.entryId)
    print("类型:", EntryTypes[entry.type])
    if entry.type == 1:
        print("单词:", entry.title)
        print("释义:")
    elif entry.type == 2 or entry.type == 4:
        print("标题:", entry.title)
        print("内容:")
    elif entry.type == 3:
        print("内容:")
    print(entry.content)
    if entry.extra:
        hasExtra = "有"
    else:
        hasExtra = "无"
    print("附加图片:", hasExtra)
    print("==================================")
    if query and entry.type == 1:
        print("词典查询:")
        printWord(entry.title)
        print("==================================")

def init():
    print("[init] Connecting to database...")
    service.initNotebook()
    service.initWordbank()
    print("[init] Done!")

def addNote():
    print("笔记类型:")
    print("""
1. 单词
2. 语法
3. 词句积累
4. 其他
""")
    entry = Entry()
    while True:
        try:
            entry.type = int(input("请选择笔记类型:"))
            if entry.type not in (1, 2, 3, 4):
                raise ValueError
        except ValueError:
            print("输入错误!")
            continue
        break
    if entry.type == 1:
        entry.title = input("请输入单词:")
        entry.content = input("请输入释义:")
    elif entry.type == 2 or entry.type == 4:
        entry.title = input("请输入标题:")
        entry.content = input("请输入内容:")
    elif entry.type == 3:
        entry.content = input("请输入内容:")
    result = service.addNote(entry)
    if result > 0:
        print("[addNote] Success!")
    else:
        print("[addNote] Error!")

def showNote(query: bool = False):
    result: list[Entry] = service.getAllNotes()
    for entry in result:
        printNote(entry, query)

def deleteNote():
    while True:
        try:
            entryId = int(input("请输入笔记编号:"))
        except ValueError:
            print("输入错误!")
            continue
        break
    result = service.deleteNoteById(entryId)
    if result > 0:
        print("[deleteNote] Success!")
    else:
        print("[deleteNote] Error!")

def editNote():
    while True:
        try:
            entryId = int(input("请输入笔记编号:"))
        except ValueError:
            print("输入错误!")
            continue
        break
    entry = service.getNoteById(entryId)
    if not entry:
        print("ID错误!")
        return
    printNote(entry)
    while True:
        try:
            entry.type = int(input("请选择笔记类型:"))
            if entry.type not in (1, 2, 3, 4):
                raise ValueError
        except ValueError:
            print("输入错误!")
            continue
        break
    if entry.type == 1:
        entry.title = input("请输入单词:")
        entry.content = input("请输入释义:")
    elif entry.type == 2 or entry.type == 4:
        entry.title = input("请输入标题:")
        entry.content = input("请输入内容:")
    elif entry.type == 3:
        entry.content = input("请输入内容:")
    result = service.updateNoteById(entry)
    if result > 0:
        print("[editNote] Success!")
    else:
        print("[editNote] Error!")

def attachNote(remote: bool = False):
    while True:
        try:
            entryId = int(input("请输入笔记编号:"))
        except ValueError:
            print("输入错误!")
            continue
        break
    entry = service.getNoteById(entryId)
    if not entry:
        print("ID错误!")
        return
    entry.extra = fileSelector(remote)
    result = service.updateNoteById(entry)
    if result > 0:
        print("[attachNote] Success!")
    else:
        print("[attachNote] Error!")

def viewImage(remote: bool = False):
    while True:
        try:
            entryId = int(input("请输入笔记编号:"))
        except ValueError:
            print("输入错误!")
            continue
        break
    entry = service.getNoteById(entryId)
    if not entry:
        print("ID错误!")
        return
    if not entry.extra:
        print("该笔记无附加图片!")
        return
    showImage(entry.extra, remote)

def detachNote():
    while True:
        try:
            entryId = int(input("请输入笔记编号:"))
        except ValueError:
            print("输入错误!")
            continue
        break
    entry = service.getNoteById(entryId)
    if not entry:
        print("ID错误!")
        return
    entry.extra = b''
    result = service.updateNoteById(entry)
    if result > 0:
        print("[detachNote] Success!")
    else:
        print("[detachNote] Error!")

def menu() -> int:
    print("===============MENU===============")
    print("1. 添加笔记")
    print("2. 查看笔记")
    print("3. 删除笔记")
    print("4. 修改笔记")
    print("5. 附加图片")
    print("6. 查看图片")
    print("7. 删除图片")
    print("8. 进入命令行模式")
    print("9. 进入单词库")
    print("0. 退出")
    print("==================================")
    while True:
        try:
            choice = int(input("请选择操作:"))
            if choice not in (1, 2, 3, 4, 5, 6, 7, 8, 9, 0):
                raise ValueError
        except ValueError:
            print("输入错误!")
            continue
        break
    return choice

def main(remote: bool = False):
    print("""
★★★★★★★★★★★★★★★★★★★★
      欢迎使用笔记管理工具!
      ©20242115叶抒宇
★★★★★★★★★★★★★★★★★★★★
""")
    init()
    while True:
        choice = menu()
        if choice == 0:
            break
        elif choice == 1:
            addNote()
        elif choice == 2:
            query = input("对于单词是否查询词典?(Y/n)")
            if query == 'Y' or query == 'y':
                showNote(True)
            else:
                showNote()
        elif choice == 3:
            deleteNote()
        elif choice == 4:
            editNote()
        elif choice == 5:
            attachNote(remote)
        elif choice == 6:
            viewImage(remote)
        elif choice == 7:
            detachNote()
        elif choice == 8:
            cmdline(remote=remote)
        elif choice == 9:
            wordbank()

命令模式需要使用正则表达式对命令进行预处理,相关函数位于common.py中。该函数将命令以及不同参数分割开,并对参数中的字符串内的转义字符进行处理:

import re

def cmdParser(command: str) -> list[str]:
    command = re.sub(r'\\\n\s*', '', command)
    
    command = re.sub(r'#.*$', '', command)
    
    pattern = r'''
        (?: "(?:\\.|[^"\\])*" |   # 双引号字符串
            '(?:\\.|[^'\\])*' |    # 单引号字符串
            [^\s"']+              # 非空白字符
        )
    '''
    matches = re.findall(pattern, command, re.VERBOSE)
    
    def unquote(s:str):
        # 处理转义字符
        if s.startswith('"') and s.endswith('"'):
            s = s[1:-1]
            # 双引号内处理转义字符
            s = re.sub(r'\\(["\\abfnrtv])', _replace_escape, s)
        elif s.startswith("'") and s.endswith("'"):
            s = s[1:-1]
            # 单引号内只允许转义单引号和反斜杠
            s = re.sub(r'\\([\'\\])', _replace_escape, s)
        return s
    
    def _replace_escape(match: re.Match) -> str:
        esc = match.group(1)
        escape_map = {
            'a': '\a',  # 响铃
            'b': '\b',  # 退格
            'f': '\f',  # 换页
            'n': '\n',  # 换行
            'r': '\r',  # 回车
            't': '\t',  # 制表符
            'v': '\v',  # 垂直制表符
            '"': '"',   # 双引号
            "'": "'",   # 单引号
            '\\': '\\', # 反斜杠
        }
        return escape_map.get(esc, esc)  # 如果找不到,返回原字符
    
    return [unquote(m) for m in matches if m.strip()]

完成命令的预处理后,就可以对命令进行正式处理了:

import service
from Entry import *
from common import cmdParser, fileSelector, showImage
from wordbank import wordbank, printWord

def cmdline(cmd: str = None, remote: bool = False):
    flag = False
    if cmd:
        flag = True
    while True:
        if not flag:
            cmd = input("noteMgr>")
        cmds = cmdParser(cmd)
        if cmds[0] == "add":
            try:
                entry = Entry()
                if cmds[1] == "word":
                    entry.type = 1
                    for i in range(2, len(cmds), 2):
                        if cmds[i] == "--title":
                            entry.title = cmds[i + 1]
                        elif cmds[i] == "--content":
                            entry.content = cmds[i + 1]
                        else:
                            raise IndexError
                elif cmds[1] == "grammar":
                    entry.type = 2
                    for i in range(2, len(cmds), 2):
                        if cmds[i] == "--title":
                            entry.title = cmds[i + 1]
                        elif cmds[i] == "--content":
                            entry.content = cmds[i + 1]
                        else:
                            raise IndexError
                elif cmds[1] == "accumulation":
                    entry.type = 3
                    for i in range(2, len(cmds), 2):
                        if cmds[i] == "--content":
                            entry.content = cmds[i + 1]
                        else:
                            raise IndexError
                elif cmds[1] == "other":
                    entry.type = 4
                    for i in range(2, len(cmds), 2):
                        if cmds[i] == "--title":
                            entry.title = cmds[i + 1]
                        elif cmds[i] == "--content":
                            entry.content = cmds[i + 1]
                        else:
                            raise IndexError
                else:
                    print("指令错误!")
                    continue
                result = service.addNote(entry)
                if result > 0:
                    print("[addNote] Success!")
                else:
                    print("[addNote] Error!")
            except IndexError:
                print("参数错误!")
        elif cmds[0] == "show":
            query = False
            showAll = True
            for i in range(1, len(cmds)):
                try:
                    if cmds[i] == "--all":
                        showAll = True
                    elif cmds[i] == "--id":
                        showAll = False
                        entry = service.getNoteById(int(cmds[i + 1]))
                    elif cmds[i] == "-q" or cmds[i] == "--query":
                        query = True
                except IndexError:
                    print("[show] Not specific or wrong options. Assumed --all.")
                    showAll = True
            if showAll:
                showNote(query)
            else:
                if entry:
                    printNote(entry, query)
                else:
                    print("ID错误!")
        elif cmds[0] == "delete":
            try:
                if cmds[1] == "--id":
                    try:
                        result = service.deleteNoteById(int(cmds[2]))
                        if result > 0:
                            print("[deleteNote] Success!")
                        else:
                            print("[deleteNote] Error!")
                    except:
                        print("ID错误!")
                else:
                    raise IndexError
            except IndexError:
                print("参数错误!")
        elif cmds[0] == "edit":
            try:
                entry = service.getNoteById(int(cmds[cmds.index("--id") + 1]))
                if cmds[1] == "word":
                    entry.type = 1
                    for i in range(2, len(cmds), 2):
                        if cmds[i] == "--title":
                            entry.title = cmds[i + 1]
                        elif cmds[i] == "--content":
                            entry.content = cmds[i + 1]
                        elif cmds[i] == "--id":
                            pass
                        else:
                            raise IndexError
                elif cmds[1] == "grammar":
                    entry.type = 2
                    for i in range(2, len(cmds), 2):
                        if cmds[i] == "--title":
                            entry.title = cmds[i + 1]
                        elif cmds[i] == "--content":
                            entry.content = cmds[i + 1]
                        elif cmds[i] == "--id":
                            pass
                        else:
                            raise IndexError
                elif cmds[1] == "accumulation":
                    entry.type = 3
                    for i in range(2, len(cmds), 2):
                        if cmds[i] == "--content":
                            entry.content = cmds[i + 1]
                        elif cmds[i] == "--id":
                            pass
                        else:
                            raise IndexError
                elif cmds[1] == "other":
                    entry.type = 4
                    for i in range(2, len(cmds), 2):
                        if cmds[i] == "--title":
                            entry.title = cmds[i + 1]
                        elif cmds[i] == "--content":
                            entry.content = cmds[i + 1]
                        elif cmds[i] == "--id":
                            pass
                        else:
                            raise IndexError
                else:
                    print("指令错误!")
                    continue
                result = service.updateNoteById(entry)
                if result > 0:
                    print("[editNote] Success!")
                else:
                    print("[editNote] Error!")
            except IndexError:
                print("参数错误!")
            except ValueError:
                print("ID错误!")
        elif cmds[0] == "exit":
            exit(0)
        elif cmds[0] == "menu":
            break
        elif cmds[0] == "bank":
            wordbank()
        elif cmds[0] == "attach":
            try:
                entry = service.getNoteById(int(cmds[cmds.index("--id") + 1]))
                if not entry:
                    print("ID错误!")
                    continue
                entry.extra = fileSelector(remote)
                result = service.updateNoteById(entry)
                if result > 0:
                    print("[attachNote] Success!")
                else:
                    print("[attachNote] Error!")
            except IndexError:
                print("参数错误!")
        elif cmds[0] == "view":
            try:
                entry = service.getNoteById(int(cmds[cmds.index("--id") + 1]))
                if not entry:
                    print("ID错误!")
                    continue
                if not entry.extra:
                    print("该笔记无附加图片!")
                    continue
                showImage(entry.extra, remote)
            except IndexError:
                print("参数错误!")
        elif cmds[0] == "detach":
            try:
                entry = service.getNoteById(int(cmds[cmds.index("--id") + 1]))
                if not entry:
                    print("ID错误!")
                    continue
                entry.extra = b''
                result = service.updateNoteById(entry)
                if result > 0:
                    print("[detachNote] Success!")
                else:
                    print("[detachNote] Error!")
            except IndexError:
                print("参数错误!")
        elif cmds[0] == "help":
            print("""
Usage: <command> [options...]
    Commands:
        add <instructions>  添加笔记
        show                查看笔记
        delete              删除笔记
        edit <instructions> 修改笔记
        menu                进入菜单模式
        bank                进入单词库
        attach              附加图片
        view                查看附加的图片
        detach              删除附加的图片
        help                显示本帮助命令
        exit                退出程序
    Instructions:
        word                指定操作笔记类型为单词
        grammar             指定操作笔记类型为语法
        accumulation        指定操作笔记类型为词句积累
        other               指定操作笔记类型为其他
    Options:
        --title <title>     指定笔记标题或单词
        --content <content> 指定笔记内容或单词释义
        --all               指定显示全部笔记(适用于show,为默认行为)
        --id                指定笔记ID(即编号,适用于show, delete, edit)
        -q, --query         对于单词执行查询操作
""")
        else:
            print("Unknown command! Type 'help' for usage.")
        if flag:
            break

2.2.5 单词库wordbank

单词库模块主要对于英语笔记中的单词进行功能增强。

单词库的最基本功能是单词查询。对于笔记中的单词,用户可能需要更详细的词典信息。因此,采用Python爬虫的方式对剑桥大学词典网站的单词页面进行爬取。

单词爬取在wordQuery.py中实现:

from bs4 import BeautifulSoup
import requests

url = "https://dictionary.cambridge.org/zhs/%E8%AF%8D%E5%85%B8/%E8%8B%B1%E8%AF%AD-%E6%B1%89%E8%AF%AD-%E7%AE%80%E4%BD%93/"

headers = {
    "Host": "dictionary.cambridge.org",
    "Referer": "https://dictionary.cambridge.org/zhs/",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0"
}

def queryWord(word: str) -> dict:
    res = requests.get(url + word, headers=headers)

    result = []
    pros = []

    if res.status_code == 200:
        html_content = res.text

        soup = BeautifulSoup(html_content, 'html.parser')

        entries = soup.find_all('div', class_="entry-body__el")
        for entry in entries:
            pos = entry.find('span', class_='pos').text.strip()  # 词性
            pro_uk = entry.find('span', class_='uk').find('span', class_='dpron').text.strip()
            pro_us = entry.find('span', class_='us').find('span', class_='dpron').text.strip()
            pros.append({"pos": pos, "uk": pro_uk, "us": pro_us})
            definitions = entry.find_all('div', class_='def-block')
            for definition in definitions:
                def_text = definition.find('div', class_='def').text.strip()  # 定义
                trans_def = definition.find('span', class_="trans").text.strip()
                #print(f"{pos}: {def_text} {trans_def}")

                examples = definition.find_all('div', class_='examp')
                ex = []
                for example in examples:
                    eg_text = example.find('span', class_='eg').text.strip()  # 英文例句
                    try:
                        trans_text = example.find('span', class_='trans').text.strip()  # 中文翻译
                    except AttributeError:
                        trans_text = ''
                    #print(f"例句: {eg_text} - {trans_text}")
                    ex.append({
                        "eg": eg_text,
                        "trans": trans_text
                    })

                result.append({
                    "pos": pos,
                    "def": def_text,
                    "trans": trans_def,
                    "eg": ex
                })
    return {"pron": pros, "defs": result}

if __name__ == "__main__":
    print(queryWord("test"))

爬取到单词后,需要将单词缓存入数据库,在应用中以单词表的形式呈现。

存入数据库的逻辑已在Service层中的单词查询服务实现:

def getWord(word: str, forceRefresh: bool = False) -> dict:
    item = dao.getWordByWord(word)
    if not forceRefresh:
        if item:
            print("[getWord] Hit.")
            return item["content"]
    print(f"[getWord] Querying '{word}'...")
    content = queryWord(word)
    if forceRefresh and item:
        result = dao.updateWordById(item["id"], word, content)
    else:
        result = dao.addWord(word, content)
    if result > 0:
        print("[getWord] Saved!")
    else:
        print("[getWord] Database error!")
    return content

其中预留出强制从网站爬取的接口。

单词库的菜单实现如下:

def wordbank():
    while True:
        print("=============WORDBANK=============")
        print("1. 查询单词")
        print("2. 查看单词表")
        print("3. 删除单词")
        print("4. 刷新单词缓存")
        print("5. 进入背单词模式")
        print("0. 退出")
        print("==================================")
        while True:
            try:
                choice = int(input("请选择操作:"))
                if choice not in (1, 2, 3, 4, 5, 0):
                    raise ValueError
            except ValueError:
                print("输入错误!")
                continue
            break
        if choice == 1:
            printWord(input("请输入单词:"))
        elif choice == 2:
            print("[wordbank] Loading...")
            words = service.getAllWords()
            print("[wordbank] Loaded!")
            print("=============================")
            print("|  ID  |        word        |")
            print("-----------------------------")
            for word in words:
                print(f"|{word["id"]:^6}|{word["word"]:^20}|")
            print("=============================")
        elif choice == 3:
            deleteWord()
        elif choice == 4:
            refreshWord()
        elif choice == 5:
            flashCard()
        else:
            break

单词库中用户对于单词的增、删、改、查相应的功能实现如下:

def printWord(word: str, forceRefresh: bool = False):
    content = service.getWord(word, forceRefresh)
    print(f"\n{word}\n\n发音:")
    for pro in content["pron"]:
        print(f"<{pro["pos"]}> UK: {pro["uk"]} US: {pro["us"]}")
    print("\n释义:", end='')
    for item in content["defs"]:
        print(f"\n[{item["pos"]}] {item["def"]} {item["trans"]}")
        print("例句:")
        i = 1
        for eg in item["eg"]:
            print(f"{i}. {eg["eg"]}")
            print(eg["trans"])
            i += 1

def deleteWord():
    while True:
        try:
            dAll = int(input("删除(1. 全部单词;2. 某个单词)?"))
            if dAll not in (1, 2):
                raise ValueError
        except ValueError:
            print("输入错误!")
            continue
        break
    if dAll == 1:
        service.dropWordbank()
        service.initWordbank()
    else:
        while True:
            try:
                wordId = int(input("请输入单词ID:"))
            except ValueError:
                print("输入错误!")
                continue
            break
        result = service.deleteWordById(wordId)
        if result > 0:
            print("[deleteWord] Success!")
        else:
            print("[deleteWord] Error!")

def refreshWord():
    while True:
        try:
            rAll = int(input("删除(1. 全部单词;2. 某个单词)?"))
            if rAll not in (1, 2):
                raise ValueError
        except ValueError:
            print("输入错误!")
            continue
        break
    if rAll == 1:
        print("[wordbank] Loading...")
        words = service.getAllWords()
        print("[wordbank] Loaded!")
        for word in words:
            service.getWord(word["word"], True)
    else:
        printWord(input("请输入单词:"), True)

为了便于用户对单词表中的单词进行复习,又增加了背单词功能。

用户进入背单词后,程序根据用户期望的背单词数量从单词库中随机抽取相应数量的单词,并逐一以闪卡(Flash card)的方式呈现给用户。接下来,程序将监听键盘按键,若用户按下f键,说明用户不认识该单词,该单词后续还会继续出现,需要连续两次选择认识才被标记为背完;若用户按下空格键,程序将输出单词释义,供用户确认自己记得的释义是否正确,接下来,用户仍然可以回答认识或不认识;若用户按下j键,说明用户认识该单词,标记为背完,后续不再出现。所有单词背完后,程序将总结背单词情况,并给出用户不熟悉的单词,提示用户加强复习。

具体实现如下:

import random
import time
import service
import keyboard

def flashCard():
    tot = service.getWordsCount()
    while True:
        try:
            cnt = int(input(f"请输入要背的单词数量(总数:{tot}):"))
        except ValueError:
            print("输入错误!")
            continue
        break
    if cnt > service.getWordsCount():
        print("你的单词表还没有这么多单词哦~试试减少数量或者多添加些单词吧^_^")
        return
    print("[wordbank] Loading...")
    words = service.getAllWords()
    print("[wordbank] Loaded!")
    words = random.sample(words, cnt)
    count = 0
    key_cnt = 0
    notFamiliar = set()
    while count < cnt:
        word = random.choice(words)
        if word.get("passed") == True:
            continue
        print("====================FLASH CARD====================")
        print("|                                                |")
        print(f"|{word["word"]:^48}|")
        print("|                                                |")
        for pro in word["content"]["pron"]:
            pro_str = f"<{pro["pos"]}> UK: {pro["uk"]} US: {pro["us"]}"
            print(f"|{pro_str:^48}|")
        print("|                                                |")
        print("|                                                |")
        print("--------------------------------------------------")
        print("f不认识  <space>查看释义  j认识")
        while True:
            if keyboard.is_pressed('space'):
                time.sleep(0.2)
                printWord(word["word"])
                key_cnt += 1
                print("====================FLASH CARD====================")
                print("|                                                |")
                print(f"|{word["word"]:^48}|")
                print("|                                                |")
                for pro in word["content"]["pron"]:
                    pro_str = f"<{pro["pos"]}> UK: {pro["uk"]} US: {pro["us"]}"
                    print(f"|{pro_str:^48}|")
                print("|                                                |")
                print("|                                                |")
                print("--------------------------------------------------")
                print("f不认识  <space>查看释义  j认识")
            if keyboard.is_pressed('j'):
                time.sleep(0.2)
                key_cnt += 1
                if word.get("passed") == False:
                    word.pop("passed")
                else:
                    word["passed"] = True
                    count += 1
                break
            if keyboard.is_pressed('f'):
                time.sleep(0.2)
                key_cnt += 1
                notFamiliar.add(word["word"])
                word["passed"] = False
                break
    print(f"背完啦!本次共背了{cnt}个单词~")
    print(f"其中,有{cnt - len(notFamiliar)}个单词很熟练,以下{len(notFamiliar)}个单词还需要注意复习哦:\n")
    for i in notFamiliar:
        print(i, end=' ')
    print("\n")
    for i in range(key_cnt):
        keyboard.press_and_release('backspace')
    time.sleep(0.5)

2.2.6 服务端

服务端和后面的客户端配合使用,可将笔记管理系统部署在服务器上,用户只需要使用客户端连接服务器即可使用。

服务端和客户端之间的通信采用socket流进行。

由于数据可能通过不安全的网络传输,需要对传输过程进行加密。这里采用Diffie-Hellman算法进行密钥交换:

from cryptography.hazmat.primitives.asymmetric import dh
from cryptography.hazmat.primitives import serialization
import socket

RFC3526_PRIME_2048 = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF
RFC3526_GENERATOR = 2

def generate_dh_parameters():
    parameter_numbers = dh.DHParameterNumbers(
        p=RFC3526_PRIME_2048,
        g=RFC3526_GENERATOR
    )
    return parameter_numbers.parameters()

def server_dh_key_agreement(conn, parameters):
    """服务端DH密钥交换"""
    # 生成服务端DH密钥对
    server_private_key = parameters.generate_private_key()
    
    # 发送公钥
    server_public_key = server_private_key.public_key()
    server_public_bytes = server_public_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo
    )
    conn.sendall(server_public_bytes)
    
    # 接收客户端公钥
    client_public_bytes = conn.recv(1024)
    client_public_key = serialization.load_pem_public_key(
        client_public_bytes,
    )
    
    # 计算共享密钥
    shared_key = server_private_key.exchange(client_public_key)
    return shared_key[:16]  # SM4需要16字节密钥

通过密钥协商后,得到共享密钥,该密钥用于服务端与客户端之间的国产对称加密算法SM4的加密。其中,SM4的封装如下:

from gmssl import sm4

def sm4_encrypt(data, key):
    """SM4加密"""
    crypt_sm4 = sm4.CryptSM4()
    crypt_sm4.set_key(key, sm4.SM4_ENCRYPT)
    return crypt_sm4.crypt_ecb(data)

def sm4_decrypt(data, key):
    """SM4解密"""
    crypt_sm4 = sm4.CryptSM4()
    crypt_sm4.set_key(key, sm4.SM4_DECRYPT)
    return crypt_sm4.crypt_ecb(data)

由于之前编写的程序都是在本地运行的,程序与用户的交互采用了标准输入输出(Standard Input/Output, stdio),若要与客户端交互,需要重新编写一套完整的交互流程,不利于程序的维护和后续迭代开发。

因此,这里采用了一种创新的方式,将controller的标准输入输出进行重定向,使其输入输出采用自己编写的socket读取器和写入器。socket读取器和写入器均采用了上面所述的加密方式。

socket读取器实现如下:

import socket
import sys
import threading

class SecureSocketReader:
    """线程安全的解密socket读取器"""
    def __init__(self, conn, key):
        self.conn = conn
        self.key = key
        self.buffer = bytearray()
        self.lock = threading.Lock()  # 读取锁保证线程安全
    
    def readline(self):
        with self.lock:
            print('__!input()_called!__')
            while True:
                # 检查缓冲区是否有完整消息
                if len(self.buffer) >= 4:
                    msg_len = int.from_bytes(self.buffer[:4], 'big')
                    if len(self.buffer) >= 4 + msg_len:
                        encrypted = self.buffer[4:4+msg_len]
                        self.buffer = self.buffer[4+msg_len:]
                        try:
                            decrypted = sm4_decrypt(encrypted, self.key)
                            return decrypted.decode('utf-8') + '\n'
                        except Exception as e:
                            print(f"解密错误: {e}", file=sys.__stdout__)
                            return '\n'
                
                # 接收新数据
                try:
                    data = self.conn.recv(4096)
                    if not data:
                        return ''
                    self.buffer += data
                except Exception as e:
                    print(f"接收错误: {e}", file=sys.__stdout__)
                    return ''

socket写入器实现如下:

import socket
import sys
import threading

class SecureSocketWriter:
    """线程安全的加密socket写入器"""
    def __init__(self, conn, key):
        self.conn = conn
        self.key = key
        self.lock = threading.Lock()  # 写入锁保证线程安全
    
    def write(self, data):
        try:
            with self.lock:
                encrypted = sm4_encrypt(data.encode('utf-8'), self.key)
                # 添加长度前缀
                self.conn.sendall(len(encrypted).to_bytes(4, 'big') + encrypted)
        except (BrokenPipeError, ConnectionResetError):
            # 连接已断开
            pass
        except Exception as e:
            print(f"加密写入错误: {e}", file=sys.__stderr__)
    
    def flush(self):
        pass

由于服务端可能同时被多个客户端连接而需要采用多线程技术,因此,对于每一个线程,都需要有自己的一套socket读取器和写入器,并在该线程的运行时间片内设置为当前的I/O。为了达到上述目的,设计了一个全局I/O管理器进行管理:

class ThreadLocalIO:
    """管理线程本地I/O重定向"""
    def __init__(self):
        self.local = threading.local()
    
    def register(self, reader, writer):
        self.local.reader = reader
        self.local.writer = writer
    
    def get_reader(self):
        return getattr(self.local, 'reader', sys.__stdin__)
    
    def get_writer(self):
        return getattr(self.local, 'writer', sys.__stdout__)

# 全局I/O管理器
thread_io_manager = ThreadLocalIO()

class ThreadSafePrint:
    def write(self, text):
        writer = thread_io_manager.get_writer()
        if hasattr(writer, 'write'):
            writer.write(text)
        else:
            sys.__stdout__.write(text)
    
    def flush(self):
        writer = thread_io_manager.get_writer()
        if hasattr(writer, 'flush'):
            writer.flush()
        else:
            sys.__stdout__.flush()

class ThreadSafeInput:
    def readline(self):
        reader = thread_io_manager.get_reader()
        if hasattr(reader, 'readline'):
            return reader.readline()
        return sys.__stdin__.readline()

# 重定向全局I/O
sys.stdout = ThreadSafePrint()
sys.stderr = ThreadSafePrint()
sys.stdin = ThreadSafeInput()

接下来,就可以设计每个客户端连接的处理线程了。客户端接入并建立起安全的加密连接后,首先实例化当前进程的socket读取器和写入器,并向全局I/O管理器注册。然后进入业务逻辑,首先要求用户输入密码进行身份验证,验证失败则断开连接。用户通过验证后,调用controller中的main()函数,进入笔记管理系统。

客户端处理线程设计如下:

class ClientHandler(threading.Thread):
    """客户端处理线程"""
    def __init__(self, conn, addr, key):
        threading.Thread.__init__(self)
        self.conn = conn
        self.addr = addr
        self.key = key
        self.reader = SecureSocketReader(conn, key)
        self.writer = SecureSocketWriter(conn, key)
        self.daemon = True  # 主线程退出时自动结束
    
    def run(self):
        try:
            # 注册线程本地I/O
            thread_io_manager.register(self.reader, self.writer)
            
            # 认证流程
            sys.stdout.write("请输入密码: ")
            password = sys.stdin.readline().strip()
            
            if password == PASSWORD:
                sys.stdout.write("认证成功!\n")
                # 调用控制器主函数
                main(True)
            else:
                sys.stdout.write("认证失败,连接关闭\n")
            
        except Exception as e:
            print(f"处理客户端 {self.addr} 时出错: {e}", file=sys.__stdout__)
        finally:
            self.conn.close()
            print(f"与 {self.addr} 的连接已关闭", file=sys.__stdout__)

主线程负责socket的初始化并开始监听,并负责在用户输入Ctrl-C时关闭服务器。当有客户端接入时,主线程使用Diffie-Hellman算法进行密钥交换协商,得到共享密钥,然后初始化客户端的处理线程,将共享密钥和其他参数传入,接着启动客户端处理线程。

主线程具体设计如下:

def start_server(host='0.0.0.0', port=65432):
    """启动服务器"""
    parameters = generate_dh_parameters()
    
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind((host, port))
        s.listen()
        s.settimeout(1)
        print(f"服务器启动,监听 {host}:{port}")
        
        try:
            while True:
                try:
                    conn, addr = s.accept()
                    print(f"接收到来自 {addr} 的新连接", file=sys.__stdout__)

                    # 执行DH密钥交换
                    shared_key = server_dh_key_agreement(conn, parameters)
                    print(f"与 {addr} 建立共享密钥: {shared_key.hex()}", file=sys.__stdout__)
                    
                    # 为每个客户端创建处理线程
                    handler = ClientHandler(conn, addr, shared_key)
                    handler.start()
                except socket.timeout:
                    continue
                
        except KeyboardInterrupt:
            print("正在关闭服务器...")
        finally:
            s.close()

if __name__ == "__main__":
    start_server()

2.2.7 客户端

客户端与服务端配合使用,负责与服务端建立socket连接并进行加密通信。客户端同样采用Diffie-Hellman算法和国产对称加密算法SM4来保障连接安全。这部分与服务端相同,不再赘述。

由于客户端不需要使用多线程,可以直接简单采用一个I/O包装器来处理从服务端通过socket重定向过来的I/O流。在包装器内同样处理了通信的加密,处理方式与服务端一致。

I/O包装器的具体实现如下:

import socket
import sys

class SecureSocketIO:
    """客户端安全I/O包装器"""
    def __init__(self, conn, key):
        self.conn = conn
        self.key = key
        self.buffer = b""
    
    def write(self, data):
        """加密并发送数据到服务器"""
        try:
            encrypted = sm4_encrypt(data.encode('utf-8'), self.key)
            self.conn.sendall(len(encrypted).to_bytes(4, 'big') + encrypted)
        except Exception as e:
            print(f"发送错误: {e}", file=sys.__stdout__)
    
    def readline(self):
        """从服务器接收并解密数据"""
        while True:
            # 检查缓冲区是否有完整消息
            if len(self.buffer) >= 4:
                msg_len = int.from_bytes(self.buffer[:4], 'big')
                if len(self.buffer) >= 4 + msg_len:
                    encrypted = self.buffer[4:4+msg_len]
                    self.buffer = self.buffer[4+msg_len:]
                    try:
                        decrypted = sm4_decrypt(encrypted, self.key)
                        return decrypted.decode('utf-8') + '\0'
                    except Exception as e:
                        print(f"解密错误: {e}", file=sys.__stdout__)
                        return '\n'
            
            # 接收更多数据
            try:
                data = self.conn.recv(4096)
                if not data:
                    return ''
                self.buffer += data
            except Exception as e:
                print(f"接收错误: {e}", file=sys.__stdout__)
                return ''
    
    def flush(self):
        pass

由于服务端会连续进行多次输出,传统的一问一答形式的客户端逻辑不再适用。为了解决这个问题,这里创新式地采用了特定字符串的方式进行输入输出切换控制。

程序定义了字符串"__!input()_called!__"来说明服务端调用了input()或其他类似函数。服务端的socket读取器被读取时,会先通过socket写入器向客户端发送该字符串。在客户端的主交互循环中,客户端不断从socket I/O包装器中读入服务端输出,若遇到形式为"__!xx()_called!__"的特殊字符串时(后续还有其他需要通过特殊字符串控制客户端的场景),按照预设的逻辑进行处理,否则显示客户端的输出。

当遇到"__!input()_called!__"时,客户端将调用input()函数获取用户输入,然后向socket I/O包装器写入用户输入,服务端即可从socket读取器中读入用户输入。

其他特殊字符串对应功能将在后文解释。

客户端控制逻辑具体实现如下:

from gmssl import sm4
from cryptography.hazmat.primitives.asymmetric import dh
from cryptography.hazmat.primitives import serialization
import socket
import sys
from common import fileSelector, showImage
import base64

def start_client(host='localhost', port=65432):
    ihost = input("输入服务器主机(默认:localhost):")
    if ihost:
        host=ihost
    """启动客户端并建立安全连接"""
    try:
        # 建立连接
        conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        conn.connect((host, port))
        print(f"已连接到服务器 {host}:{port}")
        
        # 生成DH参数并执行密钥交换
        parameters = generate_dh_parameters()
        shared_key = client_dh_key_agreement(conn, parameters)
        print(f"共享密钥已建立: {shared_key.hex()}")
        
        # 创建安全I/O包装器
        secure_io = SecureSocketIO(conn, shared_key)
        
        # 主交互循环
        while True:
            try:
                # 从服务器获取提示
                prompt = secure_io.readline()
                if not prompt:
                    break
                
                if "__!input()_called!__" in prompt:
                    # 显示提示并获取用户输入
                    user_input = input()
                    
                    # 发送输入到服务器
                    secure_io.write(user_input)
                elif "__!fileSelector()_called!__" in prompt:
                    data = fileSelector()
                    data = base64.b64encode(data).decode()
                    secure_io.write(data)
                    secure_io.readline()
                elif "__!showImage()_called!__" in prompt:
                    data = secure_io.readline().strip()
                    data = base64.b64decode(data)
                    showImage(data)
                else:
                    print(prompt, end='')
                
                    
            except KeyboardInterrupt:
                print("\n客户端中断")
                break
            except Exception as e:
                print(f"通信错误: {e}")
                break
                
    finally:
        conn.close()
        print("连接已关闭")

if __name__ == "__main__":
    start_client()

2.2.8 图片处理相关

由于用户可能存在保存实体笔记图片或其他有关图片的需求,早在数据库设计之初就已经设置了extra字段用于储存图片。由于图片的处理涉及到对二进制数据的处理和文件操作,这里单独进行说明。

在数据库中,图片作为二进制数据以blob的形式储存在extra字段中。在Python的Entry实体类中,则以bytes类型储存。在数据库相关的操作中,图片与其他数据的操作方式类似,此处不再赘述。

但在图片的输入和输出操作中,图片以文件的形式输入,以在屏幕上显示的形式输出,二者均不属于标准输入输出,因此,需要对图片的输入输出进行单独处理。而对图片的处理又需要分为两种场景:一,笔记管理系统在本地运行;二,笔记管理系统以服务端的形式运行。

对于图片的输入,若系统在本地运行,可直接要求用户输入图片的路径,程序根据路径打开文件并读入数据,进行保存;若系统在服务端运行,服务端将向客户端发送"__!fileSelector()_called!__"字符串,要求客户端代替执行fileSelector()函数进行文件读入操作,并将数据以base64编码的形式回传给服务端,再由服务端保存数据。

fileSelector()的具体实现如下:

import sys
import base64

def fileSelector(remote: bool = False) -> bytes:
    if not remote:
        file_path = input("请输入图片文件地址:")
        try:
            with open(file_path, 'rb') as f:
                data = f.read()
            return data
        except FileNotFoundError:
            print("[fileSelector] 文件打开失败!请检查文件路径。")
    else:
        sys.stdout.write("__!fileSelector()_called!__")
        data = sys.stdin.readline().strip()
        data = base64.b64decode(data)
        return data

对于图片的输出,若系统在本地运行,将数据库返回的图片文件数据通过BytesIO的形式发送给Pillow库的图片模块进行处理并显示;若系统在服务端运行,服务端将向客户端发送"__!showImage()_called!__"字符串,并将图片数据以base64编码的形式传输给客户端,由客户端代为执行showImage()函数,向用户展示图片。

showImage()的具体实现如下:

import sys
import base64
from io import BytesIO
from PIL import Image

def showImage(data: bytes, remote: bool = False):
    if not remote:
        Image.open(BytesIO(data)).show()
    else:
        sys.stdout.write("__!showImage()_called!__")
        data = base64.b64encode(data).decode()
        sys.stdout.write(data)

在客户端中,对于以上两个特殊字符串的处理如下:

elif "__!fileSelector()_called!__" in prompt:
    data = fileSelector()
    data = base64.b64encode(data).decode()
    secure_io.write(data)
    secure_io.readline()
elif "__!showImage()_called!__" in prompt:
    data = secure_io.readline().strip()
    data = base64.b64decode(data)
    showImage(data)

2.2.9 运行

若要以本地模式运行本系统,请使用以下命令:

python controller.py

若要以客户端/服务端(C/S)模式运行本系统,请在服务器使用以下命令启动服务:

python server.py

并在客户机上使用以下命令进行连接:

python client.py

2.3 实验结果

2.3.1 本地运行结果

本地所有功能均测试正常。运行示例视频如下:

(若无法播放,请从Gitee下载视频)

2.3.2 远程运行结果(C/S模式)

使用华为云服务器运行服务端,并在个人计算机上运行客户端,所有功能均测试正常,与本地运行结果无异。运行示例视频如下:

(若无法播放,请从Gitee下载视频)

2.3.3 实验代码

本实验的所有源代码均已上传至Gitee。

3. 实验过程中遇到的问题和解决过程

3.1 问题1:退出程序后对数据库的修改未保存

问题1解决方案: SQLite3对数据库进行操作后,数据修改会先保存在临时文件中,需要向数据库提交(commit) 修改后才会写入数据库文件。因此,定义以下两个函数:

conn = None

def __getCursor() -> sqlite3.Cursor:
    global conn
    conn = sqlite3.connect("note.db")
    conn.row_factory = sqlite3.Row
    return conn.cursor()

def __getClosedConn():
    global conn
    conn.commit()
    conn.close()

在每次操作前通过__getCursor()获取数据库连接,操作结束后使用__getClosedConn()提交更改并关闭连接。原子化操作也能最大程度减少程序异常退出时的数据损失。

使用示例如下:

def addNote(entry: Entry) -> int:
    cursor = __getCursor()
    cursor.execute('INSERT INTO note (type, title, content, extra) VALUES(?, ?, ?, ?)', (entry.type, entry.title, entry.content, sqlite3.Binary(entry.extra)))
    __getClosedConn()
    return cursor.rowcount

3.2 问题2:爬取单词时,部分单词页面爬取失败

问题2解决方案: 一开始只参考了一个单词的页面进行结构分析,后来分析爬取失败的单词页面后发现不同单词的页面可能会有页面标签结构的差异,综合比较多个单词的页面后,分析出共有的结构,再根据该结构进行爬取,即可成功获得数据。

3.3 问题3:背单词结束之后,前面用户按下的f、j、空格会在接下来等待用户输入的时候输入命令行

问题3解决方案: 个人猜测使用keyboard库获取按键状态时并不会把按键按下的消息从消息队列中移除。通过网络搜索和询问AI得到的建议是使用sys.stdin.flush()刷新输入缓冲区,然而经过尝试并没有效果。由于没有找到其他相关的资料,在本实验中的解决方案是记录下用户在背单词过程中按键的次数,并在背单词结束之后利用keyboard库的keyboard.press_and_release('backspace')输入相应次数的退格键,达到删除之前因用户按键而输入的字符。因此,为了隐藏这一现象,背单词结束后,将会有0.5秒的等待时间。
具体代码如下:

if keyboard.is_pressed('j'):
    time.sleep(0.2)
    key_cnt += 1
    # 其他代码...
# 其他按键类似
for i in range(key_cnt):
    keyboard.press_and_release('backspace')
time.sleep(0.5)

3.4 问题4:服务端采用socket I/O包装器的方式重定向I/O,当多个客户端接入时只有最后一个接入的客户端能与服务端交互,且同时与服务端的多个线程交互,造成混乱

问题4解决方案: 当多个客户端接入时,服务端对于每个客户端都会启动一个线程来处理。在之前的方案中,线程启动会将I/O重定向只当前连接的socket,进程结束之后恢复之前的I/O。由于对I/O的修改是全局的,之前的客户端所启动的线程的I/O也会被重定向至当前客户端,造成上述现象。解决方案为统一管理全局的I/O,并使用路由的方式保证不同进程进入其运行时间片时,I/O会切换至当前进程对应的客户端。具体方案参见上文服务端的说明。

3.5 问题5:服务端调用print("xxx", end='')时,客户端断开连接

问题5解决方案: 在问题发生时,客户端的socket I/O包装器中输入部分的关键代码如下:

while True:
    # 检查缓冲区是否有完整消息
    if len(self.buffer) >= 4:
        msg_len = int.from_bytes(self.buffer[:4], 'big')
        if len(self.buffer) >= 4 + msg_len:
            encrypted = self.buffer[4:4+msg_len]
            self.buffer = self.buffer[4+msg_len:]
            try:
                decrypted = sm4_decrypt(encrypted, self.key)
                return decrypted.decode('utf-8') # 此处!
            except Exception as e:
                print(f"解密错误: {e}", file=sys.__stdout__)
                return '\n'

当服务端调用print("xxx", end='')时,服务端发送了两次数据,分别为b'xxx'和b''。当客户端读入第二次数据后,在交互主循环中会被认为连接未发送数据,即连接已断开:

prompt = secure_io.readline()
if not prompt:
    break

因此,在接收到的数据最后加一个不可打印字符即可,同时该方法也不会影响连接是否断开的判断:

return decrypted.decode('utf-8') + '\0'

3.6 问题6:发送图片数据出错

问题6解决方案:base64.b64encode(data)得到的返回值是bytes类型,应使用base64.b64encode(data).decode()将其转换为字符串再发送。

3.7 问题7:客户端输入图片地址后卡住

问题7解决方案: 此处是由于前面客户端输入判断方式导致的问题(可以说是前面挖下的坑了)。服务端通过sys.stdin.readline()获取图片数据的base64字符串,然后触发了"__!input()_called!__",导致客户端开始等待用户输入。解决方式也很简单,发送完图片数据之后,读取一次"__!input()_called!__"字符串并丢弃即可。

3.8 问题8:服务端按下Ctrl-C后无法关闭,会等到新的客户端接入时才退出

问题8解决方案: 在服务端主线程中,socket.accept()会阻塞线程运行,线程无法接收Ctrl-C信号,当新客户端接入时,socket.accpet()返回,线程接收到信号,所以退出。解决方案是使用socket.settimeout()设置超时时间,当时间超过1秒时停止侦听,进行一轮循环后重新开始侦听。

4. 其他(感悟、思考等)

4.1 课程总结

本学期,在王志强老师的悉心指导下,我们开启了一段系统而充实的Python编程学习之旅。通过为期13周循序渐进的学习,我不仅掌握了Python编程的基础知识,更领略到了这门语言的强大魅力与应用价值。以下是我在这段时间所学到的主要知识点:

  • Python语言的特点和用处
  • Python基础语法
  • Python流程控制语句
  • Python序列
  • Python字符串
  • 正则表达式
  • Python函数编程
  • 使用Python进行面向对象编程(OOP)
  • Python模块与异常处理
  • Python文件及目录操作
  • Python Socket编程技术
  • Python操作SQLite3数据库
  • Python网络爬虫开发

首先,我们从Python语言的特性入手,深入了解了其作为解释型语言的跨平台优势、简洁优雅的语法特点,以及在数据分析、人工智能、Web开发等领域的广泛应用。这为我们后续的学习奠定了良好的认知基础。在基础语法部分,我们系统学习了变量与数据类型、运算符与表达式等核心概念。Python序列的学习是课程的一个重要转折点。我们深入探讨了列表、元组、字典和集合这四种核心数据结构,通过大量实践掌握了它们的创建、访问、修改及常用方法,也让我领略到Python的简洁之美。字符串处理部分,我们不仅学习了基本的字符串操作,还重点研究了格式化输出以及字符串常用方法。正则表达式的学习尤为实用,从基础元字符到复杂模式匹配,这些知识在后续的开发中发挥了关键作用。函数式编程模块让我能够将Python与之前学习的C语言联系起来,又为后面的面向对象编程学习作了铺垫。面向对象编程(OOP)部分是最具挑战性也最富收获的单元。从类与对象的基本概念,到继承、多态、封装三大特性,王老师通过生动的案例(蛋炒饭和盖浇饭)帮助我们建立起面向对象的编程思维。在模块与异常处理的学习中,我们掌握了模块的导入机制、包的组织结构,以及try-except-finally异常处理体系。这些知识显著提升了我们编写健壮代码的能力。文件及目录操作单元让我们熟悉了Python处理IO的各种方式,包括文件读写、上下文管理器(with语句)等。Socket编程技术的学习则带我们走进了网络编程的世界,学会了简单的客户端/服务器实现,让我们对网络通信有了直观认识。数据库操作部分,我们重点学习了使用Python操作SQLite3数据库。而网络爬虫开发则是本课程的压轴内容,从requests库的使用到BeautifulSoup解析,我们完整实现了从网页抓取到数据存储的整个流程。

这些知识点就像一把把钥匙,为我们打开了Python编程的宝库。王老师深入浅出的讲解和精心设计的实践环节,不仅让我们掌握了基础知识,更激发了深入探索的兴趣。以上的知识点毫无疑问起到了抛砖引玉的作用,在课堂上进行了基础的学习之后,我又通过使用王志强老师在第一堂课上推荐的学习方法对我感兴趣的部分进行了深入学习。

值得一提的是,在课外拓展学习过程中,我遇到了许多优质的英文技术文档和开源项目资料。这时才真正体会到王志强老师在学期初要求我们背诵英语单词的深意——这些单词的积累,让我能够更自信地阅读原版技术资料,及时获取最新的技术动态。

4.2 实验感悟

在本次实验中,我深刻感受到了Python语言的强大之处,发现了Python也能开发大型的程序,同时也体会到Python开发的简洁优雅。

本次实验的灵感来自于我在整理英语笔记时没有较好的数字化存储工具而萌生的想法。我根据我的英语笔记的特点(大部分同学应该也是类似的特点)而设计了这次实验项目中的笔记条目实体,方便我将纸质的笔记存储到电子设备中并分类整理。背单词功能的灵感则来源于王志强老师在学期初提的背单词的要求。

在设计本次实验的内容时,我综合了课堂上讲解的各种知识点,并融入了我在课外学习到的其他知识,通过以练代学的方式强化了我对于Python的掌握。

以下是Python课程知识点在本实验项目中的体现:

知识点 实验体现
Python语言的特点和用处 本实验采用Python编写
Python基础语法 本实验大量运用
Python流程控制语句 本实验大量运用(如交互控制)
Python序列 本实验大量运用(如爬取到的词典数据使用字典类型存储)
Python字符串 本实验大量运用(如输出时经常使用格式化字符串)
正则表达式 使用正则表达式进行命令的预处理并替换转义字符
Python函数编程 本实验中定义了大量函数
使用Python进行面向对象编程(OOP) 使用OOP思想定义了Entry实体类并加以运用等
Python模块与异常处理 本实验中的代码以模块形式组织,如dao模块、service模块、wordbank模块等
Python文件及目录操作 通过文件读写对图片文件进行操作
Python Socket编程技术 利用Socket技术实现服务端和客户端之间的通信
Python操作SQLite3数据库 使用SQLite3数据库存储笔记内容和单词库缓存
Python网络爬虫开发 使用爬虫技术爬取词典网站,获得单词的读音、词性、释义和例句

参考资料

posted @ 2025-06-08 14:42  ysy24  阅读(78)  评论(0)    收藏  举报