Skip to content

25. تمرین عملی: نسخه ۸

25.1. مقدمه

ما قصد داریم یک برنامهٔ جدید کلاینت/سرور بنویسیم. ویژگی جدید سرور این است که یک جلسه (session) را مدیریت خواهد کرد. به جای قرار دادن داده‌های اداره مالیات در یک شیء دامنه [application]، ما آن را در یک شیء دامنه [session] قرار خواهیم داد. با این کار، عملکرد کد را تضعیف می‌کنیم. زمانی که یک شیء می‌تواند به صورت فقط-خواندنی توسط همه کاربران به اشتراک گذاشته شود، ترجیح داده می‌شود که آن را به یک شیء با دامنه [application] تبدیل کرد تا یک شیء با دامنه [session]. حداقل این کار با کاهش اندازه کوکی جلسه، در پهنای باند صرفه‌جویی می‌کند. با این حال، ما می‌خواهیم یک برنامه کلاینت/سرور را نشان دهیم که در آن کلاینت و سرور یک کوکی جلسه را با یکدیگر مبادله می‌کنند.

معماری برنامه بدون تغییر باقی می‌ماند:

Image

25.2. وب‌سرور

ساختار دایرکتوری اسکریپت‌های سرور به شرح زیر است:

Image

پوشه [http-servers/03] در ابتدا با کپی کردن پوشه [http-servers/02] ایجاد می‌شود. سپس اصلاحات انجام می‌گیرند.

25.2.1. پیکربندی

این همانند نسخه قبلی است با چند تغییر در اسکریپت [config]:


#وابستگی‌های مطلق
    absolute_dependencies = [
        # پوشه‌های پروژه
        # BaseEntity, MyException
        f"{root_dir}/classes/02/entities",
        #InterfaceImpôtsDao, InterfaceImpôtsMétier, InterfaceImpôtsUi
        f"{root_dir}/impots/v04/interfaces",
        # AbstractImpôtsdao, ImpôtsConsole, ImpôtsMétier
        f"{root_dir}/impots/v04/services",
        # ImpotsDaoWithAdminDataInDatabase
        f"{root_dir}/impots/v05/services",
        # AdminData, ImpôtsError, TaxPayer
        f"{root_dir}/impots/v04/entities",
        #ثابت‌ها، دسته‌ها
        f"{root_dir}/impots/v05/entities",
        # index_controller
        f"{script_dir}/../controllers",
        # اسکریپت‌ها [config_database, config_layers]
        script_dir,
        # لاگر، SendAdminMail
        f"{root_dir}/impots/http-servers/02/utilities",
    ]
  • خط ۱۷: ما یک کنترلر برای تابع [index] بازنویسی خواهیم کرد که URL / را پردازش می‌کند؛
  • خط ۲۱: ما از ابزارهای نسخه قبلی استفاده می‌کنیم؛

25.2.2. اسکریپت اصلی [main]

اسکریپت جدید [main] چند تغییر را نسبت به اسکریپت اصلی [main] نسخه قبلی معرفی می‌کند:


# برنامه Flask می‌تواند شروع شود
app = Flask(__name__)
#کلید مخفی جلسه
app.secret_key = os.urandom(12).hex()
  • خط ۴: یک کلید مخفی برای برنامه ایجاد می‌شود. این برای مدیریت جلسات لازم است؛

سپس، داده‌های مالیاتی دیگر در کد [main] درخواست نمی‌شوند. خطوط زیر حذف شده‌اند:


#بازیابی داده‌ها از مراجع مالیاتی
erreur = False
try:
    #داده‌های مدیریتی فقط قابل خواندن و در محدودهٔ برنامه خواهند بود
    config["admindata"] = config["layers"]["dao"].get_admindata()
    # لاگ موفقیت
    logger.write("[serveur] connexion à la base de données réussie\n")
except ImpôtsError as ex:
    # خطا ثبت شد
    erreur = True
    # لاگ خطا
    log = f"L'erreur suivante s'est produite : {ex}"
    #کنسول
    print(log)
    # فایل گزارش
    logger.write(f"{log}\n")
    # ایمیل به مدیر
    send_adminmail(config, log)

علاوه بر این، کنترل‌کننده [index_controller] یک پارامتر اضافی به نام جلسه Flask را می‌پذیرد:


from flask import request, Flask, session
….        
         # درخواست توسط یک کنترلر اجرا می‌شود
        résultat, status_code = index_controller.execute(request, session, config)

25.2.3. کنترل‌کننده [index_controller]

کنترل‌کننده [index_controller] اکنون یک جلسه را مدیریت می‌کند:


# وارد کردن وابستگی‌ها
import os
import re
import threading

from flask_api import status
from werkzeug.local import LocalProxy

#URL پیکربندی شده: /?married=xx&children=yy&salary=zz
from AdminData import AdminData
from ImpôtsError import ImpôtsError


def execute(request: LocalProxy, session: LocalProxy, config: dict) -> tuple:
    #وابستگی‌ها
    from TaxPayer import TaxPayer

    # در ابتدا هیچ خطایی وجود ندارد
    erreurs = []
    

    # آیا خطایی وجود دارد؟
    if erreurs:
        #یک پاسخ خطا به کلاینت بازگردانده می‌شود
        return {"réponse": {"erreurs": erreurs}}, status.HTTP_400_BAD_REQUEST

    # بدون خطا، می‌توانیم ادامه دهیم
    # بازیابی پیکربندی مرتبط با نخ
    thread_name = threading.current_thread().name
    logger = config[thread_name]["config"]["logger"]
    #اجرای پرس‌وجو
    réponse = None
    try:
        #ساده‌ترین حالت – admindata قبلاً وارد شده است
        if session.get('client_id') is not None:
            #بازیابی اطلاعات جلسه
            client_id = session.get('client_id')
            admindata = AdminData().fromdict(session.get('admindata'))
            # لاگ
            logger.write(f"[index_controller] client [{client_id}], données fiscales prises en session\n")
        else:
            #بازیابی داده‌ها از مراجع مالیاتی
            admindata = config["layers"]["dao"].get_admindata()
            #ثبت admindata در جلسه
            session['admindata'] = admindata.asdict()
            # یک شماره به مشتری اختصاص می‌دهد و این شماره را در جلسه ذخیره می‌کند
            # این امکان را به ما می‌دهد تا آن‌ها را در لاگ‌های سرور ردیابی کنیم
            client_id = os.urandom(12).hex()
            session['client_id'] = client_id
            # لاگ
            logger.write(f"[index_controller] client [{client_id}], données fiscales prises dans la couche dao\n")
        #محاسبه مالیات
        taxpayer = TaxPayer().fromdict({'marié': marié, 'enfants': enfants, 'salaire': salaire})
        config["layers"]["métier"].calculate_tax(taxpayer, admindata)
        # ما پاسخ را به مشتری بازمی‌گردانیم
        return {"réponse": {"result": taxpayer.asdict()}}, status.HTTP_200_OK
    except (BaseException, ImpôtsError) as erreur:
        # ما پاسخ را به کلاینت بازمی‌گردانیم
        return {"réponse": {"erreurs": [f"{erreur}"]}}, status.HTTP_500_INTERNAL_SERVER_ERROR
  • خط ۱۴: کنترل‌کننده جلسهٔ جاری را از کلاینت وب دریافت می‌کند؛
  • خطوط ۳۵–۳۸: اگر کلاینت یک جلسه داشته باشد، آنگاه شامل دو کلید است:
    • [client_id]: یک شماره مشتری (خط ۳۷);
    • [admindata]: داده‌هایی از مقامات مالیاتی به شکل یک فرهنگ لغت (خط ۳۸);
  • خط ۳۵: بررسی می‌کنیم که آیا جلسه حاوی یکی از دو کلید مورد انتظار است؛
  • خطوط ۴۲–۵۱: حالتی که جلسه مشتری هنوز راه‌اندازی نشده است؛
    • خط ۴۳: بازیابی داده‌ها از مرجع مالیاتی از لایه [dao]؛
    • خط ۴۵: این داده‌ها به شکل یک دیکشنری در جلسه قرار داده می‌شوند؛
    • خط ۴۸: یک عدد تصادفی به کلاینت اختصاص داده می‌شود. این عدد برای هر کلاینت متفاوت خواهد بود؛
    • خط ۴۹: این عدد در جلسه ذخیره می‌شود؛
    • خط ۵۱: یک رکورد لاگ ثبت می‌شود که نشان می‌دهد داده‌های مرجع مالیاتی از طریق لایه [dao] بازیابی شده است. دسترسی به لایه [dao] عموماً پرهزینه است. به همین دلیل باید محدود شود. ایده در اینجا این است که داده‌های مالیاتی را یک بار از لایه [dao] بازیابی کرده، آن را در جلسه (session) ذخیره کنیم و در درخواست‌های بعدی از همان کلاینت، از آنجا بازیابی کنیم. باید توجه داشت که این بهترین راه‌حل نیست. از آنجایی که داده‌های سازمان مالیاتی برای همه کلاینت‌ها یکسان است، این داده‌ها باید در یک شیء در مقیاس برنامه (application-scope object) قرار گیرد؛
  • خطوط ۳۵–۴۰: جایی که جلسه مشتری در طول یک درخواست قبلی راه‌اندازی شده است؛
    • خط ۳۷: شماره مشتری از جلسه بازیابی می‌شود؛
    • خط ۳۸: داده‌های سازمان مالیاتی از جلسه بازیابی می‌شود؛
    • خط ۴۰: ما ثبت می‌کنیم که مشتری داده‌های مرجع مالیاتی را از جلسه دریافت کرده است؛

25.3. کلاینت وب

Image

25.3.1. لایه [dao]

25.3.1.1. کلاس [ImpôtsDaoWithHttpSession]

لایه [dao] توسط کلاس زیر [ImpôtsDaoWithHttpSession] پیاده‌سازی شده است:


# واردات

import requests
from flask_api import status
from myutils import decode_flask_session

from AbstractImpôtsDao import AbstractImpôtsDao
from AdminData import AdminData
from ImpôtsError import ImpôtsError
from InterfaceImpôtsMétier import InterfaceImpôtsMétier
from TaxPayer import TaxPayer


class ImpôtsDaoWithHttpSession(AbstractImpôtsDao, InterfaceImpôtsMétier):

    # سازنده
    def __init__(self, config: dict):
        # ابتدایی‌سازی والد
        AbstractImpôtsDao.__init__(self, config)
        #ذخیرهٔ عناصر پیکربندی
        # پیکربندی عمومی
        self.__config = config
        # سرور
        self.__config_server = config["server"]
        # حالت اشکال‌زدایی
        self.__debug = config["debug"]
        # لاگ‌گیر
        self.__logger = None
        # کوکی‌ها
        self.__cookies = None

    # متد بلااستفاده
    def get_admindata(self) -> AdminData:
        pass

    # محاسبه مالیات
    def calculate_tax(self, taxpayer: TaxPayer, admindata: AdminData = None):
        # اجازه دهید استثناءها منتقل شوند
        #پارامترهای GET
        params = {"marié": taxpayer.marié, "enfants": taxpayer.enfants, "salaire": taxpayer.salaire}
        # اتصال با استفاده از احراز هویت پایه؟
        if self.__config_server['authBasic']:
            response = requests.get(
                # URL از سرور پرس‌وجو شده
                self.__config_server['urlServer'],
                # URL پارامترها
                params=params,
                # احراز هویت پایه
                auth=(
                    self.__config_server["user"]["login"],
                    self.__config_server["user"]["password"]),
                cookies=self.__cookies)

        else:
            # اتصال بدون احراز هویت پایه
            response = requests.get(self.__config_server['urlServer'], params=params, cookies=self.__cookies)
        # کوکی‌ها در صورت وجود از پاسخ بازیابی می‌شوند
        if response.cookies:
            self.__cookies = response.cookies
            #کوکی جلسه را بازیابی می‌کند
            session_cookie = response.cookies.get('session')
            # آن را رمزگشایی می‌کند تا وارد شود
            if session_cookie:
                # پردازشگر ورود
                if not self.__logger:
                    self.__logger = self.__config['logger']
                # ثبت می‌کند
                self.__logger.write(f"cookie de session={decode_flask_session(session_cookie)}\n")

        # حالت اشکال‌زدایی؟
        if self.__debug:
            # لاگ‌گیر
            if not self.__logger:
                self.__logger = self.__config['logger']
            # ثبت
            self.__logger.write(f"{response.text}\n")
        # کد وضعیت پاسخ HTTP
        status_code = response.status_code
        #پاسخ jSON در یک دیکشنری قرار می‌گیرد
        résultat = response.json()
        #خطا اگر کد وضعیت 200 نباشد OK
        if status_code != status.HTTP_200_OK:
            # ما می‌دانیم که خطاها با کلید پاسخ [erreurs] مرتبط شده‌اند
            raise ImpôtsError(87, résultat['réponse']['erreurs'])
        # معلوم است که نتیجه با کلید [result] در پاسخ مرتبط بود
        #پارامتر ورودی با استفاده از این نتیجه اصلاح می‌شود
        taxpayer.fromdict(résultat["réponse"]["result"])
  • خط ۳۰: لایه [dao] یک دیکشنری از کوکی‌ها را مدیریت خواهد کرد؛
  • خط ۵۸: ویژگی [response.cookies] یک دیکشنری است که حاوی کوکی‌های ارسال‌شده توسط سرور در هدرهای HTTP و [Set-Cookie] می‌باشد؛
  • خط ۵۹: این کوکی‌ها در لایه [dao] ذخیره می‌شوند. آنها در درخواست‌های بعدی از همان کلاینت به سرور بازگردانده خواهند شد؛
  • خطوط 60–68: اگرچه ضروری نیست، کوکی جلسه را بازیابی می‌کنیم. در فرهنگ لغت کوکی‌های ارسال‌شده توسط سرور، کوکی جلسه با کلید [session] مرتبط است؛
  • خطوط ۶۲–۶۸: ما کوکی جلسه را رمزگشایی می‌کنیم تا کاربر را وارد سیستم کنیم؛
  • خط ۶۸: ما بعداً به تابع [decode_flask_sessionکه کوکی جلسه را رمزگشایی می‌کند، باز خواهیم گشت؛
  • خطوط ۵۲ و ۵۷: با هر درخواست از سوی همان کلاینت، کوکی‌های ارسال‌شده توسط سرور به آن بازگردانده می‌شوند. اینگونه است که جلسه (session) فلاسک بین کلاینت و سرور حفظ می‌شود؛

اکنون باید در نظر داشته باشیم که لایه [dao] به طور همزمان توسط چندین نخ اجرا خواهد شد. بنابراین باید تمام ویژگی‌های نمونه کلاس را بررسی کرده و ببینیم که آیا دسترسی همزمان به این ویژگی‌ها مشکلی ایجاد می‌کند یا خیر. در اینجا ما ویژگی [self.__cookies] را در خط ۳۰ اضافه کرده‌ایم. این ویژگی در خط ۵۹ تغییر داده می‌شود. بنابراین ما به داده‌های مشترک تمام نخ‌ها دسترسی نوشتن داریم. با این حال، این دسترسی یک مشکل ایجاد می‌کند: هر نخی که نماینده یک مشتری مشخص است، کوکی جلسه مخصوص به خود را دارد. در واقع، هر کوکی حاوی یک شناسه مشتری منحصربه‌فرد (= شناسه نخ) برای هر مشتری است. اگر اقدامی صورت نگیرد، نخ T2 ممکن است بر روی کوکی‌های نخ T1 بنویسد.

ما قبلاً یک راه برای مقابله با این مشکل دیده‌ایم: در داخل فایل [config]، که به عنوان پارامتر به سازنده (خط 17) ارسال می‌شود، می‌توانیم برای هر نخ کلیدهای متفاوتی ایجاد کنیم. به عنوان مثال، می‌توانیم از نام نخ به عنوان کلید استفاده کنیم:

  • در خط ۵۹، می‌توانید بنویسید:

config[thread_name][‘cookies’]=cookies
  • در خط ۵۲، سپس می‌توانیم بنویسیم:

cookies=config[thread_name][‘cookies’]

در اینجا، از یک تکنیک متفاوت استفاده خواهیم کرد: هر نخ (=کلاینت) لایه [dao] مخصوص به خود را خواهد داشت. این بدان معناست که خط ۵۹ دیگر مشکلی ایجاد نمی‌کند، زیرا کوکی‌های مورد استفاده، کوکی‌های یک کلاینت واحد خواهند بود.

برای انجام این کار، یک کلاس جدید به نام [ImpôtsDaoWithHttpSessionFactory] ایجاد خواهیم کرد.

25.3.1.2. تابع رمزگشایی جلسه Flask

تابع [decode_flask_session] در اسکریپت [myutils] تعریف شده است:

Image

ما قبلاً به اسکریپت |myutils| نگاهی انداخته‌ایم. این اسکریپت یک ماژول در مقیاس ماشین است که اسکریپت‌های مختلف این دوره می‌توانند با استفاده از دستور زیر آن را وارد کنند:

import myutils

تابع [decode_flask_session] در آن به صورت زیر تعریف شده است:


def decode_flask_session(cookie: str) -> str:
    #منبع: https://www.kirsle.net/wizards/flask-session.cgi
    compressed = False
    payload = cookie

    if payload.startswith('.'):
        compressed = True
        payload = payload[1:]

    data = payload.split(".")[0]

    data = base64_decode(data)
    if compressed:
        data = zlib.decompress(data)

    return data.decode("utf-8")
  • خط ۲: URL، جایی که این تابع را پیدا کردم؛
  • خط ۱: پارامتر [cookie] رشته‌ای است که با کلید [session] در فرهنگ لغت کوکی‌های دریافت‌شده توسط یک کلاینت وب مرتبط است؛
  • خطوط ۳–۱۶: من در مورد این کد نظری نمی‌دهم چون کاملاً آن را درک نمی‌کنم؛

ما یک واردکردن جدید به فایل [__init__.py] اضافه می‌کنیم:


from .myutils import set_syspath, json_response, decode_flask_session

نسخه جدید [myutils] با استفاده از دستور [pip install .] در ترمینال PyCharm در میان ماژول‌های در مقیاس ماشین نصب می‌شود:


(venv) C:\Data\st-2020\dev\python\cours-2020\python3-flask-2020\packages>pip install .
Processing c:\data\st-2020\dev\python\cours-2020\python3-flask-2020\packages
Using legacy setup.py install for myutils, since package 'wheel' is not installed.
Installing collected packages: myutils
  Attempting uninstall: myutils
    Found existing installation: myutils 0.1
    Uninstalling myutils-0.1:
      Successfully uninstalled myutils-0.1
    Running setup.py install for myutils ... done
Successfully installed myutils-0.1
  • خط ۱: برای وارد کردن این دستور باید در پوشه [packages] باشید؛

25.3.1.3. کلاس [ImpôtsDaoWithHttpSessionFactory]

کلاس [ImpôtsDaoWithHttpSessionFactory] به شرح زیر است:


from ImpôtsDaoWithHttpSession import ImpôtsDaoWithHttpSession


class ImpôtsDaoWithHttpSessionFactory:

    def __init__(self, config: dict):
        #پارامتر ذخیره می‌شود
        self.__config = config

    def new_instance(self):
        #یک نمونه از لایه [dao] را بازمی‌گرداند
        return ImpôtsDaoWithHttpSession(self.__config)
  • کلاس [ImpôtsDaoWithHttpSessionFactory] به شما امکان می‌دهد یک پیاده‌سازی جدید از لایه [dao] را با استفاده از متد [new_instance] در خطوط ۱۰–۱۲ ایجاد کنید؛

25.3.2. پیکربندی

اسکریپت [config_layers] که لایه‌های کلاینت وب را پیکربندی می‌کند، به شرح زیر اصلاح شده است:


def configure(config: dict) -> dict:
    # نمونه سازی لایه‌های کاربردی

    #لایه DAO
    from ImpôtsDaoWithHttpSessionFactory import ImpôtsDaoWithHttpSessionFactory
    dao_factory = ImpôtsDaoWithHttpSessionFactory(config)

    # ما لایه‌ها را پیکربندی می‌کنیم
    return {
        "dao_factory": dao_factory
    }
  • خطوط ۵–۶: به جای ایجاد یک لایه واحد [dao] همانطور که قبلاً انجام می‌شد، یک «کارخانه» برای این لایه ایجاد می‌شود (کارخانه = تأسیسات تولید شیء، در این مورد لایه [dao]
  • خطوط ۹–۱۱: پیکربندی لایه رندر می‌شود؛

25.3.3. اسکریپت اصلی کلاینت

اسکریپت [main] در مقایسه با نسخه قبلی به شرح زیر تغییر کرده است:


# راه‌اندازی برنامه

import config
config = config.configure({})

#وابستگی‌ها
from ImpôtsError import ImpôtsError
import random
import sys
import threading
from Logger import Logger


#اجرای لایه [dao] در یک نخ
# 'مالیات‌دهندگان' فهرستی از مالیات‌دهندگان است
def thread_function(thread_dao, logger, taxpayers: list):
    


# فهرست نخ‌های مشتری
threads = []
logger = None
# کد
try:
    
    l_taxpayers = len(taxpayers)
    while i < len(taxpayers):
        
        #هر نخ باید لایه [dao] خود را برای مدیریت صحیح کوکی جلسه داشته باشد
        thread_dao = dao_factory.new_instance()
        # رشته ایجاد می‌شود
        thread = threading.Thread(target=thread_function, args=(thread_dao, logger, thread_taxpayers))
        # ما آن را به فهرست نخ‌های اسکریپت اصلی اضافه می‌کنیم
        threads.append(thread)
        # رشته راه‌اندازی می‌شود – این عملیات غیرهم‌زمان است – ما منتظر نتیجه رشته نمی‌مانیم
        thread.start()
    # رشتهٔ اصلی منتظر پایان تمام رشته‌هایی که راه‌اندازی کرده است، می‌ماند
    
except BaseException as erreur:
    # نمایش خطا
    print(f"L'erreur suivante s'est produite : {erreur}")
finally:
    #لاگر بسته می‌شود
    if logger:
        logger.close()
    #کار ما تمام شد
    print("Travail terminé...")
    # پایان دادن به هر نخ که ممکن است هنوز در حال اجرا باشد اگر فرآیند به دلیل خطا متوقف شده باشد
    sys.exit()
  • خطوط ۲۹–۳۰: هر نخ لایه [dao] مخصوص به خود را دارد؛

25.3.4. اجرای کلاینت

سرور وب راه‌اندازی می‌شود، SGBD راه‌اندازی می‌شود و سرور ایمیل [hMailServer] راه‌اندازی می‌شود. سپس اسکریپت کلاینت وب [main] راه‌اندازی می‌شود. لاگ‌های اجرا در [data/logs/logs.txt] سپس به شرح زیر است:


2020-07-25 10:21:46.478511, Thread-1 : début du thread [Thread-1] avec 1 contribuable(s)
2020-07-25 10:21:46.479111, Thread-1 : début du calcul de l'impôt de {"id": 1, "marié": "oui", "enfants": 2, "salaire": 55555}
2020-07-25 10:21:46.479111, Thread-2 : début du thread [Thread-2] avec 1 contribuable(s)
2020-07-25 10:21:46.480195, Thread-3 : début du thread [Thread-3] avec 2 contribuable(s)
2020-07-25 10:21:46.480195, Thread-2 : début du calcul de l'impôt de {"id": 2, "marié": "oui", "enfants": 2, "salaire": 50000}
2020-07-25 10:21:46.481137, Thread-4 : début du thread [Thread-4] avec 3 contribuable(s)
2020-07-25 10:21:46.481137, Thread-3 : début du calcul de l'impôt de {"id": 3, "marié": "oui", "enfants": 3, "salaire": 50000}
2020-07-25 10:21:46.482279, Thread-5 : début du thread [Thread-5] avec 3 contribuable(s)
2020-07-25 10:21:46.482622, Thread-6 : début du thread [Thread-6] avec 1 contribuable(s)
2020-07-25 10:21:46.482622, Thread-4 : début du calcul de l'impôt de {"id": 5, "marié": "non", "enfants": 3, "salaire": 100000}
2020-07-25 10:21:46.485923, Thread-5 : début du calcul de l'impôt de {"id": 8, "marié": "non", "enfants": 0, "salaire": 100000}
2020-07-25 10:21:46.485923, Thread-6 : début du calcul de l'impôt de {"id": 11, "marié": "oui", "enfants": 3, "salaire": 200000}
2020-07-25 10:21:46.725910, Thread-4 : cookie de session={"admindata":{"abattement_dixpourcent_max":12502.0,"abattement_dixpourcent_min":437.0,"coeffn":[0.0,1394.96,5798.0,13913.7,20163.4],"coeffr":[0.0,0.14,0.3,0.41,0.45],"id":1,"limites":[9964.0,27519.0,73779.0,156244.0,90000.0],"plafond_decote_celibataire":1196.0,"plafond_decote_couple":1970.0,"plafond_impot_celibataire_pour_decote":1595.0,"plafond_impot_couple_pour_decote":2627.0,"plafond_qf_demi_part":1551.0,"plafond_revenus_celibataire_pour_reduction":21037.0,"plafond_revenus_couple_pour_reduction":42074.0,"valeur_reduc_demi_part":3797.0},"client_id":"fa3c83b82761c83e13217967"}
2020-07-25 10:21:46.725910, Thread-4 : {"réponse": {"result": {"marié": "non", "enfants": 3, "salaire": 100000, "impôt": 16782, "surcôte": 7176, "taux": 0.41, "décôte": 0, "réduction": 0}}}
2020-07-25 10:21:46.725910, Thread-4 : fin du calcul de l'impôt de {"id": 5, "marié": "non", "enfants": 3, "salaire": 100000, "impôt": 16782, "surcôte": 7176, "taux": 0.41, "décôte": 0, "réduction": 0}
2020-07-25 10:21:46.726960, Thread-4 : début du calcul de l'impôt de {"id": 6, "marié": "oui", "enfants": 3, "salaire": 100000}
2020-07-25 10:21:47.514108, Thread-3 : cookie de session={"admindata":{"abattement_dixpourcent_max":12502.0,"abattement_dixpourcent_min":437.0,"coeffn":[0.0,1394.96,5798.0,13913.7,20163.4],"coeffr":[0.0,0.14,0.3,0.41,0.45],"id":1,"limites":[9964.0,27519.0,73779.0,156244.0,24999.5],"plafond_decote_celibataire":1196.0,"plafond_decote_couple":1970.0,"plafond_impot_celibataire_pour_decote":1595.0,"plafond_impot_couple_pour_decote":2627.0,"plafond_qf_demi_part":1551.0,"plafond_revenus_celibataire_pour_reduction":21037.0,"plafond_revenus_couple_pour_reduction":42074.0,"valeur_reduc_demi_part":3797.0},"client_id":"700e3f5dc808c7c48f0c9007"}
2020-07-25 10:21:47.514610, Thread-3 : {"réponse": {"result": {"marié": "oui", "enfants": 3, "salaire": 50000, "impôt": 0, "surcôte": 0, "taux": 0.14, "décôte": 720, "réduction": 0}}}
2020-07-25 10:21:47.514939, Thread-3 : fin du calcul de l'impôt de {"id": 3, "marié": "oui", "enfants": 3, "salaire": 50000, "impôt": 0, "surcôte": 0, "taux": 0.14, "décôte": 720, "réduction": 0}
2020-07-25 10:21:47.514939, Thread-3 : début du calcul de l'impôt de {"id": 4, "marié": "non", "enfants": 2, "salaire": 100000}
2020-07-25 10:21:47.527211, Thread-5 : cookie de session={"admindata":{"abattement_dixpourcent_max":12502.0,"abattement_dixpourcent_min":437.0,"coeffn":[0.0,1394.96,5798.0,13913.7,20163.4],"coeffr":[0.0,0.14,0.3,0.41,0.45],"id":1,"limites":[9964.0,27519.0,73779.0,156244.0,90000.0],"plafond_decote_celibataire":1196.0,"plafond_decote_couple":1970.0,"plafond_impot_celibataire_pour_decote":1595.0,"plafond_impot_couple_pour_decote":2627.0,"plafond_qf_demi_part":1551.0,"plafond_revenus_celibataire_pour_reduction":21037.0,"plafond_revenus_couple_pour_reduction":42074.0,"valeur_reduc_demi_part":3797.0},"client_id":"9e14a5d4a3057f69ab95ab2d"}
2020-07-25 10:21:47.527211, Thread-2 : cookie de session={"admindata":{"abattement_dixpourcent_max":12502.0,"abattement_dixpourcent_min":437.0,"coeffn":[0.0,1394.96,5798.0,13913.7,20163.4],"coeffr":[0.0,0.14,0.3,0.41,0.45],"id":1,"limites":[9964.0,27519.0,73779.0,156244.0,22500.0],"plafond_decote_celibataire":1196.0,"plafond_decote_couple":1970.0,"plafond_impot_celibataire_pour_decote":1595.0,"plafond_impot_couple_pour_decote":2627.0,"plafond_qf_demi_part":1551.0,"plafond_revenus_celibataire_pour_reduction":21037.0,"plafond_revenus_couple_pour_reduction":42074.0,"valeur_reduc_demi_part":3797.0},"client_id":"a06e8fd70a44c9e311f4dce0"}
2020-07-25 10:21:47.527211, Thread-5 : {"réponse": {"result": {"marié": "non", "enfants": 0, "salaire": 100000, "impôt": 22986, "surcôte": 0, "taux": 0.41, "décôte": 0, "réduction": 0}}}
2020-07-25 10:21:47.527211, Thread-1 : cookie de session={"admindata":{"abattement_dixpourcent_max":12502.0,"abattement_dixpourcent_min":437.0,"coeffn":[0.0,1394.96,5798.0,13913.7,20163.4],"coeffr":[0.0,0.14,0.3,0.41,0.45],"id":1,"limites":[9964.0,27519.0,73779.0,156244.0,90000.0],"plafond_decote_celibataire":1196.0,"plafond_decote_couple":1970.0,"plafond_impot_celibataire_pour_decote":1595.0,"plafond_impot_couple_pour_decote":2627.0,"plafond_qf_demi_part":1551.0,"plafond_revenus_celibataire_pour_reduction":21037.0,"plafond_revenus_couple_pour_reduction":42074.0,"valeur_reduc_demi_part":3797.0},"client_id":"28c38df998f67685b3a482b8"}
2020-07-25 10:21:47.527211, Thread-2 : {"réponse": {"result": {"marié": "oui", "enfants": 2, "salaire": 50000, "impôt": 1384, "surcôte": 0, "taux": 0.14, "décôte": 384, "réduction": 347}}}
2020-07-25 10:21:47.528341, Thread-5 : fin du calcul de l'impôt de {"id": 8, "marié": "non", "enfants": 0, "salaire": 100000, "impôt": 22986, "surcôte": 0, "taux": 0.41, "décôte": 0, "réduction": 0}
2020-07-25 10:21:47.528341, Thread-1 : {"réponse": {"result": {"marié": "oui", "enfants": 2, "salaire": 55555, "impôt": 2814, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}}}
2020-07-25 10:21:47.528842, Thread-2 : fin du calcul de l'impôt de {"id": 2, "marié": "oui", "enfants": 2, "salaire": 50000, "impôt": 1384, "surcôte": 0, "taux": 0.14, "décôte": 384, "réduction": 347}
2020-07-25 10:21:47.529349, Thread-5 : début du calcul de l'impôt de {"id": 9, "marié": "oui", "enfants": 2, "salaire": 30000}
2020-07-25 10:21:47.529699, Thread-1 : fin du calcul de l'impôt de {"id": 1, "marié": "oui", "enfants": 2, "salaire": 55555, "impôt": 2814, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}
2020-07-25 10:21:47.529699, Thread-2 : fin du thread [Thread-2]
2020-07-25 10:21:47.531905, Thread-1 : fin du thread [Thread-1]
2020-07-25 10:21:47.536121, Thread-6 : cookie de session={"admindata":{"abattement_dixpourcent_max":12502.0,"abattement_dixpourcent_min":437.0,"coeffn":[0.0,1394.96,5798.0,13913.7,20163.4],"coeffr":[0.0,0.14,0.3,0.41,0.45],"id":1,"limites":[9964.0,27519.0,73779.0,156244.0,93749.0],"plafond_decote_celibataire":1196.0,"plafond_decote_couple":1970.0,"plafond_impot_celibataire_pour_decote":1595.0,"plafond_impot_couple_pour_decote":2627.0,"plafond_qf_demi_part":1551.0,"plafond_revenus_celibataire_pour_reduction":21037.0,"plafond_revenus_couple_pour_reduction":42074.0,"valeur_reduc_demi_part":3797.0},"client_id":"38499b63076516c02f2770ec"}
2020-07-25 10:21:47.537161, Thread-3 : {"réponse": {"result": {"marié": "non", "enfants": 2, "salaire": 100000, "impôt": 19884, "surcôte": 4480, "taux": 0.41, "décôte": 0, "réduction": 0}}}
2020-07-25 10:21:47.537161, Thread-6 : {"réponse": {"result": {"marié": "oui", "enfants": 3, "salaire": 200000, "impôt": 42842, "surcôte": 17283, "taux": 0.41, "décôte": 0, "réduction": 0}}}
2020-07-25 10:21:47.538156, Thread-3 : fin du calcul de l'impôt de {"id": 4, "marié": "non", "enfants": 2, "salaire": 100000, "impôt": 19884, "surcôte": 4480, "taux": 0.41, "décôte": 0, "réduction": 0}
2020-07-25 10:21:47.538557, Thread-6 : fin du calcul de l'impôt de {"id": 11, "marié": "oui", "enfants": 3, "salaire": 200000, "impôt": 42842, "surcôte": 17283, "taux": 0.41, "décôte": 0, "réduction": 0}
2020-07-25 10:21:47.538828, Thread-3 : fin du thread [Thread-3]
2020-07-25 10:21:47.538828, Thread-6 : fin du thread [Thread-6]
2020-07-25 10:21:47.546198, Thread-5 : {"réponse": {"result": {"marié": "oui", "enfants": 2, "salaire": 30000, "impôt": 0, "surcôte": 0, "taux": 0.0, "décôte": 0, "réduction": 0}}}
2020-07-25 10:21:47.546198, Thread-5 : fin du calcul de l'impôt de {"id": 9, "marié": "oui", "enfants": 2, "salaire": 30000, "impôt": 0, "surcôte": 0, "taux": 0.0, "décôte": 0, "réduction": 0}
2020-07-25 10:21:47.546198, Thread-5 : début du calcul de l'impôt de {"id": 10, "marié": "non", "enfants": 0, "salaire": 200000}
2020-07-25 10:21:47.739643, Thread-4 : {"réponse": {"result": {"marié": "oui", "enfants": 3, "salaire": 100000, "impôt": 9200, "surcôte": 2180, "taux": 0.3, "décôte": 0, "réduction": 0}}}
2020-07-25 10:21:47.739643, Thread-4 : fin du calcul de l'impôt de {"id": 6, "marié": "oui", "enfants": 3, "salaire": 100000, "impôt": 9200, "surcôte": 2180, "taux": 0.3, "décôte": 0, "réduction": 0}
2020-07-25 10:21:47.740668, Thread-4 : début du calcul de l'impôt de {"id": 7, "marié": "oui", "enfants": 5, "salaire": 100000}
2020-07-25 10:21:48.557469, Thread-5 : {"réponse": {"result": {"marié": "non", "enfants": 0, "salaire": 200000, "impôt": 64210, "surcôte": 7498, "taux": 0.45, "décôte": 0, "réduction": 0}}}
2020-07-25 10:21:48.558715, Thread-5 : fin du calcul de l'impôt de {"id": 10, "marié": "non", "enfants": 0, "salaire": 200000, "impôt": 64210, "surcôte": 7498, "taux": 0.45, "décôte": 0, "réduction": 0}
2020-07-25 10:21:48.558715, Thread-5 : fin du thread [Thread-5]
2020-07-25 10:21:48.753025, Thread-4 : {"réponse": {"result": {"marié": "oui", "enfants": 5, "salaire": 100000, "impôt": 4230, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}}}
2020-07-25 10:21:48.753318, Thread-4 : fin du calcul de l'impôt de {"id": 7, "marié": "oui", "enfants": 5, "salaire": 100000, "impôt": 4230, "surcôte": 0, "taux": 0.14, "décôte": 0, "réduction": 0}
2020-07-25 10:21:48.753540, Thread-4 : fin du thread [Thread-4]
  • در مجموع ۶ نخ (thread) وجود دارد، یعنی ۶ مشتری (رده‌های ۱، ۳، ۴، ۶، ۸ و ۹) که همزمان از سرور محاسبه مالیات استعلام می‌گیرند؛
  • ما نخ [Thread-4] را دنبال می‌کنیم که سه مؤدی (خط 6) را مدیریت می‌کند. این نخ سه درخواست متوالی به سرور محاسبه مالیات ارسال خواهد کرد؛
  • خط ۱۰: اولین درخواست از [Thread-4]؛
  • خط ۱۳: [Thread-4] پاسخ درخواست اول خود را دریافت کرده است. در این پاسخ، یک کوکی جلسه حاوی شماره [fa3c83b82761c83e13217967] که توسط سرور به آن اختصاص داده شده است، یافت می‌شود؛
  • خط ۱۴: مالیات برای مالیات‌دهنده اول؛
  • خط ۱۶: [Thread-4] درخواستی برای مالیات‌دهنده دوم ارسال می‌کند؛
  • خط ۴۳: [Thread-4] مبلغ مالیات مربوط به مالیات‌دهنده دوم را دریافت می‌کند؛
  • خط ۴۵: [Thread-4] درخواستی برای مالیات‌دهنده سوم ارسال می‌کند؛
  • خط ۴۹: [Thread-4] مالیات را از مالیات‌دهنده سوم دریافت می‌کند؛
  • خط ۵۱: [Thread-4] وظیفه خود را به پایان رسانده است؛

اکنون ببینیم که سه درخواست از [Thread-4] چگونه در سمت سرور پردازش شدند. ما می‌توانیم با استفاده از شناسهٔ مشتری آن، [fa3c83b82761c83e13217967]، آن را در لاگ‌های سرور ردیابی کنیم.

لاگ‌های سمت سرور برای [data/logs/logs.txt] به شرح زیر است:


2020-07-25 10:21:39.187366, MainThread : [serveur] démarrage du serveur
2020-07-25 10:21:40.439093, MainThread : [serveur] démarrage du serveur
2020-07-25 10:21:46.502011, Thread-2 : [index] requête : <Request 'http://127.0.0.1:5000/?married=yes&children=3&salary=50000' [GET]>
2020-07-25 10:21:46.504049, Thread-2 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-25 10:21:46.505452, Thread-3 : [index] requête : <Request 'http://127.0.0.1:5000/?married=yes&children=2&salary=55555' [GET]>
2020-07-25 10:21:46.506257, Thread-3 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-25 10:21:46.507292, Thread-4 : [index] requête : <Request 'http://127.0.0.1:5000/?married=no&children=0&salary=100000' [GET]>
2020-07-25 10:21:46.507292, Thread-4 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-25 10:21:46.508301, Thread-5 : [index] requête : <Request 'http://127.0.0.1:5000/?married=yes&children=2&salary=50000' [GET]>
2020-07-25 10:21:46.509293, Thread-5 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-25 10:21:46.511808, Thread-6 : [index] requête : <Request 'http://127.0.0.1:5000/?married=no&children=3&salary=100000' [GET]>
2020-07-25 10:21:46.517604, Thread-7 : [index] requête : <Request 'http://127.0.0.1:5000/?married=yes&children=3&salary=200000' [GET]>
2020-07-25 10:21:46.517604, Thread-7 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-25 10:21:46.719504, Thread-6 : [index_controller] client [fa3c83b82761c83e13217967], données fiscales prises dans la couche dao
2020-07-25 10:21:46.720003, Thread-6 : [index] {'réponse': {'result': {'marié': 'non', 'enfants': 3, 'salaire': 100000, 'impôt': 16782, 'surcôte': 7176, 'taux': 0.41, 'décôte': 0, 'réduction': 0}}}
2020-07-25 10:21:46.736108, Thread-8 : [index] requête : <Request 'http://127.0.0.1:5000/?married=yes&children=3&salary=100000' [GET]>
2020-07-25 10:21:46.736108, Thread-8 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-25 10:21:47.506709, Thread-2 : [index_controller] client [700e3f5dc808c7c48f0c9007], données fiscales prises dans la couche dao
2020-07-25 10:21:47.507216, Thread-2 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 3, 'salaire': 50000, 'impôt': 0, 'surcôte': 0, 'taux': 0.14, 'décôte': 720, 'réduction': 0}}}
2020-07-25 10:21:47.507216, Thread-3 : [index_controller] client [28c38df998f67685b3a482b8], données fiscales prises dans la couche dao
2020-07-25 10:21:47.508442, Thread-4 : [index_controller] client [9e14a5d4a3057f69ab95ab2d], données fiscales prises dans la couche dao
2020-07-25 10:21:47.508940, Thread-3 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 2, 'salaire': 55555, 'impôt': 2814, 'surcôte': 0, 'taux': 0.14, 'décôte': 0, 'réduction': 0}}}
2020-07-25 10:21:47.510506, Thread-4 : [index] {'réponse': {'result': {'marié': 'non', 'enfants': 0, 'salaire': 100000, 'impôt': 22986, 'surcôte': 0, 'taux': 0.41, 'décôte': 0, 'réduction': 0}}}
2020-07-25 10:21:47.511513, Thread-5 : [index_controller] client [a06e8fd70a44c9e311f4dce0], données fiscales prises dans la couche dao
2020-07-25 10:21:47.514939, Thread-5 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 2, 'salaire': 50000, 'impôt': 1384, 'surcôte': 0, 'taux': 0.14, 'décôte': 384, 'réduction': 347}}}
2020-07-25 10:21:47.520727, Thread-7 : [index_controller] client [38499b63076516c02f2770ec], données fiscales prises dans la couche dao
2020-07-25 10:21:47.523162, Thread-7 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 3, 'salaire': 200000, 'impôt': 42842, 'surcôte': 17283, 'taux': 0.41, 'décôte': 0, 'réduction': 0}}}
2020-07-25 10:21:47.530835, Thread-9 : [index] requête : <Request 'http://127.0.0.1:5000/?married=no&children=2&salary=100000' [GET]>
2020-07-25 10:21:47.531736, Thread-9 : [index_controller] client [700e3f5dc808c7c48f0c9007], données fiscales prises en session
2020-07-25 10:21:47.531905, Thread-9 : [index] {'réponse': {'result': {'marié': 'non', 'enfants': 2, 'salaire': 100000, 'impôt': 19884, 'surcôte': 4480, 'taux': 0.41, 'décôte': 0, 'réduction': 0}}}
2020-07-25 10:21:47.541899, Thread-10 : [index] requête : <Request 'http://127.0.0.1:5000/?married=yes&children=2&salary=30000' [GET]>
2020-07-25 10:21:47.542488, Thread-10 : [index_controller] client [9e14a5d4a3057f69ab95ab2d], données fiscales prises en session
2020-07-25 10:21:47.542488, Thread-10 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 2, 'salaire': 30000, 'impôt': 0, 'surcôte': 0, 'taux': 0.0, 'décôte': 0, 'réduction': 0}}}
2020-07-25 10:21:47.553628, Thread-11 : [index] requête : <Request 'http://127.0.0.1:5000/?married=no&children=0&salary=200000' [GET]>
2020-07-25 10:21:47.553628, Thread-11 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-25 10:21:47.736910, Thread-8 : [index_controller] client [fa3c83b82761c83e13217967], données fiscales prises en session
2020-07-25 10:21:47.737191, Thread-8 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 3, 'salaire': 100000, 'impôt': 9200, 'surcôte': 2180, 'taux': 0.3, 'décôte': 0, 'réduction': 0}}}
2020-07-25 10:21:47.748226, Thread-12 : [index] requête : <Request 'http://127.0.0.1:5000/?married=yes&children=5&salary=100000' [GET]>
2020-07-25 10:21:47.748226, Thread-12 : [index] mis en pause du thread pendant 1 seconde(s)
2020-07-25 10:21:48.554695, Thread-11 : [index_controller] client [9e14a5d4a3057f69ab95ab2d], données fiscales prises en session
2020-07-25 10:21:48.555070, Thread-11 : [index] {'réponse': {'result': {'marié': 'non', 'enfants': 0, 'salaire': 200000, 'impôt': 64210, 'surcôte': 7498, 'taux': 0.45, 'décôte': 0, 'réduction': 0}}}
2020-07-25 10:21:48.748753, Thread-12 : [index_controller] client [fa3c83b82761c83e13217967], données fiscales prises en session
2020-07-25 10:21:48.748753, Thread-12 : [index] {'réponse': {'result': {'marié': 'oui', 'enfants': 5, 'salaire': 100000, 'impôt': 4230, 'surcôte': 0, 'taux': 0.14, 'décôte': 0, 'réduction': 0}}}
  • کلاینت [fa3c83b82761c83e13217967] برای اولین بار در خط 14 ظاهر می‌شود: برای محاسبه مالیات، سرور مجبور بود داده‌ها را از پایگاه داده سازمان مالیاتی بازیابی کند؛
  • سپس ما کلاینت [fa3c83b82761c83e13217967] را دوباره در خط ۳۶ می‌بینیم. این بار، سرور داده‌های مرجع مالیاتی را در جلسهٔ جاری پیدا می‌کند و بدین ترتیب از دسترسی بالقوه پرهزینه به لایهٔ [dao] اجتناب می‌کند؛
  • کلاینت [fa3c83b82761c83e13217967] برای بار سوم در خط ۴۲ ظاهر می‌شود، جایی که بار دیگر سرور از جلسه کلاینت استفاده می‌کند؛

این مثال به وضوح مزیت یک جلسه (session) را برای یک کلاینت نشان می‌دهد: این جلسه حاوی داده‌هایی است که بین تمام درخواست‌های آن کلاینت مشترک است و بازیابی آن‌ها پرهزینه است.

در سمت کلاینت، نتایج در فایل [data/output/résultats.json] همانند نسخه‌های قبلی است.

25.4. آزمایش لایه [dao]

همان‌طور که در نسخه‌های قبلی انجام دادیم، لایه [dao] کلاینت را آزمایش می‌کنیم:

Image

کلاس تست در محیط زیر اجرا خواهد شد:

Image

  • پیکربندی [2] با پیکربندی [1] که به تازگی بررسی کردیم، یکسان است؛

کلاس تست [TestHttpClientDao] به شرح زیر است:


import unittest

from Logger import Logger


class TestHttpClientDao(unittest.TestCase):

    def test_1(self) -> None:
        from TaxPayer import TaxPayer

        # {'married': 'yes', 'children': 2, 'salary': 55555,
        # 'tax': 2814, 'surcharge': 0, 'discount': 0, 'reduction': 0, 'rate': 0.14}
        taxpayer = TaxPayer().fromdict({"marié": "oui", "enfants": 2, "salaire": 55555})
        dao.calculate_tax(taxpayer)
        # تأیید
        self.assertAlmostEqual(taxpayer.impôt, 2815, delta=1)
        self.assertEqual(taxpayer.décôte, 0)
        self.assertEqual(taxpayer.réduction, 0)
        self.assertAlmostEqual(taxpayer.taux, 0.14, delta=0.01)
        self.assertEqual(taxpayer.surcôte, 0)



if __name__ == '__main__':
    # راه‌اندازی برنامه
    import config
    config = config.configure({})

    # لاگ‌گیر
    logger = Logger(config["logsFilename"])
    #ذخیره آن در پیکربندی
    config["logger"] = logger
    # بازخوانی کارخانه برای لایه [dao]
    dao_factory = config["layers"]["dao_factory"]
    #یک نمونه از لایه [dao] ایجاد می‌کند
    dao = dao_factory.new_instance()

    # اجرای روش‌های آزمون
    print("tests en cours...")
    unittest.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/impots/http-clients/03/tests/TestHttpClientDao.py
tests en cours...
...........
----------------------------------------------------------------------
Ran 11 tests in 3.392s

OK

Process finished with exit code 0