2023年5月

这个问题很微妙,可能这位同学内心深处,觉得 Redis 是所有应用缓存的标配。

缓存的世界很广阔,对于
应用系统
来讲,我们经常将缓存划分为
本地缓存

分布式缓存

本地缓存
:应用中的缓存组件,缓存组件和应用在同一进程中,缓存的读写非常快,没有网络开销。但各应用或集群的各节点都需要维护自己的单独缓存,无法共享缓存。

分布式缓存
:和应用分离的缓存组件或服务,与本地应用隔离,多个应用可直接共享缓存。

1 缓存的本质

我们常常会讲:“加了缓存,我们的系统就会更快” 。

所谓的“更快”,本质上做到了如下两点:

  • 减小 CPU 消耗

    将原来需要实时计算的内容提前算好、把一些公用的数据进行复用,这可以减少 CPU 消耗,从而提升响应性能。

  • 减小 I/O 消耗

    将原来对网络、磁盘等较慢介质的读写访问变为对内存等较快介质的访问,从而提升响应性能。

假如可以通过增强 CPU、I/O 本身的性能来满足需求的话,
升级硬件往往是更好的解决方案
,即使需要一些额外的投入成本,也通常要优于引入缓存后可能带来的风险。

从开发角度来说,引入缓存会提高系统复杂度,因为你要考虑缓存的失效、更新、一致性等问题。

从运维角度来说,缓存会掩盖掉一些缺陷,让问题在更久的时间以后,出现在距离发生现场更远的位置上。

从安全角度来说,缓存可能泄漏某些保密数据,也是容易受到攻击的薄弱点。

因此,
缓存是把双刃剑

2 本地缓存 JDK Map

JDK Map 经常用于缓存实现:

  • HashMap

    HashMap 是一种基于哈希表的集合类,它提供了快速的插入、查找和删除操作。可以将键值对作为缓存项的存储方式,将键作为缓存项的唯一标识符,值作为缓存项的内容。

  • ConcurrentHashMap

    ConcurrentHashMap 是线程安全的 HashMap,它在多线程环境下可以保证高效的并发读写操作。

  • LinkedHashMap

    LinkedHashMap 是一种有序的 HashMap ,它保留了元素插入的顺序,可以按照插入顺序或者访问顺序进行遍历。

  • TreeMap

    TreeMap 是一种基于红黑树的有序 Map,它可以按照键的顺序进行遍历。

笔者曾经负责艺龙红包系统,
红包活动
就是
存储在
ConcurrentHashMap
中 ,通过
定时任务刷新缓存

核心流程:

1、红包系统启动后,初始化一个 ConcurrentHashMap 作为红包活动缓存 ;

2、数据库查询所有的红包活动 , 并将活动信息存储在 Map 中 ;

3、定时任务每隔 30 秒 ,执行缓存加载方法,刷新缓存。

为什么红包系统会将红包活动信息存储在本地内存 ConcurrentHashMap 呢 ?

  • 红包系统是高并发应用,快速将请求结果响应给前端,大大提升用户体验;

  • 红包活动数量并不多,就算全部放入到 Map 里也不会产生内存溢出的问题;

  • 定时任务刷新缓存并不会影响红包系统的业务。

笔者见过很多
单体应用
都使用这种方案,该方案的特点是简洁易用,工程实现也容易 。

3 本地缓存框架

虽然使用 JDK Map 能快捷构建缓存,但缓存的功能还是比较孱弱的。

因为现实场景里,我们可能需要给缓存添加
缓存统计

过期失效

淘汰策略
等功能。

于是,
本地缓存框架
应运而生。

流行的 Java 缓存框架包括: Ehcache , Google Guava , Caffine Cache 。

下图展示了 Caffine 框架的使用示例。

虽然本地缓存框架的功能很强大,但是本地缓存的缺陷依然明显。

1、高并发的场景,
应用重启之后,本地缓存就失效了,系统的负载就比较大
,需要花较长的时间才能恢复;

2、每个应用节点都会维护自己的单独缓存,
缓存同步比较头疼

4 分布式缓存

分布式缓存是指将缓存数据分布在多台机器上,以提高缓存容量和并发读写能力的缓存系统。分布式缓存通常由多台机器组成一个集群,每台机器上都运行着相同的缓存服务进程,缓存数据被均匀地分布在集群中的各个节点上。

Redis 是分布式缓存的首选,甚至我们一提到缓存,很多后端工程师首先想到的就它。

下图是神州专车订单的 Redis 集群架构 。将 Redis 集群拆分成四个分片,每个分片包含一主一从,主从可以切换。 应用 A 根据不同的缓存 key 访问不同的分片。

与本地缓存相比,分布式缓存具有以下优点:

1、容量和性能可扩展

通过增加集群中的机器数量,可以扩展缓存的容量和并发读写能力。同时,缓存数据对于应用来讲都是共享的。

2、高可用性

由于数据被分布在多台机器上,即使其中一台机器故障,缓存服务也能继续提供服务。

但是分布式缓存的缺点同样不容忽视。

1、网络延迟

分布式缓存通常需要通过网络通信来进行数据读写,可能会出现网络延迟等问题,相对于本地缓存而言,响应时间更长。

2、复杂性

分布式缓存需要考虑序列化、数据分片、缓存大小等问题,相对于本地缓存而言更加复杂。

笔者曾经也认为无脑上缓存 ,系统就一定更快,但直到一次事故,对于分布式缓存的观念才彻底改变。

2014年,同事开发了比分直播的系统,所有的请求都是从分布式缓存 Memcached 中获取后直接响应。常规情况下,从缓存中查询数据非常快,但在线用户稍微多一点,整个系统就会特别卡。

通过 jstat 命令发现 GC 频率极高,几次请求就将新生代占满了,而且 CPU 的消耗都在 GC 线程上。初步判断是缓存值过大导致的,果不其然,缓存大小在 300k 到 500k 左右。

解决过程还比较波折,分为两个步骤:

  1. 修改新生代大小
    ,从原来的 2G 修改成 4G,并精简缓存数据大小 (从平均 300k 左右降为 80k 左右);

  2. 缓存拆成两个部分
    ,第一部分是
    全量数据
    ,第二部分是
    增量数据
    (数据量很小)。页面第一次请求拉取全量数据,当比分有变化的时候,通过 websocket 推送增量数据。

经过这次优化,笔者理解到:缓存虽然可以提升整体速度,但是在高并发场景下,缓存对象大小依然是需要关注的点,稍不留神就会产生事故。另外我们也需要合理地控制读取策略,最大程度减少 GC 的频率 , 从而提升整体性能。

5 多级缓存

开源中国网站最开始完全是用本地缓存框架 Ehcache 。

后来随着访问量的激增,出现了一个可怕的问题:“因为 Java 程序更新很频繁,每次更新的时候都要重启。一旦重启后,整个 Ehcache 缓存里的数据都被清掉。重启后若大量访问进来的话,开源中国的数据库基本上很快就会崩掉”。

于是,开源中国开发了多级缓存框架
J2Cache
,使用了多级缓存
Ehcache + Redis

多级缓存有如下优势:

  1. 离用户越近,速度越快;
  2. 减少分布式缓存查询频率,降低序列化和反序列化的 CPU 消耗;
  3. 大幅度减少网络 IO 以及带宽消耗。

本地缓存做为一级缓存,分布式缓存做为二级缓存,首先从一级缓存中查询,若能查询到数据则直接返回,否则从二级缓存中查询,若二级缓存中可以查询到数据,则回填到一级缓存中,并返回数据。若二级缓存也查询不到,则从数据源中查询,将结果分别回填到一级缓存,二级缓存中。

2018年,笔者服务的一家电商公司需要进行 app 首页接口的性能优化。笔者花了大概两天的时间完成了整个方案,采取的是两级缓存模式,同时利用了 Guava 的惰性加载机制,整体架构如下图所示:

缓存读取流程如下:

1、业务网关刚启动时,本地缓存没有数据,读取 Redis 缓存,如果 Redis 缓存也没数据,则通过 RPC 调用导购服务读取数据,然后再将数据写入本地缓存和 Redis 中;若 Redis 缓存不为空,则将缓存数据写入本地缓存中。

2、由于步骤1已经对本地缓存预热,后续请求直接读取本地缓存,返回给用户端。

3、Guava 配置了 refresh 机制,每隔一段时间会调用自定义 LoadingCache 线程池(5个最大线程,5个核心线程)去导购服务同步数据到本地缓存和 Redis 中。

优化后,性能表现很好,平均耗时在 5ms 左右。最开始我以为出现问题的几率很小,可是有一天晚上,突然发现 app 端首页显示的数据时而相同,时而不同。

也就是说: 虽然 LoadingCache 线程一直在调用接口更新缓存信息,但是各个 服务器本地缓存中的数据并非完成一致。 说明了两个很重要的点:

1、惰性加载仍然可能造成多台机器的数据不一致

2、LoadingCache 线程池数量配置的不太合理, 导致了线程堆积

最终,我们的解决方案是:

1、
惰性加载结合消息机制来更新缓存数据
,也就是:当导购服务的配置发生变化时,通知业务网关重新拉取数据,更新缓存。

2、适当调大 LoadigCache 的线程池参数,并在线程池埋点,监控线程池的使用情况,当线程繁忙时能发出告警,然后
动态修改线程池参数

6 没有银弹

没有银弹
是 Fred Brooks 在 1987 年所发表的一篇关于软件工程的经典论文。

论文强调真正的银弹并不存在,而所谓的银弹则是指没有任何一项技术或方法可以能让软件工程的生产力在十年内提高十倍。

通俗来讲:
在技术领域中没有一种通用的解决方案可以解决所有问题

技术本质上是为了解决问题而存在的,每个问题都有其独特的环境和限制条件,没有一种通用的技术或工具可以完美地解决所有问题。

虽然技术不断发展和进步,但是对于复杂的问题,仍需要结合多种技术和方法,进行系统性的思考和综合性的解决方案设计,才能得到最优解决方案。

回到文章开头的问题 ,如何说服技术老大用 Redis ?

假如应用就是一个单体应用,缓存可以不共享,通过定时任务刷新缓存对业务没有影响,而且本地内存可以 Hold 住缓存的对象大小,那么你的技术老大的方案没有问题。

假如应用业务比较复杂,需要使用缓存提升系统的性能,同时分布式缓存共享的特性对于研发来讲开发更加快捷,Redis 确实是个不错的选择,可以从研发成本、代码维护、人力模型等多个角度和技术老大提出自己的观点。

总而言之,
在技术领域中,没有银弹
。我们需要不断探索和研究新的技术,但同时也需要认识到技术的局限性,不盲目追求所谓的“银弹”,而是结合具体问题和需求,选择最适合的解决方案。


如果我的文章对你有所帮助,还请帮忙
点赞、在看、转发
一下,你的支持会激励我输出更高质量的文章,非常感谢!

image

我是一个容易焦虑的人,工作时候想着跳槽,辞职休息时候想着工作,休息久了又觉得自己每天在虚度光阴毫无意义,似乎陷入了一个自我怀疑自我焦虑的死循环了。我想我该做的点什么去跳出这个循环。。。

自我叙述

我相信,每个人都有一个自命不凡的梦,总觉得自己应该和别人不一样,我不可能如此普通,自己的一生不应该泯然众生,平凡平庸的度过。尤其是干我们it这一行业的,都有一个自己的程序员梦,梦想着,真的能够用
“代码改变世界”

入行回顾

你们还记得自己是什么时候,入行it行业的吗

我今年已经
28
岁了,想起来入行,还挺久远的,应该是2016入行的,我也算是半路出家的,中间有过
武术梦
歌唱梦
但是电脑什么上学那会就喜欢玩,当然是指游戏,

武术梦

来讲讲我得第一个·梦,武术梦,可能是从小受到武打演员动作电视剧的影响,尤其那个时候,成龙大哥的电影,一直再放,我觉得学武术是很酷的一件事情,尤其那会上小学,还是初中我的体育还是非常好的,


然后我们家那个时候电视还是黑白的,电视机。哈哈哈

本文示例代码已上传至我的
Github
仓库
https://github.com/CNFeffery/DataScienceStudyNotes

1 简介


大家好我是费老师,昨天晚上
geopandas
正式发布了其
0.13.0
版本,距离其上一个版本更新已经过去了5个多月,在这一次更新中除了日常的bug修复以外,还为我们带来了多项新功能,今天的文章中,费老师我就将带大家一起学习其中主要的更新内容

痛点

前端测试

在进行前端页面开发或者测试的时候,我们会遇到这一类场景:

  1. 在开发阶段,前端想通过调用真实的接口返回响应
  2. 在开发或者生产阶段需要验证前端页面的一些 异常场景 或者 临界值 时
  3. 在测试阶段,想直接通过修改接口响应来验证前端页面是否正常
  4. 想验证后端服务响应比较慢的情况下,验证前端页面是否正常(模拟接口超时或者模拟接口响应的时间的场景)
  5. 想更新请求头,请求体或者请求参数达到测试的目的

后端测试

在后端开发或者测试的过程中,我们可能会遇到这些场景:

  1. 依赖方接口不稳定或者造数困难

曾使用的应对措施

根据以上的痛点,我也做过一些探索。

Charles

在验证前端页面的时候,我曾使用抓包软件Charles在捕捉流量的时候,设置断点(类似debug功能),并手动去调整接口的请求体或者响应体。
工作原理:
Charles 断点的实现原理是在客户端和服务器之间插入一个代理服务器,拦截并监视网络流量。当我们设置断点时,Charles 会在代理服务器上暂停请求或响应,直到我们决定继续或取消请求或响应。

  • 具体来说,Charles 会在代理服务器上拦截请求或响应,并根据用户设置的规则进行处理。当 Charles 检测到一个请求或响应匹配了一个断点规则时,它会暂停请求或响应,并在 Charles 的 UI 中弹出一个对话框,让用户选择是否继续执行请求或响应。
  • 在实现过程中,Charles 使用了代理服务器、Socket 和线程等技术。当我们启动 Charles 时,它会在本地计算机上开启一个代理服务器,并将其配置为系统默认的代理服务器。然后,当我们发送一个请求时,Charles 会将该请求发送到代理服务器上,并在代理服务器上等待响应。当代理服务器收到响应后,Charles 会在 UI 中显示响应,并等待用户决定是否继续执行请求或响应。
  • 需要注意的是,由于 Charles 会拦截网络流量并在代理服务器上处理请求和响应,因此它可能会对网络速度和性能造成一定的影响。此外,在设置断点时,我们应该仔细考虑断点规则的范围和条件,避免无意中影响其他请求和响应。

Chrome 插件

通过使用Chrome插件(一般使用Chrome浏览器:如
g0ngjie/ajax-proxy
),设定一些规则来拦截接口,更改接口的响应。或者对请求进行重定向。但此方法仅局限在Chrome浏览器中使用。
工作原理:
这个工具是一个基于 Node.js 和 Express 框架的 AJAX 代理服务器,可以将 AJAX 请求发送到其他域名下的 API 接口,并将响应返回给客户端。其实现原理如下:

  1. 客户端向 AJAX 代理服务器发送 AJAX 请求,请求包含目标 API 的 URL 以及其他参数。
  2. 代理服务器接收到请求后,解析出目标 API 的 URL 和参数,并将其转发到目标 API 的服务器。
  3. 目标 API 服务器接收到请求后,处理请求并返回响应。
  4. 代理服务器接收到响应后,将响应转发给客户端。

在这个过程中,代理服务器可以对请求和响应进行一些处理,例如修改请求头、添加身份认证信息、对响应结果进行过滤等。这些处理可以在服务器端通过编写中间件来实现。

实现mock工具

实现一款mock工具,设计方案大致如下:

  • 对于后端而言
    • 代码层面的域名改为mock服务器,不能指定请求,会将所有的请求都转发到mock server(若没有匹配的规则,mock server无法进行处理)。
    • 代码层面的域名不变,在服务器中配置代理,将请求转发至mock server,在mock server设置拦截规则,未匹配规则的请求返回自定义的静态响应或者动态响应。未命中规则的请求,将转发至真实的服务器。
  • 对于前端而言
    • 在网关层面,将指定的请求转发至mock server,但这个无法通过mock server平台控制。
    • 本地代理:本地启动代理,将请求转发到mock server,在mock server设置拦截规则,未匹配规则的请求返回自定义的静态响应或者动态响应。未命中规则的请求,将转发至真实的服务器。

Requestly

其实Requestly的工作原理和ajax-proxy 浏览器插件的原理差不多。
Requestly 是一个请求修改工具,可以帮助用户在浏览器中修改网络请求,从而达到一些调试、测试、模拟等目的。其工作原理如下:

  1. 用户在 Requestly 中创建规则,规则包含了要修改的请求 URL、请求头、请求参数等信息,以及要执行的操作,如重定向、修改响应等。
  2. 当用户访问网站时,Requestly 会拦截浏览器发出的请求,并根据规则对请求进行修改。修改后的请求会被发送到服务器。
  3. 服务器处理修改后的请求,并将响应返回给浏览器。
  4. Requestly 接收到响应后,根据规则对响应进行修改,然后将修改后的响应返回给浏览器。

在这个过程中,Requestly 可以对请求和响应进行多种类型的修改,包括重定向、添加请求头、修改请求参数、模拟网络请求等。这些修改可以帮助用户快速定位和解决问题,加速开发和测试过程。
除了使用浏览器插件的方式抓紧请求外,还可以通过启动本地代理的方式来拦截任何请求。

这款工具大大提升了我的工作效率,在前端页面的开发以及测试来说,就是一款网络调试神器!

Requestly的功能

requestly主要有以下的功能:

  • 网络请求与响应的拦截
  • 网络请求与响应的修改

image.png

  • api Mock and file mock
  • Android Debugger

流量捕捉方式

1.浏览器插件
2.本地代理:通过启动本地代理来捕获各个端的流量(与Charles类似)

  • 捕捉浏览器的网络请求
  • 捕捉app的网络请求
  • 捕捉模拟器的网路请求
  • 捕捉终端的网络请求
  • 捕捉其他地方的网络请求

请求的修改

Cancel Request

可以选择要取消的特定网络请求,以便在进行调试和测试时排除干扰。
image.png

Redirect Request

将特定的网络请求重定向到其他url。

  • 重定向到mock。可以在mock server进行配置。
  • 重定向到其他url。如下规则:访问www.baidu.com时,会被重定向到www.qq.com

image.png

Delay network requests

模拟接口延时。通过使用URL匹配或者host匹配或者path匹配来设置规则(支持正则匹配),命中规则的接口将会被requestly捕捉,并在延迟的时间后返回响应
image.png

Modify Request Url(Replace string)

  • 更新请求路径: 比如说,我们将v1接口升级到v2,想在上线前对v2接口做下验证。我们可以选择使用流量回放功能进行验证,也可以使用该功能进行验证。将v1请求的接口更新为v2接口,并做相关的验证。

image.png

Query Params

修改或者删除请求的查询参数
image.png

Modify Headers

  • 支持添加自定义的请求头
  • 修改或者删除原有的请求头
  • 支持添加自定义的响应头
  • 支持修改或者删除原有的响应头

image.png

Modify User Agent

  • 更改请求头的用户代理

用户代理在网络通信中起着非常重要的作用,它能够告诉服务器请求的来源和请求的方式。这些信息可以帮助服务器更好地处理请求,提供更好的服务和用户体验。
我们使用chrome浏览器验证功能的时候,可以匹配的域名的用户代理改成对应的代理。
image.png

Modify Api Request

  • static data(静态数据)

提供json格式的数据,并直接替换请求的请求体

  • Dynamic (JavaScript)

通过JS修改请求中的请求体,并替换原有的请求体。使用频率最高的功能是:通过对bodyJson进行调整,并重新返回bodyAsJson,达到修改请求体的目的。

function modifyRequestBody(args) {
  const { method, url, body, bodyAsJson } = args;
  // Change request body below depending upon request attributes received in args
  //可以对bodyJson进行调整,并重新返回bodyAsJson
  
  return body;
}

响应的修改

Modify Headers

  • 支持添加自定义的请求头
  • 修改或者删除原有的请求头
  • 支持添加自定义的响应头
  • 支持修改或者删除原有的响应头

Modify Api Response

支持api类型:REST API与GraphQL API

  • static data(静态数据)

提供json格式的数据,并直接将该数据返回给前端

  • Dynamic (JavaScript)

修改真实服务器返回的响应并返回给前端

function modifyResponse(args) {
  const {method, url, response, responseType, requestHeaders, requestData, responseJSON} = args;
  // Change response below depending upon request attributes received in args
  const mock_res = JSON.parse(response)
  console.log('mock_res itemList:',mock_res.data.itemList)
  itemList = mock_res.data.itemList
  if (itemList!==undefined && itemList.length !== 0){
    for(var i=0; i<itemList.length;i++){
      itemList[i].productName = itemList[i].itemId
      itemList[i].status = 1
      itemList[i].statusName = Math.random() < 0.5 ? "DELETE" : "BANNED";

      itemList[i].timeFormatted = new Date().toLocaleString().replaceAll('/','-');
      itemList[i].time = Date.now().toString();

    }
  }
  // mock_res.data.itemList = itemList
  return mock_res;
}


请求响应录制

Requestly Sessions 是一种可用于捕获和共享网络请求的功能。这个功能可以记录和存储浏览器请求以及对应的响应,并在需要时重新发送这些请求。这意味着我们可以跨不同浏览器会话和不同设备之间以及与其他用户共享这些请求。这个功能特别适用于需要经常复制和粘贴相同请求的开发人员、测试人员和网络爬虫等。

  • 比如说,我们在开发联调或者测试的过程中,发现某些问题,可以通过录制请求并共享给其他开发人员,方便他们排查问题。

image.png

我对Requestly的应用

1.验证前端在各种状态的展示:通过修改接口的返回响应来模拟各个场景。(当时,你也可以通过在db层面造数来实现接口响应的正确放回:如直接改db或者写造数脚本,这个不在我们今天讨论的范围内。)
我们可以使用
Dynamic (JavaScript)
来调整响应。可以针对不同的场景自定义不同的json字符串,也可以在原有的响应的基础来进行调整。

  • 如果该场景的验证只涉及单个接口,我们可以套用一下代码模板:我们定义一个status变量,在验证某个场景的时候,就将status设置为某个场景的值,然后在if..else中设置对应的响应。
function modifyResponse(args) {
  const {method, url, response, responseType, requestHeaders, requestData, responseJSON} = args;
  // Change response below depending upon request attributes received in args
  var status=0
  if(status === 0 ){
    console.log("json更新为status=0场景")
  }else if(status ===1 ){
    console.log("json更新为status=1场景")
  }
  return response;
}
  • 如果该场景的验证涉及多个接口,我们可以结合Modify Header Request 与 Dynamic (JavaScript)进行调整。思路如下:为相关接口的请求头新增一个自定义的请求头参数,如statusHeader。然后在处理接口响应的时候,先读取statusHeader的值,再根据statusHeader的值返回相关的响应

2.模拟接口延迟,验证前端页面的处理

3.捕捉各个端的网络请求

  • 捕捉浏览器的网络请求
  • 捕捉模拟器的网路请求
  • 捕捉终端的网络请求

总结

总结一下Requestlty常用的功能:

  1. 修改网络请求:Requestly 可以帮助您修改 HTTP 请求和响应。例如,您可以使用 Requestly 修改请求参数、请求头、请求体或响应头、响应体,以便更好地测试和调试应用程序。
  2. 模拟网络请求:Requestly 可以帮助您模拟网络请求。例如,您可以使用 Requestly 模拟 AJAX 请求、模拟响应超时或模拟 HTTP 状态码,以便测试应用程序的稳定性和性能。
  3. 代理服务器:Requestly 可以帮助您配置代理服务器,以便更好地控制网络请求。例如,您可以使用 Requestly 配置代理服务器以拦截和修改网络请求,以便更好地测试和调试应用程序。

总之,Requestly 是一款非常强大和有用的调试和测试工具,可以帮助开发人员和测试人员更好地测试和调试应用程序。它具有丰富的功能和灵活的配置选项,可以满足不同的测试需求。

深入理解python虚拟机:黑科技的幕后英雄——描述器

在本篇文章当中主要给大家介绍一个我们在使用类的时候经常使用但是却很少在意的黑科技——描述器,在本篇文章当中主要分析描述器的原理,以及介绍使用描述器实现属性访问控制和 orm 映射等等功能!在后面的文章当中我们将继续去分析描述器的实现原理。

描述器的基本用法

描述器是一个实现了
__get__

__set__

__delete__
中至少一个方法的 Python 类。这些方法分别用于在属性被访问、设置或删除时调用。当一个描述器被定义为一个类的属性时,它可以控制该属性的访问、修改和删除。

下面是一个示例,演示了如何定义一个简单的描述器:

class Descriptor:
    def __get__(self, instance, owner):
        print(f"Getting {self.__class__.__name__}")
        return instance.__dict__.get(self.attrname)

    def __set__(self, instance, value):
        print(f"Setting {self.__class__.__name__}")
        instance.__dict__[self.attrname] = value

    def __delete__(self, instance):
        print(f"Deleting {self.__class__.__name__}")
        del instance.__dict__[self.attrname]

    def __set_name__(self, owner, name):
        self.attrname = name

在这个例子中,我们定义了一个名为 Descriptor 的描述器类,它有三个方法:
__get__

__set__

__delete__
。当我们在另一个类中使用这个描述器时,这些方法将被调用,以控制该类的属性的访问和修改。

要使用这个描述器,我们可以在另一个类中将其定义为一个类属性:

class MyClass:
    x = Descriptor()

现在,我们可以创建一个 MyClass 对象并访问其属性:

>>> obj = MyClass()
>>> obj.x = 1
Setting Descriptor
>>> obj.x
Getting Descriptor
1
>>> del obj.x
Deleting Descriptor
>>> obj.x
Getting Descriptor

在这个例子中,我们首先创建了一个 MyClass 对象,并将其 x 属性设置为 1。然后,我们再次访问 x 属性时,会调用
__get__
方法并返回 1。最后,我们删除了 x 属性,并再次访问它时,会调用
__get__
方法并返回 None。从上面的输出结果可以看到对应的方法都被调用了,这是符合上面对描述器的定义的。如果一个类对象不是描述器,那么在使用对应的属性的时候是不会调用
__get__

__set__

__delete__
三个方法的。比如下面的代码:

class NonDescriptor(object):
    pass


class MyClass():

    nd = NonDescriptor()


if __name__ == '__main__':
    a = MyClass()
    print(a.nd)

上面的代码输出结果如下所示:

<__main__.NonDescriptor object at 0x1012cce20>

从上面程序的输出结果可以知道,当使用一个非描述器的类属性的时候是不会调用对应的方法的,而是直接得到对应的对象。

描述器的实现原理

描述器的实现原理可以用以下三个步骤来概括:

  • 当一个类的属性被访问时,Python 解释器会检查该属性是否是一个描述器。如果是,它会调用描述器的
    __get__
    方法,并将该类的实例作为第一个参数,该实例所属的类作为第二个参数,并将属性名称作为第三个参数传递给
    __get__
    方法。

  • 当一个类的属性被设置时,Python 解释器会检查该属性是否是一个描述器。如果是,它会调用描述器的
    __set__
    方法,并将该类的实例作为第一个参数,设置的值作为第二个参数,并将属性名称作为第三个参数传递给
    __set__
    方法。

  • 当一个类的属性被删除时,Python 解释器会检查该属性是否是一个描述器。如果是,它会调用描述器的
    __delete__
    方法,并将该类的实例作为第一个参数和属性名称作为第二个参数传递给
    __delete__
    方法。

在描述器的实现中,通常还会使用
__set_name__
方法来在描述器被绑定到类属性时设置属性名称。这使得描述器可以在被多个属性使用时,正确地识别每个属性的名称。

现在来仔细了解一下上面的几个函数的参数,我们以下面的代码为例子进行说明:


class Descriptor(object):

    def __set_name__(self, obj_type, attr_name):
        print(f"__set_name__ : {obj_type } {attr_name = }")
        return "__set_name__"

    def __get__(self, obj, obj_type):
        print(f"__get__ : {obj = } { obj_type = }")
        return "__get__"

    def __set__(self, instance, value):
        print(f"__set__ : {instance = } {value = }")
        return "__set__"

    def __delete__(self, obj):
        print(f"__delete__ : {obj = }")
        return "__delete__"


class MyClass(object):

    des = Descriptor()


if __name__ == '__main__':
    a = MyClass()
    _ = MyClass.des
    _ = a.des
    a.des = "hello"
    del a.des

上面的代码输入结果如下所示:

__set_name__ : <class '__main__.MyClass'> attr_name = 'des'
__get__ : obj = None  obj_type = <class '__main__.MyClass'>
__get__ : obj = <__main__.MyClass object at 0x1054abeb0>  obj_type = <class '__main__.MyClass'>
__set__ : instance = <__main__.MyClass object at 0x1054abeb0> value = 'hello'
__delete__ : obj = <__main__.MyClass object at 0x1054abeb0>
  • __set_name__
    这个函数一共有两个参数传入的参数第一个参数是使用描述器的类,第二个参数是使用这个描述器的类当中使用的属性名字,在上面的例子当中就是 "des" 。
  • __get__
    ,这个函数主要有两个参数,一个是使用属性的对象,另外一个是对象的类型,如果是直接使用类名使用属性的话,obj 就是 None,比如上面的 MyClass.des 。
  • __set__
    ,这个函数主要有两个参数一个是对象,另外一个是需要设置的值。
  • __delete__
    ,这函数有一个参数,就是传入的对象,比如 del a.des 传入的就是对象 a 。

描述器的应用场景

描述器在 Python 中有很多应用场景。以下是其中的一些示例:

实现属性访问控制

通过使用描述器,可以实现对类属性的访问控制,例如只读属性、只写属性、只读/只写属性等。通过在
__get__

__set__
方法中添加相应的访问控制逻辑,可以限制对类属性的访问和修改。

class ReadOnly:
    def __init__(self, value):
        self._value = value
    
    def __get__(self, instance, owner):
        return self._value
    
    def __set__(self, instance, value):
        raise AttributeError("Read only attribute")
        
class MyClass:
    read_only_prop = ReadOnly(42)
    writeable_prop = None
    
my_obj = MyClass()
print(my_obj.read_only_prop)  # 42
my_obj.writeable_prop = "hello"
print(my_obj.writeable_prop)  # hello
my_obj.read_only_prop = 100  # raises AttributeError

在上面的例子中,
ReadOnly
描述器只实现了
__get__
方法,而
__set__
方法则抛出了
AttributeError
异常,从而实现了只读属性的访问控制。

实现数据验证和转换

描述器还可以用于实现数据验证和转换逻辑。通过在
__set__
方法中添加数据验证和转换逻辑,可以确保设置的值符合某些特定的要求。例如,可以使用描述器来确保设置的值是整数、在某个范围内、符合某个正则表达式等。

class Bounded:
    def __init__(self, low, high):
        self._low = low
        self._high = high
    
    def __get__(self, instance, owner):
        return self._value
    
    def __set__(self, instance, value):
        if not self._low <= value <= self._high:
            raise ValueError(f"Value must be between {self._low} and {self._high}")
        self._value = value

class MyClass:
    bounded_prop = Bounded(0, 100)

my_obj = MyClass()
my_obj.bounded_prop = 50
print(my_obj.bounded_prop)  # 50
my_obj.bounded_prop = 200  # raises ValueError

在上面的例子中,
Bounded
描述器在
__set__
方法中进行了数值范围的检查,如果值不在指定范围内,则抛出了
ValueError
异常。

实现延迟加载和缓存

描述器还可以用于实现延迟加载和缓存逻辑。通过在
__get__
方法中添加逻辑,可以实现属性的延迟加载,即当属性第一次被访问时才进行加载。此外,还可以使用描述器来实现缓存逻辑,以避免重复计算。

class LazyLoad:
    def __init__(self, func):
        self._func = func

    def __get__(self, instance, owner):
        if instance is None:
            return self
        value = self._func(instance)
        setattr(instance, self._func.__name__, value)
        return value


class MyClass:
    def __init__(self):
        self._expensive_data = None

    @LazyLoad
    def expensive_data(self):
        print("Calculating expensive data...")
        self._expensive_data = [i ** 2 for i in range(10)]
        return self._expensive_data


my_obj = MyClass()
print(my_obj.expensive_data)  # Calculating expensive data... 
print(my_obj.expensive_data)

上面的程序的输出结果如下所示:

Calculating expensive data...
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

从上面的结果可以看到,只有在第一次使用属性的时候才调用函数,后续再次调用函数将不会再调用函数而是直接返回缓存的结果。

实现 ORM 映射

ORM 的主要作用是把数据库中的关系数据转化为面向对象的数据,让开发者可以通过编写面向对象的代码来操作数据库。ORM 技术可以把面向对象的编程语言和关系数据库之间的映射关系抽象出来,开发者可以不用写 SQL 语句,而是直接使用面向对象的语法进行数据库操作。

我们现在需要实现一个功能,user.name 直接从数据库的 user 表当中查询 name 等于 user.name 的数据,user.name = "xxx" 根据 user 的主键 id 进行更新数据。这个功能我们就可以使用描述器实现,因为只需要了解如何使用描述器的,因此在下面的代码当中并没有连接数据库:

conn = dict()


class Field:

    def __set_name__(self, owner, name):
        self.fetch = f'SELECT {name} FROM {owner.table} WHERE {owner.key}=?;'
        print(f"{self.fetch = }")
        self.store = f'UPDATE {owner.table} SET {name}=? WHERE {owner.key}=?;'
        print(f"{self.store = }")

    def __get__(self, obj, objtype=None):
        return conn.execute(self.fetch, [obj.key]).fetchone()[0]

    def __set__(self, obj, value):
        conn.execute(self.store, [value, obj.key])
        conn.commit()


class User:
    table = 'User'                    # Table name
    key = 'id'                       # Primary key
    name = Field()
    age = Field()

    def __init__(self, key):
        self.key = key


if __name__ == '__main__':
    u = User("Bob")

上面的程序输出结果如下所示:

self.fetch = 'SELECT name FROM User WHERE id=?;'
self.store = 'UPDATE User SET name=? WHERE id=?;'
self.fetch = 'SELECT age FROM User WHERE id=?;'
self.store = 'UPDATE User SET age=? WHERE id=?;

从上面的输出结果我们可以看到针对 name 和 age 两个字段的查询和更新语句确实生成了,当我们调用 u.name = xxx 或者 u.age = xxx 的时候就执行
__set__
函数,就会连接数据库进行相应的操作了。

总结

在本篇文章当中主要给大家介绍了什么是描述器以及我们能够使用描述器来实现什么样的功能,事实上 python 是一个比较随意的语言,因此我们可以利用很多有意思的语法做出黑多黑科技。python 语言本身也利用描述器实现了很多有意思的功能,比如 property、staticmethod 等等,这些内容我们在后面的文章当中再进行分析。


本篇文章是深入理解 python 虚拟机系列文章之一,文章地址:
https://github.com/Chang-LeHung/dive-into-cpython

更多精彩内容合集可访问项目:
https://github.com/Chang-LeHung/CSCore

关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识。