21. 缓存上下文CacheContext

“上下文Context”这个词是什么意思呢?平常生活中它常见于语言、文字交流里面,意思是当前交流处于一个特定的环境下,依托前面的内容交流才有意义

比如这句话:“他正在学习drupal”,如果单独说是没有意义的,因为你不知道“他”指代谁,在交流中前面一定定义清楚了“他”是谁,这个“他”就是上下文,这个谁就是上下文的值

在软件工程中,上下文是一种属性的有序序列,它们为驻留在环境内的对象定义环境。不过你无需去理会这样晦涩的定义,只需要知道“上下文”相当于“环境”就行了,它们是等价的。

假设将来能制造出真正的类人智能机器人,那么把它投放到社会中,激活那一刻,他第一件事情就是侦查环境,换句话说就是搞清楚自己所在的上下文,然后他才能有所行动

可见上下文概念是如此重要,在脑子里面建立一个印象:有目的的行为是建立在环境之上的,万事万物皆是如此

drupal中上下文就是指当请求到来时,系统所处的工作环境,这个环境由请求和系统设置共同构成,系统首先要搞清楚环境(上下文)才知道自己该怎么行动(正应前文所讲)。

那么缓存上下文CacheContext呢,就是指相对于缓存系统的环境(缓存环境是系统环境的子集),缓存系统依据此环境才能正确行动,具体实现就是缓存依据这个上下文来存放或取回正确的数据。

在缓存系统中,相对于缓存标签代表缓存有效性而言,缓存上下文代表数据的变体,同一份数据不同环境有不同变体,可以说缓存上下文主要目的就是产生缓存id。

缓存上下文决定了缓存id,也就是Cid,它唯一标识一条缓存,用它取回和设置一条缓存,关于缓存系统的介绍请看本系列前面的主题。

下面我们来看一看缓存上下文怎么使用,以及系统是如何实现的。

缓存上下文的用法:

缓存上下文的值是一个字符串数组,字符串是特定的,代表一种上下文,这个上下文用一个上下文对象实现,以容器的服务形式存在,加上“cache_context.”前缀就是它对应的容器服务id。

这个字符串经常被叫做“token”、上下文id、缓存上下文占位符等。

本质上讲它是某一环境参数的标识符,在计算Cid(缓存id)时将通过对应的上下文服务对象得到具体的环境值。

一个简单的例子,比如在控制器返回的渲染数组可以这样指定缓存属性:

$build = [  
  '#markup' => t('Hi, %name, welcome back!', ['%name' => $current_user->getUsername()]),  
  '#cache' => [  
    'keys' => [...],  
    'contexts' => ['user'],  
    'tags' => [...],  
    'max-age' => -1  
     ]  
];  

这里的'user'就是缓存上下文id,可以有多个上下文id,它们组成一个上下文数组,系统默认提供了以下上下文id:(见辅助内容区域)

Array  
 (  
     [0] => cookies  
     [1] => headers  
     [2] => ip  
     [3] => languages  
     [4] => request_format  
     [5] => route  
     [6] => route.menu_active_trails  
     [7] => route.name  
     [8] => session  
     [9] => session.exists  
     [10] => theme  
     [11] => timezone  
     [12] => url  
     [13] => url.path  
     [14] => url.path.parent  
     [15] => url.query_args  
     [16] => url.query_args.pagers  
     [17] => url.site  
     [18] => user  
     [19] => user.is_super_user  
     [20] => user.node_grants  
     [21] => user.permissions  
     [22] => user.roles  
 )  

 

这些上下文id都有对应的上下文对象,加上“cache_context.”前缀就是这些对象的容器服务id,下面我们来看一下缓存上下文的具体实现:

通过理解具体实现能够掌握更多高级用法

处理缓存上下文的代码位于:\core\lib\Drupal\Core\Cache\Context

每种类型的缓存上下文系统都定义了一个缓存上下文对象来处理,这些对象具备共同特性,其被归纳在缓存上下文接口中:Drupal\Core\Cache\Context\CacheContextInterface

这个接口很简单,定义了以下三方法:

public static function getLabel(); 得到缓存上下文标签,这个标签用于描述缓存上下文,显示给人类看

public function getContext(); 将缓存上下文id转换为缓存上下文值,比如指定了缓存上下文id为“ip”,那么这个方法将返回具体的客户端ip

public function getCacheableMetadata(); 返回可缓存元数据,定义这个方法是为了配合缓存上下文优化(见下文)

这里有一个问题,请思考:我们知道一个缓存上下文id指代一种环境参数,体现了这种环境参数的改变带来的数据变体改变,然而环境参数是非常非常多的,比如请求头、cookie他们都包含很多子条目

每个子条目都可以是一个上下文id,那么我们岂不是要定义非常多的缓存上下文对象?但我们发现这样的上下文有一个共同特点:这些细碎繁多的上下文它们都是同性质的一个大类的子类

具体说就是所有的请求头条目都是请求头,所有的cookie变量都是cookie

为了解决这个问题,引入了计算型缓存上下文接口Drupal\Core\Cache\Context\CalculatedCacheContextInterface

CacheContextInterface相比,不同之处仅是他们的方法多了一个参数,用参数指定一个大类的具体小类,这样大大减少了缓存上下文对象的数量

因为实现这个接口的缓存上下文对象有许多小类,所以约定这样的缓存上下文以复数形式命名上下文id

使用这样的缓存上下文时可以传递参数,上下文id和参数间用冒号分割,这些参数代表这一类上下文的某一具体缓存上下文,不指定参数代表全部子类,相当于指定了全部参数

笔者注:在写这篇帖子时(版本8.2.3),缓存上下文idsession 类:Drupal\Core\Cache\Context\SessionCacheContext没有实现接口,已向官网报告bug

如何自定义一个缓存上下文呢?

定义一个服务,它实现了以上两种接口之一,服务id为:cache_context.context_id,这里“cache_context.”是系统要求的强制前缀,“context_id”就是要使用的上下文id

定义好服务后给出“cache.context”标签,下面是一个列子:

  cache_context.ip:
    class: Drupal\Core\Cache\Context\IpCacheContext
    arguments: ['@request_stack']
    tags:
      - { name: cache.context }

然后重新编译容器即可(清除缓存),编译器:\core\lib\Drupal\Core\Cache\Context\CacheContextsPass将在容器编译阶段收集处理这些缓存上下文,并将它们保存到容器参数cache_contexts

 

系统提供了一个叫做缓存上下文管理器的对象以方便使用,服务id为“cache_contexts_manager”,类:Drupal\Core\Cache\Context\CacheContextsManager

它的主要作用是接受缓存上下文定义参数然后把它转化为缓存idconvertTokensToKeys(array $context_tokens)

这个方法返回缓存id的时候,并不是一个字符串,而是Drupal\Core\Cache\Context\ContextCacheKeys对象,为什么要这样处理?因为缓存上下文优化,见下。

 

缓存上下文管理器同时也提供了一些其他辅助方法,比较简单,不多讲,重点是缓存上下文优化

缓存上下文优化

你可能已经注意到了有些缓存id包含句点,这可能代表什么吗?其实缓存上下文是分层级的,句点就是层级间的分割,从左到右就是从父到子

为什么要分层级?这是为了起到缓存上下文优化作用,怎么优化呢?背后的原理是什么?优化的目的不是避免产生大量变体,而是避免在产生Cid时进行过多的计算

实际上层级间分级的原则就是父更基础,子更具象

举个例子:如果一个上下文同时指定了useruser.permissions,其实用户的改变也就暗含了权限的改变,父层更加基础,所以这里权限上下文是多余的,它可以被优化掉(也就是去掉了)

然而这样的优化会产生一个问题,当用户权限变化时,会取回之前的缓存,这可能是有问题的,怎么办?这就需要将权限的变化反应到缓存的数据中

因此解决办法是给缓存上下文加上缓存属性,并把这些被优化掉的缓存上下文的缓存数据冒泡到被缓存的数据中,这也就是缓存上下文接口为什么需要有getCacheableMetadata方法的原因。

如上列中user.permissions虽然被优化掉了,但它的缓存标签却不会丢掉,它的缓存属性会传递给被缓存的数据,此时权限变化将引起缓存失效,这样就保证了数据的正确性

这种办法还带来一个额外的好处,就是避免僵尸缓存(永不过期、也不失效,但永不会用到的缓存),想想上面这个例子如果不进行优化,当权限变化是将产生新的Cid,原Cid可能就成为僵尸缓存了

更一般的抽象是:如果一个缓存上下文依赖于某配置,而配置可能改变,导致缓存上下文具体值改变,那么当优化掉该上下文时,必须将它的缓存属性合并(也叫做冒泡)到被缓存的数据

这其实是一种隐式的指定上下文,效果相当于缓存了getContext()的结果,避免去再次计算getContext(),就达到了优化的目的:性能提升!对它的计算已经体现在缓存标签上面了

如果该缓存上下文依赖的配置改变的太快,就可以设置max-age = 0来禁止上下文优化

回过头来,应该明白为什么缓存管理器在计算返回cid时返回的是ContextCacheKeys对象了吧,它承担缓存数据冒泡的工作,这个对象包含了缓存属性,缓存系统将合并它到被缓存数据的缓存属性

 

上下文优化的原则是指定的缓存上下文中,如果同时存在具备共同父级的上下文,将只保留共同父级上下文,冒号视为句点,具体优化转变列子如下:

['a', 'a.b'] -> ['a']

['a', 'a.b.c'] -> ['a']

['a.b', 'a.b.c'] -> ['a.b']

['a', 'a.b', 'a.b.c'] -> ['a']

['x', 'x:foo'] -> ['x']

['a', 'a.b.c:bar'] -> ['a']

优化实现的代码见: Drupal\Core\Cache\Context\CacheContextsManager::optimizeTokens(array $context_tokens)

额外的笔者提醒思考以下问题:

缓存ID碰撞:

有没有可能两份数据出现同一个Cid呢?

其实是有可能的,但系统采取的措施很难遇到这样的情况,首先不同用途的数据已经分别被放在不同缓存Bin里面,即使碰撞也没有关系,再次数据会使用多个缓存上下文的组合,这降低了碰撞概率

每一个缓存上下文对应一个特定环境,同一个特定环境出现两份数据的概率相当低

此外为了进一步防范Cid碰撞,还可以额外指定缓存键,比如渲染数组就采用了这样的措施

 

缓存上下文和标签的区别:

在本文开头大量叙述了上下文,就是为了强调上下文代表的是相对于数据而言外在的环境,而标签代表的是数据自身,有两个关键词“外在”、“自身”特别重要

上文的user.permissions上下文,它被优化掉后其标签虽然和数据标签合并,但它的标签带来的数据失效是在这种环境下数据失效了,而数据自己的标签带来的失效是所有情况下它都失效了

所以这两种标签依然体现了外在环境和自己本身之间的区别,合并只是一种技巧而已。

缓存上下文官网文档:

https://www.drupal.org/docs/8/api/cache-api/cache-contexts

bug报告:

cookiesheaders上下文对象的getContext($parameter = NULL) 方法,参数为空时,返回的是数组,它应该是返回一个字符串,修复办法:返回数组的哈希值

缓存上下文:session 类:Drupal\Core\Cache\Context\SessionCacheContext 没有实现接口CacheContextInterface

题外话:

一直坚持每周出一篇帖子来介绍drupal,这篇帖子恰逢是2016年的最后一篇,时间过的好快,drupal8一岁了,我的两个宝贝,小的也一岁多了,在drupal中和生活里都有好多感慨

为什么要坚持写作呢?

一方面源于希望对中国开源社区有所贡献。现阶段国内人们都很忙,生活压力比较大,少有精力去开源社区免费工作,不像发达国家即使什么也不做至少政府会提供基本生活保障

这导致很难发展出一个好的开源社区,很现实的说国内难有这样的条件,但开源软件有非常多的好处,我们需要足够优秀且开源的软件,预测未来drupal会成为国内网站系统的主流,就像linux一样

一个好的软件不只是技术那么简单,更重要的是生态,生态里面有数量众多愿意贡献的人,不止有开发者还有使用者,这才是关键!万众雕琢才能创造不凡,drupal就是这样的产品,足够优秀,生态足够大。

既然国内难有培养开源的土壤,drupal在全球又做的那么出色,那就参与吧,推广它让大家都受益,但是国内缺乏中文资料,成了学习障碍,与其枯燥的翻译不如原创,写一些能让国人看的懂的文字。

 

另一方面我有一个愿景:当今我国日新月异,创业创新生气勃勃,是一个精彩的世界。人,生来是需要做点事情的,短短一生希望能留下点什么,我有一种情怀,特别向往那些去世后能在墓碑上面留下一行公式的科学家,

人生的目的应该不是为了得到,就算得到离开时也必将放手,留下才有意义,才是真实的,那代表自己存在过,并在自己死去后代表自己一直活着,所以我追求付出,让付出的在自己离开这个世界时得以留下。

能使用drupal开发的工程师水平都比较高,希望在这个过程中结识一批优秀的人才,一起去做点事,推动社会的发展,得以留下一些东西,让生命有所价值,可以通过留下的联系方式让我们走到一起。

 

最后奉上非常喜欢的一段话:

人生中出现的一切,都无法占有,只能经历。我们只是时间的过客,总有一天,我们会和所有的一切永别。

深知这一点的人,就会懂得:无所谓失去,而只是经过而已;亦无所谓得到,那只是体验罢了。

经过的,即使再美好,终究只能是一种记忆;得到的,就该好好珍惜,然后在失去时坦然地告别。

本书共98小节:

评论 (写第一个评论)