Skip to content

14. 分层架构与接口编程

14.1. 引言

我们计划编写一个应用程序,用于显示某所中学学生的成绩。该应用程序可以采用多层架构:

Image

  • [ui]层(用户界面)是与应用程序用户交互的层;
  • [métier] 层实现了应用程序的管理规则,例如工资或发票的计算。 该层通过 [présentation] 层使用来自用户的数据,并通过 [dao] 层使用来自 SGBD 的数据;
  • [dao] 层(数据访问对象)负责管理对 SGBD 层(数据库管理系统)中数据的访问。

这是 |Python 2 课程| 中采用的架构。也可以引入一种变体:

Image

与前面的分层结构相比,区别如下:

  • 一个名为 [main] 的主脚本负责组织各层的实例化;
  • [ui, métier, dao] 层之间不再必然相互通信。若需通信,脚本 [main] 会为其提供所需的层引用;

此处的代码按职能中心进行组织,并由一个“总指挥”协调:

  • 总协调器即主脚本 [main]
  • [ui][dao][métier] 层即为各职能中心;

这种组织结构可称为“管弦乐队式组织”。

14.2. 示例 1

我们将通过一个简单的控制台应用程序来说明分层架构:

  • 该应用不涉及数据库;
  • [dao] 层将管理实体 ElèveClasseMatière、Note,用于管理学生的成绩;
  • [métier] 层将用于计算特定学生的成绩指标;
  • [ui] 层将是一个控制台应用程序,用于显示学生的成绩;

该应用程序的 PyCharm 项目如下:

:蓝色文件夹属于 PyCharm 项目下的 [Sources Root]。

14.2.1. 应用程序的实体

我们将那些唯一作用是封装数据的类称为实体。为此,我们可以使用字典。该类的优势在于能够验证对象中存储数据的有效性,并提供一种方法,以字符串形式返回对象的标识符。

14.2.1.1. 实体 [Classe]

实体 [Classe] (Classe.py) 代表中学的一个类:


# 导入
from BaseEntity import BaseEntity
from MyException import MyException
from Utils import Utils


class Classe(BaseEntity):
    # 从类状态中排除的属性
    excluded_keys = []

    # 类属性
    @staticmethod
    def get_allowed_keys() -> list:
        # id:类标识符
        # 名称:类名
        return BaseEntity.get_allowed_keys() + ["nom"]

    # 获取器
    @property
    def nom(self: object) -> str:
        return self.__nom

    #  设置器
    @nom.setter
    def nom(self: object, nom: str):
        # 名称必须为非空字符串
        if Utils.is_string_ok(nom):
            self.__nom = nom
        else:
            raise MyException(11, f"Le nom de la classe {self.id} doit être une chaîne de caractères non vide")

注释

  • 第7行:实体[Classe]源自第|BaseEntity班级|段落中讨论的实体[BaseEntity]
  • 第11-16行:一个类由编号idnom(第16行)定义。 属性 [id] 由类 [BaseEntity] 提供,名称由类 [Classe] 提供;
  • 第 18-30 行:属性 [nom] 的 getter/setter;

14.2.1.2. 实体 [Matière]

[Matière] (matière.py) 如下所示:


# 导入
from BaseEntity import BaseEntity
from MyException import MyException
from Utils import Utils


class Matière(BaseEntity):
    # 从类状态中排除的属性
    excluded_keys = []

    # 类的属性
    @staticmethod
    def get_allowed_keys() -> list:
        # id:课程标识符
        # 名称:课程名称
        # 系数:课程系数
        return BaseEntity.get_allowed_keys() + ["nom", "coefficient"]

    # 获取器
    @property
    def nom(self: object) -> str:
        return self.__nom

    @property
    def coefficient(self: object) -> float:
        return self.__coefficient

    #  设置器
    @nom.setter
    def nom(self: object, nom: str):
        # 名称必须为非空字符串
        if Utils.is_string_ok(nom):
            self.__nom = nom
        else:
            raise MyException(21, f"Le nom de la matière {self.id} doit être une chaîne de caractères non vide")

    @coefficient.setter
    def coefficient(self, coefficient: float):
        # 系数必须为大于等于0的实数
        erreur = False
        if isinstance(coefficient, (int, float)):
            if coefficient >= 0:
                self.__coefficient = coefficient
            else:
                erreur = True
        else:
            erreur = True
        # 错误?
        if erreur:
            raise MyException(22, f"Le coefficient de la matière {self.nom} doit être un réel >=0")

注释

  • 第 7 行:类 [Classe] 派生自类 [BaseEntity]
  • 第11-17行:一门课程由其编号[id]、名称[nom]及学分系数[coefficient]定义;
  • 第19-50行:类属性的getter/setter方法;

14.2.1.3. 实体 [Elève]

[Elève] (élève.py) 如下所示:


# 导入
from BaseEntity import BaseEntity
from Classe import Classe
from MyException import MyException

from Utils import Utils


class Elève(BaseEntity):
    # 类状态中排除的属性
    excluded_keys = []

    # 类属性
    @staticmethod
    def get_allowed_keys() -> list:
        # id:学生 ID
        # 姓:学生的姓
        # 名字:学生的名字
        # 班级:学生的班级
        return BaseEntity.get_allowed_keys() + ["nom", "prénom", "classe"]

    # 获取器
    @property
    def nom(self: object) -> str:
        return self.__nom

    @property
    def prénom(self: object) -> str:
        return self.__prénom

    @property
    def classe(self: object) -> Classe:
        return self.__classe

    #  设置器
    @nom.setter
    def nom(self: object, nom: str) -> str:
        # 姓必须为非空字符串
        if Utils.is_string_ok(nom):
            self.__nom = nom
        else:
            raise MyException(41, f"Le nom de l'élève {self.id} doit être une chaîne de caractères non vide")

    @prénom.setter
    def prénom(self: object, prénom: str) -> str:
        # 名字必须为非空字符串
        if Utils.is_string_ok(prénom):
            self.__prénom = prénom
        else:
            raise MyException(42, f"Le prénom de l'élève {self.id} doit être une chaîne de caractères non vide")

    @classe.setter
    def classe(self: object, value):
        try:
            # 预期类型为 Class
            if isinstance(value, Classe):
                self.__classe = value
            # 或字典类型
            elif isinstance(value,dict):
                self.__classe=Classe().fromdict(value)
            # 或 json 类型
            elif isinstance(value,str):
                self.__classe = Classe().fromjson(value)
        except BaseException as erreur:
            raise MyException(43, f"L'attribut [{value}] de l'élève {self.id} doit être de type Classe ou dict ou json. Erreur : {erreur}")

注释

  • 第 9 行:类 [Elève] 继承自类 [BaseEntity]
  • 第13-20行:一名学生由其编号[id]、姓氏[nom]、名字[prénom]以及班级[classe]来标识。 最后一个参数是对象 [Classe] 的引用;
  • 第22-65行:类属性的getter/setter方法;

14.2.1.4. 实体 [Note]

[Note](note.py)如下:


# 导入
from BaseEntity import BaseEntity
from Elève import Elève
from Matière import Matière
from MyException import MyException


class Note(BaseEntity):
    # 类状态中排除的属性
    excluded_keys = []

    # 类的属性
    @staticmethod
    def get_allowed_keys() -> list:
        # id:评分标识符
        # 值:成绩本身
        # 学生:与该成绩相关的学生(类型为学生)
        # 科目:与该成绩相关的科目(类型为 Matière)
        # 因此,Note 对象即为某学生在某学科中的成绩
        return BaseEntity.get_allowed_keys() + ["valeur", "élève", "matière"]

    # 获取器
    @property
    def valeur(self: object) -> float:
        return self.__valeur

    @property
    def élève(self: object) -> Elève:
        return self.__élève

    @property
    def matière(self: object) -> Matière:
        return self.__matière

    # 获取器
    @valeur.setter
    def valeur(self: object, valeur: float):
        # 成绩必须是 0 到 20 之间的实数
        if isinstance(valeur, (int, float)) and 0 <= valeur <= 20:
            self.__valeur = valeur
        else:
            raise MyException(31,
                              f"L'attribut {valeur} de la note {self.id} doit être un nombre dans l'intervalle [0,20]")

    @élève.setter
    def élève(self: object, value):
        try:
            # 期望类型为 Elève
            if isinstance(value, Elève):
                self.__élève = value
            # 或 dict 类型
            elif isinstance(value, dict):
                self.__élève = Elève().fromdict(value)
            # 或 json 类型
            elif isinstance(value, str):
                self.__élève = Elève().fromjson(value)
        except BaseException as erreur:
            raise MyException(32,
                              f"L'attribut [{value}] de la note {self.id} doit être de type Elève ou dict ou json. Erreur : {erreur}")

    @matière.setter
    def matière(self: object, value):
        try:
            # 预期类型为 Matière
            if isinstance(value, Matière):
                self.__matière = value
            # 或字典类型
            elif isinstance(value, dict):
                self.__matière = Matière().fromdict(value)
            # 或 json 类型
            elif isinstance(value, str):
                self.__matière = Matière().fromjson(value)
        except BaseException as erreur:
            raise MyException(33,
                              f"L'attribut [{value}] de la note {self.id} doit être de type Matière ou dict ou json. Erreur : {erreur}")

注释

  • 第 8 行:类 [Note] 继承自类 [BaseEntity]
  • 第12-20行:一个[Note]对象由其编号[id]、 成绩值 [valeur]、指向获得该成绩的学生对象的引用 [élève],以及指向该成绩所对应科目对象的引用 [matière]
  • 第22-75行:类属性的getter/setter方法;

14.2.2. 应用程序配置

文件 [config.py] 配置了主脚本 [main] (1) 以及测试脚本 (2) 的环境。所有这些脚本的代码开头都包含一条 [import config] 指令。 需要提醒的是,包含 [python script] 命令所针对脚本的文件夹会自动成为 Python Path.Si 的一部分,因此 [config] 位于与包含 [import config] 指令的脚本相同的文件夹中,系统将能找到它。 文件 [1][2] 在此处内容相同。但实际情况可能并非如此。

文件 [config.sys] 内容如下:


def configure():
    import os

    # 该脚本文件的绝对路径
    script_dir = os.path.dirname(os.path.abspath(__file__))
    # root_dir
    root_dir="C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/classes"
    # 绝对依赖项
    absolute_dependencies=[
        # 包含类和接口的本地文件夹
        f"{root_dir}/02/entities",
        f"{script_dir}/../entities",
        f"{script_dir}/../interfaces",
        f"{script_dir}/../services",
    ]

    # 更新 syspath
    from myutils import set_syspath
    set_syspath(absolute_dependencies)

    # 生成配置
    return {}
  • 第 11-14 行:应包含在 Python 路径中的文件夹(sys.path);
  • 文件 [f"{root_dir}/02/entities"] 提供了对类 [BaseEntity][MyException] 的访问;
  • 文件夹 [f"{script_dir}/../entities"] 包含类 [Elève][Classe][Matière][Note]
  • 文件夹 [f"{script_dir}/../interfaces",] 提供对应用程序接口的访问;
  • 文件夹 [f"{script_dir}/../services"] 提供了实现这些接口的类;

14.2.3. 实体测试

在此我们将编写由名为 [unittest] 的工具执行的测试。PyCharm 附带了多个测试框架。可在 PyCharm 的配置中选择其中之一:

Image

  • [4] 中,有多种测试框架可供选择:

Image

14.2.3.1. 测试类 [TestBaseEntity]

测试脚本 [TestBaseEntity] 将如下所示:


import unittest

# 配置应用程序
import config

config = config.configure()


class TestBaseEntity (unittest.TestCase):

    def test_note1(self):
        # 导入
        from Note import Note
        from Elève import Elève
        from Classe import Classe
        from Matière import Matière
        # 根据字符串生成评分jSON
        note = Note().fromjson(
            '{"id": 8, "value": 12, "student": {"id": 42, "last_name": "last_name4", "first_name": "first_name4", "班级": {"id": 2, "名称": "班级2"}}, "科目": {"id": 2, "名称": "科目2", "权重": 2}}')
        # 校验
        self.assertIsInstance(note, Note)
        self.assertIsInstance(note.élève, Elève)
        self.assertIsInstance(note.élève.classe, Classe)
        self.assertIsInstance(note.matière, Matière)


    def test_note2(self):
        # 导入
        from Note import Note
        from Elève import Elève
        from Classe import Classe
        from Matière import Matière
        # 根据字典构建成绩
        note = Note().fromdict(
            {"id": 8, "valeur": 12, "élève": {"id": 42, "nom": "nom4", "prénom": "prénom4",
                                              "classe": {"id": 2, "nom": "classe2"}},
             "matière": {"id": 2, "nom": "matière2", "coefficient": 2}})
        # 验证
        self.assertIsInstance(note, Note)
        self.assertIsInstance(note.élève, Elève)
        self.assertIsInstance(note.élève.classe, Classe)
        self.assertIsInstance(note.matière, Matière)

if __name__ == '__main__':
    unittest.main()

注释

  • 第 1 行:导入模块 [unittest],该模块将提供各种测试方法;
  • 第 3-6 行:配置应用程序,以便找到测试所需的类;
  • 第 9 行:测试类 [unittest] 必须继承自类 [unittest.TestCase]
  • 第 11、27 行:测试函数的名称必须以 [test] 开头,否则将无法被识别;
  • 第13-16行:导入所需的类;
  • 在此测试类中,需验证方法 [BaseEntity.fromdict](第 34 行)和 [BaseEntity.fromjson](第 18 行)的行为。类 [Note] 的属性是其他类的引用。 我们需要验证前两个方法是否能创建有效的 [Note] 对象;
  • 第 18 行:基于 jSON 对象创建 [Note] 对象;
  • 第 21 行:验证创建的对象确实属于 [Note] 类型。方法 [assertIsInstance] 是类 [unittest.TestCase] 的方法,而 [unittest.TestCase] 是类 [TestBaseEntity] 的父类;
  • 第 22 行:验证 [note.élève] 是否确实属于 [Elève] 类型;
  • 第 23 行:验证 [note.élève.classe] 是否确实属于 [Classe] 类型;
  • 第24行:验证[note.matière]是否确实属于[Matière]类型;
  • 第33-42行:对方法[BaseEntity.fromdict]进行同样的验证;

执行测试有多种方式:

  • [1-2] 中,使用框架 [UnitTest] 执行 [TestBaseEntity]
  • [3-5] 中,测试失败。[UnitTests] 表示未找到任何待执行的测试;

测试失败的原因在于 [TestBaseEntity] 的代码组织方式:


import unittest

# 配置应用程序
import config

config = config.configure()


class TestBaseEntity(unittest.TestCase):

[UnitTest]框架的问题在于,在第9行的测试类定义之前,第3-6行存在可执行代码。

因此,我们将代码重新组织如下:


import unittest


class TestBaseEntity(unittest.TestCase):

    def setUp(self):
        # 配置应用程序
        import config

        config.configure()

    def test_note1(self):
        

    def test_note2(self):
        


if __name__ == '__main__':
    unittest.main()
  • 第6-10行:定义了一个名为[setUp]的函数。该函数具有特殊作用:它会在每个测试函数(test_note1、test_note2)执行之前运行;

完成上述操作后,执行类 [TestBaseEntity] 将产生以下结果:

这次两个测试方法均已执行,且测试均通过。

让我们看看当测试失败时会发生什么。我们将 [test_note1] 的代码修改如下:


    def test_note1(self):
        # 故意错误 - 验证 1==2
        self.assertEqual(1,2)
        # 导入
        from Note import Note

  • 第 2 行:验证 1==2;

执行结果如下:

点击失败的测试 [2],即可了解错误原因:

  • [7-8] 中,显示了错误原因;

另一种执行测试类的方法是在终端中运行:


(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\troiscouches\v01\tests>python -m unittest TestBaseEntity.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.026s

OK

第 6 行表明两个测试均已通过(已移除了 1==2 的错误);

最后,第三种在终端中执行测试类 [TestBaseEntity] 的方法如下。测试类以第 6-7 行代码结束;



        self.assertIsInstance(note.élève.classe, Classe)
        self.assertIsInstance(note.matière, Matière)


if __name__ == '__main__':
    unittest.main()
  • 第6行:变量[__name__]是正在执行的脚本的名称。 当脚本由命令 [python script.py] 启动时,变量 [__name__] 的值为 [__main__](标识符前后各带有两个下划线字符)。 因此,只有当脚本 [TestBaseEntity] 由命令 [python TestBaseEntity.py] 启动时,第 7 行才会被执行。 指令 [unittest.main()] 通过框架 [UnitTest] 启动脚本的执行。以下是一个示例:

(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\troiscouches\v01\tests>python TestBaseEntity.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.013s

OK

14.2.3.2. 测试类 [TestEntités]

测试类 [TestEntités] 如下所示:


import unittest


class TestEntités(unittest.TestCase):
    def setUp(self):
        # 正在配置应用程序
        import config

        config.configure()

    def test_code1a(self):
        # 导入
        from Elève import Elève
        from MyException import MyException
        # 错误代码
        code = None
        try:
            # ID 无效
            Elève().fromdict({"id": "x", "nom": "y", "prénom": "z", "classe": "t"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 1)

    def test_code41(self):
        # 导入
        from Elève import Elève
        from MyException import MyException
        # 错误代码
        code = None

        try:
            # 名称无效
            Elève().fromdict({"id": 1, "nom": "", "prénom": "z", "classe": "t"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 41)

    def test_code42(self):
        # 导入
        from Elève import Elève
        from MyException import MyException
        # 错误代码
        code = None
        try:
            # 名字无效
            Elève().fromdict({"id": 1, "nom": "y", "prénom": "", "classe": "t"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 42)

    def test_code43(self):
        # 导入
        from Elève import Elève
        from MyException import MyException
        # 错误代码
        code = None
        try:
            # 类无效
            Elève().fromdict({"id": 1, "nom": "y", "prénom": "z", "classe": "t"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 43)

    def test_code1b(self):
        # 导入
        from Classe import Classe
        from MyException import MyException
        # 错误代码
        code = None
        try:
            # 标识符无效
            Classe().fromdict({"id": "x", "nom": "y"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 1)

    def test_code11(self):
        # 导入
        from Classe import Classe
        from MyException import MyException

        # 错误代码
        code = None
        try:
            # 名称无效
            Classe().fromdict({"id": 1, "nom": ""})
        except MyException as ex:
            code = ex.code
        # 验证
        self.assertEqual(code, 11)

    def test_code1c(self):
        # 导入
        from Matière import Matière
        from MyException import MyException

        # 错误代码
        code = None
        try:
            # 用户名无效
            Matière().fromdict({"id": "x", "nom": "y", "coefficient": "t"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 1)

    def test_code21(self):
        # 导入
        from Matière import Matière
        from MyException import MyException
        # 错误代码
        code = None
        try:
            # 名称无效
            Matière().fromdict({"id": "1", "nom": "", "coefficient": "t"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 21)

    def test_code22(self):
        # 导入
        from Matière import Matière
        from MyException import MyException
        # 错误代码
        code = None
        try:
            # 系数无效
            Matière().fromdict({"id": 1, "nom": "y", "coefficient": "t"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 22)

    def test_code1d(self):
        # 导入
        from Note import Note
        from MyException import MyException
        # 错误代码
        code = None
        try:
            # 标识无效
            Note().fromdict({"id": "x", "valeur": "x", "élève": "y", "matière": "z"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 1)

    def test_code31(self):
        # 导入
        from Note import Note
        from MyException import MyException

        # 错误代码
        code = None
        try:
            # 无效值
            Note().fromdict({"id": 1, "valeur": "x", "élève": "y", "matière": "z"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 31)

    def test_code32(self):
        # 导入
        from Note import Note
        from MyException import MyException

        # 错误代码
        code = None
        try:
            # 学生无效
            Note().fromdict({"id": 1, "valeur": 10, "élève": "y", "matière": "z"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 32)

    def test_code33(self):
        # 导入
        from Elève import Elève
        from Note import Note
        from Classe import Classe
        from MyException import MyException

        # 错误代码
        code = None
        try:
            # 科目无效
            classe = Classe().fromdict({"id": 1, "nom": "x"})
            élève = Elève().fromdict({"id": 1, "nom": "a", "prénom": "b", "classe": classe})
            Note().fromdict({"id": 1, "valeur": 10, "élève": élève, "matière": "z"})
        except MyException as ex:
            print(f"\ncode erreur={ex.code}, message={ex}")
            code = ex.code
        # 验证
        self.assertEqual(code, 33)

    def test_exception(self):
        # 导入
        from Elève import Elève
        # 测试必须运行类型 [MyException] 才能成功
        from MyException import MyException
        with self.assertRaises(MyException):
            # 测试
            Elève().fromdict({"id": "x", "nom": "y", "prénom": "z", "classe": "t"})


if __name__ == '__main__':
    unittest.main()
  • 该测试脚本旨在测试类中的设置器:验证是否无法为不同实体的属性赋值错误的值;
  • 第 11-24 行:测试是否无法为学生传入无效的标识符。由于在第 16 行将值 'x' 作为学生标识符传入,预期会抛出异常。因此,程序应跳转至第 20-22 行;
  • 第 21 行:显示错误信息;
  • 第22行:获取错误代码(参见段落 |实体 MyException|);
  • 第24行:断言(assert)错误代码为1。此处需验证两点:
    • 确实发生了错误;
    • 错误代码为1;
  • 该过程在第24至213行的函数中重复执行;
  • 第215-222行:测试某操作是否会抛出特定类型的异常;
  • 第220行:若抛出类型为[MyException]的异常,则判定测试通过;

结果

执行测试脚本:

获得的结果如下:


Testing started at 09:39 ...
C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe "C:\Program Files\JetBrains\PyCharm Community Edition 2020.1.2\plugins\python-ce\helpers\pycharm\_jb_unittest_runner.py" --path C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v01/tests/TestEntités.py
Launching unittests with arguments python -m unittest C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v01/tests/TestEntités.py in C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\troiscouches\v01\tests


code erreur=1, message=MyException[1, L'identifiant d'une entité <class 'Elève.Elève'> doit être un entier >=0]

code erreur=1, message=MyException[1, L'identifiant d'une entité <class 'Classe.Classe'> doit être un entier >=0]

code erreur=1, message=MyException[1, L'identifiant d'une entité <class 'Matière.Matière'> doit être un entier >=0]

code erreur=1, message=MyException[1, L'identifiant d'une entité <class 'Note.Note'> doit être un entier >=0]

code erreur=21, message=MyException[21, Le nom de la matière 1 doit être une chaîne de caractères non vide]

code erreur=22, message=MyException[22, Le coefficient de la matière y doit être un réel >=0]

code erreur=31, message=MyException[31, L'attribut x de la note 1 doit être un nombre dans l'intervalle [0,20]]

code erreur=32, message=MyException[32, L'attribut [y] de la note 1 doit être de type Elève ou dict ou json. Erreur : Expecting value: line 1 column 1 (char 0)]

code erreur=33, message=MyException[33, L'attribut [z] de la note 1 doit être de type Matière ou dict ou json. Erreur : Expecting value: line 1 column 1 (char 0)]

code erreur=41, message=MyException[41, Le nom de l'élève 1 doit être une chaîne de caractères non vide]

code erreur=42, message=MyException[42, Le prénom de l'élève 1 doit être une chaîne de caractères non vide]

code erreur=43, message=MyException[43, L'attribut [t] de l'élève 1 doit être de type Classe ou dict ou json. Erreur : Expecting value: line 1 column 1 (char 0)]


Ran 14 tests in 0.040s

OK

Process finished with exit code 0

此处所有测试均已通过

14.2.4. [dao]

Image

[dao] 层实现了 [InterfaceDao] [1] 接口。该接口由类 [Dao] (2) 实现。 脚本 [tests_dao] (3) 测试了 [dao] 层的方法。

14.2.4.1. 接口 [InterfaceDao]

接口是调用方代码与被调用方代码之间达成的契约。由被调用方代码提供该接口:

  • 调用方代码 [1] 并不了解被调用方代码 [3] 的实现。 它只知道如何调用它。这是由接口 [2] 告诉它的。该接口定义了一系列用于与被调用代码交互的方法/函数。该接口也被称为 API(应用程序编程接口);

[dao]层将提供以下接口:

  • [get_classes] 返回中学的班级列表;
  • [get_matières] 返回该中学的课程列表;
  • [get_élèves] 返回该中学的学生名单;
  • [get_notes] 返回所有学生的成绩列表;
  • [get_notes_for_élève_by_id] 输出特定学生的成绩;
  • [get_élève_by_id] 根据学号检索指定学生;

调用方代码仅需使用这些方法。它无需了解这些方法的具体实现方式。数据可以来自不同的来源(硬编码、数据库、文本文件等),而不会影响调用方代码。这被称为接口编程

Python 3 有一个概念与接口相近:抽象类。我们将使用它。我们将把本例中的接口集中到 [interfaces] 文件夹中。

我们为 [dao] 层定义了一个名为 [InterfaceDao](InterfaceDao.py)的抽象类:


# 导入
from abc import ABC, abstractmethod

# Dao 接口
from Elève import Elève


class InterfaceDao(ABC):
    # 类列表
    @abstractmethod
    def get_classes(self: object) -> list:
        pass

    # 学生列表
    @abstractmethod
    def get_élèves(self: object) -> list:
        pass

    # 科目列表
    @abstractmethod
    def get_matières(self: object) -> list:
        pass

    # 成绩列表
    @abstractmethod
    def get_notes(self: object) -> list:
        pass

    # 某学生成绩列表
    @abstractmethod
    def get_notes_for_élève_by_id(self: object, élève_id: int) -> list:
        pass

    # 按ID查询学生
    @abstractmethod
    def get_élève_by_id(self, élève_id: int) -> Elève:
        pass

  • 第 2 行:ABC=抽象基类。 从模块 [abc] 中导入类 ABC 以及装饰器 [abstractmethod],该装饰器用于第 10、15、20、25、30 和 35 行;
  • 第8行:抽象类名为[InterfaceDao],并继承自类[ABC];
  • 该抽象类的方法被装饰器 [@abstractmethod] 装饰,这使得被装饰的方法成为抽象方法:其代码未被定义。尽管如此,我们仍在此处放置了代码:指令 [pass],该指令不执行任何操作;
  • 抽象类 [InterfaceDao] 无法被实例化。只有那些继承自 [InterfaceDao] 且实现了 [InterfaceDao] 所有方法的派生类才能被实例化。 因此,如果创建两个从类 [InterfaceDao] 派生的类 [Dao1] 和 [Dao2],它们都将实现 [InterfaceDao] 的抽象方法。 因此可以说它们实现了接口 [InterfaceDao]
  • 同时支持接口和抽象类的编程语言赋予接口与抽象类不同的角色。接口没有属性,也不能被实例化。类可以通过定义接口中的所有方法来实现该接口;

14.2.4.2. [Dao] 的实现

[Dao] (dao.py) 以如下方式实现了接口 [InterfaceDao]


# 导入实体和接口
from Classe import Classe
from Elève import Elève
from InterfaceDao import InterfaceDao
from Matière import Matière
from MyException import MyException
from Note import Note


# [dao] 层实现了 InterfaceDao 接口
class Dao(InterfaceDao):
    # 构造函数
    # 构建硬编码列表
    def __init__(self):
        # 实例化类
        classe1 = Classe().fromdict({"id": 1, "nom": "classe1"})
        classe2 = Classe().fromdict({"id": 2, "nom": "classe2"})
        self.classes = [classe1, classe2]
        # 学科
        matière1 = Matière().fromdict({"id": 1, "nom": "matière1", "coefficient": 1})
        matière2 = Matière().fromdict({"id": 2, "nom": "matière2", "coefficient": 2})
        self.matières = [matière1, matière2]
        # 学生
        élève11 = Elève().fromdict({"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": classe1})
        élève21 = Elève().fromdict({"id": 21, "nom": "nom2", "prénom": "prénom2", "classe": classe1})
        élève32 = Elève().fromdict({"id": 32, "nom": "nom3", "prénom": "prénom3", "classe": classe2})
        élève42 = Elève().fromdict({"id": 42, "nom": "nom4", "prénom": "prénom4", "classe": classe2})
        self.élèves = [élève11, élève21, élève32, élève42]
        # 学生各科成绩
        note1 = Note().fromdict({"id": 1, "valeur": 10, "élève": élève11, "matière": matière1})
        note2 = Note().fromdict({"id": 2, "valeur": 12, "élève": élève21, "matière": matière1})
        note3 = Note().fromdict({"id": 3, "valeur": 14, "élève": élève32, "matière": matière1})
        note4 = Note().fromdict({"id": 4, "valeur": 16, "élève": élève42, "matière": matière1})
        note5 = Note().fromdict({"id": 5, "valeur": 6, "élève": élève11, "matière": matière2})
        note6 = Note().fromdict({"id": 6, "valeur": 8, "élève": élève21, "matière": matière2})
        note7 = Note().fromdict({"id": 7, "valeur": 10, "élève": élève32, "matière": matière2})
        note8 = Note().fromdict({"id": 8, "valeur": 12, "élève": élève42, "matière": matière2})
        self.notes = [note1, note2, note3, note4, note5, note6, note7, note8]

    # -----------
    # 界面 IDao
    # -----------
    

  • 第 1-7 行:导入实体和接口 [InterfaceDao]
  • 第 11 行:类 [Dao] 继承自抽象类 [InterfaceDao]。可以说它实现了接口 [InterfaceDao]
  • 第 14 行:构造函数没有参数。它硬编码了四个列表:
    • 第15-18行:课程列表;
    • 第 19-22 行:科目列表;
    • 第 23-28 行:学生列表;
    • 第29-38行:成绩列表;
  • 第40-44行:实现接口 [Interface Dao] 的方法。此处我们不进行定义,以便观察 Python 发出的错误信息;

一个测试程序可能如下所示:[tests-dao.py]


# 配置应用程序
import config

config = config.configure()

# 实例化层 [dao]
from Dao import Dao

daoImpl = Dao()

# 班级列表
for classe in daoImpl.get_classes():
    print(classe)

# 科目列表
for matière in daoImpl.get_matières():
    print(matière)

# 班级列表
for élève in daoImpl.get_élèves():
    print(élève)

# 成绩列表
for note in daoImpl.get_notes():
    print(note)

:脚本 [tests-dao.py] 并非测试脚本 [unittest],因为它不包含名称以 [test_] 开头的方法。

注释已足够说明。第 11-25 行使用了 [dao] 层的接口。这里并未对该层的实际实现做出任何假设。第 9 行,我们实例化了 [dao] 层。

执行此脚本的结果如下:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v01/tests/tests_dao.py
Traceback (most recent call last):
  File "C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v01/tests/tests_dao.py", line 9, in <module>
    daoImpl = Dao()
TypeError: Can't instantiate abstract class Dao with abstract methods get_classes, get_matières, get_notes, get_notes_for_élève_by_id, get_élève_by_id, get_élèves

Process finished with exit code 1

可以看到,在实例化类 [Dao] 时(见上文第 3 行)就发生了错误。Python 3 解释器提示无法实例化该类,这是因为我们尚未定义抽象方法 [get_classes, get_matières, get_notes, get_notes_for_élève_by_id, get_élève_by_id, get_élèves]

Pycharm 同样支持抽象类概念,并建议我们为其定义方法:

  • [1] 中,右键单击代码;
  • [2-3] 中,选择 [Generate / Implement Methods] 来实现类 [Dao] 中缺失的方法;
  • [4] 中,选择要实现的方法,此处选择全部;

完成上述操作后,[Dao] 类将由 PyCharm 类按以下方式补充:


    # -----------
    # 接口 IDao
    # -----------

    def get_classes(self: object) -> list:
        pass

    def get_élèves(self: object) -> list:
        pass

    def get_matières(self: object) -> list:
        pass

    def get_notes(self: object) -> list:
        pass

    def get_notes_for_élève_by_id(self: object, élève_id: int) -> list:
        pass

    def get_élève_by_id(self, élève_id: int) -> Elève:
        pass

我们按以下方式补充类 [Dao]


    # -----------
    # 接口 IDao
    # -----------
    
    # 班级列表
    def get_classes(self) -> list:
        return self.classes

    # 科目列表
    def get_matières(self) -> list:
        return self.matières

    # 学生名单
    def get_élèves(self) -> list:
        return self.élèves

    # 成绩列表
    def get_notes(self) -> list:
        return self.notes

    def get_notes_for_élève_by_id(self, élève_id: int) -> dict:
        # 查询学生
        élève = self.get_élève_by_id(élève_id)
        # 查询成绩
        notes = list(filter(lambda n: n.élève.id == élève_id, self.get_notes()))
        # 返回结果
        return {"élève": élève, "notes": notes}

    def get_élève_by_id(self, élève_id: int) -> Elève:
        # 筛选学生
        élèves = list(filter(lambda e: e.id == élève_id, self.get_élèves()))
        # 找到了吗?
        if not élèves:
            raise MyException(10, f"L'élève d'identifiant {élève_id} n'existe pas")
        # 结果
        return élèves[0]
  • 第 5-19 行没有难点;
  • 第29-36行:该方法返回传入编号对应的学生。若该学生不存在,则抛出异常;
  • 第31行:函数[filter]用于过滤列表:
    • 第一个参数是筛选条件;
    • 第二个参数是待筛选的列表,此处为学生列表;
  • 第31行:列表的筛选条件通过函数[f(e :Elève)->bool]实现。该函数应用于待筛选列表中的每个元素。若元素满足筛选条件,则保留在筛选后的列表中;否则被排除在外。此处可选择:
    • 指定函数 f 的名称,并在其他地方实现该函数。此时对函数 [filter] 的调用将变为 [filter(f,self.get_élèves()]
    • 给出函数 f 的定义。此时对函数 [filter] 的调用将变为 [filter(f(e :Elève){…},self.get_élèves()],其中 [e] 代表过滤后列表中的一个元素,即一名学生。本文中采用的就是这种做法。 函数 f 的定义在此为 [f(e :Elève){return e.id==élève_id)]:只有当 [id] 的编号不等于所查找的编号时,该学生才会被选中。此类函数可替换为所谓的 lambda 函数:[lambda e: e.id == élève_id]
      • e:代表函数 f 的参数,此处为一名学生。可以使用任意名称;
      • e.id==élève_id 是筛选条件:只有当学生 [e] 的编号 [id] 与所查找的编号相同时,该学生才会被选中;
  • 第 31 行:函数 [filter] 返回的过滤后列表类型并非 [list],但该类型支持转换为 [list]。 这就是我们在此使用表达式 [list(liste filtrée)] 所做的事情;
  • 第 33-34 行:如果过滤后的列表为空,说明所查找的学生不存在。此时抛出异常;
  • 第36行:若执行到此处,说明未发生异常。此时可确定获取到的列表仅包含1个元素(不存在两个学生拥有相同的[id]编号)。因此返回列表的第一个元素;
  • 第21-27行:方法 [get_notes_for_élève_by_id] 必须返回传入编号 [id] 对应学生的成绩;
  • 第22-23行:首先使用刚刚注释掉的[get_élève_by_id]方法查找编号为[élève_id]的学生。如果要查找的学生不存在,可能会抛出异常。 由于第23行的语句周围没有try/catch语句,异常将回传至调用代码。这正是我们所期望的;
  • 第24-25行:获取学生信息后,进一步获取该学生的所有成绩。此过程再次通过过滤器实现:
    • 过滤器为 [filter(critère, self_getnotes()]。因此,待过滤的列表是该中学所有学生的全部成绩列表;
    • 筛选条件通过函数 [lambda] 表示:lambda n: n.élève.id == élève_id。参数 n 是待筛选列表中的一个元素,即一个成绩。 类型 [Note] 具有一个属性 [élève],该属性表示该成绩所属的学生。因此,表示该学生编号的 [n.élève.id] 必须等于要查找的学生编号;

随后执行脚本 [tests-dao.py]


# 配置应用程序
import config

config = config.configure()

# 实例化 [dao] 层
from Dao import Dao

daoImpl = Dao()

# 类列表
for classe in daoImpl.get_classes():
    print(classe)

# 科目列表
for matière in daoImpl.get_matières():
    print(matière)

# 班级列表
for élève in daoImpl.get_élèves():
    print(élève)

# 成绩列表
for note in daoImpl.get_notes():
    print(note)

# 某位学生
print(daoImpl.get_élève_by_id(11))

# 其成绩列表
dict1 = daoImpl.get_notes_for_élève_by_id(11)
print(f"élève n° 11 = {dict1['élève']}")
for note in dict1["notes"]:
    print(f"note de l'élève n° 11 = {note}")

随后我们将获得以下结果:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v01/tests/tests_dao.py
{"id": 1, "nom": "classe1"}
{"id": 2, "nom": "classe2"}
{"id": 1, "nom": "matière1", "coefficient": 1}
{"id": 2, "nom": "matière2", "coefficient": 2}
{"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": {"id": 1, "nom": "classe1"}}
{"id": 21, "nom": "nom2", "prénom": "prénom2", "classe": {"id": 1, "nom": "classe1"}}
{"id": 32, "nom": "nom3", "prénom": "prénom3", "classe": {"id": 2, "nom": "classe2"}}
{"id": 42, "nom": "nom4", "prénom": "prénom4", "classe": {"id": 2, "nom": "classe2"}}
{"id": 1, "valeur": 10, "élève": {"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": {"id": 1, "nom": "classe1"}}, "matière": {"id": 1, "nom": "matière1", "coefficient": 1}}
{"id": 2, "valeur": 12, "élève": {"id": 21, "nom": "nom2", "prénom": "prénom2", "classe": {"id": 1, "nom": "classe1"}}, "matière": {"id": 1, "nom": "matière1", "coefficient": 1}}
{"id": 3, "valeur": 14, "élève": {"id": 32, "nom": "nom3", "prénom": "prénom3", "classe": {"id": 2, "nom": "classe2"}}, "matière": {"id": 1, "nom": "matière1", "coefficient": 1}}
{"id": 4, "valeur": 16, "élève": {"id": 42, "nom": "nom4", "prénom": "prénom4", "classe": {"id": 2, "nom": "classe2"}}, "matière": {"id": 1, "nom": "matière1", "coefficient": 1}}
{"id": 5, "valeur": 6, "élève": {"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": {"id": 1, "nom": "classe1"}}, "matière": {"id": 2, "nom": "matière2", "coefficient": 2}}
{"id": 6, "valeur": 8, "élève": {"id": 21, "nom": "nom2", "prénom": "prénom2", "classe": {"id": 1, "nom": "classe1"}}, "matière": {"id": 2, "nom": "matière2", "coefficient": 2}}
{"id": 7, "valeur": 10, "élève": {"id": 32, "nom": "nom3", "prénom": "prénom3", "classe": {"id": 2, "nom": "classe2"}}, "matière": {"id": 2, "nom": "matière2", "coefficient": 2}}
{"id": 8, "valeur": 12, "élève": {"id": 42, "nom": "nom4", "prénom": "prénom4", "classe": {"id": 2, "nom": "classe2"}}, "matière": {"id": 2, "nom": "matière2", "coefficient": 2}}
{"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": {"id": 1, "nom": "classe1"}}
élève n° 11 = {"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": {"id": 1, "nom": "classe1"}}
note de l'élève n° 11 = {"id": 1, "valeur": 10, "élève": {"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": {"id": 1, "nom": "classe1"}}, "matière": {"id": 1, "nom": "matière1", "coefficient": 1}}
note de l'élève n° 11 = {"id": 5, "valeur": 6, "élève": {"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": {"id": 1, "nom": "classe1"}}, "matière": {"id": 2, "nom": "matière2", "coefficient": 2}}

Process finished with exit code 0

可以注意到,当显示成绩时(其他对象的情况类似),我们还会看到:

  • 笔记的所有者学生;
  • 笔记所涉及的学科;

这是由函数 [BaseEntity.asdict] 生成的结果(参见“链接”段落)。

14.2.5. [métier]

  • [InterfaceMétier][métier] 层的接口;
  • [Métier][métier] 层的实现类;
  • [Testmétier][Métier] 类的测试类 [UnitTest]

14.2.5.1. 接口 [InterfaceMétier]

[métier] 层将实现以下 [InterfaceMétier] 接口(InterfaceMétier.py):


# 导入
from abc import ABC, abstractmethod

from StatsForElève import StatsForElève


# 业务接口
class InterfaceMétier(ABC):
    # 学生统计计算
    @abstractmethod
    def get_stats_for_élève(self, idElève: int) -> StatsForElève:
        pass
  • [get_stats_for_élève] 返回学生编号 idElève 的成绩以及相关信息:加权平均分最低分最高分。这些信息封装在一个 [StatsForElève] 类型的对象中;

14.2.5.2. 实体 [StatsForElève]

封装学生统计数据(成绩、最低分、最高分、加权平均分)的类型 [StatsForElève](StatsForElève.py)如下:


# 导入
from BaseEntity import BaseEntity


# 特定学生的统计数据


class StatsForElève(BaseEntity):
    # 班级状态中排除的属性
    excluded_keys = []

    # 班级属性
    @staticmethod
    def get_allowed_keys() -> list:
        # id:成绩标识符
        # 学生:相关学生
        # 成绩:该学生的成绩
        # moyennePondérée:按科目权重计算的平均分
        # min:其最低成绩
        # max:其最高分

        return BaseEntity.get_allowed_keys() + ["élève", "notes", "moyenne_pondérée", "min", "max"]

    # toString
    def __str__(self) -> str:
        # 无成绩的学生情况
        if len(self.notes) == 0:
            return f"Elève={self.élève}, notes=[]"
        # 有成绩的学生情况
        str = ""
        for note in self.notes:
            str += f"{note.valeur} "
        return f"Elève={self.élève}, notes=[{str.strip()}], max={self.max}, min={self.min}, " \
               f"moyenne pondérée={self.moyenne_pondérée:4.2f}"

  • 第 8 行:类 [StatsForElève] 继承自类 [BaseEntity]
  • 第13-22行:类的属性;
    • 一个来自 [BaseEntity] 的标识符 [id]
    • 学生 [élève],其统计数据被封装;
    • 其成绩 [notes]
    • 其加权平均分 [moyenne_pondérée]
    • 其最低评分 [min]
    • 其最高评分 [max]
  • 这些属性不定义 getter/setter 方法。我们假设是 [métier] 层创建此类对象,且该层不会创建无效对象;
  • 第 23-33 行:函数 [__str__] 返回一个字符串,其中包含该对象的属性;

14.2.5.3. [Métier] 的实现

[Métier](Metier.py)对接口 [InterfaceMétier] 的实现如下:


# 导入
from InterfaceDao import InterfaceDao
from InterfaceMétier import InterfaceMétier
from StatsForElève import StatsForElève


class Métier(InterfaceMétier):

    # 构造函数
    def __init__(self, dao: InterfaceDao):
        # 保存参数
        self.__dao = dao

    # -----------
    # 接口
    # -----------

    # 特定学生的成绩指标
    def get_stats_for_élève(self, id_élève: int) -> StatsForElève:
        # 编号为 idEleve 的学生统计数据
        # id_élève:学生编号

        # 通过 [dao] 层获取其成绩
        notes_élève = self.__dao.get_notes_for_élève_by_id(id_élève)
        élève = notes_élève["élève"]
        notes = notes_élève["notes"]

        # 若无成绩则停止
        if len(notes) == 0:
            # 返回结果
            return StatsForElève().fromdict({"élève": élève, "notes": []})

        # 处理学生的成绩
        somme_pondérée = 0
        somme_coeff = 0
        max = -1
        min = 21
        for note in notes:
            # 成绩值
            valeur = note.valeur
            # 科目权重
            coeff = note.matière.coefficient
            # 权重系数之和
            somme_coeff += coeff
            # 加权总分
            somme_pondérée += valeur * coeff
            # 求最小值
            if valeur < min:
                min = valeur
            # 求最大值
            if valeur > max:
                max = valeur
        # 缺失值计算
        moyenne_pondérée = float(somme_pondérée) / somme_coeff

        # 以 [StatsForElève] 类型呈现结果
        return StatsForElève(). \
            fromdict({"élève": élève, "notes": notes,
                      "moyenne_pondérée": moyenne_pondérée,
                      "min": min, "max": max})

注释

  • 第 7 行:类 [Métier] 继承自类 [InterfaceMétier]。通常我们说它实现了接口 [InterfaceMétier]
  • 第 9-12 行:构造函数仅接收一个指向 [dao] 层的引用作为参数。第 10 行,请注意参数 [dao] 被赋予了类型 [InterfaceDao]。 我们并不要求具体的实现,只需实现符合 [InterfaceDao] 接口即可。在此处这并不重要,因为 Python 不会考虑该类型,但使用接口而非具体实现是一种良好的编程习惯。这样代码就更容易修改;
  • 第 19-60 行:实现 [get_stats_for_élève] 方法;
  • 第 19 行:该方法接收一个参数,即需要获取统计数据的学生的编号 [idElève]
  • 第24行:向[dao]层查询该学生的成绩。若该学生不存在,此请求将引发异常。由于未进行异常处理(缺少try/catch),异常将回传至调用代码;
  • 第25行:若未发生异常,则执行此处代码。此时[notes_élève]是一个包含两个键的字典,键为[élève, note]
    • 第25行:获取学生信息(姓名、班级等);
    • 第26行:获取学生的成绩;
  • 第28-31行:检查学生是否有成绩。若无成绩,则无需计算统计数据;
  • 第 31 行:返回一个基于字典并通过 [BaseEntity.fromdict] 方法构建的 [StatsForElève] 对象;
  • 第33-54行:利用学生的成绩计算所需的统计数据。代码中的注释应足以理解其含义;
  • 第 56-60 行:使用 [BaseEntity.fromdict] 方法,基于字典构建并返回一个 [StatsForElève] 对象;

14.2.5.4. [métier] 层的测试

[métier] 层中的一个 [UnitTest] 脚本可能如下所示(TestMétier.py):


# 导入
import unittest


class Testmétier(unittest.TestCase):
    def setUp(self):
        # 配置应用程序
        import config
        config.configure()

    def test_statsForEleve11(self):
        # 导入
        from Dao import Dao
        from Métier import Métier
        # 测试学生11的指标
        dao = Dao()
        stats_for_élève = Métier(dao).get_stats_for_élève(11)
        # 显示
        print(f"\nstats={stats_for_élève}")
        # 验证
        self.assertEqual(stats_for_élève.min, 6)
        self.assertEqual(stats_for_élève.max, 10)
        self.assertAlmostEqual(stats_for_élève.moyenne_pondérée, 7.333, delta=1e-3)


if __name__ == '__main__':
    unittest.main()

注释

  • 第 6-9 行:此处使用函数 [setUp] 来配置测试的 Python 路径;
  • 第 16 行:实例化 [dao] 层;
  • 第17行:实例化[métier]层,并使用其[get_stats_for_élève]方法计算第11号学生的统计数据;
  • 第 19 行:显示获得的结果 [StatsForElève]。 由于 [StatsForElève] 继承自 [BaseEntity],因此此处显示的是 [StatsForElève] 的子链 jSON;
  • 第 21 行:验证学生的最低分数;
  • 第22行:检查学生的最高分;
  • 第 23 行:验证加权平均分是否在 7.333 左右(误差在 10⁻³ 以内)。通常情况下,无法精确比较实数,因为在内部,它们通常只是近似表示;

测试结果如下:


Testing started at 18:17 ...
C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe "C:\Program Files\JetBrains\PyCharm Community Edition 2020.1.2\plugins\python-ce\helpers\pycharm\_jb_unittest_runner.py" --path C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v01/tests/TestMétier.py
Launching unittests with arguments python -m unittest C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v01/tests/TestMétier.py in C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\troiscouches\v01\tests



Ran 1 test in 0.015s

OK

stats=Elève={"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": {"id": 1, "nom": "classe1"}}, notes=[10 6], max=10, min=6, moyenne pondérée=7.33

Process finished with exit code 0

14.2.6. [ui]

Image

  • [1] 中,[ui] 层的接口;
  • [2],该接口的实现;
  • [3],应用程序的主脚本;

14.2.6.1. 接口 [InterfaceUi]

[UI] 层接口如下:


# 导入
from abc import ABC, abstractmethod


# 接口UI
class InterfaceUi(ABC):
    # 执行层UI
    @abstractmethod
    def run(self: object):
        pass

  • 第 9-10 行:[UI] 层仅包含一个方法,即 [run]

14.2.6.2. [Console] 的实现

[console] 层由以下脚本 [Console.py] 实现:


# 层导入

from InterfaceDao import InterfaceDao
from InterfaceMétier import InterfaceMétier
from InterfaceUi import InterfaceUi

# 其他依赖项
from MyException import MyException


class Console(InterfaceUi):
    # 构造函数
    def __init__(self: object, métier: InterfaceMétier):
        # 业务:[métier] 层

        # 存储属性
        self.métier = métier


        # -----------
        # 界面
        # -----------

    def run(self):
        # 用户对话
        fini = False
        while not fini:
            # 问答
            réponse = input("Numéro de l'élève (>=1 et * pour arrêter) : ").strip()
            # 完成了吗?
            if réponse == "*":
                break
            # 输入正确吗?
            ok = False
            try:
                id_élève = int(réponse, 10)
                ok = id_élève >= 1
            except ValueError as erreur:
                pass
            # 数据正确吗?
            if not ok:
                print("Saisie incorrecte. Recommencez...")
                continue
            # 为所选学生计算统计数据
            try:
                print(self.métier.get_stats_for_élève(id_élève))
            except MyException as erreur:
                print(f"L'erreur suivante s'est produite : {erreur}")
  • 第 3-5 行:导入所有接口;
  • 第 11 行:类 [Console] 实现了接口 [InterfaceUi]
  • 第 12-17 行:类 [Console] 的构造函数接收一个指向 [métier] 层的引用作为参数。 需要注意的是,该参数被赋予了类型 [InterfaceMétier],以此提醒我们正在处理的是接口而非具体的实现;
  • 第 24 行:实现接口中的 [run] 方法;
  • 第27行:一个循环,当第31行的条件成立时停止;
  • 第29行:输入键盘输入的数据。 函数 [input] 接收一个可选参数:用于在屏幕上显示的输入提示信息。该输入始终以字符串形式获取。函数 [strip] 会去除该字符串前后多余的空格;
  • 第34-39行:验证输入(学生编号)是否有效。该值必须为大于等于1的整数。需注意,该输入是以字符串形式获取的;
  • 第36行:尝试将输入转换为十进制整数。若无法转换,函数[int]将抛出异常;
  • 第37行:仅当未发生异常时才会执行此行。验证获取到的整数是否确实≥1;
  • 第38-39行:处理异常。若发生异常,第34行的变量[ok]将保持为[False]
  • 第41-43行:若输入有误,则显示错误信息并重新循环(第43行);
  • 第45-48行:计算已输入学号学生的统计数据;
  • 第46行:调用[métier]层中的[get_stats_for_élève]方法。若该学生不存在,该方法将抛出异常。该异常在第47-48行进行处理。 已知 [dao][métier] 层会抛出 [MyException] 异常;

14.3. 主脚本 [main]

主脚本 [main] 如下所示(main.py):


# 配置应用程序
import config

config = config.configure()

# syspath 已配置——可以进行导入
from Console import Console
from Dao import Dao
from Métier import Métier

# ----------- [console] 层
try:
    # 实例化 [dao] 层
    dao = Dao()
    # 实例化 [métier] 层
    métier = Métier(dao)
    # [ui] 层实例化
    console = Console(métier)
    # 执行层 [console]
    console.run()
except BaseException as ex:
    # 显示错误
    print(f"L'erreur suivante s'est produite : {ex}")
finally:
    pass
  • 第 1-4 行:配置应用程序的 Python 路径;
  • 第 6-9 行:导入所需的类和接口;
  • 第 14 行:实例化 [dao] 层;
  • 第 16 行:实例化 [métier] 层;
  • 第 18 行:实例化 [ui] 层;
  • 第20行:启动与用户的对话;
  • 第13-20行:通常这些行不会抛出任何异常。来自[dao]和[métier]层的异常会被[Console]层拦截。 如果不完全了解所使用的层(此处并非如此),异常处理便是一门高深的学问。如有疑问,可以添加代码来拦截被执行代码可能抛出的任何类型的异常。此处第21-23行所做的正是如此。 捕获所有继承自 [BaseException] 的异常,即捕获所有异常;
  • 第24-25行:[finally]子句在此处不产生任何作用。它的存在仅是为了便于将第21-23行注释掉。实际上,在调试模式下,我们并不希望捕获异常。 此时,Python 解释器会捕获这些异常,并返回异常发生的行号。这是一项必不可少的信息。当第 21-23 行被注释掉时,第 24-25 行的存在确保了 try/catch 语句在语法上正确。如果缺少这两行,Python 会报错;

以下是一个运行示例:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v01/main/main.py
Numéro de l'élève (>=1 et * pour arrêter) : 11
Elève={"id": 11, "nom": "nom1", "prénom": "prénom1", "classe": {"id": 1, "nom": "classe1"}}, notes=[10 6], max=10, min=6, moyenne pondérée=7.33
Numéro de l'élève (>=1 et * pour arrêter) : 1
L'erreur suivante s'est produite : MyException[10, L'élève d'identifiant 1 n'existe pas]
Numéro de l'élève (>=1 et * pour arrêter) : *

Process finished with exit code 0

14.4. 示例 2

这个新的分层架构示例旨在展示接口编程的优势。这种方式有助于应用程序的维护和测试。我们将再次使用三层架构:

Image

每层将采用两种不同的实现方式。我们希望展示:可以轻松更改某层的实现,同时对其他层的影响最小。

14.4.1. [dao]

Image

[InterfaceDao] 接口如下:


# 导入
from abc import ABC, abstractmethod


# Dao 接口
class InterfaceDao(ABC):
    # 仅有一个方法
    @abstractmethod
    def do_something_in_dao_layer(self, x: int, y: int) -> int:
        pass
  • 第 8-10 行:方法 [do_something_in_dao_layer] 是该接口的唯一方法;

[DaoImpl1] 以如下方式实现了接口 [InterfaceDao]


from InterfaceDao import InterfaceDao


class DaoImpl1(InterfaceDao):
    # 实现InterfaceDao
    def do_something_in_dao_layer(self: InterfaceDao, x: int, y: int) -> int:
        return x + y

[DaoImpl2] 以如下方式实现了接口 [InterfaceDao]


from InterfaceDao import InterfaceDao


class DaoImpl2(InterfaceDao):
    # 实现 InterfaceDao
    def do_something_in_dao_layer(self: InterfaceDao, x: int, y: int) -> int:
        return x - y

14.4.2. [métier]

Image

[InterfaceMétier] 接口如下:


# 导入
from abc import ABC, abstractmethod


# 业务接口
class InterfaceMétier(ABC):
    # 单个方法
    @abstractmethod
    def do_something_in_métier_layer(self, x: int, y: int) -> int:
        pass
  • 第 8-10 行:方法 [do_something_in_métier_layer] 是该接口的唯一方法;

[AbstractBaseMétier] 以如下方式实现了接口 [InterfaceMétier]


# 导入
from abc import ABC, abstractmethod

from InterfaceDao import InterfaceDao
from InterfaceMétier import InterfaceMétier


class AbstractBaseMétier(InterfaceMétier, ABC):
    # 属性
    # __dao 是 [dao] 层上的引用
    @property
    def dao(self) -> InterfaceDao:
        return self.__dao

    @dao.setter
    def dao(self, dao: InterfaceDao):
        self.__dao = dao

    # 是接口 [InterfaceMétier] 的实现
    @abstractmethod
    def do_something_in_métier_layer(self, x: int, y: int) -> int:
        pass
  • 第 8 行:类 [AbstractBaseMétier] 派生了两个类:
    • [InterfaceMétier]:类 [AbstractBaseMétier] 在第 19-22 行实现了该接口。 实际上可以看到,它并未实现方法 [do_something_in_métier_layer],而是将其声明为抽象方法(第 20 行)。该方法将由派生类来实现;
    • [ABC] 用于访问注解 [@abstractmethod]
    • 顺序是有意义的:如果在这里颠倒顺序,Python 会在运行时报错;

这是首次使用多重继承(继承自多个类)。类 [AbstractBaseMétier] 同时继承了类 [InterfaceMétier][ABC] 的属性。

  • 第 9-17 行:定义属性 [dao],该属性将作为 [dao] 层的引用;

接口旨在被实现。当不同的实现共享某些属性时,将这些属性放在父类中以避免重复定义是明智之举。此处的 [dao] 属性即属此类情况。父类通常总是抽象的,因为它无法实现接口中的所有方法。

[MétierImpl1] 以如下方式实现了接口 [InterfaceMétier]


from AbstractBaseMétier import AbstractBaseMétier


class MétierImpl1(AbstractBaseMétier):
    # 接口的实现 [InterfaceMétier]
    def do_something_in_métier_layer(self:AbstractBaseMétier, x: int, y: int) -> int:
        x += 1
        y += 1
        return self.dao.do_something_in_dao_layer(x, y)
  • 第 4 行:类 [MétierImpl1] 继承自类 [AbstractbaseMétier]。因此,它继承了该类的属性 [dao]
  • 第6-9行:实现了父类[AbstractbaseMétier]未实现的接口[InterfaceMétier]
  • 第 9 行:使用 [dao] 层;

[MétierImpl2] 以类似的方式实现了接口 [InterfaceMétier]


from AbstractBaseMétier import AbstractBaseMétier


class MétierImpl2(AbstractBaseMétier):
    # 接口实现 [InterfaceMétier]
    def do_something_in_métier_layer(self:AbstractBaseMétier, x: int, y: int) -> int:
        x -= 1
        y -= 1
        return self.dao.do_something_in_dao_layer(x, y)

14.4.3. [ui]

Image

接口 [InterfaceUi] 如下所示:


# 导入
from abc import ABC, abstractmethod


# Ui 接口
class InterfaceUi(ABC):
    # 单个方法
    @abstractmethod
    def do_something_in_ui_layer(self, x: int, y: int) -> int:
        pass
  • 第 8-10 行:接口的唯一方法;

[AbstractBaseUi] 以如下方式实现了接口 [InterfaceUi]


# 导入
from abc import ABC, abstractmethod

from InterfaceMétier import InterfaceMétier
from InterfaceUi import InterfaceUi


class AbstractBaseUi(InterfaceUi, ABC):
    # 属性
    # 业务是 [métier] 层上的引用
    @property
    def métier(self) -> InterfaceMétier:
        return self.__métier

    @métier.setter
    def métier(self, métier: InterfaceMétier):
        self.__métier = métier

    # 接口的实现 [InterfaceUI]
    @abstractmethod
    def do_something_in_ui_layer(self: InterfaceUi, x: int, y: int) -> int:
        pass
  • [AbstractBaseUi] 是一个抽象类(第 20 行)。必须通过继承该类来实现接口 [InterfaceUi]
  • 第 9-17 行:类 [AbstractBaseUi] 持有对 [métier] 层的引用;

实现类 [UiImpl1] 如下:


from AbstractBaseUi import AbstractBaseUi


class UiImpl1(AbstractBaseUi):
    # 接口的实现 [InterfaceUi]
    def do_something_in_ui_layer(self: AbstractBaseUi, x: int, y: int) -> int:
        x += 1
        y += 1
        return self.métier.do_something_in_métier_layer(x, y)
  • 第 4 行:类 [UiImpl1] 派生自 [AbstractBaseUi],因此继承了其属性 [métier]。该属性在第 9 行被使用;

实现类 [UiImpl2] 与此类似:


from AbstractBaseUi import AbstractBaseUi


class UiImpl2(AbstractBaseUi):
    # 接口实现 [InterfaceUi]
    def do_something_in_ui_layer(self: AbstractBaseUi, x: int, y: int) -> int:
        x -= 1
        y -= 1
        return self.métier.do_something_in_métier_layer(x, y)
  • 第 4 行:类 [UiImpl2] 继承自 [AbstractBaseUi],因此继承了其属性 [métier]。该属性在第 9 行被使用;

14.4.4. 配置文件

Image

  • [config1, config2]文件通过两种不同方式配置应用程序;
  • 文件 [main] 是应用程序的主脚本;

文件 [config1] 如下:


def configure():
    # 步骤 1 ------
    # 此脚本文件的绝对路径
    import os
    script_dir = os.path.dirname(os.path.abspath(__file__))
    # 依赖项
    absolute_dependencies = [
        # Python Path 的本地文件夹
        f"{script_dir}/../dao",
        f"{script_dir}/../ui",
        f"{script_dir}/../métier",
    ]

    # 配置 syspath
    from myutils import set_syspath
    set_syspath(absolute_dependencies)

    # 步骤 2 ------
    # 配置应用程序层
    from DaoImpl1 import DaoImpl1
    from MétierImpl1 import MétierImpl1
    from UiImpl1 import UiImpl1
    # 实例化各层
    # DAO
    dao = DaoImpl1()
    # 业务层
    métier = MétierImpl1()
    métier.dao = dao
    # UI
    ui = UiImpl1()
    ui.métier = métier

    # 将层实例放入配置中
    # 这里仅需 ui 层
    config = {"ui": ui}

    # 生成配置
    return config
  • 第 2-16 行:配置应用程序的 Python 路径;
  • 第18-31行:实例化[dao, métier, ui]类。为实现其接口,每次均选择首个构建的实现;
  • 第 33-35 行:将层引用放入配置中。在此,主脚本仅需 [ui] 层;

文件 [config2] 与此类似,并使用第二个可用的实现来实现每个接口:


def configure():
    # 步骤 1 ---
    # 此脚本文件的绝对路径
    import os
    script_dir = os.path.dirname(os.path.abspath(__file__))
    # 依赖项
    absolute_dependencies = [
        # Python Path 的本地文件夹
        f"{script_dir}/../dao",
        f"{script_dir}/../ui",
        f"{script_dir}/../métier",
    ]

    # 配置 syspath
    from myutils import set_syspath

    set_syspath(absolute_dependencies)

    # 步骤 2 ------
    # 配置应用程序层
    from DaoImpl2 import DaoImpl2
    from MétierImpl2 import MétierImpl2
    from UiImpl2 import UiImpl2
    # 实例化各层
    # DAO
    dao = DaoImpl2()
    # 业务层
    métier = MétierImpl2()
    métier.dao = dao
    # UI
    ui = UiImpl2()
    ui.métier = métier

    # 将层实例放入配置中
    # 这里仅需 ui 层
    config = {"ui": ui}

    # 生成配置
    return config

14.4.5. 主脚本 [main]

Image

主脚本如下:


# 导入
import importlib
import sys

# main ---------

# 需要两个参数
nb_args = len(sys.argv)
if nb_args != 2 or (sys.argv[1] != "config1" and sys.argv[1] != "config2"):
    print(f"Syntaxe : {sys.argv[0]} config1 ou config2")
    sys.exit()

# 应用程序配置
module = importlib.import_module(sys.argv[1])
config = module.configure()

# 执行层 [ui]
print(config["ui"].do_something_in_ui_layer(10, 20))

该脚本接收一个参数:

  • [config1] 用于使用配置 1;
  • [config2] 用于使用配置 2;

Python 将参数存储在列表 [sys.argv] 中:

  • sys.argv[0] 是脚本名称,此处为 [main]。该参数始终存在;
  • sys.argv[1]是传递给脚本的第一个参数sys.argv[2]是第二个参数,……

  • 第8行:获取参数数量;

  • 第9-11行:验证是否确实存在该参数,且其值为[config1][config2]。 若不满足条件,则显示错误信息(第10行)并退出程序(第11行);

一旦确定了所需的配置,我们就需要执行该配置。例如,如果选择了配置 1,我们需要执行以下代码:

import config1
config1.configure()

这里的问题在于,要使用的配置位于一个变量中,即变量 [len[liste1]]。要导入名称位于变量中的模块,我们需要使用包 [importlib](第 2 行)。

  • 第14行:导入名称位于[email.message_from_bytes(data2[0][1])]中的模块;
  • 第15行:完成上述操作后,执行该模块中的函数[configure]。获取一个名为[config]的字典,该字典即为应用程序的配置;
  • 第 18 行:已知 [ui] 层的引用位于 config[‘ui’] 中。利用该引用调用方法 [do_something_in_ui_layer]。 已知该方法将调用 [métier] 层中的一个方法,而该方法又将调用 [dao] 层中的一个方法;

例如,函数 [do_something_in_ui_layer] 如下所示:


class UiImpl1(AbstractBaseUi):
    # 接口实现[InterfaceUi]
    def do_something_in_ui_layer(self: AbstractBaseUi, x: int, y: int) -> int:
        x += 1
        y += 1
        return self.métier.do_something_in_métier_layer(x, y)
  • 上文第 6 行使用了第 1 行中 [UiImpl1] 类的 [métier] 属性。然而在 [config1] 的配置中写道:

# 业务
    métier = MétierImpl1()
    métier.dao = dao
    # ui
    ui = UiImpl1()
    ui.métier = métier
  • 第6行:[UIImpl1]的属性[métier]引用了类[MétierImpl1](第2行)。 因此,将执行的是类 [MétierImpl1] 的方法 [do_something_in_ui_layer]

在类 [MétierUiImpl1] 中写道:


class MétierImpl1(AbstractBaseMétier):
    # 接口实现 [InterfaceMétier]
    def do_something_in_métier_layer(self: AbstractBaseMétier, x: int, y: int) -> int:
        x += 1
        y += 1
        return self.dao.do_something_in_dao_layer(x, y)
  • 第6行,由[ui]层调用的方法,将进一步调用[MétierImpl1]类中[dao]属性的方法;

然而在 [config1] 的配置中,写的是:


# DAO
    dao = DaoImpl1()
    # 业务
    métier = MétierImpl1()
    métier.dao = dao
  • 第 5 行:属性 [MétierImpl1.dao] 的类型为 [DaoImpl1](第 2 行);

这里要说明的是,脚本 [main] 无需关注 [métier][dao] 这两层。 它只需关注 [ui] 层,因为该层与其他层之间的关联已通过配置建立。

Image

要将参数 [config1][config2] 传递给脚本 [main], 请按以下方式操作:

Image

  • [1-2] 中,创建所谓的执行配置;
  • [3] 中,为该配置命名以便后续查找;
  • [4] 中,选择要执行的脚本。如果已按照 [1-2] 的步骤操作,则正确的脚本已被选中;
  • [5] 中,此处填写要传递给脚本的参数。在此处传入字符串 [config1],以要求脚本使用配置编号 1;
  • [6] 中,确认执行配置;

Image

  • [1-2] 中,请求查看现有的执行上下文;
  • [3] 中,选择现有执行上下文并将其复制为 [4]

Image

  • [5] 中,为新配置命名。 该配置将通过传递参数 [config2] [7] 来执行脚本 [main] [6]

执行配置位于 PyCharm 窗口的右上角:

Image

只需选择 [2][3],然后点击 [4],即可使用使用 [config1][config2] 中的任意一个参数。

使用 [config1] 时,执行 [main] 将产生以下结果:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v02/main/main.py config1
34

Process finished with exit code 0

使用 [config2] 时,执行 [main] 会得到以下结果:


C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\venv\Scripts\python.exe C:/Data/st-2020/dev/python/cours-2020/python3-flask-2020/troiscouches/v02/main/main.py config2
-10

Process finished with exit code 0

请读者核对这些结果。