-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsearch.json
1 lines (1 loc) · 70.7 KB
/
search.json
1
[{"categories":["技术"],"content":"概述 写博客的已经一个月了,我也逐渐适应了平日收集灵感,周末码字的习惯,Hugo 作为 SSG 工具,本身的构建过程足够快速和简单,但随着博客配置的不断完善,自动化部署和发布的需求逐渐提上了日程。\n持续构建 构建(Build)的概念来源于软件工程,指的是源码提交到代码仓库之后的编译、测试、安装、部署等步骤,通常 Web 应用程序的构建可能包含以下过程:\n 源码编译:编译是最基础的构建,编译器需要知道从何处引入依赖库并按照特定的编译参数编译源码,例如 TypeScript、SCSS 编写的代码需要编译成浏览器可运行的 JavaScript 和 CSS; 资源处理:静态资源文件比如 JavaScript 和 CSS 的压缩; 代码混淆:变量名的混淆处理,常见与前端与移动应用; 签名:计算构件输出文件的摘要并用开发者的证书签名,常见于移动应用; 容器化部署:创建 Docker 映像并在容器环境下启动应用; 这些步骤非常繁琐,因环境的不同而需要不同的配置参数,尤其是编译需要极高的计算力资源,人为地一一操作容易出错且影响效率,所以持续构建的概念应运而生。\n持续指的是通过技术的手段让对应的过程尽可能迅速而自动化的完成,将整个迭代流程推向下一阶段,并且尽早地暴露问题,与此相关的概念还有持续集成、持续部署。\n部署的石器时代 在使用持续部署前,我的博客怎么做上线呢?当我写完了新的文章,或者对错别字和样式做修复后,我会在自己机器上运行命令\nhugo --gc 命令会有如下的输出,表示生成了多少个 ⌘ + ⌥ + ⇧ + ⇪ 静态页面:\n然后提交到版本控制系统,如果这个修改是主题范围内的,还要修改主题的仓库。\n每次的修订和发布涉及到 3 个 Git 仓库的联动:\n 博客源仓库 Hugo 生成的源站,包含 Hugo 配置文件、所有文章的 Markdown 源文件、静态资源、CNAME 文件等。 博客主题仓库 作为源仓库的 submodule,主题作为一个单独的仓库,因为我需要同时跟踪自定义修改和主题作者的迭代。 构建后的静态站点仓库 基于源站由 hugo 命令构建生成,是最终发布的构件。 每次的发布的步骤为:\n 如果主题仓库有变动,比如修改了主题样式,提交并推送至 GitHub; 如果源仓库有变动,比如新增了文章,提交并推送至 GitHub; 在博客源仓库根目录运行命令 hugo --gc 生成最新的静态站点至 public 文件夹; 由于博客使用了 Service Worker,需要重新使用 Gulp 生成 sw.js 文件; 将构建后的静态站点推送至 GitHub,打好日期标签; 推送完成后 GitHub Pages 的内容会发生变化,由于博客使用了 CloudFlare 的页面缓存功能,我需要登录 CloudFlare 操作清理 CDN 缓存。 一通操作都完成Hugo又没有hexo d这样的一键部署后才算发布成功,虽然 Hugo 本身的构建非常迅速,但是考虑到推送远程仓库和后续 CloudFlare 的操作,稍微修改地频繁一点就显得有点繁琐了,这时候持续构建的必要性就体现出来了。\n持续部署 Hugo 的解决方案 一般对 Hugo 的持续部署有以下几种选择:\n 名称 描述 Travis CI Netlify GitLab CI 持续集成平台 Buddy 持续集成平台 Buddy Works 是一款简单好用的 CI 平台,用户界面比较友好,免费计划足够个人部署博客使用。\nTravis CI 和 GitLab CI 的尝试 基于 Buddy 做持续构建 首先确定构建的步骤,假设构建的机器上只有一个新安装的 Linux 发行版,每次发布的流程可以概括为:\n 在构建机器上安装 Git、Hugo 和 Node 的发行版,以便拉取代码、生成博客页面和更新 Service Worker1; 从「博客源仓库」同步最新代码; 从「博客主题仓库」同步最新代码,主题仓库作为源仓库的 submodule 更新,如果 submodule 是私有仓库,确保构建机器具有访问仓库的权限(配置 Access Token 或者 SSH Key); 让 Hugo 生成最新的博客页面; 调用 NPM 安装 Gulp 和 Service Worker 的依赖; 通过 Gulp 生成新的 Service Worker 脚本; 推送所有生成的内容至「博客静态站点仓库」; 通过 API 调用 CloudFlare 清除 CDN 缓存; 用流程图表示为:\ngraph TD; BS[/构建开始/] BE[/构建结束/] ID([依赖安装完成]) SS([源码同步]) HS([博客静态站点生成]) GPS([GitHub Pages 同步]) CS([CDN 同步]) BS -- |在目标机器上安装依赖| ID ID -- |拉取博客和主题源码| SS SS -- |Hugo 生成站点 \u0026 Gulp 生成 sw.js| HS HS -- |推送内容至 GitHub Pages| GPS GPS -- |CloudFlare 清除 CDN 缓存| CS CS -- BE 工作空间配置 用你自己的方式注册和登录 Buddy,并授权 Buddy 访问你的 GitHub 仓库,\n创建 Docker 映像 这一步主要是撰写 Dockerfile\nFROMalpine:3.11# Hugo 版本ARG HUGO_VERSION=0.69.0ARG HUGO_TYPE=_extended ARG HUGO_URL=https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo${HUGO_TYPE}_${HUGO_VERSION}_Linux-64bit.tar.gz# 安装必要依赖RUN apk upgrade \u0026\u0026\\ apk add --update wget libc6-compat libstdc++ nodejs npm git openssh \u0026\u0026\\ wget -O hugo.tar.gz ${HUGO_URL} \u0026\u0026\\ tar xvf hugo.tar.gz \u0026\u0026\\ mv hugo /usr/bin \u0026\u0026\\ rm hugo.tar.gz \u0026\u0026\\ npm install workbox-build gulp gulp-uglify readable-stream uglify-es --global \u0026\u0026\\ hugo version生成博客页面 更新 Service Worker 清理缓存(可选) 总结 延伸阅读 Service Worker 的构建步骤是可选的,如果你的站点不是一个 PWA 程序的话。 ↩︎\n ","description":"","title":"在 Buddy 上持续部署 Hugo","uri":"/posts/setup-continuous-build-to-hugo-with-buddy/"},{"categories":["设计"],"content":"概述 相比于国内已有的内容平台,比如豆瓣、知乎、公众号,独立博客在搭建、配置和维护上会花费额外的精力,也需要一定动手能力才能持续运作,但是相对地,独立博客能更加灵活地控制内容产出,更加自由地挥洒笔墨,对排版样式更可以进行像素级的控制。\n排版简单来说就是考虑如何组织文本,让文章对读者更加友好 — 这涉及到字体、字型、段落等元素的样式平衡。排版定义了网站的整体基调,引导读者阅读,决定用户体验,正如 中文文案排版指北 所说,一致的排版能够降低团队成员之间的沟通成本,增强网站气质,整齐划一的排版也是我写博客所追求的目标。\n接下来我想谈谈博客在样式上的配置,包括相关 CSS 特性的讨论,以及我对技术博客排版的个人理解。文章组成博客,段落组成文章,段落的排版决定了博客的排版,段落的排版又以字体、行距、对齐最为关键。\n两端对齐 正文段落 两端对齐(justify),与 Web 中常规的 左对齐(Left justify)相比,两端对齐保持各行左右边距的基线一致,视觉上更加整齐,适合中文这样单个字符构成的语言。\n左对齐时,字符之间的间隙均等,行尾超过容器宽度的长单词折行显示;两端对齐时,字符之间的间隙不等,行尾的长单词同样会折行,但是会相应调整上一行的字符间隙来填充空白。\n在 Web 中实现两端对齐,完整且保证兼容性的 CSS 写法为:\np { text-align: justify; /* 文本两端对齐 */ text-justify: inter-ideograph; /* 调整表意文字间距以保持两端对齐 */ } 其中 text-align: justify 对应文本两端对齐,text-justify 表示在保持两端对齐的情况下如何处理间距,中文段落一般选择 inter-ideograph,它表示调整 CJK 表意文字字符和单词的间距来适应布局,也可以用 distribute 代替:\n不过主流浏览器对 text-justify 的支持不佳,截至本文完成,只有 Firefox 有 较好的支持。\n除了段落 \u003cp\u003e 之外,以下可能存在折行内容的标签也建议使用两端对齐:\n 列表项(\u003cli\u003e) 定义列表项(\u003cdd\u003e) 两端对齐的不足 两端对齐的不足主要在于中西文混排时的行间疏密参差不齐,这一点在移动设备上更为明显,下图中的单词 RedisCache 显得过于松散,是因为浏览器为了保持对齐而做了字符间距补偿:\n目前这种情况没有一劳永逸的解决方案,只能等未来 CSS 标准和浏览器实现能支持更加智能的折行,临时方案要么使用左对齐,要么尽量在文本中少用西文单词。\n折行 提到对齐方式不得不说折行,折行规定了文本过长时容器的处理方式。不同语言的书写系统对折行有不同要求,东亚语言(中文、日文、韩文等)用「音节」而不是「空格」区分单词,这些语言的文本几乎可以在任何字符之间折行1。建议的配置如下:\np { line-break: auto; word-break: break-word; overflow-wrap: break-word; } 其中 word-break 控制断字符,overflow-wrap 控制断词,这几个 CSS 属性很容易混淆,上述的配置已经适用于大部分中英混排的场景。\n段落 内容按段落划分,段落标题应当从 \u003ch2\u003e 开始,为什么不是 \u003ch1\u003e 呢?因为 \u003ch1\u003e 一般作为网页标题而特殊存在,一个页面建议只有一个 \u003ch1\u003e 标签,即 \u003ch1\u003e 是单例的。\n 当被加载到浏览器中的时候,元素 \u003ch1\u003e 会出现在页面中 —— 通常它应该在一个页面中只被使用一次,它被用来标记你的页面内容的标题(故事的标题,新闻标题或者任何适当的方式)。\n —— MDN·HTML 介绍 行高一般设置在 1.5~2 之间即可,本博客是 1.75,用 CSS 表示为:\np { line-height: 1.75; } 西文段落 纯西文段落更加适合左对齐,应当在 CSS 用伪选择器为其单独设置语言属性。举例来说,假设 HTML 文档的语言为中文,即 \u003chtml lang=\"zh\"\u003e 时,有段落如下:\n\u003cp\u003e朝辞白帝彩云间,千里江陵一日还。\u003c/p\u003e 此时标签 \u003cp\u003e 没有显式设置 \u003clang\u003e 属性,将使用当前 HTML 的语言属性 zh,而对西文段落,例如:\nFour score and seven years ago our fathers brought forth on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal.\n为了追求更好的排版效果,我们添加 lang=“en” 属性,并单独设置行高的对齐:\np:lang(en) { line-height: 1.5; /* 西文字母较小,行距从 1.75 减小至 1.5 */ text-align: left; /* 西文文本左对齐 */ } 此外,可以进一步设置基于浏览器词典的自动断词:\np:lang(en) { hyphens: auto; /* 西文自动断词,包括以下两个 -\u003cvendor\u003e-hyphens 的兼容性选项 */ -webkit-hyphens: auto; -moz-hyphens: auto; } 对比下以下两种排版效果,第一段是是默认的 1.75 行距两端对齐段落,第二段是 1.5 行距左对齐段落:\nFour score and seven years ago our fathers brought forth on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal.\nFour score and seven years ago our fathers brought forth on this continent, a new nation, conceived in Liberty, and dedicated to the proposition that all men are created equal.\n相较于上面第一段的默认效果,用 lang=“en” 描述的段落的 1.5 倍行距更加紧凑,文本左对齐和自动断词达到了更加贴近英文印刷品的排版效果,如果你的浏览器宽度恰好,甚至能看到行尾的 “a new nation” 进行了连字符折断。\n字体与字号 中西文字体分别使用 思源宋体 和 Zilla Slab,从 Google Fonts 加载,字号为 16px,确定字体大小后保证每行字数在 38~42,CSS 的配置为:\n@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700\u0026family=Zilla+Slab:ital,wght@0,400;0,600;1,400;1,600\u0026display=swap'); body { font-size: 16px; font-family: 'Zilla Slab', 'Noto Serif SC', serif; } 统一的字体是为了各平台下的显示效果完全一致,给读者连续的阅读体验,代价是在读者本地没有安装对应字体的情况下,页面有字体请求的网络开销。\n思源系列字体 思源系列字体是 Google 和 Adobe 联合开发的开源免费字体集(Google 称作 Noto 系列,Adode 称作 Source 系列),特点是设计优雅,可读性高,对 CJK 书写系统的支持很好,任何人都可免费下载和几乎在任何地方使用,宋体相对于黑体增加的笔锋更加接近于书本的效果,更具文字美感。\n基于以上诸多优点,我建议每个人的电脑里都应该安装思源系列字体。\n衬线体与无衬线体 另一个问题是,正文选用衬线体还是无衬线体?即选用宋体还是黑体。\n如果是五年前,无笔锋的黑体更适合,黑体在中低分辨率屏幕的可读性更好,尤其是各平台默认黑体几乎是最安全的选择。现在则凭个人喜好,一方面开源的衬线体比如思源宋体获取门槛降低,另一方面随着 Web 技术和显示器分辨率的进步,使用衬线体的效果渐渐不再逊色于无衬线体,甚至能呈现更逼真的纸张模拟。\n另外,正文中的无衬线字体应当降低颜色对比度,让文本整体更加偏灰以减少攻击性和视觉冲击,比如下图中 少数派 页面的正文效果:\nHan.css Han.css 是一套用于 Web 的汉字排版解决方案,作为已有 CSS 的补充为网页提供了丰富地用于汉字书写系统的特性,尤其针对那些已有 CSS 属性无法支持的排版特性比如:\n 中西文间混排 .25em 间隙,即所谓的盘古之白 标点挤压 标点悬挂 其他样式等 Han.css 可对页面整体使用,也可对某个子元素使用,甚至是只开启部分功能。出于摸索阶段的谨慎,我只开启了标点挤压功能。\n标点挤压是指:汉字排版连续使用多个符号时,字与字间将出现一个汉字宽度的空隙,不甚美观,而用额外的 JavaScript 脚本缩减连续标点及行首/行尾标点的多余空间。\n启用标点挤压 在头部引入 Han.css 的脚本和样式:\n\u003clink rel=\"stylesheet\" media=\"all\" href=\"//cdnjs.cloudflare.com/ajax/libs/Han/3.3.0/han.min.css\"\u003e \u003cscript src=\"//cdnjs.cloudflare.com/ajax/libs/Han/3.3.0/han.min.js\"\u003e\u003c/script\u003e 引入 Han.css 相关的 JavaScript 依赖后,在网页中插入以下脚本即可开启 \u003cbody\u003e 元素下的标点挤压:\n\u003cscript\u003e window.addEventListener('DOMContentLoaded', function () { Han(document.body) .initCond() // 初始化脚本 .renderJiya() // 渲染标点挤压 }) \u003c/script\u003e 开启前后的效果对比如下图所示,注意第二行的右括号和逗号的处理:\n因为目前的 CSS 标准并没有覆盖到该特性,所以这项技术的本质是依赖外部样式和脚本做后置渲染。\n其他 首行缩进 MemE 主题的正文的分段样式有两种选择,margin 和 indent,前者就是一般的依靠 CSS 上下外边距分隔段落,后者是类似书本的首行缩进,这里的选用凭个人喜好,具体见 MemE 主题的 配置文件。\n何时使用 italic 另外就是,技术文章中大量用到的「类型名」、「函数名」、「变量名」。严格意义上应当使用 \u003cvar\u003e 标签标记,但考虑到 Markdown 里面没有类似的语法,所以我参照 Baeldung 的样式,使用 \u003cem\u003e 标签标记,比如下图中的 SearchCriteria 作为类型名称被渲染成了斜体。\n总结 在「够看」的情况下继续深入优化排版,像对待印刷品一样对待 Web 页面,是一种工匠精神的体现,但另一方面我们又不得不面对一个尴尬的现实:文中所提到的那些高阶排版技巧,除了已经纳入 CSS 标准的特性外,有些需要微调页面元素,有些需要 JavaScript 脚本参与,有些则要求打字者一一手动校对,而 Web 网页作为「快速消费品」,大多数用户根本不会注意到这些额外的排版特性带来的效果增益,所以浏览器也没有足够的理由为支持这些特性而投入成本。\n就像《死亡搁浅》中场景里的某朵小花,它会在你送快递的时候于视野中一闪而过,构成你对游戏整体体验的一部分,但是如果没有它,也不影响你继续游戏过程,更不会降低你对《死亡搁浅》的评价。\n但是,这不能成为我们对 Web 排版满足现状的理由,因为排版的意义在于让人类更加舒适地阅读文字 — 每纠正一个标点符号,每对齐一行文本,每划分一个段落,都是对艺术和美的追求。\n最后,技术总是在进步,我们在 HTML 页面上对美学的要求总会随着标准草案的迭代和 Web 基础能力的支持而不断向前。可以肯定的是,Web 页面的质量将会无限趋近甚至超越纸质印刷品,现有的标准也会逐渐覆盖人类所有的语言和书写系统,甚至是这些系统里的冷门而小众的特性。\n延伸阅读 中文排版需求·W3C 中文文案排版指北·GitHub 漢字標準格式 — 印刷品般的汉字排版框架 从《中文排版需求》开始·The Type 社区文档撰写指南·LearnKu 产品论坛 针对 Adobe InDesign 标点挤压中文默认设置的反馈·The Type 排版左对齐(left)与两端对齐(justify)的思考·Hungl Zzz’s Blog Word breaking online in East Asian languages·Code \u0026 Notes 摘录自 Code \u0026 Notes,原文为:\n Latin and other Western language systems use spaces and punctuation to separate words. East Asian Languages as Japanese, Chinese and sometimes also Korean however do not. Instead they rely on syllable boundaries. In these systems a line can break anywhere except between certain character combinations. ↩︎ ","description":"","title":"谈谈技术博客的排版","uri":"/posts/talking-typesetting/"},{"categories":["技术"],"content":"缓存是提升应用程序性能的首要途径,我们一般会使用 Redis 来实现缓存层以减小对持久层的访问压力,随之带来的问题是:即便在缓存命中的情况下,应用程序依然需要访问 Redis 服务器并消耗一定的 CPU 算力和网络带宽资源,随着业务量增长,代价可能变得更加明显。本文将以 Spring Cache 为背景,探讨如何以最小化的改动来实现给 Redis 加持内存缓存。\nSpring Cache 抽象 Spring Cache 作为 Spring 最核心的模块之一,提供了开箱即用的缓存支持,应用程序只需要在任意 Configuration 类上加入注解 @EnableCaching 即可启用缓存:\nSpring Cache 的实现位 org.springframework.cache 包下,如果使用 Maven 的话需要引入 spring-context 模块,其中最核心的 2 个接口定义如下:\n Cache 代表通用缓存对象的抽象,定义了与缓存交互的接口,包含基本的读取、写入、淘汰和清空操作,管理一系列的缓存键值对,按键寻址,拥有唯一的名称。 CacheManager 代表缓存管理器的抽象,管理一系列缓存对象,按名称寻址缓存。 一句话概括就是:Spring 在核心模块 spring-context 就包含了对缓存的支持,通过注解 @EnableCaching 来使用。需要被缓存的对象由 Cache 管理并按键寻址,Cache 按照名称区分彼此,统一地注册在 CacheManager 中1。\nConcurrentMapCache 和 RedisCache 简单来说,Cache 和 CacheManager 定义了如何存储具体的缓存对象,是存储在本地还是远程服务器,实际的方式不同实现有不同表现,一般我们用得最多的实现要数以下两种:\n ConcurrentMapCache 基于 ConcurrentHashMap 实现的本地缓存,也是此次的内存缓存实现类,对应的缓存管理器是 ConcurrentMapCacheManager; RedisCache 基于 Redis 实现的分布式缓存,使用时需要引入 spring-data-redis 依赖,对应的缓存管理器是 RedisCacheManager。 实现二级内存缓存 回到开头的问题,如果需要在 RedisCache 存在的情况下,为应用程序加入内存二级缓存的支持,要如何做呢?典型的场景是,对于某个缓存键,若在本地内存缓存中存在,则使用内存缓存的值,否则查询 Redis 缓存,若存在,将取得的值回写入内存缓存中,流程图表示为:\ngraph TD; Q[/Query Start/] QE[/Query End/] M([Memory]) R([Redis]) Q --|KEY| M M -- KP{Memory KEY exists?} KP --|Y| RMV[Return memory value] RMV --QE KP --|N| QR[Query Redis] QR --R R -- RKP{Redis KEY exists?} RKP -- |Y| WM[Write memory] WM --QE RKP -- |N| DB[(Database)] 虽然 Spring 提供了名为 CompositeCacheManager 的实现来组合多个 CacheManager,但也仅仅是在名称寻址时,迭代所管理的 CacheManager 集合,返回第一个寻址不为 null 的 Cache 对象,并不能完成上述的缓存回写的实现。\n我们可以用 AbstractCacheManager 的特性来解决这个问题,AbstractCacheManager 提供了名为 decorateCache 的保护方法来对 Cache 对象做封装,它的定义如下:\n/** * Decorate the given Cache object if necessary. * * @param cache the Cache object to be added to this CacheManager * @return the decorated Cache object to be used instead, * or simply the passed-in Cache object by default */ protected Cache decorateCache(Cache cache) { return cache; } decorateCache 方法的调用时机有 2 个:\n CacheManager 初始化缓存; 向 CacheManager 请求了它所没有的缓存(Missing Cache),且 CacheManager 被配置成自动创建不存在的缓存时,decorateCache 会在 Missing Cache 被创建时被调用。2 重载 decorateCache 用的是典型的 装饰模式 的思想,在子类中重写该方法,我们可以将参数中的 Cache 对象包装成我们想要的实现,从而达到在不修改原有缓存的情况动态地下改变原缓存的行为。\n缓存装饰器 首先我们展示一个缓存装饰器的简单示例,它在每次缓存读取和写入时打印一条日志:\n@Slf4j public class SimpleLoggingCacheDecorator implements Cache { private final Cache delegate; public LoggingCacheDecorator(Cache delegate) { this.delegate = delegate; } @Override public String getName() { return delegate.getName(); } @Override public Object getNativeCache() { return delegate.getNativeCache(); } @Nullable @Override public ValueWrapper get(Object key) { ValueWrapper valueWrapper = delegate.get(key); log.debug(\"Get cache value, key = {}\", key); return valueWrapper; } @Override public void put(Object key, @Nullable Object value) { log.debug(\"Put cache value, key = {}\", key); delegate.put(key, value); } // 所有的方法都转发到委托对象,下同 } 接着继承现有的 CacheManager 并重写 decorateCache 方法:\n@Override protected Cache decorateCache(Cache cache) { return new SimpleLoggingCacheDecorator(cache); } 这样我们就实现了在日志中捕捉缓存的方法调用,可以看出,通过装饰器模式,我们能够无侵入地修改原对象的行为,这也为我们后续进一步 hack 缓存提供了设计基础。\n内存缓存装饰器 接下来我们沿用上一节的设计,尝试实现一个内存缓存的装饰器(Memory Cache Decorator),它的作用是按照上述流程图所描述的逻辑来改变已有 Redis 缓存的行为。\n不难分析出,这个装饰器会具有如下特征:\n 持有一个上游缓存的引用,并管理一个内存缓存; 修改上游缓存读取方法的行为,在方法返回 null 时转而查询本地的内存缓存,依据查询的结果判断是否需要回写本地缓存; 修改上游缓存写入方法的行为,在方法执行的同时也同步到到本地的内存缓存。 以下是它的部分代码实现:\n@Slf4j public class MemoryCacheDecorator implements Cache { private final Cache memory; private final Cache source; public MemoryCacheDecorator(Cache source) { this.memory = new ConcurrentMapCache(\"memory-\" + source.getName()); this.source = source; } @NonNull @Override public String getName() { return source.getName(); } @NonNull @Override public Object getNativeCache() { return source.getNativeCache(); } @Nullable @Override public ValueWrapper get(@NonNull Object key) { ValueWrapper valueWrapper = memory.get(key); if (valueWrapper != null) { return valueWrapper; } valueWrapper = source.get(key); if (valueWrapper != null) { memory.put(key, valueWrapper.get()); } return valueWrapper; } @Override public void put(@NonNull Object key, @Nullable Object value) { source.put(key, value); memory.put(key, value); } // 其他的 GET/PUT 方法省略 } MemoryCacheDecorator 的逻辑并不复杂,仅仅是拦截了上游缓存的读取操作,其中:\n 第 8 行创建了替代上游缓存的内存缓存对象,采用 ConcurrentMapCache 实现,为了健壮起见,内存缓存的名称是上游缓存名称前加 memory-; 第 27 ~ 35 行是真正起作用的部分:先查询内存缓存,依据结果判断是否需要进一步查询上游缓存,且保证查询上游缓存后回写内存缓存以保证一致性; 同样为健壮起见,在上游缓存被修改时也需要同步到内存缓存中,如第 41 行所示。 扩展 RedisCacheManager 现在是时候扩展现有的缓存管理器了,由于上游缓存是 RedisCache,我们需要扩展它所对应的缓存管理器 — RedisCacheManager,并重写 decorateCache 方法,代码如下:\npublic class MemoryRedisCacheManager extends RedisCacheManager { // 构造方法省略 @NonNull @Override protected Cache decorateCache(@NonNull Cache cache) { return new MemoryCacheDecorator(cache); } } 在任何用到 RedisCacheManager 的地方使用 MemoryRedisCacheManager 替换,保证程序中最终起作用的 CacheManager 是我们实现的 MemoryRedisCacheManager 即可。\n此时所有的 Cache 对象在初始化时,都会被包装成 MemoryCacheDecorator 类型,在读取和写入时会先从内存缓存中查询,这样便完成了二级缓存的实现。\n实际上,按照这样的方式上游缓存不一定是 RedisCache,任何可以远程缓存比如 EhCacheCache 也可以通过这样的方式来整合本地二级缓存。\n内存缓存的优化 虽然通过扩展 RedisCacheManager 类和少量代码便能实现本地的内存缓存,但这也只是完成了第一步,现在的代码如果在生产环境使用仍然具有不少问题:\n 缓存过期:我们没有确定内存缓存何时过期,Redis 缓存的过期由 Redis 服务器的键过期能力来保证,但 ConcurrentMapCache 没有。况且实际的服务通常是集群部署,存在着多个实例负载均衡,因此各个实例之间的缓存一致性也是需要考虑的,否则可能出现用户访问的结果同时存在新旧两个版本。\n 序列化与线程安全:ConcurrentMapCache 中默认保存的是缓存值本身,即多线程环境下各个线程对同一个缓存键获取的值是同一个对象实例。若其中一个线程修改了该实例,则会其他线程的读取,比如一个业务读取缓存中的配置数据,根据自己的业务逻辑修改了对象的字段,由于对象只有一份,这个修改将会被所有其他线程知晓。\n 条件化启用:业务中的缓存可能会分为用户数据的缓存(热数据)和配置数据的缓存(冷数据)两组,不同组的缓存修改的频率不同,比如用户缓存随着用户行为的发生而被淘汰,而配置数据的更新频率往往是按周来算,因此我们一般只会对不常变化的配置数据做内存二级缓存,这就要求 CacheManager 条件化地对 Cache 进行装饰。\n 基于以上 3 点,我们可以对现有代码做优化。\n缓存过期 内存缓存需要过期(严格来说是清空),并且最好是所有服务实例在同一时间点过期,典型的解决方案就是基于 Cron 表达式 的定时任务。\nSpring 中设置 Cron 定时任务的方式非常方便,如果服务已经配置了启用定时任务的注解 @EnableScheduling,则可以让我们的缓存管理器简单地实现 SchedulingConfigurer 接口,如果没有配置的话,定时任务也是 Spring 的 spring-context 模块就支持的,不需要引入其他的依赖。在此之前,首先让 MemoryCacheDecorator 提供一个公共的清理内存缓存的方法:\n@Slf4j class MemoryCacheDecorator implements Cache { public void cleanMemoryCache() { memoryCache.clear(); } } 再让缓存管理器注册 Cron 定时任务,比如按每分钟的第 30 秒执行清空:\n@Slf4j public class MemoryRedisCacheManager extends RedisCacheManager implements SchedulingConfigurer { @Override public void configureTasks(@NonNull ScheduledTaskRegistrar taskRegistrar) { // 向容器注册清理缓存的 CRON 任务,若没有配置 @EnableScheduling,这里不会执行,需要手动调用 clearMemoryCache 清理 taskRegistrar.addCronTask(this::clearMemoryCache, clearCacheCronExpression); log.info(\"Register cron task for clear memory cache, cron = {}\", clearCacheCronExpression); } /** 手动清理所有内存缓存。 */ public void clearMemoryCache() { Collection\u003cString\u003e cacheNames = getCacheNames(); for (String cacheName : cacheNames) { Cache cache = getCache(cacheName); if (!(cache instanceof MemoryCacheDecorator)) { continue; } // 对所有 MemoryCacheDecorator 类型的缓存做清理 MemoryCacheDecorator memoryCacheDecorator = (MemoryCacheDecorator) cache; memoryCacheDecorator.cleanMemoryCache(); } } } 这样就能保证所有的服务实例,几乎在同一时间点清空内存缓存。3\n序列化 缓存的序列化机制通过复制对象来保证线程安全,如果每次从缓存中获取到的总是全新的对象,那么就不存在上述的多线程修改缓存对象互相影响的问题。\nConcurrentMapCache 可以在 构造方法 中指定一个 序列化实现。若指定了序列化实现,则被缓存的对象会经由序列化转化成字节数组保存,否则直接保存对象引用,同时在读取的时候将字节数组反序列化成对象。。\nConcurrentMapCache 序列化机制的接口定义于 spring-core 中,Spring 自身只提供了 JDK 序列化版本的实现4。其实大多数情况下这两个类足以满足要求,不过因为是 JDK 序列化,所以对于被序列化的类有诸多的要求,比如必须实现 Serializable 接口,而且众所周知,JDK 序列化的性能低于其他序列化实现。这里我们选择 JSON 序列化,并且使用 Fastjson 来实现。\n得益于 Fastjson 的简单性,最终实现的代码如下:\nfinal class FastjsonSerializationDelegate implements Serializer\u003cObject\u003e, Deserializer\u003cObject\u003e { private static final ParserConfig ENABLE_AUTO_TYPE_PARSER_CONFIG = new ParserConfig(); static { // 使用非全局的 ParserConfig 并设置支持 autoType ENABLE_AUTO_TYPE_PARSER_CONFIG.setAutoTypeSupport(true); } @NonNull @Override public Object deserialize(@NonNull InputStream inputStream) throws IOException { // 读取所有 inputStream 中的数据至字节数组中,很多库的 I/O 工具类都能做到 byte[] bytes = IOUtils.toByteArray(inputStream); String input = new String(bytes, StandardCharsets.UTF_8); return JSON.parseObject(inputStream, Object.class); } @Override public void serialize(@NonNull Object object, @NonNull OutputStream outputStream) throws IOException { // 这里需要带上 SerializerFeature.WriteClassName,否则 List\u003cLong\u003e 经过序列化反序列化会变成 List\u003cInteger\u003e JSON.writeJSONString(outputStream, object, SerializerFeature.WriteClassName); } } 需要注意的就是第 17 行的调用中需要加入 WriteClassName 的序列化特性,否则 List\u003cLong\u003e 在序列反序列化后会被解析成 List\u003cInteger\u003e。另外,Fastjson 在版本 1.2.25 之后限制了 JSON 反序列化时的类型解析功能,所以我们在第 4 行使用一个非全局的 ParserConfig 对象,单独对该对象启用 autoType 并在第 19 行使用,如果不指定的话就会使用全局的对象5,这样可能出现 autotype is not support 报错。具体的配置可以参阅官方的 升级公告 和 enable_autotype 配置。\n接着扩展 ConcurrentMapCache 类:\nfinal class FastjsonSerializationConcurrentMapCache extends ConcurrentMapCache { /** 该缓存的 FastJson 序列化实现。 */ private static final FastjsonSerializationDelegate SERIALIZATION_DELEGATE = new FastjsonSerializationDelegate(); public FastjsonSerializationConcurrentMapCache(@NonNull String name) { super( name, new ConcurrentHashMap\u003c\u003e(256), true, new SerializationDelegate(SERIALIZATION_DELEGATE, SERIALIZATION_DELEGATE)); } } 最后在 MemoryCacheDecorator 的构造方法中替换原来的 ConcurrentMapCache:\n@Slf4j class MemoryCacheDecorator implements Cache { private final ConcurrentMapCache memoryCache; private final Cache targetCache; public MemoryCacheDecorator(@NonNull Cache targetCache) { // this.memoryCache = new ConcurrentMapCache(\"memory-\" + targetCache.getName()); this.memoryCache = new FastjsonSerializationConcurrentMapCache(\"memory-\" + targetCache.getName()); this.targetCache = targetCache; } } 缓存对象实际以 JSON 字符串的形式保存在内存中,并且带有字段的类型信息,每次访问的结果都是全新的反序列化对象,这样就实现了内存缓存的线程安全访问。\n条件化启用 条件化启用应该是最好实现的一点优化了,只需要在构造方法中加入一个列表,在 decorateCache 方法中判断属于列表中的缓存才做包装,类似的代码如下:\n@Slf4j public class MemoryRedisCacheManager extends RedisCacheManager implements SchedulingConfigurer { /** 只有这里配置的缓存才会加持内存缓存层。 */ private final List\u003cString\u003e decoratedCacheNameList; /** * 覆盖 {@link AbstractCacheManager} 的装饰缓存方法,若参数中的缓存包含 {@link #decoratedCacheNameList} * 中,则在将该对象包装成具有内存缓存能力的对象。 * * @see AbstractCacheManager * @param cache 原缓存对象 * @return 原缓存对象或具有内存缓存能力的对象 */ @NonNull @Override protected Cache decorateCache(@NonNull Cache cache) { // 先让基类包装一次 Cache superCache = super.decorateCache(cache); // 判断是否为该缓存配置了内存缓存 if (decoratedCacheNameList.contains(cache.getName())) { // 包装缓存 return new MemoryCacheDecorator(superCache); } // 不包装缓存 return superCache; } } 总结 我们介绍了 Spring Cache 的 2 个核心接口,以及基于 Spring Cache 来为 Redis 缓存建立二级本地内存缓存,也讨论了如何在这个基础上做优化以便生产环境使用。\n实现的方法基于 AbstractCacheManager 的 decorateCache 函数,重写该方法可以将原本的 Cache 对象封装成另一个 Cache 对象,借此我们可以改变原有缓存的行为,最终以新增不超过 5 个类的代价,将核心逻辑内聚在 RedisCacheManager 的子类中,也易于未来的扩展,具体的详情可以参考我的 GitHub 项目 memory-cache-in-redis。\n 如何使用 Spring Cache 以及如何集成 Redis 不在本文的讨论范围内,相关的内容可以参阅 Baeldung 的 A Guide To Caching in Spring、IBM 知识库的 注释驱动的 Spring Cache 缓存介绍。Redis Cache 的相关内容可以参阅 Spring Data Redis 官方文档章节。 ↩︎\n 个人认为不应当让 CacheManager 自动创建缺失的缓存,而是在一开始就确定程序的缓存命名空间,并创建好所有类型的缓存。 ↩︎\n Cron 表达式依照机器本地时间执行,不同机器本地时间可能存在分钟级差异,为了追求更高的一致性,应当用外部手段保证各个机器时间戳尽量趋近,比如时间服务器。 ↩︎\n 将对象类型的名称序列化至 JSON,在反序列化时可能存在安全问题,比如指定 JSON 反序列化后的类型为 java.lang.Thread,就能通过非常规方法创建线程。 ↩︎\n 即 DefaultSerializer 和 DefaultDeserializer。 ↩︎\n ","description":"","title":"在 Spring Cache 中为 Redis 添加内存缓存","uri":"/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/"},{"categories":["技术"],"content":"Nashorn 脚本引擎 Request 作用域的 Bean 未来版本可能的变化 ","description":"","title":"为你的 API 提供客户端脚本支持","uri":"/posts/providing-scripting-support-for-your-api/"},{"categories":["技术"],"content":"自 Java 6.0 开始,JDK 提供了名为 SPI(Service Provider Interface)的加载机制,SPI 能够在运行时发现某个接口/抽象类的实现类,为接口消费方提供了一致的模型来使用接口,对于接口实现方,按 SPI 的规范注册的实现类可实现运行时自动加载。这种方式既解除了接口与实现的耦合,又解决了实现类的自动初始化,比较典型的用例有 JDBC 驱动类的注册、Charset 字符集注册等,Spring Framework 和 Dubbo 的代码中也或多或少参考和封装了该机制。\nSPI 机制 在 SPI 里,接口或者抽象类被称为服务(Service)或服务提供者接口(Service Provider Interface),实现类被称为服务提供者(Service Provider)。虽然常见的概念被赋予了不太好理解的名称,但是二者在本质上还是代表了面向对象编程中规范(Specification)和实现(Implementation)的关系。\n服务提供者 在 SPI 的规范中,服务提供者的实现类应当配置在资源目录下的 META-INF/services 目录1下。该目录下,每一个服务接口对应一个单独的文本文件,文件名为服务接口的完全限定名,文件内容按行区分,每一行是服务实现类的完全限定名2。\nSPI 的核心类是范型类 ServiceLoader,它负责发现类路径中配置的实现类并实例化它们。ServiceLoader 维护了一个 LinkedHashMap\u003cString, T\u003e 的内部缓存来惰性实例化实现类,其中类型 T 为服务接口类。ServiceLoader.load(T.class) 是最常调用的方法,它返回类型 T 的 ServiceLoader 实例。3\nCharset 的加载方式 JDK 以 CharsetProvider 来实现字符集框架。Oracle JDK 扩展了该实现,提供了标准字符集和扩展字符集的 Provider 实现(StandardCharsets 和 ExtendedCharsets):\n StandardCharsets 标准字符集提供者,包括 Unicode 和 ASCII 字符集的管理;\n ExtendedCharsets 扩展字符集提供者,包括 CJK 字符集的管理。\n 这两个类都是在 sun.nio.cs 包下。\n在 Java 中我们通过调用 Charset.forName(“charset-name”) 来访问字符集 API,forName 方法在底层会进行一系列的 lookup 操作,按照标准字符集提供者、扩展字符集提供者和 SPI 字符集提供者的顺序查询 charset-name 对应的字符集实现类,当无法在前两个内置的字符集提供者中找到对应名称的字符集实现,SPI 字符集提供者便会起作用,SPI 字符集提供者以接口 CharsetProvider 为核心,因此我们可以为该接口插入自己的实现类。\n安装自定义字符集 假设现在有一个比 UTF-8 更高效且通用的字符串编码算法,它相对于 UTF-8 可能信噪比更低、更适合压缩甚至是支持火星语编码,我们暂且叫它 9527。它的编解码算法已经公开,我们现在需要赶在 Oracle 发布新的 JDK 支持它之前将它嵌入到我们的应用程序中,并且程序只需要将使用字符集的地方替换为 Charset.forName(“9527”) 即可。\n定义字符集 首先我们需要一个实现类继承自 java.nio.charset.Charset,Charset 是所有字符集的基类。在该案例中的实现类假设叫做 _9527Charset,并且为方便示例,我们假定 _9527Charset 本质上就是 UTF-8 的实现,因此它会持有一个 UTF-8 的实例,对它的所有方法调用都会被转发至 UTF-8 对应的方法中:\npackage zhix.encoding.spi; public class _9527Charset extends Charset { // 为方便示例,假定 _9527Charset 本质上就是 UTF-8 的实现 private final static Charset DELEGATE = StandardCharsets.UTF_8; public _9527Charset() { // 名称和别名集合 super(\"9527\", new String[] {\"mew-9527\", \"mew\"}); } // 所有的方法调用一并转发 public boolean contains(Charset cs) { return DELEGATE.contains(cs); } public CharsetDecoder newDecoder() { return DELEGATE.newDecoder(); } public CharsetEncoder newEncoder() { return DELEGATE.newEncoder(); } } 因为字符集在 JDK 中是以命名服务实现的,所以我们同时还要设置新字符集的规则名称(Canonical Name)和别名(Aliases),这里将规则名称设置为 9527,将别名集合设置为 mew-9527 和 mew。规则名称在命名空间中唯一确定一个字符集,别名提供了额外的查询方式。\n第 15 - 25 行是 Charset 的子类需要实现的 3 个方法,包括编码器和解码器,这里直接将逻辑转发给 UTF-8 的实现,真实的情况会更加复杂,因为我们需要自行实现编解码器,并做真正的底层字节处理。\n定义 CharsetProvider 接下来是实现 CharsetProvider,但通常我们只需要扩展 AbstractCharsetProvider 即可,AbstractCharsetProvider 提供了基本的字符集管理实现,包括名称管理、别名管理、缓存。\n构造 AbstractCharsetProvider 时还可以提供一个名为 pkgPrefixName 的参数,它用于指定该字符集提供者所管理的字符集从哪一个包中查找实现类,默认包前缀为 sun.nio.cs。\nprotected AbstractCharsetProvider() { packagePrefix = \"sun.nio.cs\"; } protected AbstractCharsetProvider(String pkgPrefixName) { packagePrefix = pkgPrefixName; } 以下代码展示了名为 _9527CharsetProvider 的实现,并指定在 zhix.encoding.spi 的包中查询字符集。\npackage zhix.encoding.spi; @Slf4j public class _9527CharsetProvider extends AbstractCharsetProvider { static final String CANONICAL_NAME = \"9527\"; static final String[] ALIASES = {\"mew\", \"mew-9527\"}; private boolean initialized = false; public _9527CharsetProvider() { super(\"zhix.encoding.spi\"); } @Override protected void init() { if (initialized) { return; } super.init(); log.info(\"{} initialized.\", getClass().getName()); charset(\"mew\", _9527Charset.class.getSimpleName(), ALIASES); log.info( \"Register charset {} with class {}.\", CANONICAL_NAME, _9527Charset.class.getName()); initialized = true; } } 第 16 行的 init 方法会在查询 SPI 字符集时被调用,外部的逻辑可能会多次调用该方法,因此需要开发者自己来保证只初始化一次,比如这里用第 9 行定义的 initialized 变量来控制。\n第 23 行的 charset 方法由基类 AbstractCharsetProvider 提供,用于注册字符集的元信息描述:\n/* Declare support for the given charset */ protected void charset(String name, String className, String[] aliases) { synchronized (this) { put(classMap, name, className); for (int i = 0; i \u003c aliases.length; i++) put(aliasMap, aliases[i], name); put(aliasNameMap, name, aliases); cache.clear(); } } AbstractCharsetProvider 根据参数的名称和别名,为字符集建立查询数据结构,在查询时若有匹配的字符集描述,则根据上文提到的 pkgPrefixName + 类名,通过反射创建 Charset 的实例,完成 Charset 的查询并初始化。\n所有的 Charset 初始化都是惰性的,并且 AbstractCharsetProvider 维护了一个缓存来避免重复初始化。因此最佳实践是在应用程序里只使用一种类型的 Charset。\n配置 _9527CharsetProvider 在项目的 resources/META-INF/services 目录下新建文本文件\njava.nio.charset.spi.CharsetProvider 写入一个内容为\nzhix.encoding.spi._9527CharsetProvider 的新行。\n 配置 _9527CharsetProvider\n 单元测试 在 test/resources 目录下创建单元测试类 _9527CharsetProviderTest:\npackage zhix.encoding; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @SuppressWarnings(\"InjectedReferences\") @Slf4j public class _9527CharsetProviderTest { private String text = \"我能吞下玻璃而不伤身体\"; private Charset charset; @Before public void setUp() { // 触发 SPI 加载 charset = Charset.forName(\"9527\"); } // 测试编解码结果一致 @Test public void testEncodingAndDecoding() { log.info(\"Charset name = {}\", charset.name()); log.info(\"Charset displayName = {}\", charset.displayName()); log.info(\"Charset aliases = {}\", charset.aliases()); Assert.assertEquals(text, new String(text.getBytes(charset))); } // 测试 _9527 和 UTF-8 一致 @Test public void testEncodingCompareToUTF8() { Charset utf8 = StandardCharsets.UTF_8; byte[] utf8Bytes = text.getBytes(utf8); byte[] _9527Bytes = text.getBytes(charset); Assert.assertArrayEquals(utf8Bytes, _9527Bytes); } // 测试使用别名查询 Charset @Test public void testEncodingAndDecodingWithAlias() { charset = Charset.forName(\"mew\"); Assert.assertEquals(text, new String(text.getBytes(charset))); } } 运行单元测试的结果如下:\n _9527CharsetProvider 单元测试结果\n 可以看到我们可以通过 Charset.forName(\"9527\") 的方式获得我们自己定义的 Charset 实例,且实例的类型就是 _9527Charset。\nSPI 的延伸讨论 SPI 使用延迟加载,会扫描整个类路径下的 META-INF/services 目录,所有配置的实现类的无参构造方法都会被调用并实例化,也就是一次访问,所有候选类都会被加载。如果实际场景不需要使用所有的实现类,这些类就会白白占用 JVM 内存,其次如果实现类是一个重型类的话,更会造成严重的内存浪费。\n另外一个缺陷是,你只能通过 load 方法返回的迭代器来迭代访问实现类,这是一种相当底层的编程接口,意味着你无法灵活地根据参数不同获取某个的实现类。如果要实际使用方便,一种可能的最佳的实现是:封装 load 方法,根据传入的参数控制返回的实现类的查找逻辑,并且设置一个类变量缓存查找的结果。\nSPI 与 API SPI 和 API 本质上都是 Specification 和 Implementation 的不同表现形式,区别在于:\n API 的使用者不关心规范的具体实现细节,只关心 API 的使用规范,开发者通过组织 API 提供的功能来实现目标。 SPI 的开发者按照规范实现接口,通过满足规范的规约来实现目标。 简单来说就是,对于一套编程规范,如果你使用规范提供的功能来编程,规范对你来说就是 API,如果你通过编程来满足规范的所有要求,则规范对你来说就是 SPI。\n也可以参考 StackOverflow 上的 这个回答。\n结语 JDK 在 6.0 的时候发布了 SPI 机制,解决了实现类在运行时如何确定的问题,有利于应用程序的扩展,对 Spring 等框架也产生了重要影响,现如今看来,它的实现方式比较底层,一般需要在外层封装更抽象的控制逻辑来使用,同样 SPI 也存在内存占用的缺陷,静态绑定4 机制可以解决这个问题。\n通过 SPI,我们可以实现一些 JDK 内置功能的模块插入,比如自行实现 Charset。以上字符集加载的完整代码可以在 GitHub 项目 9527-charset-encoding 中查看,如果有任何问题和建议可以在项目里提交 Issue 给我。\n JAR 文件的规范和 META-INF 目录的详细介绍参见 Oracle 的 Java SE Documentation ↩︎\n Baeldung 中关于 Service Provider 的介绍 ↩︎\n SPI 的详细配置规范参见 Oracle 的 The Java™ Tutorials 教程 ↩︎\n 静态绑定的典型应用是 Slf4J ↩︎\n ","description":"","title":"谈谈 Java SPI:以字符集举例","uri":"/posts/talking-spi-in-java/"},{"categories":["技术"],"content":"我的算法知识启蒙源自大学时期的「人工智能技术与运用」课程,这门课教授的第一个技术要点是「搜索问题」,由启发式搜索的概念展开,后续描述了各类算法实现,比如遗传算法、模拟退火,也包括各种实际问题的解决,比如迷宫寻路、七桥问题。工作之后的第一年,我在一家游戏公司担任服务端工程师,期间项目组正在制作一款 TPS 类型的坦克载具类 5v5 对战网游,类似于移动版的「坦克世界」,当时我需要解决的第一个比较棘手的问题是:如何为匹配服务器设计一个分配算法,使得双方 5 辆坦克的类型、战斗力、玩家战斗水平都尽量公平。\n澡盆玩具生产问题 详细讨论上述匹配问题之前,先聊聊另一个更加简单的问题,《Head First Data Analysis》这本书的第三章描述了一个入门的规划问题:\n假设你是一家名为「浴盆宝」的公司的数据分析师,这家公司的业务是生产和销售澡盆玩具,主要的产品线有两个:橡皮鸭和橡皮鱼,其中每只橡皮鸭和橡皮鱼的利润分别是 5 美元和 4 美元,它们分别消耗 100 单位和 125 单位的橡胶成本,问如果想让产品在下个月上架销售,橡皮鸭的产量不高于 400 只,橡皮鱼的产量不高于 300 只、且成本不超过 50000 单位橡胶的情况下,怎样的生产组合能够使利润最大。\n上述参数转化为表格的描述如下:\n 产品 最大产量 利润 单位成本 产量 Duck 400 $5 100 $N_d$ Fish 300 $4 125 $N_f$ 即产量满足约束\n$$ 100N_d + 125N_f \\leq 50000 \\mid N_d \\leq 400, N_f \\leq 300 $$\n时,使得 $5N_d + 4N_f$ 最大。\n书中引入这个案例更多是为了介绍如何在 Microsoft Excel 中操作以做规划求解,而如何通过解不等式方程得到最优解不在本文讨论范围之内。这里介绍这个基础的案例是为了讨论如何用遗传算法解该问题。\n遗传算法 遗传算法(Genetic Algorithm (GA) )是计算数学中用于解决最优化的搜索算法,是进化算法的一种。进化算法最初是借鉴了进化生物学中的一些现象而发展起来的,这些现象包括遗传、突变、自然选择以及杂交等。\n遗传算法通常实现方式为一种计算机模拟。对于一个最优化问题,一定数量的候选解(称为个体)可抽象表示为染色体,使种群向更好的解进化。传统上,解用二进制表示(即 0 和 1 的串),但也可以用其他表示方法。进化从完全随机个体的种群开始,之后一代一代发生。在每一代中评价整个种群的适应度,从当前种群中随机地选择多个个体(基于它们的适应度),通过自然选择和突变产生新的生命种群,该种群在算法的下一次迭代中成为当前种群。\n 遗传算法以达尔文的进化论为理论提出以下结论:生物以种群(Population)为单位做演化,种群由若干数量的个体组成,每个个体都有自己的基因(Gene)序列,不同的基因序列表现不同的性状,因此具有不同的环境适应度(Fitness),适应度高的个体更容易在自然选择中生存下来,将自己的基因序列遗传给下一代个体,两个个体之间会发生基因的交叉(Crossover),即繁殖过程,单个个体的基因序列存在一定概率发生突变(Mutation)而产生新的基因序列。\n所谓的基因序列,就是将解空间中的解进行编码,使得解空间的每一个解都有唯一的一组基因对应。比较通用的编码方式就是 0-1 二进制串,若某个问题的解是可枚举的,且总共存在 $M$ 种不同的可能解,那么可以选择 $2n$ 中第一个超过 $M$ 的 $n$ 的自然数作为基因的编码长度,比如解有 960 中可能时用 10 位二进制串编码基因,解有 60000 中可能时用 16 位二进制串编码基因,依次类推。根据该结论,随着种群一代一代演化,适应度低的个体会被逐渐淘汰,保留下来的个体的基因序列都倾向于有更高的适应度,经过若干轮处理后,种群朝着整体适应度更高的方向发展,适应度最高的个体的基因序列便是我们需要的解。\n在实践中我们发现,演化的方向可能是贪婪的,因此可能出现种群整体朝着一个次优的方向演化并在某一代收敛,即新产生的基因的适应度不再高于种群整体的适应度,以至于之后的演化不在产生更好的解。想象一个求极大值的函数的函数图像在给定定义域里存在两个波峰,它们的值一高一低,贪婪的演化可能导致基因序列在演化开始后逐渐趋近于低波峰附近的解且无法跳出。引入突变则使得即使是较高适应度的基因序列仍有一定概率突变成更好的或者更差的基因序列,从而跳出当前的解范围。从生物学的角度来说就是,遗传物质的复制过程中存在一定概率出现差错,进而为个体带来全新的基因序列,这种差错是完全随机的,带来的优劣也是因环境而异。\n 突变通常会导致细胞运作不正常或死亡,甚至可以在较高等生物中引发癌症。但同时,突变也被视为演化的「推动力」:不理想的突变会经天择过程被淘汰,而对物种有利的突变则会被累积下去。\n 通用的遗传算法一般会有以下几个控制参数:基因长度、种群大小、突变率、演化次数、随机数种子和适应度函数。\n 基因长度 正如之前描述的,依据解空间的大小可以确定基因长度,比如对于 10 位基因长度、解空间大小为 960 的问题来说,一个可能的解为 0100101101 (301)。 种群大小 计算机依据种群为单位批量计算,种群大小即计算机一次处理的样本大小,设置过大的种群大小会消耗更多的计算资源,设置过小则会导致进化缓慢,甚至可能出现演化结束后依旧无法找到最优解。最佳的大小值可能因问题和计算力而异。 突变(概)率 突变的产生是概率性的,表现在算法中就是,每一个个体在演化过程中都有一定概率使得自己基因序列的某一位或者某几位发生比特逆转。设置过小的突变率,比如 0,即不发生突变,会出现上一节描述的次优解陷阱,而过高的突变率会使得算法退化为无目的的随机搜索,一般来说 5% ~ 10% 的突变率就能够适应大部分问题。 演化次数 遗传算法本质上是一个有限步骤的算法,我们必须决定何时结束算法的循环,通常会设置经过多少代的演化后结束,或是直到种群中的某个个体适应度超过给定值后结束。无论是哪一种,这个参数是用来表示何时算法得以中止。同样,过快的终止条件可能导致尚未搜索出最优解的情况下中止,过慢的中止条件则导致后期已经收敛的种群演化效率低下。 随机数种子 用于生成初始种群的基因序列,相同的随机数种子应当每次都生成相同的初始种群。可随意设置,一般设置为当前的时间戳。 适应度函数 适应度函数模拟了环境的选择,决定了某一个基因序列对应的解的优劣。这里的优劣完全取决于适应度函数的定义,对于同一个问题,不同的适应度函数可能导致最终产生完全不同的种群,这与实际的生物演化是相符的,就好比橘树栽在淮南就是橘,而栽在淮北却变成了枳,或是亚洲象和非洲象在性状上的差异。无论如何,适应度函数的定义域等于解空间。 通常来说,我们面对的问题还会设置一些限制条件,比如生产问题中消耗量不能高于供给量,或是匹配问题中的玩家平均等级差不超过给定范围等。若最终演化后种群中的个体的基因序列对应的解违反了这些限制条件,则意味着演化出现了无用的解。因此有必要指定一些致死基因(Lethal Gene)来优化演化算法,所谓的致死基因就是那些对应的解违反了限制条件的基因序列,若演化得到的新基因序列包含致死基因,则设置该基因序列的适应度为 0,因为适应度为 0 的基因序列永远无法通过自然选择(随机轮盘)将自身遗传到下一代,用伪代码表示就是:\nif (gene is lethal gene) { fitness = 0 } else { fitness = Fitness(gene) } 遗传算法是 遗传编程 的一个实现,这类编程范式的本质在于告诉计算机需要完成什么而不是如何完成,通过特定的策略去搜索解空间中的解并逐步收敛,最终找到最优解。\n用遗传算法解决澡盆玩具问题 以上一节中的澡盆玩具生产问题为例,我们用 $x$ 代表最终生产的橡皮鸭数量,$y$ 代表最终生产的橡皮鱼数量,则任意合法的、不包含致死基因 $x$ 和 $y$ 的组合都是一个解,则问题的本质变成了:我们需要在有限的步骤内找到一组 $(x, y)$ 使得 $\\text{Fitness}(x, y)$ 最大。\n基因定义 由于 $x$ 不超过 400,$y$ 不超过 300,与 400 和 300 最接近的 $2n$ 为 512 即 $n$ = 9,因此可以设置基因序列的长度为 9 + 9 = 18 位,解的个数应当低于 218=262144 个,但实际有效的解会低于这个数字,因为这个数字包括了 $y\\gt400$ 或 $y\\gt300$ 的情况。想象一个长度为 18 的比特串,索引 0-8 的位置分配给 $x$,索引 9-17 的位置分配给 $y$,这个比特串就是这个问题的基因序列的编码方式。\n参数设置 我们已经确定基因序列的长度为 18,这里我们使用如下的参数配置来初始化算法。实际上,寻找一个正确而高效的参数来配置遗传算法可能需要多次调试:\n 参数名称 参数值 基因长度 18 位 种群大小 200 个 突变率 7.5% 随机数种子 0 演化次数 200 代 设置适应度函数为:\n$$ F = \\begin{cases} 5x + 4y, \u0026 \\text{if $x \\leq 400 \\land y \\leq 300 \\land 100x + 125y \\leq 50000$} \\\\\n0, \u0026 \\text{otherwise} \\end{cases} $$\n自然选择 下面开始真正的算法迭代过程,首先进行的是自然选择步骤,为了简便起见,我们假设种群只包含 4 个个体,初始种群的个体基因序列是随机生成的,我们以 0 为随机数种子生成初始种群,假定生成的 4 个个体 $a \\sim d$ 的基因序列如下:\n 个体名称 基因序列 𝑎 001111000011001000 𝑏 100101100011001000 𝑐 000111100001001011 𝑑 001110100010000010 通过拆分二进制串可以计算得到每个基因序列对应的 $x$、$y$ 和适应度:\n 个体名称 基因序列 𝑥 和 𝑦 值 适应度 𝑎 001111000011001000 120, 200 1400 𝑏 100101100011001000 300, 200 2300 𝑐 000111100001001011 60, 75 600 𝑑 001110100010000010 116, 130 1100 在遗传算法的迭代过程中,种群的个体数量始终恒定不变,n 个个体的种群演化至下一代仍有 n 个个体,自然选择的过程就是通过某种选择策略,从上一代的 n 个个体选择 n 次,组建出下一代的 n 个个体,通常的选择策略便是依据个体适应度大小的加权随机采样,想象将 $a \\sim d$ 依据适应度绘制一个饼图:\n加权随机的意思是在该圆中随机某一点,该点对应 $a \\sim d$ 哪一个的区域,就选择哪一个个体,重复 4 次。加权随机的具体实现算法在此不赘述,不管怎样,适应度越高的个体,在饼图中占据的区域越大,随机生成的点落在该个体区域的概率也越高,反之,低适应度的个体更加不容易被选中,对应于自然界的「适者生存」。假设经过一轮自然选择后的种群为 $\\{b_1, a, b_2, d\\}$:意味着在下一轮的 4 次选择中,$b$ 在第 1 次和第 3 次被命中,第 2 次命中了 𝑎,第 4 次命中了 $d$,而 $c$ 因为适应度最低不幸没有被命中。这是一个很大可能的结果,因为 $c$ 的命中概率为 11.1% 而远低于 $b$ 的 42.6%,此时的种群基因序列如下\n 个体(选择前) 适应度(选择前) 个体(选择后) 适应度(选择后) $a$ 1400 $b_1$ 2300 $b$ 2300 $a$ 1400 $c$ 60 $b_2$ 2300 $d$ 1100 $d$ 1100 可以看出选择前种群的平均适应度为 1350,而选择后为 1775,即经过一轮自然选择,种群中的个体普遍比上一代个体具有更高的适应度。同时,带有致死基因的个体因为其适应度为 0 而永远不会被自然选择命中,这保证了我们能够及早的在搜索结果中排除那些不满足限制条件的解。\n基因交叉 演化迭代的第二步是基因交叉,基因交叉发生与两两个体之间,指的是两个个体间的基因序列在随机某个位置之后的子序列发生交换,假设对于上一节经过自然选择之后的种群 $\\{b_1, a, b_2, d\\}$ 在基因位置 14 之后的子序列进行基因交叉,即 $b_1$ 与 $a$ 交叉,$b_2$ 与 $d$ 交叉,则如下表格描述了经过交叉后的种群的基因序列和对应的适应度:\n 个体 交叉前基因序列后 9 位 交叉后基因序列后 9 位 新 $x$ 和 $y$ 新适应度 $b_1$ 011001000 011001000 300, 200 2300 $a$ 011001000 011001000 120, 200 1400 $b_2$ 011001000 011000010 300, 194 2276 $d$ 010000010 010001100 116, 140 1140 可以看出交叉前种群的平均适应度为 1775,而交叉后为 1779,即经过一轮基因交叉,种群中的个体的适应度比上一代个体略有上升,若选择另一位置的字序列交叉,则可能出现更坏的情况。如果说自然选择是已有基因序列的择优筛选,本质上并没有为种群引入新的基因序列,而从这一步开始,种群内的个体产生了新的基因序列。\n基因变异 演化迭代的第三步是基因变异,基因变异发生在单个个体间,每个个体在每一轮迭代中都有一定概率出现遗传物质的复制错误,该行为模拟了生物 DNA 的转录错误,表现在算法中就是比特串某一位置的比特值发生反转。与基因交叉类似,变异可能产生原本种群内不存在基因,这可能改变种群整体的进化方向以避免之前讨论过的次有解陷阱。\n假设经过上一步交叉后的种群里,只有个体 $b_1$ 发生了基因突变,且突变的位置为 4,则 $b_i$ 突变后的基因即从 1001..0..1100011001000 变异为 100..1..01100011001000。\n种群更替与搜索结果 在种群完成「自然选择」、「基因交叉」和「基因突变」后,种群便完成了一次代际的更替,一般来说,新的种群会比上一代种群更加适合生存。如果选择了合适的参数,算法能够在中止前完成搜索的解收敛。\n我在我的机器上按照上述参数实现了一版算法,运行后最终种群在第 4 代便完成了解的收敛,最终算法给出的适应度最高的基因为 110010000001010000,即 $x$=400,$y$=80 时,利润最高,达到 2320 美元。\n","description":"","title":"用遗传算法解决规划问题(一)","uri":"/posts/solving-planing-problem-by-genetic-algorithm/"},{"categories":["技术"],"content":"Spring Boot 的 EnableAutoConfiguration 注解提供了自动发现和加载配置的能力,赋予了应用程序强大的可扩展性,该特性依赖于 SpringFactoryLoader 在 CLASSPATH 中寻找并解析 META-INF 目录下的 spring.factories 文件,这种做法延伸于 JDK 6.0 引入的 Service Provider Interface 机制,该机制的目的则是简化可扩展应用程序的设计和代码解耦。\n可扩展应用程序 可扩展的应用程序 即在不修改源代码的情况下,原程序可通过某种方式的处理以实现功能的伸缩和特性的增减,所谓的可扩展通常表现为支持插件或者模块子系统。因此,这类应用程序的使用者除了最终用户以外,还有第三方开发商。对于 Java 程序而言,这样的可扩展通常表现为「提供对外的 API 调用」、「在 CLASSPATH 引入 JAR 依赖」、「在 META-INF 目录下做配置」或者「在特定目录下放入插件文件」等方式,各个表现方式和它们对应的示例如下表所示:\n 表现形式 举例 应用程序提供 API 调用 Netty 对特定 Channel 的 Pipeline 处理 CLASSPATH 引入 JAR 依赖 Spring Boot 配置自动发现 META-INF 目录下配置文件 JDK 的 SPI 机制、Spring Factory 机制 特定目录下放入插件文件 IntelliJ IDEA 插件管理、游戏 MOD 管理 以游戏中的 MOD 管理为例,游戏团队并不能在编译期确定所有 MOD 列表,具体的 MOD 由第三方开发者在运行时实现。对于游戏团队来说,需要制定一个统一的 MOD 接入规范,并在程序启动时,以配置文件或者自动扫描的方式发现并初始化所有 MOD,而第三方开发者通过开发新的 MOD 来扩展原游戏的功能或改变原游戏的行为,因而那些支持 MOD 的游戏通常会表现出极强的可扩展性。\n可以看出,设计可扩展的应用程序的核心思想是解耦,即服务调用方不关心实现方的具体实现,也不用在代码中引入任何实现方的具体类。\nMETA-INF 目录 META-INF 从字面意义上理解就是元信息,对于 META-INF 目录,Oracle 的 文档 有如下的描述:\n In many cases, JAR files are not just simple archives of java classes files and/or resources. They are used as building blocks for applications and extensions. The META-INF directory, if it exists, is used to store package and extension configuration data, including security, versioning, extension and services.\n以配置文件或者自动扫描的方式发现并初始化所有 MOD,而第三方开发者通过开发新的 MOD 来扩展原游戏的功能或改变原游戏的行为,因而那些支持 MOD 的游戏通常会表现出极强的可扩展性。\n 即虽然 JAR 文件在内容上打包了 CLASS 和资源文件,但随着 Java 技术的发展,JAR 也逐渐成为了应用程序的扩展模块,而配置数据正是放在该目录下。所以 META-INF 目录本质上来说是一个对 JAR 文件有特殊意义的目录,里面存放了一些特殊的数据,比如扩展配置、签名数据等。\nMETA-INF 目录的读取在很多框架中都有涉及,比如 Spring Framework、Dubbo、携程的 Apollo。我们也可以在该目录下设置自己的元信息格式,甚至都不强制一定在 META-INF 目录下,只是一般约定会将元信息统一放在一个文件夹下,实际上 JAR 文件中的资源都可以通过如下方式读取:\nURL resourceUrl = ClassLoader.getSystemClassLoader().getResource(\"\u003cresource-name\u003e\"); URL metaInfResourceUrl = ClassLoader.getSystemClassLoader().getResource(\"META-INF/\u003cresource-name\u003e\"); Java 中的 SPI 机制 Service Provider Interface 在 JDK 6.0 之后引入,旨在简化可扩展应用程序的设计,通过 SPI 机制,开发者可以更为方便地为应用程序创建可替换的模组。SPI 的本质是自动发现和装载特定接口的实现类,它解决了接口的「运行时实现」和「运行时实现的自动发现」问题。\nService Loader SPI 的核心是 ServiceLoader 类,它被声明为 final 的,提供一个惰性加载的 load 方法:\npackage java.util; public final class ServiceLoader\u003cS\u003e implements Iterable\u003cS\u003e { public static \u003cS\u003e ServiceLoader\u003cS\u003e load(Class\u003cS\u003e service, ClassLoader loader) } 其中,service 参数定义了需要被加载的接口或者抽象类的 Class 对象,loader 参数则指定了加载使用的加载器。\nJDK 中 SPI 的应用 JDK 的很多内置模块和应用都使用了 SPI 机制,比如:\n java.sql.Driver JDBC API 在 4.0 版本之后使用 SPI 加载数据库驱动程序(4.0 版本之前使用 Class.forName(\"\"))。 java.nio.charset.CharsetProvider JDK 中的字符集使用 SPI 加载所有 CharsetProvider 的实现类。 一些第三方程序和框架也大量使用:\n org.slf4j.ILoggerFactory SLF4J 中定义的日志器工厂类,使用 SPI 在运行时发现可用的实现。 除此之外,在货币、本地化、日期时间的处理也是基于 SPI 实现,相应的,我们也可以使用 SPI 对 JDK 扩展我们自己的本地化或日期时间实现。\nSPI 约定 根据 ServiceLoader的 JavaDoc 文档描述,若要使用 SPI 对应用程序进行扩展,需要遵循以下几个约定:\n 在应用程序 CLASSPATH 下存在 META-INF/services 目录; 对每一个需要被实现的接口或抽象类,用其完全限定名为文件名称创建文本文件,文件的编码格式必须为 UTF-8; 文件每一行的内容为实现类的完全限定名。 以 JDBC 中的驱动程序 java.sql.Driver 为例,若我们打算在 JDK 注册自己的 JDBC 驱动实现,我们需要在 META-INF/services 目录下创建名为 java.sql.Driver 的文本文件,并确保编码格式为 UTF-8,并在文件中写入一行:\nresources/ └── META-INF/ └── services/ └── java.sql.Driver 并在文件 java.sql.Driver 中写入如下一行:\nyour.package.DriverImpl 至此,我们可以在应用程序中安全地调用如下代码:\nServiceLoader\u003cDriver\u003e driverServiceSSSSSSSSSiverS 实战基于 SPI 的字符集扩展 下面以对 java.nio.charset.Charset 的字符集扩展来实践 SPI 在日常开发中的使用:假设你设计了下一代的 UTF-8 字符编解码实现版本,并为该实现版本取名为 UTF-8-NG,你需要赶在下一版 JDK 公布之前,将这一版本接入到自己的应用程序中,并保证在你的应用程序 API 分发后,其他开发人员可以使用如下代码消费你的 UTF-8 实现:\nimport java.nio.Charset; Charset yourCharsetImpl = Charset.forName(\"utf-8-ng\"); 在通常情况下,上述代码会因为找不到对应的字符集而抛出 java.nio.charset.UnsupportedCharsetException 异常。 在 JDK 中,对字符集的管理是通过 java.nio.charset.spi.CharsetProvider 进行,它的基本定义如下:\npublic abstract class CharsetProvider { // 获得所有受该字符集提供者所管理的字符集集合的迭代器对象 public abstract Iterator\u003cCharset\u003e charsets(); // 在该字符集提供者所管理的字符集集合根据名称查找字符集对象 public abstract Charset charsetForName(String charsetName); } 这是一个典型的 SPI 接口,不同的 JDK vendor 有不同的实现,在截至本文完成时,Oracle JDK 中对应的实现是 sun.nio.cs.StandardCharsets,该类作为字符集管理的一个入口类,与之相伴的是其他由 Oracle 实现的具体字符集类。\nSpring Framework 中的 spring.factories 文件 EnableAutoConfiguration 注解 注解类 EnableAutoConfiguration 在 Spring Boot 中承担两个作用:「是否在被注解的包行启用配置自动发现机制」和「其完全限定名作为 META-INF/spring.factories 中的键来配置自动发现」。\nSpring Boot 的配置自动发现机制是以 Spring SPI 为基础实现的,Spring Boot 约定了自动发现的配置键在 META-INF/spring.factories 文件中的键为 org.springframwork.boot.autoconfigure.EnableAutoConfiguration,即启用自动发现的注解类 EnableAutoConfiguration 的完全限定名。\n","description":"","title":"谈谈 Spring Boot 的自动配置","uri":"/posts/spring-factories-and-jdk-spi-mechanism/"}]