项目地址博客地址异步文章最接近Frodo的初衷。对于通信和数据的内容,使用传统框架的思路是一样的。异步思想只是改变了几个场景的实现方式。异步编程并不是一个新概念,但它并没有规定明确的技术特点和路线。相关概念不是很清楚,很少有文章能详细解释阻塞/非阻塞、异步/同步、并行/并发、分布式、IO多路复用、协程之间的区别和联系。这些概念可能在CS专业的OS和分布式系统课程中设计过,但是具体的实现层面可能很少涉及。具体到Python这门语言,看了很多业界和python工作者(或者pythonistas)写的文章。下面两篇文章最值得一读:小白的asyncio:原理,源码到实现(一)-聊完后的文章-知乎;当然,这个标题是作者的谦虚。本文作者结合CPython中的asyncio标准源码、函数栈帧源码和python函数上下文源码实现,描述了python异步的设计原理,写了一个简单版的事件循环和asyncio-future手动对象。深入理解Python异步编程(上);这篇文章写于2017年,那时候asyncio还没有成为标准库。本文使用python和linux的epoll接口一步步实现单线程异步IO,最后引出了asyncio的事件循环,证明了它的便捷性。作者计划在第二、二章讲解asyncio的原理,还没等下一篇呢。作者放文章代码的仓库已经积累了几十个问题催更新。基本问题大家还记得我们在“通信篇”画的时序图吗?用它来表示用户执行的逻辑是没有问题的,但是在实际实现中,我们真的可以这样写代码吗?这里有两个基本问题:并发访问的问题,如何让多人同时访问你的博客web进程?如何避免io阻塞,从而充分利用cpu时间片?第一个问题对于做过web开发的人来说很熟悉,解决方案有很多,因为这是软件开发必须面对的问题:os层面,io多路复用机制,linux成熟的epoll机制,Nginx就是基于这个实现访问并发。编程语言使用多线程来解决问题。以Flask为例,它使用本地线程来解决线程安全问题。编程语言使用异步编程解决,以nodejs为例,promise+callback的方法。Python是以asyncio为代表的异步生态系统。第二题其实和第一题一样,只是把object换成了cpu。Frodo通过使用类似于asyncio事件循环的uvloop循环解决了第一个问题。它使用ASGI协议打包成一个web服务器uvicorn。它可以启动多个使用ASGI标准编写的应用程序,并且内置了一个事件循环来实现并发访问。uvicornmain:app--reload--host0.0.0.0--port8001着重介绍Frodo对第二个问题的解决方案,体现在程序细节中。问题分析:IO阻塞在哪里?我们以《通信》中CRUD的通信逻辑为例。我们先把IO阻塞的地方标记出来,然后对应到程序设计中的环节,然后在实现中思考如何解决。图中标注了三种io场景,有的是串行需求,有的是并发(可以并发)需求。单独解释一下:第一类:网络连接和断开,http是一种基于tcp的可靠传输协议,建立连接的过程也是一个耗时的io操作。与数据库的连接是网络连接或者是socket文件读写链接,对于io.这些代码主要在web中的checkpoint函数中,在Frodo的views目录下。第二种:通信异步是指客户端发送请求,等待数据准备好,返回的过程。这部分等待时间其实就是后端的dataio操作,此时应该不会占用CPU。这部分代码在Frodo的mdoels下。第三类:数据异步是指数据库操作等待数据返回所需要的时间消耗。这部分时间也应该归还给CPU。上面很多场景都必须串行完成,比如建立数据库连接-->数据操作-->断开连接。还有一些场景(主要是不涉及数据一致性的场景)可以并行,比如缓存更新和删除,因为KV数据库不涉及并发关系,可以并行删除。第一类解决方案:连接耗时的数据库连接退出同步会想到使用带with关键字的连接池。异步的,为了让这个连接过程“等待”或者把执行权交给主程序,需要用async关键字把它包裹起来,实现异步上下文方法__aenter__,__aexit__.importdatabasesclassAioDataBase():asyncdef__aenter__(self):db=databases.Database(DB_URL.replace('+pymysql',''))awaitdb.connect()self.db=dbreturndbasyncdef__aexit__(self,exc_type,exc,tb):ifexc:traceback.print_exc()awaitself.db.disconnect()其实aiomysql已经帮我们实现了类似的功能,可惜aiomysql不能和sqlalchemy一起使用。database是一个简单的异步数据库驱动引擎,可以执行sqlalchemy生成的sql。第二类:通信是否耗时是异步的,直观的决定了web应用的响应速度。asynchrony下的checkpoint函数本身就是一个带有asyncdef关键字的协程,然后通过uvloop进行调度。此类功能的要求是对所有阻塞操作使用await,请参见示例:@app.post('/auth')asyncdeflogin(req:Request,username:str=Form(...),password:str=Form(...)):user_auth:schemas.User=\##IO相关函数需要等待awaituser.authenticate_user(username,password)ifnotuser_auth:raiseHTTPException(status_code=400,detail='Incorrect用户授权。')access_token_expires=timedelta(minutes=int(config.ACCESS_TOKEN_EXPIRE_MINUTES))access_token=awaituser.create_access_token(data={'sub':user_auth.name},expires_delta=access_token_expires)return{...}async_def(username:str,password:str)->schemas.User:user=awaitUser.async_first(name=username)user=schemas.UserAuth(**user)如果不是user:如果不是则返回Falseverify_password(password,user.password):returnFalsereturnuser你可能已经注意到一些函数比如verify_password没有等他,因为是计算任务,不能等待,所以我们只需要按照逻辑等待耗时的io操作即可。第三类:耗时的数据操作这体现在异步ORM方法的设计上。database+sqlalchemy的实现示例如下:@classmethodasyncdefasave(cls,*args,**kwargs):'''update'''table=cls.__table__id=kwargs.pop('id')asyncwithAioDataBase()asdb:query=table.update().\where(table.c.id==id).\values(**kwargs)##等待1:执行sql语句rv=awaitdb.execute(query=query)##等待2:获取数据构造对象obj=cls(**(awaitcls.async_first(id=id)))##等待3:清除对象涉及的缓存awaitcls.__flush__(obj)returnrv以更新数据为例,涉及等待。像pymysql这样的同步ORM框架不能在db.execute(...)方法中等待,直接阻塞。异步写的时候,你要等待他的结果,好处就是等待的时候把执行权交还给主程序,让它去处理其他的事务。并行实现异步下的并行是指很多io操作不涉及数据一致性,可以并行处理,比如删除不相关的数据,查询部分数据,更新不相关的数据等,都可以并行化。这些并行性在异步中也是允许的,这是在asycio.gather(*coros)方法的帮助下实现的。该方法将传递过来的协程放入事件循环队列中,一个一个执行类似coro.send(None)的操作,因为协程会立即退出,所以可以“同时”唤醒所有的协程等待,达到并行的效果。Thetricksusedinclassdesign本节的内容是使用python异步的一些技巧,可以帮助我们实现更好的设计。序列化类的@property属性序列化对象是很常见的,尤其是当你想在缓存中存储对象时。对象的一些属性是用异步@property完成的。与其他属性不同,它们需要特殊调用:andcontent=awaitpost.html_content每次使用该属性都需要,没有async和await的属性可以直接访问content=post.html_content。这给我们的序列化方法带来了麻烦。我们希望类有一个功能,知道自己有哪些异步属性,这样就可以在BaseModel中实现统一的序列化方法(单独在子类中实现序列化方法是不现实的)。让类附加一个partials属性来存储需要等待的属性。对于python来说,要控制类的行为(注意是类的创建行为,不是实例的创建行为)需要改变它的元类。我们设计了一个名为PropertyHolder的元类。让他的行为控制所有数据类的生成:classPropertyHolder(type):"""我们想让我们的类有一些有用的属性,过滤私有属性。"""def__new__(cls,name,bases,attrs):new_cls=type.__new__(cls,name,bases,attrs)new_cls.property_fields=[]forattrinlist(attrs)+sum([list(vars(base))forbaseinbases],[]):ifattr.startswith('_')orattrinIGNORE_ATTRS:continueifisinstance(getattr(new_cls,attr),property):new_cls.property_fields.append(attr)returnnew_cls他的作用是过滤掉我们需要的@property,Pay直接到类的properties属性。下一步是更改生成的BaseModel元类:@as_declarative()classBase():__name__:str@declared_attrdef__tablename__(cls)->str:returncls.__name__.lower()@propertydefurl(self):返回f'/{self.__class__.__name__.lower()}/{self.id}/'@propertydefcanonical_url(self):passclassModelMeta(Base.__class__,PropertyHolder):...classBaseModel(Base,metaclass=ModelMeta):...Base是ORM的基类,自己的元类也变了(意思不是类型)。如果直接改了,我们的数据类型就失去了ORM的功能。实现两全其美的最好方法是创建一个新类,同时继承Base和PropertyHolder,使该类成为一个新的混合元类。(_好纠结,这里不想出现套娃现象,我会慢慢找更好的解决办法..._)。技巧:如何获取类的元类?调用cls.__class__获取它所基于的元类。请记住,python中的类本身就是对象。他的创作也受到控制。关于fastapi,已经介绍了第一版Frodo的核心设计思想。在之前的描述中,我很少提到fastapi,因为异步web本身与框架无关。这套内容换成sanic、aiohttp、tornado甚至Django都是一样的,只是具体实现方式不同而已。比如Django的异步实现就是基于他设计的通道。但是fastapi也有它的特别之处,设计思路是包容的,我想了很多。在开发中,我强烈推荐使用几个地方:数据模型schema的设计,以及支持pydantic的类型检查,让python这种动态语言变得更易读,更容易调试,语法更规范,我相信这是未来的趋势。在Depends的设计中,我们想过将复用的逻辑封装成类、函数、装饰器,但是fastapi直接在参数上做文章,让我很意外。替换context,multi-parameters,formparameters,Authenticationparameters等。兼容同步写,包括WSGI,用fastapi同步技术库没问题。它允许同步函数的存在。原因是基于它的ASGI认为自己是WSGI的超集,应该兼容这两种写法。支持swagger-doc和后端的好处,不需要花时间学习OpenAPI语法,就可以成功打造出前后端人员都能使用和理解的调试平台和文档,省时省力。佛罗多的三部介绍到此结束。在校外和科研时间的空隙中完成的项目,难免漏洞百出。但经过一个月的战斗,第一个版本终于完成了。未来的目标是星辰大海。新语言的加入,多服务的拆分,虚拟化的部署,都需要时间的考验,努力吧~!
