-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathrss.xml
906 lines (906 loc) · 130 KB
/
rss.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
<?xml version="1.0" encoding="utf-8"?><rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>今天的天空差了点</title><link>https://zhix.co/</link><description>zhix 的精神避难所。</description><generator>Hugo 0.86.0 https://gohugo.io/</generator><language>zh-CN</language><managingEditor>[email protected] (zhix)</managingEditor><webMaster>[email protected] (zhix)</webMaster><copyright>[CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh)</copyright><lastBuildDate>Wed, 27 Oct 2021 18:07:16 +0000</lastBuildDate><atom:link rel="self" type="application/rss+xml" href="https://zhix.co/rss.xml"/><item><title>谈谈技术博客的排版</title><link>https://zhix.co/posts/talking-typesetting/</link><guid isPermaLink="true">https://zhix.co/posts/talking-typesetting/</guid><pubDate>Tue, 31 Mar 2020 22:01:33 +0800</pubDate><author>[email protected] (zhix)</author><copyright>[CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh)</copyright><description><h2 id="概述">概述</h2>
<p>相比于国内已有的内容平台,比如豆瓣、知乎、公众号,独立博客在搭建、配置和维护上会花费额外的精力,也需要一定动手能力才能持续运作,但是相对地,独立博客能更加灵活地控制内容产出,更加自由地挥洒笔墨,对排版样式更可以进行像素级的控制。</p>
<p>排版简单来说就是考虑如何组织文本,让文章对读者更加友好 &mdash; 这涉及到字体、字型、段落等元素的样式平衡。排版定义了网站的整体基调,引导读者阅读,决定用户体验,正如 <a href="https://github.com/sparanoid/chinese-copywriting-guidelines/">中文文案排版指北</a> 所说,一致的排版能够降低团队成员之间的沟通成本,增强网站气质,整齐划一的排版也是我写博客所追求的目标。</p>
<p>接下来我想谈谈博客在样式上的配置,包括相关 CSS 特性的讨论,以及我对技术博客排版的个人理解。文章组成博客,段落组成文章,段落的排版决定了博客的排版,段落的排版又以字体、行距、对齐最为关键。</p>
<h2 id="两端对齐">两端对齐</h2>
<p>正文段落 <a href="https://zh.wikipedia.org/wiki/%E5%B0%8D%E9%BD%8A#%E4%B8%A4%E7%AB%AF%E5%B0%8D%E9%BD%8A/">两端对齐</a>(<em><span lang="en">justify</span></em>),与 Web 中常规的 <a href="https://zh.wikipedia.org/wiki/%E5%B0%8D%E9%BD%8A#%E9%9D%A0%E5%B7%A6%E5%B0%8D%E9%BD%8A/">左对齐</a>(<em><span lang="en">Left justify</span></em>)相比,两端对齐保持各行左右边距的基线一致,视觉上更加整齐,适合中文这样单个字符构成的语言。</p>
<p><img src="https://zhix.co/blog/20200405182842.png#border" alt="两端对齐(上)与左对齐(下)的效果对比" title="两端对齐(上)与左对齐(下)的效果对比"></p>
<p>左对齐时,字符之间的间隙均等,行尾超过容器宽度的长单词折行显示;两端对齐时,字符之间的间隙不等,行尾的长单词同样会折行,但是会相应调整上一行的字符间隙来填充空白。</p>
<p>在 Web 中实现两端对齐,完整且保证兼容性的 CSS 写法为:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="nt">p</span> <span class="p">{</span>
<span class="k">text-align</span><span class="p">:</span> <span class="kc">justify</span><span class="p">;</span> <span class="c">/* 文本两端对齐 */</span>
<span class="k">text-justify</span><span class="p">:</span> <span class="n">inter-ideograph</span><span class="p">;</span> <span class="c">/* 调整表意文字间距以保持两端对齐 */</span>
<span class="p">}</span>
</code></pre></div><p>其中 <code>text-align: justify</code> 对应文本两端对齐,<code>text-justify</code> 表示在保持两端对齐的情况下如何处理间距,中文段落一般选择 <code>inter-ideograph</code>,它表示调整 CJK 表意文字字符和单词的间距来适应布局,也可以用 <code>distribute</code> 代替:</p>
<p>不过主流浏览器对 <code>text-justify</code> 的支持不佳,截至本文完成,只有 Firefox 有 <a href="https://developer.mozilla.org/zh-CN/docs/Web/CSS/text-justify/">较好的支持</a>。</p>
<p>除了段落 &lt;p&gt; 之外,以下可能存在折行内容的标签也建议使用两端对齐:</p>
<ul>
<li>列表项(<em>&lt;li&gt;</em>)</li>
<li>定义列表项(<em>&lt;dd&gt;</em>)</li>
</ul>
<h3 id="两端对齐的不足">两端对齐的不足</h3>
<p>两端对齐的不足主要在于中西文混排时的行间疏密参差不齐,这一点在移动设备上更为明显,下图中的单词 <em>RedisCache</em> 显得过于松散,是因为浏览器为了保持对齐而做了字符间距补偿:</p>
<p><img src="https://zhix.co/blog/20200406002400.png#mobile-screenshot" alt="width-360" title="iPhone 8 上的显示效果"></p>
<p>目前这种情况没有一劳永逸的解决方案,只能等未来 CSS 标准和浏览器实现能支持更加智能的折行,临时方案要么使用左对齐,要么尽量在文本中少用西文单词。</p>
<h3 id="折行">折行</h3>
<p>提到对齐方式不得不说折行,折行规定了文本过长时容器的处理方式。不同语言的书写系统对折行有不同要求,东亚语言(中文、日文、韩文等)用「音节」而不是「空格」区分单词,这些语言的文本几乎可以在任何字符之间折行<sup id="fnref:1"><a href="https://zhix.co/posts/talking-typesetting/#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>。建议的配置如下:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="nt">p</span> <span class="p">{</span>
<span class="k">line-break</span><span class="p">:</span> <span class="kc">auto</span><span class="p">;</span>
<span class="k">word-break</span><span class="p">:</span> <span class="kc">break-word</span><span class="p">;</span>
<span class="k">overflow-wrap</span><span class="p">:</span> <span class="kc">break-word</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div><p>其中 <code>word-break</code> 控制断字符,<code>overflow-wrap</code> 控制断词,这几个 CSS 属性很容易混淆,上述的配置已经适用于大部分中英混排的场景。</p>
<h2 id="段落">段落</h2>
<p>内容按段落划分,段落标题应当从 <em>&lt;h2&gt;</em> 开始,为什么不是 <em>&lt;h1&gt;</em> 呢?因为 <em>&lt;h1&gt;</em> 一般作为网页标题而特殊存在,一个页面建议只有一个 <em>&lt;h1&gt;</em> 标签,即 <em>&lt;h1&gt;</em> 是单例的。</p>
<blockquote>
<p>当被加载到浏览器中的时候,元素 <em>&lt;h1&gt;</em> 会出现在页面中 —— 通常它应该在一个页面中只被使用一次,它被用来标记你的页面内容的标题(故事的标题,新闻标题或者任何适当的方式)。</p>
<footer>
—— <cite>MDN·HTML 介绍</cite>
</footer>
</blockquote>
<p>行高一般设置在 1.5~2 之间即可,本博客是 1.75,用 CSS 表示为:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="nt">p</span> <span class="p">{</span>
<span class="k">line-height</span><span class="p">:</span> <span class="mf">1.75</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div><h3 id="西文段落">西文段落</h3>
<p>纯西文段落更加适合左对齐,应当在 CSS 用伪选择器为其单独设置语言属性。举例来说,假设 HTML 文档的语言为中文,即 <code>&lt;html lang=&quot;zh&quot;&gt;</code> 时,有段落如下:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>朝辞白帝彩云间,千里江陵一日还。<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
</code></pre></div><p>此时标签 <em>&lt;p&gt;</em> 没有显式设置 <em>&lt;lang&gt;</em> 属性,将使用当前 HTML 的语言属性 <em>zh</em>,而对西文段落,例如:</p>
<p>Four 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.</p>
<p>为了追求更好的排版效果,我们添加 <em>lang=&ldquo;en&rdquo;</em> 属性,并单独设置行高的对齐:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="nt">p</span><span class="p">:</span><span class="nd">lang</span><span class="o">(</span><span class="nt">en</span><span class="o">)</span> <span class="p">{</span>
<span class="k">line-height</span><span class="p">:</span> <span class="mf">1.5</span><span class="p">;</span> <span class="c">/* 西文字母较小,行距从 1.75 减小至 1.5 */</span>
<span class="k">text-align</span><span class="p">:</span> <span class="kc">left</span><span class="p">;</span> <span class="c">/* 西文文本左对齐 */</span>
<span class="p">}</span>
</code></pre></div><p>此外,可以进一步设置基于浏览器词典的自动断词:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="nt">p</span><span class="p">:</span><span class="nd">lang</span><span class="o">(</span><span class="nt">en</span><span class="o">)</span> <span class="p">{</span>
<span class="k">hyphens</span><span class="p">:</span> <span class="kc">auto</span><span class="p">;</span> <span class="c">/* 西文自动断词,包括以下两个 -&lt;vendor&gt;-hyphens 的兼容性选项 */</span>
<span class="kp">-webkit-</span><span class="k">hyphens</span><span class="p">:</span> <span class="kc">auto</span><span class="p">;</span>
<span class="kp">-moz-</span><span class="k">hyphens</span><span class="p">:</span> <span class="kc">auto</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div><p>对比下以下两种排版效果,第一段是是默认的 1.75 行距两端对齐段落,第二段是 1.5 行距左对齐段落:</p>
<p>Four 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.</p>
<p lang="en">Four 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.</p>
<!--  -->
<p>相较于上面第一段的默认效果,用 <em>lang=&ldquo;en&rdquo;</em> 描述的段落的 <em>1.5</em> 倍行距更加紧凑,文本左对齐和自动断词达到了更加贴近英文印刷品的排版效果,如果你的浏览器宽度恰好,甚至能看到行尾的 “<span lang="en">a new nation</span>” 进行了连字符折断。</p>
<h2 id="字体与字号">字体与字号</h2>
<p>中西文字体分别使用 <a href="https://fonts.google.com/specimen/Noto+Serif+SC/">思源宋体</a> 和 <a href="https://fonts.google.com/specimen/Zilla+SLab/">Zilla Slab</a>,从 <a href="https://fonts.google.com/">Google Fonts</a> 加载,字号为 16px,确定字体大小后保证每行字数在 38~42,CSS 的配置为:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="p">@</span><span class="k">import</span> <span class="nt">url</span><span class="o">(</span><span class="s1">&#39;https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;700&amp;family=Zilla+Slab:ital,wght@0,400;0,600;1,400;1,600&amp;display=swap&#39;</span><span class="o">)</span><span class="p">;</span>
<span class="nt">body</span> <span class="p">{</span>
<span class="k">font-size</span><span class="p">:</span> <span class="mi">16</span><span class="kt">px</span><span class="p">;</span>
<span class="k">font-family</span><span class="p">:</span> <span class="s1">&#39;Zilla Slab&#39;</span><span class="p">,</span> <span class="s1">&#39;Noto Serif SC&#39;</span><span class="p">,</span> <span class="kc">serif</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div><p>统一的字体是为了各平台下的显示效果完全一致,给读者连续的阅读体验,代价是在读者本地没有安装对应字体的情况下,页面有字体请求的网络开销。</p>
<h3 id="思源系列字体">思源系列字体</h3>
<p>思源系列字体是 Google 和 Adobe 联合开发的开源免费字体集(Google 称作 Noto 系列,Adode 称作 Source 系列),特点是设计优雅,可读性高,对 CJK 书写系统的支持很好,任何人都可免费下载和几乎在任何地方使用,宋体相对于黑体增加的笔锋更加接近于书本的效果,更具文字美感。</p>
<p><img src="https://zhix.co/blog/20200405182812.png#border" alt="思源宋体:开源的泛 CJK 字体"></p>
<p>基于以上诸多优点,我建议每个人的电脑里都应该安装思源系列字体。</p>
<h3 id="衬线体与无衬线体">衬线体与无衬线体</h3>
<p>另一个问题是,正文选用衬线体还是无衬线体?即选用宋体还是黑体。</p>
<p>如果是五年前,无笔锋的黑体更适合,黑体在中低分辨率屏幕的可读性更好,尤其是各平台默认黑体几乎是最安全的选择。现在则凭个人喜好,一方面开源的衬线体比如思源宋体获取门槛降低,另一方面随着 Web 技术和显示器分辨率的进步,使用衬线体的效果渐渐不再逊色于无衬线体,甚至能呈现更逼真的纸张模拟。</p>
<p><img src="https://zhix.co/blog/20200405182754.png#border" alt="衬线思源宋体(上)与无衬线苹方(下)的显示效果对比" title="衬线思源宋体(上)与无衬线苹方(下)的显示效果对比"></p>
<p>另外,正文中的无衬线字体应当降低颜色对比度,让文本整体更加偏灰以减少攻击性和视觉冲击,比如下图中 <a href="https://sspai.com/">少数派</a> 页面的正文效果:</p>
<p><img src="https://zhix.co/blog/20200405183037.png#border" alt="少数派的正文字体颜色偏灰" title="少数派的正文字体颜色偏灰"></p>
<h2 id="hancss">Han.css</h2>
<p><a href="https://github.com/ethantw/Han/">Han.css</a> 是一套用于 Web 的汉字排版解决方案,作为已有 CSS 的补充为网页提供了丰富地用于汉字书写系统的特性,尤其针对那些已有 CSS 属性无法支持的排版特性比如:</p>
<ul>
<li>中西文间混排 <em>.25em</em> 间隙,即所谓的盘古之白</li>
<li>标点挤压</li>
<li>标点悬挂</li>
<li>其他样式等</li>
</ul>
<p>Han.css 可对页面整体使用,也可对某个子元素使用,甚至是只开启部分功能。出于摸索阶段的谨慎,我只开启了标点挤压功能。</p>
<p>标点挤压是指:汉字排版连续使用多个符号时,字与字间将出现一个汉字宽度的空隙,不甚美观,而用额外的 JavaScript 脚本缩减连续标点及行首/行尾标点的多余空间。</p>
<p><img src="https://zhix.co/blog/20200405182709.png#border" alt="博客中使用了标点挤压模式 B" title="博客中使用了标点挤压模式 B(图来自 The Type)"></p>
<h3 id="启用标点挤压">启用标点挤压</h3>
<p>在头部引入 Han.css 的脚本和样式:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;stylesheet&#34;</span> <span class="na">media</span><span class="o">=</span><span class="s">&#34;all&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;//cdnjs.cloudflare.com/ajax/libs/Han/3.3.0/han.min.css&#34;</span><span class="p">&gt;</span>
<span class="p">&lt;</span><span class="nt">script</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;//cdnjs.cloudflare.com/ajax/libs/Han/3.3.0/han.min.js&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</code></pre></div><p>引入 Han.css 相关的 JavaScript 依赖后,在网页中插入以下脚本即可开启 <em>&lt;body&gt;</em> 元素下的标点挤压:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
<span class="nb">window</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;DOMContentLoaded&#39;</span><span class="p">,</span> <span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
<span class="nx">Han</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span>
<span class="p">.</span><span class="nx">initCond</span><span class="p">()</span> <span class="c1">// 初始化脚本
</span><span class="c1"></span> <span class="p">.</span><span class="nx">renderJiya</span><span class="p">()</span> <span class="c1">// 渲染标点挤压
</span><span class="c1"></span> <span class="p">})</span>
<span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
</code></pre></div><p>开启前后的效果对比如下图所示,注意第二行的右括号和逗号的处理:</p>
<p><img src="https://zhix.co/blog/20200406001103.png#border" alt="" title="启用标点挤压(上)和无标点挤压(下)的效果对比"></p>
<p>因为目前的 CSS 标准并没有覆盖到该特性,所以这项技术的本质是依赖外部样式和脚本做后置渲染。</p>
<h2 id="其他">其他</h2>
<h3 id="首行缩进">首行缩进</h3>
<p>MemE 主题的正文的分段样式有两种选择,<span lang="en">margin</span> 和 <span lang="en">indent</span>,前者就是一般的依靠 CSS 上下外边距分隔段落,后者是类似书本的首行缩进,这里的选用凭个人喜好,具体见 MemE 主题的 <a href="https://github.com/reuixiy/hugo-theme-meme/blob/a323e45d5ece0afb741fe90b495a723698600b8c/config-examples/zh-cn/config.toml#L916/">配置文件</a>。</p>
<p><img src="https://zhix.co/blog/20200406001535.png#border" alt="margin 和 indent 的区别" title="margin 和 indent 的区别"></p>
<h3 id="何时使用-italic">何时使用 italic</h3>
<p>另外就是,技术文章中大量用到的「类型名」、「函数名」、「变量名」。严格意义上应当使用 <em>&lt;var&gt;</em> 标签标记,但考虑到 Markdown 里面没有类似的语法,所以我参照 <a href="https://www.baeldung.com/">Baeldung</a> 的样式,使用 <em>&lt;em&gt;</em> 标签标记,比如下图中的 <em>SearchCriteria</em> 作为类型名称被渲染成了斜体。</p>
<p><img src="https://zhix.co/blog/20200401205159.jpg#border" alt="Bealdung 网站的文本使用 标签描述类型名" title="Bealdung 网站的文本使用 &lt;em&gt; 标签描述类型名"></p>
<h2 id="总结">总结</h2>
<p>在「够看」的情况下继续深入优化排版,像对待印刷品一样对待 Web 页面,是一种工匠精神的体现,但另一方面我们又不得不面对一个尴尬的现实:文中所提到的那些高阶排版技巧,除了已经纳入 CSS 标准的特性外,有些需要微调页面元素,有些需要 JavaScript 脚本参与,有些则要求打字者一一手动校对,而 Web 网页作为「快速消费品」,大多数用户根本不会注意到这些额外的排版特性带来的效果增益,所以浏览器也没有足够的理由为支持这些特性而投入成本。</p>
<p>就像《死亡搁浅》中场景里的某朵小花,它会在你送快递的时候于视野中一闪而过,构成你对游戏整体体验的一部分,但是如果没有它,也不影响你继续游戏过程,更不会降低你对《死亡搁浅》的评价。</p>
<p>但是,这不能成为我们对 Web 排版满足现状的理由,因为排版的意义在于让人类更加舒适地阅读文字 &mdash; 每纠正一个标点符号,每对齐一行文本,每划分一个段落,都是对艺术和美的追求。</p>
<p>最后,技术总是在进步,我们在 HTML 页面上对美学的要求总会随着标准草案的迭代和 Web 基础能力的支持而不断向前。可以肯定的是,Web 页面的质量将会无限趋近甚至超越纸质印刷品,现有的标准也会逐渐覆盖人类所有的语言和书写系统,甚至是这些系统里的冷门而小众的特性。</p>
<h2 id="延伸阅读">延伸阅读</h2>
<ul>
<li><a href="https://w3c.github.io/clreq/">中文排版需求·W3C</a></li>
<li><a href="https://github.com/sparanoid/chinese-copywriting-guidelines/">中文文案排版指北·GitHub</a></li>
<li><a href="https://hanzi.pro/">漢字標準格式 &mdash; 印刷品般的汉字排版框架</a></li>
<li><a href="https://www.thetype.com/2015/04/9171/">从《中文排版需求》开始·The Type</a></li>
<li><a href="https://learnku.com/docs/writing-docs/">社区文档撰写指南·LearnKu 产品论坛</a></li>
<li><a href="https://www.thetype.com/2018/04/14734/">针对 Adobe InDesign 标点挤压中文默认设置的反馈·The Type</a></li>
<li><a href="https://zzao.im/blog/post/left-or-justify.html">排版左对齐(left)与两端对齐(justify)的思考·Hungl Zzz&rsquo;s Blog</a></li>
<li><a href="https://www.codesandnotes.com/front-end/word-breaking-in-east-asian-languages/"><span lang="en" class="latin">Word breaking online in East Asian languages·Code &amp; Notes</span></a></li>
</ul>
<section class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1" role="doc-endnote">
<p>摘录自 <a href="https://www.codesandnotes.com/front-end/word-breaking-in-east-asian-languages/">Code &amp; Notes</a>,原文为:</p>
<blockquote>
<p lang="en">
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.
</p>
</blockquote>
&#160;<a href="https://zhix.co/posts/talking-typesetting/#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></li>
</ol>
</section></description><category domain="https://zhix.co/categories/%E8%AE%BE%E8%AE%A1/">设计</category><category domain="https://zhix.co/series/%E6%8E%92%E7%89%88%E5%AD%A6/">排版学</category><category domain="https://zhix.co/tags/%E5%8D%9A%E5%AE%A2/">博客</category><category domain="https://zhix.co/tags/%E8%AE%BE%E8%AE%A1/">设计</category><category domain="https://zhix.co/tags/%E6%8E%92%E7%89%88/">排版</category><category domain="https://zhix.co/tags/%E5%AD%97%E4%BD%93/">字体</category><category domain="https://zhix.co/tags/css/">css</category><category domain="https://zhix.co/tags/baeldung/">baeldung</category><category domain="https://zhix.co/tags/markdown/">markdown</category><category domain="https://zhix.co/tags/%E5%85%AC%E4%BC%97%E5%8F%B7/">公众号</category></item><item><title>在 Spring Cache 中为 Redis 添加内存缓存</title><link>https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/</link><guid isPermaLink="true">https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/</guid><pubDate>Sun, 29 Mar 2020 12:44:41 +0800</pubDate><author>[email protected] (zhix)</author><copyright>[CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh)</copyright><description><p>缓存是提升应用程序性能的首要途径,我们一般会使用 Redis 来实现缓存层以减小对持久层的访问压力,随之带来的问题是:即便在缓存命中的情况下,应用程序依然需要访问 Redis 服务器并消耗一定的 CPU 算力和网络带宽资源,随着业务量增长,代价可能变得更加明显。本文将以 Spring Cache 为背景,探讨如何以最小化的改动来实现给 Redis 加持内存缓存。</p>
<h2 id="spring-cache-抽象">Spring Cache 抽象</h2>
<p><a href="https://docs.spring.io/spring/docs/5.0.0.RELEASE/spring-framework-reference/integration.html#cache">Spring Cache</a> 作为 Spring 最核心的模块之一,提供了开箱即用的缓存支持,应用程序只需要在任意 <em>Configuration</em> 类上加入注解 <em>@EnableCaching</em> 即可启用缓存:</p>
<p>Spring Cache 的实现位 <em>org.springframework.cache</em> 包下,如果使用 Maven 的话需要引入 <a href="https://search.maven.org/search?q=g:org.springframework%20a:spring-context"><em>spring-context</em></a> 模块,其中最核心的 2 个接口定义如下:</p>
<dl>
<dt><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/Cache.html"><em>Cache</em></a></dt>
<dd>代表通用缓存对象的抽象,定义了与缓存交互的接口,包含基本的读取、写入、淘汰和清空操作,管理一系列的缓存键值对,按键寻址,拥有唯一的名称。</dd>
<dt><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/CacheManager.html"><em>CacheManager</em></a></dt>
<dd>代表缓存管理器的抽象,管理一系列缓存对象,按名称寻址缓存。</dd>
</dl>
<p>一句话概括就是:Spring 在核心模块 <em>spring-context</em> 就包含了对缓存的支持,通过注解 <em>@EnableCaching</em> 来使用。需要被缓存的对象由 <em>Cache</em> 管理并按键寻址,<em>Cache</em> 按照名称区分彼此,统一地注册在 <em>CacheManager</em> 中<sup id="fnref:1"><a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>。</p>
<h2 id="concurrentmapcache-和-rediscache">ConcurrentMapCache 和 RedisCache</h2>
<p>简单来说,<em>Cache</em> 和 <em>CacheManager</em> 定义了如何存储具体的缓存对象,是存储在本地还是远程服务器,实际的方式不同实现有不同表现,一般我们用得最多的实现要数以下两种:</p>
<dl>
<dt><a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/concurrent/ConcurrentMapCache.html"><em>ConcurrentMapCache</em></a></dt>
<dd>基于 <em>ConcurrentHashMap</em> 实现的本地缓存,也是此次的内存缓存实现类,对应的缓存管理器是 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/concurrent/ConcurrentMapCacheManager.html"><em>ConcurrentMapCacheManager</em></a>;</dd>
<dt><a href="https://docs.spring.io/spring-data/data-redis/docs/current/api/org/springframework/data/redis/cache/RedisCache.html"><em>RedisCache</em></a></dt>
<dd>基于 Redis 实现的分布式缓存,使用时需要引入 <a href="https://search.maven.org/search?q=g:org.springframework%20a:spring-data-redis"><em>spring-data-redis</em></a> 依赖,对应的缓存管理器是 <a href="https://docs.spring.io/spring-data/data-redis/docs/current/api/org/springframework/data/redis/cache/RedisCacheManager.html"><em>RedisCacheManager</em></a>。</dd>
</dl>
<h2 id="实现二级内存缓存">实现二级内存缓存</h2>
<p>回到开头的问题,如果需要在 <em>RedisCache</em> 存在的情况下,为应用程序加入内存二级缓存的支持,要如何做呢?典型的场景是,对于某个缓存键,若在本地内存缓存中存在,则使用内存缓存的值,否则查询 Redis 缓存,若存在,将取得的值回写入内存缓存中,流程图表示为:</p>
<div class="mermaid">
graph 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)]
</div>
<p>虽然 Spring 提供了名为 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/support/CompositeCacheManager.html"><em>CompositeCacheManager</em></a> 的实现来组合多个 <em>CacheManager</em>,但也仅仅是在名称寻址时,迭代所管理的 <em>CacheManager</em> 集合,返回第一个寻址不为 null 的 <em>Cache</em> 对象,并不能完成上述的缓存回写的实现。</p>
<p>我们可以用 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/support/AbstractCacheManager.html"><em>AbstractCacheManager</em></a> 的特性来解决这个问题,<em>AbstractCacheManager</em> 提供了名为 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/support/AbstractCacheManager.html#decorateCache-org.springframework.cache.Cache-"><em>decorateCache</em></a> 的保护方法来对 <em>Cache</em> 对象做封装,它的定义如下:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="cm">/**
</span><span class="cm"> * Decorate the given Cache object if necessary.
</span><span class="cm"> *
</span><span class="cm"> * @param cache the Cache object to be added to this CacheManager
</span><span class="cm"> * @return the decorated Cache object to be used instead,
</span><span class="cm"> * or simply the passed-in Cache object by default
</span><span class="cm"> */</span>
<span class="kd">protected</span> <span class="n">Cache</span> <span class="nf">decorateCache</span><span class="o">(</span><span class="n">Cache</span> <span class="n">cache</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">cache</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div><p><em>decorateCache</em> 方法的调用时机有 2 个:</p>
<ol>
<li><em>CacheManager</em> 初始化缓存;</li>
<li>向 <em>CacheManager</em> 请求了它所没有的缓存(<span lang="en">Missing Cache</span>),且 <em>CacheManager</em> 被配置成自动创建不存在的缓存时,<em>decorateCache</em> 会在 <span lang="en">Missing Cache</span> 被创建时被调用。<sup id="fnref:2"><a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup></li>
</ol>
<p>重载 <em>decorateCache</em> 用的是典型的 <a href="https://zh.wikipedia.org/wiki/%E4%BF%AE%E9%A5%B0%E6%A8%A1%E5%BC%8F">装饰模式</a> 的思想,在子类中重写该方法,我们可以将参数中的 <em>Cache</em> 对象包装成我们想要的实现,从而达到在不修改原有缓存的情况动态地下改变原缓存的行为。</p>
<h3 id="缓存装饰器">缓存装饰器</h3>
<p>首先我们展示一个缓存装饰器的简单示例,它在每次缓存读取和写入时打印一条日志:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="nd">@Slf4j</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">SimpleLoggingCacheDecorator</span> <span class="kd">implements</span> <span class="n">Cache</span> <span class="o">{</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="n">Cache</span> <span class="n">delegate</span><span class="o">;</span>
<span class="kd">public</span> <span class="nf">LoggingCacheDecorator</span><span class="o">(</span><span class="n">Cache</span> <span class="n">delegate</span><span class="o">)</span> <span class="o">{</span>
<span class="k">this</span><span class="o">.</span><span class="na">delegate</span> <span class="o">=</span> <span class="n">delegate</span><span class="o">;</span>
<span class="o">}</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="n">String</span> <span class="nf">getName</span><span class="o">()</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">delegate</span><span class="o">.</span><span class="na">getName</span><span class="o">();</span>
<span class="o">}</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="n">Object</span> <span class="nf">getNativeCache</span><span class="o">()</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">delegate</span><span class="o">.</span><span class="na">getNativeCache</span><span class="o">();</span>
<span class="o">}</span>
<span class="nd">@Nullable</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="n">ValueWrapper</span> <span class="nf">get</span><span class="o">(</span><span class="n">Object</span> <span class="n">key</span><span class="o">)</span> <span class="o">{</span>
<span class="n">ValueWrapper</span> <span class="n">valueWrapper</span> <span class="o">=</span> <span class="n">delegate</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>
<span class="n">log</span><span class="o">.</span><span class="na">debug</span><span class="o">(</span><span class="s">&#34;Get cache value, key = {}&#34;</span><span class="o">,</span> <span class="n">key</span><span class="o">);</span>
<span class="k">return</span> <span class="n">valueWrapper</span><span class="o">;</span>
<span class="o">}</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">put</span><span class="o">(</span><span class="n">Object</span> <span class="n">key</span><span class="o">,</span> <span class="nd">@Nullable</span> <span class="n">Object</span> <span class="n">value</span><span class="o">)</span> <span class="o">{</span>
<span class="n">log</span><span class="o">.</span><span class="na">debug</span><span class="o">(</span><span class="s">&#34;Put cache value, key = {}&#34;</span><span class="o">,</span> <span class="n">key</span><span class="o">);</span>
<span class="n">delegate</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">value</span><span class="o">);</span>
<span class="o">}</span>
<span class="c1">// 所有的方法都转发到委托对象,下同
</span><span class="c1"></span><span class="o">}</span>
</code></pre></div><p>接着继承现有的 <em>CacheManager</em> 并重写 <em>decorateCache</em> 方法:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="nd">@Override</span>
<span class="kd">protected</span> <span class="n">Cache</span> <span class="nf">decorateCache</span><span class="o">(</span><span class="n">Cache</span> <span class="n">cache</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="k">new</span> <span class="n">SimpleLoggingCacheDecorator</span><span class="o">(</span><span class="n">cache</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div><p>这样我们就实现了在日志中捕捉缓存的方法调用,可以看出,通过装饰器模式,我们能够无侵入地修改原对象的行为,这也为我们后续进一步 hack 缓存提供了设计基础。</p>
<h3 id="内存缓存装饰器">内存缓存装饰器</h3>
<p>接下来我们沿用上一节的设计,尝试实现一个内存缓存的装饰器(<span lang="en">Memory Cache Decorator</span>),它的作用是按照上述流程图所描述的逻辑来改变已有 Redis 缓存的行为。</p>
<p>不难分析出,这个装饰器会具有如下特征:</p>
<ol>
<li>持有一个上游缓存的引用,并管理一个内存缓存;</li>
<li>修改上游缓存读取方法的行为,在方法返回 null 时转而查询本地的内存缓存,依据查询的结果判断是否需要回写本地缓存;</li>
<li>修改上游缓存写入方法的行为,在方法执行的同时也同步到到本地的内存缓存。</li>
</ol>
<p>以下是它的部分代码实现:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="nd">@Slf4j</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MemoryCacheDecorator</span> <span class="kd">implements</span> <span class="n">Cache</span> <span class="o">{</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="n">Cache</span> <span class="n">memory</span><span class="o">;</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="n">Cache</span> <span class="n">source</span><span class="o">;</span>
<span class="kd">public</span> <span class="nf">MemoryCacheDecorator</span><span class="o">(</span><span class="n">Cache</span> <span class="n">source</span><span class="o">)</span> <span class="o">{</span>
<span class="k">this</span><span class="o">.</span><span class="na">memory</span> <span class="o">=</span> <span class="k">new</span> <span class="n">ConcurrentMapCache</span><span class="o">(</span><span class="s">&#34;memory-&#34;</span> <span class="o">+</span> <span class="n">source</span><span class="o">.</span><span class="na">getName</span><span class="o">());</span>
<span class="k">this</span><span class="o">.</span><span class="na">source</span> <span class="o">=</span> <span class="n">source</span><span class="o">;</span>
<span class="o">}</span>
<span class="nd">@NonNull</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="n">String</span> <span class="nf">getName</span><span class="o">()</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">source</span><span class="o">.</span><span class="na">getName</span><span class="o">();</span>
<span class="o">}</span>
<span class="nd">@NonNull</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="n">Object</span> <span class="nf">getNativeCache</span><span class="o">()</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">source</span><span class="o">.</span><span class="na">getNativeCache</span><span class="o">();</span>
<span class="o">}</span>
<span class="nd">@Nullable</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="n">ValueWrapper</span> <span class="nf">get</span><span class="o">(</span><span class="nd">@NonNull</span> <span class="n">Object</span> <span class="n">key</span><span class="o">)</span> <span class="o">{</span>
<span class="n">ValueWrapper</span> <span class="n">valueWrapper</span> <span class="o">=</span> <span class="n">memory</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>
<span class="k">if</span> <span class="o">(</span><span class="n">valueWrapper</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">valueWrapper</span><span class="o">;</span>
<span class="o">}</span>
<span class="n">valueWrapper</span> <span class="o">=</span> <span class="n">source</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>
<span class="k">if</span> <span class="o">(</span><span class="n">valueWrapper</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
<span class="n">memory</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">valueWrapper</span><span class="o">.</span><span class="na">get</span><span class="o">());</span>
<span class="o">}</span>
<span class="k">return</span> <span class="n">valueWrapper</span><span class="o">;</span>
<span class="o">}</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">put</span><span class="o">(</span><span class="nd">@NonNull</span> <span class="n">Object</span> <span class="n">key</span><span class="o">,</span> <span class="nd">@Nullable</span> <span class="n">Object</span> <span class="n">value</span><span class="o">)</span> <span class="o">{</span>
<span class="n">source</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">value</span><span class="o">);</span>
<span class="n">memory</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">value</span><span class="o">);</span>
<span class="o">}</span>
<span class="c1">// 其他的 GET/PUT 方法省略
</span><span class="c1"></span><span class="o">}</span>
</code></pre></div><p><em>MemoryCacheDecorator</em> 的逻辑并不复杂,仅仅是拦截了上游缓存的读取操作,其中:</p>
<ul>
<li>第 8 行创建了替代上游缓存的内存缓存对象,采用 <em>ConcurrentMapCache</em> 实现,为了健壮起见,内存缓存的名称是上游缓存名称前加 <code>memory-</code>;</li>
<li>第 27 ~ 35 行是真正起作用的部分:先查询内存缓存,依据结果判断是否需要进一步查询上游缓存,且保证查询上游缓存后回写内存缓存以保证一致性;</li>
<li>同样为健壮起见,在上游缓存被修改时也需要同步到内存缓存中,如第 41 行所示。</li>
</ul>
<h3 id="扩展-rediscachemanager">扩展 RedisCacheManager</h3>
<p>现在是时候扩展现有的缓存管理器了,由于上游缓存是 <em>RedisCache</em>,我们需要扩展它所对应的缓存管理器 &mdash; <em>RedisCacheManager</em>,并重写 <em>decorateCache</em> 方法,代码如下:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="kd">public</span> <span class="kd">class</span> <span class="nc">MemoryRedisCacheManager</span> <span class="kd">extends</span> <span class="n">RedisCacheManager</span> <span class="o">{</span>
<span class="c1">// 构造方法省略
</span><span class="c1"></span>
<span class="nd">@NonNull</span>
<span class="nd">@Override</span>
<span class="kd">protected</span> <span class="n">Cache</span> <span class="nf">decorateCache</span><span class="o">(</span><span class="nd">@NonNull</span> <span class="n">Cache</span> <span class="n">cache</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="k">new</span> <span class="n">MemoryCacheDecorator</span><span class="o">(</span><span class="n">cache</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p>在任何用到 <em>RedisCacheManager</em> 的地方使用 <em>MemoryRedisCacheManager</em> 替换,保证程序中最终起作用的 <em>CacheManager</em> 是我们实现的 <em>MemoryRedisCacheManager</em> 即可。</p>
<p>此时所有的 <em>Cache</em> 对象在初始化时,都会被包装成 <em>MemoryCacheDecorator</em> 类型,在读取和写入时会先从内存缓存中查询,这样便完成了二级缓存的实现。</p>
<p>实际上,按照这样的方式上游缓存不一定是 <em>RedisCache</em>,任何可以远程缓存比如 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/ehcache/EhCacheCache.html"><em>EhCacheCache</em></a> 也可以通过这样的方式来整合本地二级缓存。</p>
<h2 id="内存缓存的优化">内存缓存的优化</h2>
<p>虽然通过扩展 <em>RedisCacheManager</em> 类和少量代码便能实现本地的内存缓存,但这也只是完成了第一步,现在的代码如果在生产环境使用仍然具有不少问题:</p>
<ol>
<li>
<p><strong>缓存过期</strong>:我们没有确定内存缓存何时过期,Redis 缓存的过期由 Redis 服务器的键过期能力来保证,但 <em>ConcurrentMapCache</em> 没有。况且实际的服务通常是集群部署,存在着多个实例负载均衡,因此各个实例之间的缓存一致性也是需要考虑的,否则可能出现用户访问的结果同时存在新旧两个版本。</p>
</li>
<li>
<p><strong>序列化与线程安全</strong>:<em>ConcurrentMapCache</em> 中默认保存的是缓存值本身,即多线程环境下各个线程对同一个缓存键获取的值是同一个对象实例。若其中一个线程修改了该实例,则会其他线程的读取,比如一个业务读取缓存中的配置数据,根据自己的业务逻辑修改了对象的字段,由于对象只有一份,这个修改将会被所有其他线程知晓。</p>
</li>
<li>
<p><strong>条件化启用</strong>:业务中的缓存可能会分为用户数据的缓存(热数据)和配置数据的缓存(冷数据)两组,不同组的缓存修改的频率不同,比如用户缓存随着用户行为的发生而被淘汰,而配置数据的更新频率往往是按周来算,因此我们一般只会对不常变化的配置数据做内存二级缓存,这就要求 <em>CacheManager</em> 条件化地对 <em>Cache</em> 进行装饰。</p>
</li>
</ol>
<p>基于以上 3 点,我们可以对现有代码做优化。</p>
<h3 id="缓存过期">缓存过期</h3>
<p>内存缓存需要过期(严格来说是清空),并且最好是所有服务实例在同一时间点过期,典型的解决方案就是基于 <a href="https://www.baeldung.com/cron-expressions">Cron 表达式</a> 的定时任务。</p>
<p>Spring 中设置 Cron 定时任务的方式非常方便,如果服务已经配置了启用定时任务的注解 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/EnableScheduling.html"><em>@EnableScheduling</em></a>,则可以让我们的缓存管理器简单地实现 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/SchedulingConfigurer.html"><em>SchedulingConfigurer</em></a> 接口,如果没有配置的话,定时任务也是 Spring 的 <em>spring-context</em> 模块就支持的,不需要引入其他的依赖。在此之前,首先让 <em>MemoryCacheDecorator</em> 提供一个公共的清理内存缓存的方法:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="nd">@Slf4j</span>
<span class="kd">class</span> <span class="nc">MemoryCacheDecorator</span> <span class="kd">implements</span> <span class="n">Cache</span> <span class="o">{</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">cleanMemoryCache</span><span class="o">()</span> <span class="o">{</span>
<span class="n">memoryCache</span><span class="o">.</span><span class="na">clear</span><span class="o">();</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p>再让缓存管理器注册 Cron 定时任务,比如按每分钟的第 30 秒执行清空:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="nd">@Slf4j</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MemoryRedisCacheManager</span> <span class="kd">extends</span> <span class="n">RedisCacheManager</span> <span class="kd">implements</span> <span class="n">SchedulingConfigurer</span> <span class="o">{</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">configureTasks</span><span class="o">(</span><span class="nd">@NonNull</span> <span class="n">ScheduledTaskRegistrar</span> <span class="n">taskRegistrar</span><span class="o">)</span> <span class="o">{</span>
<span class="c1">// 向容器注册清理缓存的 CRON 任务,若没有配置 @EnableScheduling,这里不会执行,需要手动调用 clearMemoryCache 清理
</span><span class="c1"></span> <span class="n">taskRegistrar</span><span class="o">.</span><span class="na">addCronTask</span><span class="o">(</span><span class="k">this</span><span class="o">::</span><span class="n">clearMemoryCache</span><span class="o">,</span> <span class="n">clearCacheCronExpression</span><span class="o">);</span>
<span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">&#34;Register cron task for clear memory cache, cron = {}&#34;</span><span class="o">,</span> <span class="n">clearCacheCronExpression</span><span class="o">);</span>
<span class="o">}</span>
<span class="cm">/** 手动清理所有内存缓存。 */</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">clearMemoryCache</span><span class="o">()</span> <span class="o">{</span>
<span class="n">Collection</span><span class="o">&lt;</span><span class="n">String</span><span class="o">&gt;</span> <span class="n">cacheNames</span> <span class="o">=</span> <span class="n">getCacheNames</span><span class="o">();</span>
<span class="k">for</span> <span class="o">(</span><span class="n">String</span> <span class="n">cacheName</span> <span class="o">:</span> <span class="n">cacheNames</span><span class="o">)</span> <span class="o">{</span>
<span class="n">Cache</span> <span class="n">cache</span> <span class="o">=</span> <span class="n">getCache</span><span class="o">(</span><span class="n">cacheName</span><span class="o">);</span>
<span class="k">if</span> <span class="o">(!(</span><span class="n">cache</span> <span class="k">instanceof</span> <span class="n">MemoryCacheDecorator</span><span class="o">))</span> <span class="o">{</span>
<span class="k">continue</span><span class="o">;</span>
<span class="o">}</span>
<span class="c1">// 对所有 MemoryCacheDecorator 类型的缓存做清理
</span><span class="c1"></span> <span class="n">MemoryCacheDecorator</span> <span class="n">memoryCacheDecorator</span> <span class="o">=</span> <span class="o">(</span><span class="n">MemoryCacheDecorator</span><span class="o">)</span> <span class="n">cache</span><span class="o">;</span>
<span class="n">memoryCacheDecorator</span><span class="o">.</span><span class="na">cleanMemoryCache</span><span class="o">();</span>
<span class="o">}</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p>这样就能保证所有的服务实例,几乎在同一时间点清空内存缓存。<sup id="fnref:3"><a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup></p>
<h3 id="序列化">序列化</h3>
<p>缓存的序列化机制通过复制对象来保证线程安全,如果每次从缓存中获取到的总是全新的对象,那么就不存在上述的多线程修改缓存对象互相影响的问题。</p>
<p><em>ConcurrentMapCache</em> 可以在 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/concurrent/ConcurrentMapCache.html#ConcurrentMapCache-java.lang.String-java.util.concurrent.ConcurrentMap-boolean-org.springframework.core.serializer.support.SerializationDelegate-">构造方法</a> 中指定一个 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/serializer/support/SerializationDelegate.html">序列化实现</a>。若指定了序列化实现,则被缓存的对象会经由序列化转化成字节数组保存,否则直接保存对象引用,同时在读取的时候将字节数组反序列化成对象。。</p>
<p><em>ConcurrentMapCache</em> 序列化机制的接口定义于 <a href="https://search.maven.org/search?q=g:org.springframework%20a:spring-core"><em>spring-core</em></a> 中,Spring 自身只提供了 JDK 序列化版本的实现<sup id="fnref:4"><a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup>。其实大多数情况下这两个类足以满足要求,不过因为是 JDK 序列化,所以对于被序列化的类有诸多的要求,比如必须实现 <em>Serializable</em> 接口,而且众所周知,JDK 序列化的性能低于其他序列化实现。这里我们选择 JSON 序列化,并且使用 <a href="https://github.com/alibaba/fastjson">Fastjson</a> 来实现。</p>
<p>得益于 Fastjson 的简单性,最终实现的代码如下:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="kd">final</span> <span class="kd">class</span> <span class="nc">FastjsonSerializationDelegate</span> <span class="kd">implements</span> <span class="n">Serializer</span><span class="o">&lt;</span><span class="n">Object</span><span class="o">&gt;,</span> <span class="n">Deserializer</span><span class="o">&lt;</span><span class="n">Object</span><span class="o">&gt;</span> <span class="o">{</span>
<span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="n">ParserConfig</span> <span class="n">ENABLE_AUTO_TYPE_PARSER_CONFIG</span> <span class="o">=</span> <span class="k">new</span> <span class="n">ParserConfig</span><span class="o">();</span>
<span class="kd">static</span> <span class="o">{</span>
<span class="c1">// 使用非全局的 ParserConfig 并设置支持 autoType
</span><span class="c1"></span> <span class="n">ENABLE_AUTO_TYPE_PARSER_CONFIG</span><span class="o">.</span><span class="na">setAutoTypeSupport</span><span class="o">(</span><span class="kc">true</span><span class="o">);</span>
<span class="o">}</span>
<span class="nd">@NonNull</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="n">Object</span> <span class="nf">deserialize</span><span class="o">(</span><span class="nd">@NonNull</span> <span class="n">InputStream</span> <span class="n">inputStream</span><span class="o">)</span> <span class="kd">throws</span> <span class="n">IOException</span> <span class="o">{</span>
<span class="c1">// 读取所有 inputStream 中的数据至字节数组中,很多库的 I/O 工具类都能做到
</span><span class="c1"></span> <span class="kt">byte</span><span class="o">[]</span> <span class="n">bytes</span> <span class="o">=</span> <span class="n">IOUtils</span><span class="o">.</span><span class="na">toByteArray</span><span class="o">(</span><span class="n">inputStream</span><span class="o">);</span>
<span class="n">String</span> <span class="n">input</span> <span class="o">=</span> <span class="k">new</span> <span class="n">String</span><span class="o">(</span><span class="n">bytes</span><span class="o">,</span> <span class="n">StandardCharsets</span><span class="o">.</span><span class="na">UTF_8</span><span class="o">);</span>
<span class="k">return</span> <span class="n">JSON</span><span class="o">.</span><span class="na">parseObject</span><span class="o">(</span><span class="n">inputStream</span><span class="o">,</span> <span class="n">Object</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
<span class="o">}</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">serialize</span><span class="o">(</span><span class="nd">@NonNull</span> <span class="n">Object</span> <span class="n">object</span><span class="o">,</span> <span class="nd">@NonNull</span> <span class="n">OutputStream</span> <span class="n">outputStream</span><span class="o">)</span>
<span class="kd">throws</span> <span class="n">IOException</span> <span class="o">{</span>
<span class="c1">// 这里需要带上 SerializerFeature.WriteClassName,否则 List&lt;Long&gt; 经过序列化反序列化会变成 List&lt;Integer&gt;
</span><span class="c1"></span> <span class="n">JSON</span><span class="o">.</span><span class="na">writeJSONString</span><span class="o">(</span><span class="n">outputStream</span><span class="o">,</span> <span class="n">object</span><span class="o">,</span> <span class="n">SerializerFeature</span><span class="o">.</span><span class="na">WriteClassName</span><span class="o">);</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p>需要注意的就是第 17 行的调用中需要加入 <em>WriteClassName</em> 的序列化特性,否则 <em>List&lt;Long&gt;</em> 在序列反序列化后会被解析成 <em>List&lt;Integer&gt;</em>。另外,<em>Fastjson</em> 在版本 1.2.25 之后限制了 JSON 反序列化时的类型解析功能,所以我们在第 4 行使用一个非全局的 <em>ParserConfig</em> 对象,单独对该对象启用 <em>autoType</em> 并在第 19 行使用,如果不指定的话就会使用全局的对象<sup id="fnref:5"><a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fn:5" class="footnote-ref" role="doc-noteref">5</a></sup>,这样可能出现 <em>autotype is not support</em> 报错。具体的配置可以参阅官方的 <a href="https://github.com/alibaba/fastjson/wiki/security_update_20170315#2-%E5%8D%87%E7%BA%A7%E4%B9%8B%E5%90%8E%E6%8A%A5%E9%94%99autotype-is-not-support">升级公告</a> 和 <a href="https://github.com/alibaba/fastjson/wiki/enable_autotype">enable_autotype</a> 配置。</p>
<p>接着扩展 <em>ConcurrentMapCache</em> 类:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="kd">final</span> <span class="kd">class</span> <span class="nc">FastjsonSerializationConcurrentMapCache</span> <span class="kd">extends</span> <span class="n">ConcurrentMapCache</span> <span class="o">{</span>
<span class="cm">/** 该缓存的 FastJson 序列化实现。 */</span>
<span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="n">FastjsonSerializationDelegate</span> <span class="n">SERIALIZATION_DELEGATE</span> <span class="o">=</span>
<span class="k">new</span> <span class="n">FastjsonSerializationDelegate</span><span class="o">();</span>
<span class="kd">public</span> <span class="nf">FastjsonSerializationConcurrentMapCache</span><span class="o">(</span><span class="nd">@NonNull</span> <span class="n">String</span> <span class="n">name</span><span class="o">)</span> <span class="o">{</span>
<span class="kd">super</span><span class="o">(</span>
<span class="n">name</span><span class="o">,</span>
<span class="k">new</span> <span class="n">ConcurrentHashMap</span><span class="o">&lt;&gt;(</span><span class="n">256</span><span class="o">),</span>
<span class="kc">true</span><span class="o">,</span>
<span class="k">new</span> <span class="n">SerializationDelegate</span><span class="o">(</span><span class="n">SERIALIZATION_DELEGATE</span><span class="o">,</span> <span class="n">SERIALIZATION_DELEGATE</span><span class="o">));</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p>最后在 <em>MemoryCacheDecorator</em> 的构造方法中替换原来的 <em>ConcurrentMapCache</em>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="nd">@Slf4j</span>
<span class="kd">class</span> <span class="nc">MemoryCacheDecorator</span> <span class="kd">implements</span> <span class="n">Cache</span> <span class="o">{</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="n">ConcurrentMapCache</span> <span class="n">memoryCache</span><span class="o">;</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="n">Cache</span> <span class="n">targetCache</span><span class="o">;</span>
<span class="kd">public</span> <span class="nf">MemoryCacheDecorator</span><span class="o">(</span><span class="nd">@NonNull</span> <span class="n">Cache</span> <span class="n">targetCache</span><span class="o">)</span> <span class="o">{</span>
<span class="c1">// this.memoryCache = new ConcurrentMapCache(&#34;memory-&#34; + targetCache.getName());
</span><span class="c1"></span> <span class="k">this</span><span class="o">.</span><span class="na">memoryCache</span> <span class="o">=</span>
<span class="k">new</span> <span class="n">FastjsonSerializationConcurrentMapCache</span><span class="o">(</span><span class="s">&#34;memory-&#34;</span> <span class="o">+</span> <span class="n">targetCache</span><span class="o">.</span><span class="na">getName</span><span class="o">());</span>
<span class="k">this</span><span class="o">.</span><span class="na">targetCache</span> <span class="o">=</span> <span class="n">targetCache</span><span class="o">;</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p>缓存对象实际以 JSON 字符串的形式保存在内存中,并且带有字段的类型信息,每次访问的结果都是全新的反序列化对象,这样就实现了内存缓存的线程安全访问。</p>
<h3 id="条件化启用">条件化启用</h3>
<p>条件化启用应该是最好实现的一点优化了,只需要在构造方法中加入一个列表,在 <em>decorateCache</em> 方法中判断属于列表中的缓存才做包装,类似的代码如下:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="nd">@Slf4j</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MemoryRedisCacheManager</span> <span class="kd">extends</span> <span class="n">RedisCacheManager</span> <span class="kd">implements</span> <span class="n">SchedulingConfigurer</span> <span class="o">{</span>
<span class="cm">/** 只有这里配置的缓存才会加持内存缓存层。 */</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="n">List</span><span class="o">&lt;</span><span class="n">String</span><span class="o">&gt;</span> <span class="n">decoratedCacheNameList</span><span class="o">;</span>
<span class="cm">/**
</span><span class="cm"> * 覆盖 {@link AbstractCacheManager} 的装饰缓存方法,若参数中的缓存包含 {@link #decoratedCacheNameList}
</span><span class="cm"> * 中,则在将该对象包装成具有内存缓存能力的对象。
</span><span class="cm"> *
</span><span class="cm"> * @see AbstractCacheManager
</span><span class="cm"> * @param cache 原缓存对象
</span><span class="cm"> * @return 原缓存对象或具有内存缓存能力的对象
</span><span class="cm"> */</span>
<span class="nd">@NonNull</span>
<span class="nd">@Override</span>
<span class="kd">protected</span> <span class="n">Cache</span> <span class="nf">decorateCache</span><span class="o">(</span><span class="nd">@NonNull</span> <span class="n">Cache</span> <span class="n">cache</span><span class="o">)</span> <span class="o">{</span>
<span class="c1">// 先让基类包装一次
</span><span class="c1"></span> <span class="n">Cache</span> <span class="n">superCache</span> <span class="o">=</span> <span class="kd">super</span><span class="o">.</span><span class="na">decorateCache</span><span class="o">(</span><span class="n">cache</span><span class="o">);</span>
<span class="c1">// 判断是否为该缓存配置了内存缓存
</span><span class="c1"></span> <span class="k">if</span> <span class="o">(</span><span class="n">decoratedCacheNameList</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="n">cache</span><span class="o">.</span><span class="na">getName</span><span class="o">()))</span> <span class="o">{</span>
<span class="c1">// 包装缓存
</span><span class="c1"></span> <span class="k">return</span> <span class="k">new</span> <span class="n">MemoryCacheDecorator</span><span class="o">(</span><span class="n">superCache</span><span class="o">);</span>
<span class="o">}</span>
<span class="c1">// 不包装缓存
</span><span class="c1"></span> <span class="k">return</span> <span class="n">superCache</span><span class="o">;</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><h2 id="总结">总结</h2>
<p>我们介绍了 Spring Cache 的 2 个核心接口,以及基于 Spring Cache 来为 Redis 缓存建立二级本地内存缓存,也讨论了如何在这个基础上做优化以便生产环境使用。</p>
<p>实现的方法基于 <em>AbstractCacheManager</em> 的 <em>decorateCache</em> 函数,重写该方法可以将原本的 <em>Cache</em> 对象封装成另一个 <em>Cache</em> 对象,借此我们可以改变原有缓存的行为,最终以新增不超过 5 个类的代价,将核心逻辑内聚在 <em>RedisCacheManager</em> 的子类中,也易于未来的扩展,具体的详情可以参考我的 GitHub 项目 <a href="https://github.com/wzhix/memory-cache-in-redis">memory-cache-in-redis</a>。</p>
<section class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1" role="doc-endnote">
<p>如何使用 Spring Cache 以及如何集成 Redis 不在本文的讨论范围内,相关的内容可以参阅 Baeldung 的 <a href="https://www.baeldung.com/spring-cache-tutorial">A Guide To Caching in Spring</a>、IBM 知识库的 <a href="https://www.ibm.com/developerworks/cn/opensource/os-cn-spring-cache/index.html">注释驱动的 Spring Cache 缓存介绍</a>。Redis Cache 的相关内容可以参阅 <a href="https://docs.spring.io/spring-data/redis/docs/2.2.6.RELEASE/reference/html/#redis:support:cache-abstraction">Spring Data Redis 官方文档章节</a>。&#160;<a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>个人认为不应当让 <em>CacheManager</em> 自动创建缺失的缓存,而是在一开始就确定程序的缓存命名空间,并创建好所有类型的缓存。&#160;<a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>Cron 表达式依照机器本地时间执行,不同机器本地时间可能存在分钟级差异,为了追求更高的一致性,应当用外部手段保证各个机器时间戳尽量趋近,比如时间服务器。&#160;<a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4" role="doc-endnote">
<p>将对象类型的名称序列化至 JSON,在反序列化时可能存在安全问题,比如指定 JSON 反序列化后的类型为 <code>java.lang.Thread</code>,就能通过非常规方法创建线程。&#160;<a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:5" role="doc-endnote">
<p> 即 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/serializer/DefaultSerializer.html"><em>DefaultSerializer</em></a> 和 <a href="https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/serializer/DefaultDeserializer.html"><em>DefaultDeserializer</em></a>。&#160;<a href="https://zhix.co/posts/spring-cache-in-practice-adding-memory-cache-in-front-of-redis-cache/#fnref:5" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</section></description><category domain="https://zhix.co/categories/%E6%8A%80%E6%9C%AF/">技术</category><category domain="https://zhix.co/series/spring-framework/">Spring Framework</category><category domain="https://zhix.co/tags/java/">Java</category><category domain="https://zhix.co/tags/%E7%BC%96%E7%A8%8B/">编程</category><category domain="https://zhix.co/tags/%E8%BD%AF%E4%BB%B6%E5%B7%A5%E7%A8%8B/">软件工程</category><category domain="https://zhix.co/tags/%E7%BC%93%E5%AD%98/">缓存</category><category domain="https://zhix.co/tags/%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1/">面向对象</category><category domain="https://zhix.co/tags/cache/">cache</category><category domain="https://zhix.co/tags/spring-framework/">spring framework</category><category domain="https://zhix.co/tags/spring-cache/">spring cache</category><category domain="https://zhix.co/tags/redis/">redis</category></item><item><title>谈谈 Java SPI:以字符集举例</title><link>https://zhix.co/posts/talking-spi-in-java/</link><guid isPermaLink="true">https://zhix.co/posts/talking-spi-in-java/</guid><pubDate>Wed, 11 Mar 2020 20:12:51 +0800</pubDate><author>[email protected] (zhix)</author><copyright>[CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh)</copyright><description><p>自 Java 6.0 开始,JDK 提供了名为 SPI(<span lang="en">Service Provider Interface</span>)的加载机制,SPI 能够在运行时发现某个接口/抽象类的实现类,为接口消费方提供了一致的模型来使用接口,对于接口实现方,按 SPI 的规范注册的实现类可实现运行时自动加载。这种方式既解除了接口与实现的耦合,又解决了实现类的自动初始化,比较典型的用例有 JDBC 驱动类的注册、<em>Charset</em> 字符集注册等,Spring Framework 和 Dubbo 的代码中也或多或少参考和封装了该机制。</p>
<h2 id="spi-机制">SPI 机制</h2>
<p>在 SPI 里,接口或者抽象类被称为服务(<span lang="en">Service</span>)或服务提供者接口(<span lang="en">Service Provider Interface</span>),实现类被称为服务提供者(<span lang="en">Service Provider</span>)。虽然常见的概念被赋予了不太好理解的名称,但是二者在本质上还是代表了面向对象编程中规范(<span lang="en">Specification</span>)和实现(<span lang="en">Implementation</span>)的关系。</p>
<h3 id="服务提供者">服务提供者</h3>
<p>在 SPI 的规范中,服务提供者的实现类应当配置在资源目录下的 <em>META-INF/services</em> 目录<sup id="fnref:1"><a href="https://zhix.co/posts/talking-spi-in-java/#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup>下。该目录下,每一个服务接口对应一个单独的文本文件,文件名为服务接口的完全限定名,文件内容按行区分,每一行是服务实现类的完全限定名<sup id="fnref:2"><a href="https://zhix.co/posts/talking-spi-in-java/#fn:2" class="footnote-ref" role="doc-noteref">2</a></sup>。</p>
<p>SPI 的核心类是范型类 <a href="https://docs.oracle.com/javase/8/docs/api/java/util/ServiceLoader.html"><em>ServiceLoader</em></a>,它负责发现类路径中配置的实现类并实例化它们。<em>ServiceLoader</em> 维护了一个 <em>LinkedHashMap&lt;String, T&gt;</em> 的内部缓存来惰性实例化实现类,其中类型 <em>T</em> 为服务接口类。<em>ServiceLoader.load(T.class)</em> 是最常调用的方法,它返回类型 <em>T</em> 的 <em>ServiceLoader</em> 实例。<sup id="fnref:3"><a href="https://zhix.co/posts/talking-spi-in-java/#fn:3" class="footnote-ref" role="doc-noteref">3</a></sup></p>
<h2 id="charset-的加载方式">Charset 的加载方式</h2>
<p>JDK 以 <a href="https://docs.oracle.com/javase/8/docs/api/java/nio/charset/spi/CharsetProvider.html"><em>CharsetProvider</em></a> 来实现字符集框架。Oracle JDK 扩展了该实现,提供了标准字符集和扩展字符集的 Provider 实现(<em>StandardCharsets</em> 和 <em>ExtendedCharsets</em>):</p>
<dl>
<dt><em>StandardCharsets</em></dt>
<dd>
<p>标准字符集提供者,包括 Unicode 和 ASCII 字符集的管理;</p>
</dd>
<dt><em>ExtendedCharsets</em></dt>
<dd>
<p>扩展字符集提供者,包括 CJK 字符集的管理。</p>
</dd>
</dl>
<p>这两个类都是在 <em>sun.nio.cs</em> 包下。</p>
<p>在 Java 中我们通过调用 <em>Charset.forName(&ldquo;charset-name&rdquo;)</em> 来访问字符集 API,<em>forName</em> 方法在底层会进行一系列的 lookup 操作,按照标准字符集提供者、扩展字符集提供者和 SPI 字符集提供者的顺序查询 charset-name 对应的字符集实现类,当无法在前两个内置的字符集提供者中找到对应名称的字符集实现,SPI 字符集提供者便会起作用,SPI 字符集提供者以接口 <em>CharsetProvider</em> 为核心,因此我们可以为该接口插入自己的实现类。</p>
<h2 id="安装自定义字符集">安装自定义字符集</h2>
<p>假设现在有一个比 UTF-8 更高效且通用的字符串编码算法,它相对于 UTF-8 可能信噪比更低、更适合压缩甚至是支持火星语编码,我们暂且叫它 9527。它的编解码算法已经公开,我们现在需要赶在 Oracle 发布新的 JDK 支持它之前将它嵌入到我们的应用程序中,并且程序只需要将使用字符集的地方替换为 <em>Charset.forName(&ldquo;9527&rdquo;)</em> 即可。</p>
<h3 id="定义字符集">定义字符集</h3>
<p>首先我们需要一个实现类继承自 <em>java.nio.charset.Charset</em>,<em>Charset</em> 是所有字符集的基类。在该案例中的实现类假设叫做 <em>_9527Charset</em>,并且为方便示例,我们假定 <em>_9527Charset</em> 本质上就是 UTF-8 的实现,因此它会持有一个 UTF-8 的实例,对它的所有方法调用都会被转发至 UTF-8 对应的方法中:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="kn">package</span> <span class="nn">zhix.encoding.spi</span><span class="o">;</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">_9527Charset</span> <span class="kd">extends</span> <span class="n">Charset</span> <span class="o">{</span>
<span class="c1">// 为方便示例,假定 _9527Charset 本质上就是 UTF-8 的实现
</span><span class="c1"></span> <span class="kd">private</span> <span class="kd">final</span> <span class="kd">static</span> <span class="n">Charset</span> <span class="n">DELEGATE</span> <span class="o">=</span> <span class="n">StandardCharsets</span><span class="o">.</span><span class="na">UTF_8</span><span class="o">;</span>
<span class="kd">public</span> <span class="nf">_9527Charset</span><span class="o">()</span> <span class="o">{</span>
<span class="c1">// 名称和别名集合
</span><span class="c1"></span> <span class="kd">super</span><span class="o">(</span><span class="s">&#34;9527&#34;</span><span class="o">,</span> <span class="k">new</span> <span class="n">String</span><span class="o">[]</span> <span class="o">{</span><span class="s">&#34;mew-9527&#34;</span><span class="o">,</span> <span class="s">&#34;mew&#34;</span><span class="o">});</span>
<span class="o">}</span>
<span class="c1">// 所有的方法调用一并转发
</span><span class="c1"></span>
<span class="kd">public</span> <span class="kt">boolean</span> <span class="nf">contains</span><span class="o">(</span><span class="n">Charset</span> <span class="n">cs</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">DELEGATE</span><span class="o">.</span><span class="na">contains</span><span class="o">(</span><span class="n">cs</span><span class="o">);</span>
<span class="o">}</span>
<span class="kd">public</span> <span class="n">CharsetDecoder</span> <span class="nf">newDecoder</span><span class="o">()</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">DELEGATE</span><span class="o">.</span><span class="na">newDecoder</span><span class="o">();</span>
<span class="o">}</span>
<span class="kd">public</span> <span class="n">CharsetEncoder</span> <span class="nf">newEncoder</span><span class="o">()</span> <span class="o">{</span>
<span class="k">return</span> <span class="n">DELEGATE</span><span class="o">.</span><span class="na">newEncoder</span><span class="o">();</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p>因为字符集在 JDK 中是以命名服务实现的,所以我们同时还要设置新字符集的规则名称(<span lang="en">Canonical Name</span>)和别名(<span lang="en">Aliases</span>),这里将规则名称设置为 <em>9527</em>,将别名集合设置为 <em>mew-9527</em> 和 <em>mew</em>。规则名称在命名空间中唯一确定一个字符集,别名提供了额外的查询方式。</p>
<p>第 15 - 25 行是 <em>Charset</em> 的子类需要实现的 3 个方法,包括编码器和解码器,这里直接将逻辑转发给 UTF-8 的实现,真实的情况会更加复杂,因为我们需要自行实现编解码器,并做真正的底层字节处理。</p>
<h3 id="定义-charsetprovider">定义 CharsetProvider</h3>
<p>接下来是实现 <em>CharsetProvider</em>,但通常我们只需要扩展 <em>AbstractCharsetProvider</em> 即可,<em>AbstractCharsetProvider</em> 提供了基本的字符集管理实现,包括名称管理、别名管理、缓存。</p>
<p>构造 <em>AbstractCharsetProvider</em> 时还可以提供一个名为 <em>pkgPrefixName</em> 的参数,它用于指定该字符集提供者所管理的字符集从哪一个包中查找实现类,默认包前缀为 <em>sun.nio.cs</em>。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="kd">protected</span> <span class="nf">AbstractCharsetProvider</span><span class="o">()</span> <span class="o">{</span>
<span class="n">packagePrefix</span> <span class="o">=</span> <span class="s">&#34;sun.nio.cs&#34;</span><span class="o">;</span>
<span class="o">}</span>
<span class="kd">protected</span> <span class="nf">AbstractCharsetProvider</span><span class="o">(</span><span class="n">String</span> <span class="n">pkgPrefixName</span><span class="o">)</span> <span class="o">{</span>
<span class="n">packagePrefix</span> <span class="o">=</span> <span class="n">pkgPrefixName</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div><p>以下代码展示了名为 <em>_9527CharsetProvider</em> 的实现,并指定在 <em>zhix.encoding.spi</em> 的包中查询字符集。</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="kn">package</span> <span class="nn">zhix.encoding.spi</span><span class="o">;</span>
<span class="nd">@Slf4j</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">_9527CharsetProvider</span> <span class="kd">extends</span> <span class="n">AbstractCharsetProvider</span> <span class="o">{</span>
<span class="kd">static</span> <span class="kd">final</span> <span class="n">String</span> <span class="n">CANONICAL_NAME</span> <span class="o">=</span> <span class="s">&#34;9527&#34;</span><span class="o">;</span>
<span class="kd">static</span> <span class="kd">final</span> <span class="n">String</span><span class="o">[]</span> <span class="n">ALIASES</span> <span class="o">=</span> <span class="o">{</span><span class="s">&#34;mew&#34;</span><span class="o">,</span> <span class="s">&#34;mew-9527&#34;</span><span class="o">};</span>
<span class="kd">private</span> <span class="kt">boolean</span> <span class="n">initialized</span> <span class="o">=</span> <span class="kc">false</span><span class="o">;</span>
<span class="kd">public</span> <span class="nf">_9527CharsetProvider</span><span class="o">()</span> <span class="o">{</span>
<span class="kd">super</span><span class="o">(</span><span class="s">&#34;zhix.encoding.spi&#34;</span><span class="o">);</span>
<span class="o">}</span>
<span class="nd">@Override</span>
<span class="kd">protected</span> <span class="kt">void</span> <span class="nf">init</span><span class="o">()</span> <span class="o">{</span>
<span class="k">if</span> <span class="o">(</span><span class="n">initialized</span><span class="o">)</span> <span class="o">{</span>
<span class="k">return</span><span class="o">;</span>
<span class="o">}</span>
<span class="kd">super</span><span class="o">.</span><span class="na">init</span><span class="o">();</span>
<span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">&#34;{} initialized.&#34;</span><span class="o">,</span> <span class="n">getClass</span><span class="o">().</span><span class="na">getName</span><span class="o">());</span>
<span class="n">charset</span><span class="o">(</span><span class="s">&#34;mew&#34;</span><span class="o">,</span> <span class="n">_9527Charset</span><span class="o">.</span><span class="na">class</span><span class="o">.</span><span class="na">getSimpleName</span><span class="o">(),</span> <span class="n">ALIASES</span><span class="o">);</span>
<span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span>
<span class="s">&#34;Register charset {} with class {}.&#34;</span><span class="o">,</span> <span class="n">CANONICAL_NAME</span><span class="o">,</span> <span class="n">_9527Charset</span><span class="o">.</span><span class="na">class</span><span class="o">.</span><span class="na">getName</span><span class="o">());</span>
<span class="n">initialized</span> <span class="o">=</span> <span class="kc">true</span><span class="o">;</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p>第 16 行的 init 方法会在查询 SPI 字符集时被调用,外部的逻辑可能会多次调用该方法,因此需要开发者自己来保证只初始化一次,比如这里用第 9 行定义的 <em>initialized</em> 变量来控制。</p>
<p>第 23 行的 <em>charset</em> 方法由基类 <em>AbstractCharsetProvider</em> 提供,用于注册字符集的元信息描述:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="cm">/* Declare support for the given charset
</span><span class="cm"> */</span>
<span class="kd">protected</span> <span class="kt">void</span> <span class="nf">charset</span><span class="o">(</span><span class="n">String</span> <span class="n">name</span><span class="o">,</span> <span class="n">String</span> <span class="n">className</span><span class="o">,</span> <span class="n">String</span><span class="o">[]</span> <span class="n">aliases</span><span class="o">)</span> <span class="o">{</span>
<span class="kd">synchronized</span> <span class="o">(</span><span class="k">this</span><span class="o">)</span> <span class="o">{</span>
<span class="n">put</span><span class="o">(</span><span class="n">classMap</span><span class="o">,</span> <span class="n">name</span><span class="o">,</span> <span class="n">className</span><span class="o">);</span>
<span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="n">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">aliases</span><span class="o">.</span><span class="na">length</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span>
<span class="n">put</span><span class="o">(</span><span class="n">aliasMap</span><span class="o">,</span> <span class="n">aliases</span><span class="o">[</span><span class="n">i</span><span class="o">],</span> <span class="n">name</span><span class="o">);</span>
<span class="n">put</span><span class="o">(</span><span class="n">aliasNameMap</span><span class="o">,</span> <span class="n">name</span><span class="o">,</span> <span class="n">aliases</span><span class="o">);</span>
<span class="n">cache</span><span class="o">.</span><span class="na">clear</span><span class="o">();</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p><em>AbstractCharsetProvider</em> 根据参数的名称和别名,为字符集建立查询数据结构,在查询时若有匹配的字符集描述,则根据上文提到的 <em>pkgPrefixName</em> + 类名,通过反射创建 <em>Charset</em> 的实例,完成 <em>Charset</em> 的查询并初始化。</p>
<p>所有的 <em>Charset</em> 初始化都是惰性的,并且 <em>AbstractCharsetProvider</em> 维护了一个缓存来避免重复初始化。因此最佳实践是在应用程序里只使用一种类型的 <em>Charset</em>。</p>
<h3 id="配置-_9527charsetprovider">配置 _9527CharsetProvider</h3>
<p>在项目的 <em>resources/META-INF/services</em> 目录下新建文本文件</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback">java.nio.charset.spi.CharsetProvider
</code></pre></div><p>写入一个内容为</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback">zhix.encoding.spi._9527CharsetProvider
</code></pre></div><p>的新行。</p>
<figure><img src="https://zhix.co/blog/configure-spi-in-meta-inf-directory.png"
alt="配置 _9527CharsetProvider"/><figcaption>
<p>配置 _9527CharsetProvider</p>
</figcaption>
</figure>
<h3 id="单元测试">单元测试</h3>
<p>在 <em>test/resources</em> 目录下创建单元测试类 <em>_9527CharsetProviderTest</em>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="kn">package</span> <span class="nn">zhix.encoding</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.nio.charset.Charset</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">java.nio.charset.StandardCharsets</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">lombok.extern.slf4j.Slf4j</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.junit.Assert</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.junit.Before</span><span class="o">;</span>
<span class="kn">import</span> <span class="nn">org.junit.Test</span><span class="o">;</span>
<span class="nd">@SuppressWarnings</span><span class="o">(</span><span class="s">&#34;InjectedReferences&#34;</span><span class="o">)</span>
<span class="nd">@Slf4j</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">_9527CharsetProviderTest</span> <span class="o">{</span>
<span class="kd">private</span> <span class="n">String</span> <span class="n">text</span> <span class="o">=</span> <span class="s">&#34;我能吞下玻璃而不伤身体&#34;</span><span class="o">;</span>
<span class="kd">private</span> <span class="n">Charset</span> <span class="n">charset</span><span class="o">;</span>
<span class="nd">@Before</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">setUp</span><span class="o">()</span> <span class="o">{</span>
<span class="c1">// 触发 SPI 加载
</span><span class="c1"></span> <span class="n">charset</span> <span class="o">=</span> <span class="n">Charset</span><span class="o">.</span><span class="na">forName</span><span class="o">(</span><span class="s">&#34;9527&#34;</span><span class="o">);</span>
<span class="o">}</span>
<span class="c1">// 测试编解码结果一致
</span><span class="c1"></span> <span class="nd">@Test</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">testEncodingAndDecoding</span><span class="o">()</span> <span class="o">{</span>
<span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">&#34;Charset name = {}&#34;</span><span class="o">,</span> <span class="n">charset</span><span class="o">.</span><span class="na">name</span><span class="o">());</span>
<span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">&#34;Charset displayName = {}&#34;</span><span class="o">,</span> <span class="n">charset</span><span class="o">.</span><span class="na">displayName</span><span class="o">());</span>
<span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">&#34;Charset aliases = {}&#34;</span><span class="o">,</span> <span class="n">charset</span><span class="o">.</span><span class="na">aliases</span><span class="o">());</span>
<span class="n">Assert</span><span class="o">.</span><span class="na">assertEquals</span><span class="o">(</span><span class="n">text</span><span class="o">,</span> <span class="k">new</span> <span class="n">String</span><span class="o">(</span><span class="n">text</span><span class="o">.</span><span class="na">getBytes</span><span class="o">(</span><span class="n">charset</span><span class="o">)));</span>
<span class="o">}</span>
<span class="c1">// 测试 _9527 和 UTF-8 一致
</span><span class="c1"></span> <span class="nd">@Test</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">testEncodingCompareToUTF8</span><span class="o">()</span> <span class="o">{</span>
<span class="n">Charset</span> <span class="n">utf8</span> <span class="o">=</span> <span class="n">StandardCharsets</span><span class="o">.</span><span class="na">UTF_8</span><span class="o">;</span>
<span class="kt">byte</span><span class="o">[]</span> <span class="n">utf8Bytes</span> <span class="o">=</span> <span class="n">text</span><span class="o">.</span><span class="na">getBytes</span><span class="o">(</span><span class="n">utf8</span><span class="o">);</span>
<span class="kt">byte</span><span class="o">[]</span> <span class="n">_9527Bytes</span> <span class="o">=</span> <span class="n">text</span><span class="o">.</span><span class="na">getBytes</span><span class="o">(</span><span class="n">charset</span><span class="o">);</span>
<span class="n">Assert</span><span class="o">.</span><span class="na">assertArrayEquals</span><span class="o">(</span><span class="n">utf8Bytes</span><span class="o">,</span> <span class="n">_9527Bytes</span><span class="o">);</span>
<span class="o">}</span>
<span class="c1">// 测试使用别名查询 Charset
</span><span class="c1"></span> <span class="nd">@Test</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">testEncodingAndDecodingWithAlias</span><span class="o">()</span> <span class="o">{</span>
<span class="n">charset</span> <span class="o">=</span> <span class="n">Charset</span><span class="o">.</span><span class="na">forName</span><span class="o">(</span><span class="s">&#34;mew&#34;</span><span class="o">);</span>
<span class="n">Assert</span><span class="o">.</span><span class="na">assertEquals</span><span class="o">(</span><span class="n">text</span><span class="o">,</span> <span class="k">new</span> <span class="n">String</span><span class="o">(</span><span class="n">text</span><span class="o">.</span><span class="na">getBytes</span><span class="o">(</span><span class="n">charset</span><span class="o">)));</span>
<span class="o">}</span>
<span class="o">}</span>
</code></pre></div><p>运行单元测试的结果如下:</p>
<figure><img src="https://zhix.co/blog/20.3.16-unit-test-result-of-_9527-charset-provider.png"
alt="_9527CharsetProvider 单元测试结果"/><figcaption>
<p>_9527CharsetProvider 单元测试结果</p>
</figcaption>
</figure>
<p>可以看到我们可以通过 <code>Charset.forName(&quot;9527&quot;)</code> 的方式获得我们自己定义的 <em>Charset</em> 实例,且实例的类型就是 <em>_9527Charset</em>。</p>
<h2 id="spi-的延伸讨论">SPI 的延伸讨论</h2>
<p>SPI 使用延迟加载,会扫描整个类路径下的 <em>META-INF/services</em> 目录,所有配置的实现类的无参构造方法都会被调用并实例化,也就是一次访问,所有候选类都会被加载。如果实际场景不需要使用所有的实现类,这些类就会白白占用 JVM 内存,其次如果实现类是一个重型类的话,更会造成严重的内存浪费。</p>
<p>另外一个缺陷是,你只能通过 <em>load</em> 方法返回的迭代器来迭代访问实现类,这是一种相当底层的编程接口,意味着你无法灵活地根据参数不同获取某个的实现类。如果要实际使用方便,一种可能的最佳的实现是:封装 load 方法,根据传入的参数控制返回的实现类的查找逻辑,并且设置一个类变量缓存查找的结果。</p>
<h3 id="spi-与-api">SPI 与 API</h3>
<p>SPI 和 API 本质上都是 <span lang="en">Specification</span> 和 <span lang="en">Implementation</span> 的不同表现形式,区别在于:</p>
<ul>
<li>API 的使用者不关心规范的具体实现细节,只关心 API 的使用规范,开发者通过组织 API 提供的功能来实现目标。</li>
<li>SPI 的开发者按照规范实现接口,通过满足规范的规约来实现目标。</li>
</ul>
<p>简单来说就是,对于一套编程规范,如果你使用规范提供的功能来编程,规范对你来说就是 API,如果你通过编程来满足规范的所有要求,则规范对你来说就是 SPI。</p>
<p>也可以参考 StackOverflow 上的 <a href="https://stackoverflow.com/a/2956803">这个回答</a>。</p>
<h2 id="结语">结语</h2>
<p>JDK 在 6.0 的时候发布了 SPI 机制,解决了实现类在运行时如何确定的问题,有利于应用程序的扩展,对 Spring 等框架也产生了重要影响,现如今看来,它的实现方式比较底层,一般需要在外层封装更抽象的控制逻辑来使用,同样 SPI 也存在内存占用的缺陷,<a href="https://cloud.tencent.com/developer/article/1121665">静态绑定</a><sup id="fnref:4"><a href="https://zhix.co/posts/talking-spi-in-java/#fn:4" class="footnote-ref" role="doc-noteref">4</a></sup> 机制可以解决这个问题。</p>
<p>通过 SPI,我们可以实现一些 JDK 内置功能的模块插入,比如自行实现 <em>Charset</em>。以上字符集加载的完整代码可以在 GitHub 项目 <a href="https://github.com/wzhix/9527-charset-encoding">9527-charset-encoding</a> 中查看,如果有任何问题和建议可以在项目里提交 Issue 给我。</p>
<section class="footnotes" role="doc-endnotes">
<hr>
<ol>
<li id="fn:1" role="doc-endnote">
<p>JAR 文件的规范和 <em>META-INF</em> 目录的详细介绍参见 Oracle 的 <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html">Java SE Documentation</a>&#160;<a href="https://zhix.co/posts/talking-spi-in-java/#fnref:1" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:2" role="doc-endnote">
<p>Baeldung 中关于 <a href="https://www.baeldung.com/java-spi#3-service-provider"><em>Service Provider</em></a> 的介绍&#160;<a href="https://zhix.co/posts/talking-spi-in-java/#fnref:2" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:3" role="doc-endnote">
<p>SPI 的详细配置规范参见 Oracle 的 <a href="https://docs.oracle.com/javase/tutorial/sound/SPI-intro.html">The Java™ Tutorials</a> 教程&#160;<a href="https://zhix.co/posts/talking-spi-in-java/#fnref:3" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
<li id="fn:4" role="doc-endnote">
<p>静态绑定的典型应用是 <a href="http://www.slf4j.org/">Slf4J</a>&#160;<a href="https://zhix.co/posts/talking-spi-in-java/#fnref:4" class="footnote-backref" role="doc-backlink">&#x21a9;&#xfe0e;</a></p>
</li>
</ol>
</section></description><category domain="https://zhix.co/categories/%E6%8A%80%E6%9C%AF/">技术</category><category domain="https://zhix.co/series/java-%E6%A0%B8%E5%BF%83/">Java 核心</category><category domain="https://zhix.co/tags/java/">Java</category><category domain="https://zhix.co/tags/%E7%BC%96%E7%A8%8B/">编程</category><category domain="https://zhix.co/tags/%E8%BD%AF%E4%BB%B6%E5%B7%A5%E7%A8%8B/">软件工程</category><category domain="https://zhix.co/tags/utf-8/">UTF-8</category><category domain="https://zhix.co/tags/unicode/">Unicode</category><category domain="https://zhix.co/tags/jdk/">JDK</category><category domain="https://zhix.co/tags/spi/">SPI</category><category domain="https://zhix.co/tags/%E5%AD%97%E7%AC%A6%E9%9B%86/">字符集</category></item><item><title>用遗传算法解决规划问题(一)</title><link>https://zhix.co/posts/solving-planing-problem-by-genetic-algorithm/</link><guid isPermaLink="true">https://zhix.co/posts/solving-planing-problem-by-genetic-algorithm/</guid><pubDate>Sat, 15 Feb 2020 20:54:41 +0800</pubDate><author>[email protected] (zhix)</author><copyright>[CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh)</copyright><description><p>我的算法知识启蒙源自大学时期的「人工智能技术与运用」课程,这门课教授的第一个技术要点是「搜索」,由启发式搜索的概念展开,后续描述了各类算法实现,比如 <a href="https://zh.wikipedia.org/wiki/%E9%81%97%E4%BC%A0%E7%AE%97%E6%B3%95">遗传算法</a>、<a href="https://zh.wikipedia.org/wiki/%E6%A8%A1%E6%8B%9F%E9%80%80%E7%81%AB">模拟退火</a>,也包括各种实际问题的解决,比如迷宫寻路、<a href="https://zh.wikipedia.org/wiki/%E6%9F%AF%E5%B0%BC%E6%96%AF%E5%A0%A1%E4%B8%83%E6%A1%A5%E9%97%AE%E9%A2%98">七桥问题</a>。</p>
<p>工作之后的第一年,我在一家游戏公司担任服务端工程师,届时参与制作一款 TPS 类型坦克载具类网游,类似于移动版「坦克世界」,需要解决的第一个棘手需求是:如何为匹配服务器设计一个算法,使得对战双方 N 辆坦克的类型、战斗力、玩家战斗水平都尽量公平。</p>
<h2 id="澡盆玩具生产问题">澡盆玩具生产问题</h2>
<p>详细讨论上述匹配需求之前,先聊聊另一个更加简单的问题,<a href="https://book.douban.com/subject/5257905/">《<span lang="en">Head First Data Analysis</span>》</a>这本书的第三章描述了一个入门的规划问题:</p>
<p><em>假设你是一家名为「浴盆宝」的公司的数据分析师,这家公司的业务是生产和销售澡盆玩具,主要的产品线有两个:橡皮鸭和橡皮鱼,其中每只橡皮鸭和橡皮鱼的利润分别是 5 美元和 4 美元,它们分别消耗 100 单位和 125 单位的橡胶成本,问如果想让产品在下个月上架销售,橡皮鸭的产量不高于 400 只,橡皮鱼的产量不高于 300 只、且成本不超过 50,000 单位橡胶的情况下,怎样的生产组合能够使利润最大。</em></p>
<p>上述参数转化为表格的描述如下:</p>
<table>
<thead>
<tr>
<th style="text-align:center">产品</th>
<th style="text-align:center">最大产量</th>
<th style="text-align:center">利润</th>
<th style="text-align:center">单位成本</th>
<th style="text-align:center">产量</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:center">Duck</td>
<td style="text-align:center">400</td>
<td style="text-align:center">$5</td>
<td style="text-align:center">100</td>
<td style="text-align:center">$N_d$</td>
</tr>
<tr>
<td style="text-align:center">Fish</td>
<td style="text-align:center">300</td>
<td style="text-align:center">$4</td>
<td style="text-align:center">125</td>
<td style="text-align:center">$N_f$</td>
</tr>
</tbody>
</table>
<p>即产量满足约束</p>
<p>$$
100N_d + 125N_f \leq 50000 \mid N_d \leq 400, N_f \leq 300
$$</p>
<p>时,使得 $5N_d + 4N_f$ 最大。</p>
<p>书中引入这个案例更多是为了介绍如何在 <span lang="en">Microsoft Excel</span> 中操作以做规划求解,而如何通过解不等式方程得到最优解不在本文讨论范围之内。这里介绍这个基础的案例是为了讨论如何用遗传算法解该问题。</p>
<h2 id="遗传算法">遗传算法</h2>
<blockquote>
<p>遗传算法(<span lang="en">Genetic Algorithm (GA)</span> )是计算数学中用于解决最优化的搜索算法,是进化算法的一种。进化算法最初是借鉴了进化生物学中的一些现象而发展起来的,这些现象包括遗传、突变、自然选择以及杂交等。</p>
<p>遗传算法通常实现方式为一种计算机模拟。对于一个最优化问题,一定数量的候选解(称为个体)可抽象表示为染色体,使种群向更好的解进化。传统上,解用二进制表示(即 0 和 1 的串),但也可以用其他表示方法。进化从完全随机个体的种群开始,之后一代一代发生。在每一代中评价整个种群的适应度,从当前种群中随机地选择多个个体(基于它们的适应度),通过自然选择和突变产生新的生命种群,该种群在算法的下一次迭代中成为当前种群。</p>
</blockquote>
<p>遗传算法以达尔文的进化论为理论提出以下结论:生物以种群(<span lang="en">Population</span>)为单位做演化,种群由若干数量的个体组成,每个个体都有自己的基因(<span lang="en">Gene</span>)序列,不同的基因序列表现不同的性状,因此具有不同的环境适应度(<span lang="en">Fitness</span>),适应度高的个体更容易在自然选择中生存下来,将自己的基因序列遗传给下一代个体,两个个体之间会发生基因的交叉(<span lang="en">Crossover</span>),即繁殖过程,单个个体的基因序列存在一定概率发生突变(<span lang="en">Mutation</span>)而产生新的基因序列。</p>
<p>所谓的基因序列,就是将解空间中的解进行编码,使得解空间的每一个解都有唯一的一组基因对应。比较通用的编码方式就是 0-1 二进制串,若某个问题的解是可枚举的,且总共存在 $M$ 种不同的可能解,那么可以选择 $2n$ 中第一个超过 $M$ 的 $n$ 的自然数作为基因的编码长度,比如解有 960 中可能时用 10 位二进制串编码基因,解有 60000 中可能时用 16 位二进制串编码基因,依次类推。根据该结论,随着种群一代一代演化,适应度低的个体会被逐渐淘汰,保留下来的个体的基因序列都倾向于有更高的适应度,经过若干轮处理后,种群朝着整体适应度更高的方向发展,适应度最高的个体的基因序列便是我们需要的解。</p>
<p>在实践中我们发现,演化的方向可能是贪婪的,因此可能出现种群整体朝着一个次优的方向演化并在某一代收敛,即新产生的基因的适应度不再高于种群整体的适应度,以至于之后的演化不在产生更好的解。想象一个求极大值的函数的函数图像在给定定义域里存在两个波峰,它们的值一高一低,贪婪的演化可能导致基因序列在演化开始后逐渐趋近于低波峰附近的解且无法跳出。引入突变则使得即使是较高适应度的基因序列仍有一定概率突变成更好的或者更差的基因序列,从而跳出当前的解范围。从生物学的角度来说就是,遗传物质的复制过程中存在一定概率出现差错,进而为个体带来全新的基因序列,这种差错是完全随机的,带来的优劣也是因环境而异。</p>
<blockquote>
<p>突变通常会导致细胞运作不正常或死亡,甚至可以在较高等生物中引发癌症。但同时,突变也被视为演化的「推动力」:不理想的突变会经天择过程被淘汰,而对物种有利的突变则会被累积下去。</p>
</blockquote>
<p>通用的遗传算法一般会有以下几个控制参数:基因长度、种群大小、突变率、演化次数、随机数种子和适应度函数。</p>
<dl>
<dt>基因长度</dt>
<dd>正如之前描述的,依据解空间的大小可以确定基因长度,比如对于 10 位基因长度、解空间大小为 960 的问题来说,一个可能的解为 0100101101 (301)。</dd>
<dt>种群大小</dt>
<dd>计算机依据种群为单位批量计算,种群大小即计算机一次处理的样本大小,设置过大的种群大小会消耗更多的计算资源,设置过小则会导致进化缓慢,甚至可能出现演化结束后依旧无法找到最优解。最佳的大小值可能因问题和计算力而异。</dd>
<dt>突变(概)率</dt>
<dd>突变的产生是概率性的,表现在算法中就是,每一个个体在演化过程中都有一定概率使得自己基因序列的某一位或者某几位发生比特逆转。设置过小的突变率,比如 0,即不发生突变,会出现上一节描述的次优解陷阱,而过高的突变率会使得算法退化为无目的的随机搜索,一般来说 5% ~ 10% 的突变率就能够适应大部分问题。</dd>
<dt>演化次数</dt>
<dd>遗传算法本质上是一个有限步骤的算法,我们必须决定何时结束算法的循环,通常会设置经过多少代的演化后结束,或是直到种群中的某个个体适应度超过给定值后结束。无论是哪一种,这个参数是用来表示何时算法得以中止。同样,过快的终止条件可能导致尚未搜索出最优解的情况下中止,过慢的中止条件则导致后期已经收敛的种群演化效率低下。</dd>
<dt>随机数种子</dt>
<dd>用于生成初始种群的基因序列,相同的随机数种子应当每次都生成相同的初始种群。可随意设置,一般设置为当前的时间戳。</dd>
<dt>适应度函数</dt>
<dd>适应度函数模拟了环境的选择,决定了某一个基因序列对应的解的优劣。这里的优劣完全取决于适应度函数的定义,对于同一个问题,不同的适应度函数可能导致最终产生完全不同的种群,这与实际的生物演化是相符的,就好比橘树栽在淮南就是橘,而栽在淮北却变成了枳,或是亚洲象和非洲象在性状上的差异。无论如何,适应度函数的定义域等于解空间。</dd>
</dl>
<p>通常来说,我们面对的问题还会设置一些限制条件,比如生产问题中消耗量不能高于供给量,或是匹配问题中的玩家平均等级差不超过给定范围等。若最终演化后种群中的个体的基因序列对应的解违反了这些限制条件,则意味着演化出现了无用的解。因此有必要指定一些致死基因(<span lang="en">Lethal Gene</span>)来优化演化算法,所谓的致死基因就是那些对应的解违反了限制条件的基因序列,若演化得到的新基因序列包含致死基因,则设置该基因序列的适应度为 0,因为适应度为 0 的基因序列永远无法通过自然选择(随机轮盘)将自身遗传到下一代,用伪代码表示就是:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-coffeescript" data-lang="coffeescript"><span class="k">if</span> <span class="p">(</span><span class="nx">gene</span> <span class="o">is</span> <span class="nx">lethal</span> <span class="nx">gene</span><span class="p">)</span> <span class="p">{</span>
<span class="nv">fitness = </span><span class="mi">0</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="nv">fitness = </span><span class="nx">Fitness</span><span class="p">(</span><span class="nx">gene</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div><p>遗传算法是 <a href="https://zh.wikipedia.org/wiki/%E9%81%97%E4%BC%A0%E7%BC%96%E7%A8%8B">遗传编程</a> 的一个实现,这类编程范式的本质在于告诉计算机需要完成什么而不是如何完成,通过特定的策略去搜索解空间中的解并逐步收敛,最终找到最优解。</p>
<h2 id="用遗传算法解决澡盆玩具问题">用遗传算法解决澡盆玩具问题</h2>
<p>以上一节中的澡盆玩具生产问题为例,我们用 $x$ 代表最终生产的橡皮鸭数量,$y$ 代表最终生产的橡皮鱼数量,则任意合法的、不包含致死基因 $x$ 和 $y$ 的组合都是一个解,则问题的本质变成了:我们需要在有限的步骤内找到一组 $(x, y)$ 使得 $\text{Fitness}(x, y)$ 最大。</p>
<h3 id="基因定义">基因定义</h3>
<p>由于 $x$ 不超过 400,$y$ 不超过 300,与 400 和 300 最接近的 $2n$ 为 512 即 $n$ = 9,因此可以设置基因序列的长度为 9 + 9 = 18 位,解的个数应当低于 2<sup>18</sup>=262144 个,但实际有效的解会低于这个数字,因为这个数字包括了 $y\gt400$ 或 $y\gt300$ 的情况。想象一个长度为 18 的比特串,索引 0-8 的位置分配给 $x$,索引 9-17 的位置分配给 $y$,这个比特串就是这个问题的基因序列的编码方式。</p>
<h3 id="参数设置">参数设置</h3>
<p>我们已经确定基因序列的长度为 18,这里我们使用如下的参数配置来初始化算法。实际上,寻找一个正确而高效的参数来配置遗传算法可能需要多次调试:</p>
<table>
<thead>
<tr>
<th style="text-align:center">参数名称</th>
<th style="text-align:center">参数值</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:center">基因长度</td>
<td style="text-align:center">18 位</td>
</tr>
<tr>
<td style="text-align:center">种群大小</td>
<td style="text-align:center">200 个</td>
</tr>
<tr>
<td style="text-align:center">突变率</td>
<td style="text-align:center">7.5%</td>
</tr>
<tr>
<td style="text-align:center">随机数种子</td>
<td style="text-align:center">0</td>
</tr>
<tr>
<td style="text-align:center">演化次数</td>
<td style="text-align:center">200 代</td>
</tr>
</tbody>
</table>
<p>设置适应度函数为:</p>
<p>$$
F =
\begin{cases}
5x + 4y, &amp; \text{if $x \leq 400 \land y \leq 300 \land 100x + 125y \leq 50000$} \\<br>
0, &amp; \text{otherwise}
\end{cases}
$$</p>
<h3 id="自然选择">自然选择</h3>
<p>下面开始真正的算法迭代过程,首先进行的是自然选择步骤,为了简便起见,我们假设种群只包含 4 个个体,初始种群的个体基因序列是随机生成的,我们以 0 为随机数种子生成初始种群,假定生成的 4 个个体 $a \sim d$ 的基因序列如下:</p>
<table>
<thead>
<tr>
<th>个体名称</th>
<th>基因序列</th>
</tr>
</thead>
<tbody>
<tr>
<td>𝑎</td>
<td><code>001111000011001000</code></td>
</tr>
<tr>
<td>𝑏</td>
<td><code>100101100011001000</code></td>
</tr>
<tr>
<td>𝑐</td>
<td><code>000111100001001011</code></td>
</tr>
<tr>
<td>𝑑</td>
<td><code>001110100010000010</code></td>
</tr>
</tbody>
</table>
<p>通过拆分二进制串可以计算得到每个基因序列对应的 $x$、$y$ 和适应度:</p>
<table>
<thead>
<tr>
<th style="text-align:left">个体名称</th>
<th style="text-align:left">基因序列</th>
<th style="text-align:right">𝑥 和 𝑦 值</th>
<th style="text-align:right">适应度</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">𝑎</td>
<td style="text-align:left"><code>001111000011001000</code></td>
<td style="text-align:right">120, 200</td>
<td style="text-align:right">1400</td>
</tr>
<tr>
<td style="text-align:left">𝑏</td>
<td style="text-align:left"><code>100101100011001000</code></td>
<td style="text-align:right">300, 200</td>
<td style="text-align:right">2300</td>
</tr>
<tr>
<td style="text-align:left">𝑐</td>
<td style="text-align:left"><code>000111100001001011</code></td>
<td style="text-align:right">60, 75</td>
<td style="text-align:right">600</td>
</tr>
<tr>
<td style="text-align:left">𝑑</td>
<td style="text-align:left"><code>001110100010000010</code></td>
<td style="text-align:right">116, 130</td>
<td style="text-align:right">1100</td>
</tr>
</tbody>
</table>
<p>在遗传算法的迭代过程中,种群的个体数量始终恒定不变,n 个个体的种群演化至下一代仍有 n 个个体,自然选择的过程就是通过某种选择策略,从上一代的 n 个个体选择 n 次,组建出下一代的 n 个个体,通常的选择策略便是依据个体适应度大小的加权随机采样,想象将 $a \sim d$ 依据适应度绘制一个饼图:</p>
<p>加权随机的意思是在该圆中随机某一点,该点对应 $a \sim d$ 哪一个的区域,就选择哪一个个体,重复 4 次。加权随机的具体实现算法在此不赘述,不管怎样,适应度越高的个体,在饼图中占据的区域越大,随机生成的点落在该个体区域的概率也越高,反之,低适应度的个体更加不容易被选中,对应于自然界的「适者生存」。假设经过一轮自然选择后的种群为 $\{b_1, a, b_2, d\}$:意味着在下一轮的 4 次选择中,$b$ 在第 1 次和第 3 次被命中,第 2 次命中了 𝑎,第 4 次命中了 $d$,而 $c$ 因为适应度最低不幸没有被命中。这是一个很大可能的结果,因为 $c$ 的命中概率为 11.1% 而远低于 $b$ 的 42.6%,此时的种群基因序列如下</p>
<table>
<thead>
<tr>
<th style="text-align:left">个体(选择前)</th>
<th style="text-align:right">适应度(选择前)</th>
<th style="text-align:left">个体(选择后)</th>
<th style="text-align:right">适应度(选择后)</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">$a$</td>
<td style="text-align:right">1400</td>
<td style="text-align:left">$b_1$</td>
<td style="text-align:right">2300</td>
</tr>
<tr>
<td style="text-align:left">$b$</td>
<td style="text-align:right">2300</td>
<td style="text-align:left">$a$</td>
<td style="text-align:right">1400</td>
</tr>
<tr>
<td style="text-align:left">$c$</td>
<td style="text-align:right">60</td>
<td style="text-align:left">$b_2$</td>
<td style="text-align:right">2300</td>
</tr>
<tr>
<td style="text-align:left">$d$</td>
<td style="text-align:right">1100</td>
<td style="text-align:left">$d$</td>
<td style="text-align:right">1100</td>
</tr>
</tbody>
</table>
<p>可以看出选择前种群的平均适应度为 1350,而选择后为 1775,即经过一轮自然选择,种群中的个体普遍比上一代个体具有更高的适应度。同时,带有致死基因的个体因为其适应度为 0 而永远不会被自然选择命中,这保证了我们能够及早的在搜索结果中排除那些不满足限制条件的解。</p>
<h3 id="基因交叉">基因交叉</h3>
<p>演化迭代的第二步是基因交叉,基因交叉发生与两两个体之间,指的是两个个体间的基因序列在随机某个位置之后的子序列发生交换,假设对于上一节经过自然选择之后的种群 $\{b_1, a, b_2, d\}$ 在基因位置 14 之后的子序列进行基因交叉,即 $b_1$ 与 $a$ 交叉,$b_2$ 与 $d$ 交叉,则如下表格描述了经过交叉后的种群的基因序列和对应的适应度:</p>
<table>
<thead>
<tr>
<th style="text-align:left">个体</th>
<th style="text-align:left">交叉前基因序列后 9 位</th>
<th style="text-align:left">交叉后基因序列后 9 位</th>
<th style="text-align:right">新 $x$ 和 $y$</th>
<th style="text-align:right">新适应度</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align:left">$b_1$</td>
<td style="text-align:left"><code>011001000</code></td>
<td style="text-align:left"><code>011001000</code></td>
<td style="text-align:right">300, 200</td>
<td style="text-align:right">2300</td>
</tr>
<tr>
<td style="text-align:left">$a$</td>
<td style="text-align:left"><code>011001000</code></td>
<td style="text-align:left"><code>011001000</code></td>
<td style="text-align:right">120, 200</td>
<td style="text-align:right">1400</td>
</tr>
<tr>
<td style="text-align:left">$b_2$</td>
<td style="text-align:left"><code>011001000</code></td>
<td style="text-align:left"><code>011000010</code></td>
<td style="text-align:right">300, 194</td>
<td style="text-align:right">2276</td>
</tr>
<tr>
<td style="text-align:left">$d$</td>
<td style="text-align:left"><code>010000010</code></td>
<td style="text-align:left"><code>010001100</code></td>
<td style="text-align:right">116, 140</td>
<td style="text-align:right">1140</td>
</tr>
</tbody>
</table>
<p>可以看出交叉前种群的平均适应度为 1775,而交叉后为 1779,即经过一轮基因交叉,种群中的个体的适应度比上一代个体略有上升,若选择另一位置的字序列交叉,则可能出现更坏的情况。如果说自然选择是已有基因序列的择优筛选,本质上并没有为种群引入新的基因序列,而从这一步开始,种群内的个体产生了新的基因序列。</p>
<h3 id="基因变异">基因变异</h3>
<p>演化迭代的第三步是基因变异,基因变异发生在单个个体间,每个个体在每一轮迭代中都有一定概率出现遗传物质的复制错误,该行为模拟了生物 DNA 的转录错误,表现在算法中就是比特串某一位置的比特值发生反转。与基因交叉类似,变异可能产生原本种群内不存在基因,这可能改变种群整体的进化方向以避免之前讨论过的次有解陷阱。</p>
<p>假设经过上一步交叉后的种群里,只有个体 $b_1$ 发生了基因突变,且突变的位置为 4,则 $b_i$ 突变后的基因即从 1001..0..1100011001000 变异为 100..1..01100011001000。</p>
<h3 id="种群更替与搜索结果">种群更替与搜索结果</h3>
<p>在种群完成「自然选择」、「基因交叉」和「基因突变」后,种群便完成了一次代际的更替,一般来说,新的种群会比上一代种群更加适合生存。如果选择了合适的参数,算法能够在中止前完成搜索的解收敛。</p>
<p>我在我的机器上按照上述参数实现了一版算法,运行后最终种群在第 4 代便完成了解的收敛,最终算法给出的适应度最高的基因为 110010000001010000,即 $x$=400,$y$=80 时,利润最高,达到 2320 美元。</p></description><category domain="https://zhix.co/categories/%E6%8A%80%E6%9C%AF/">技术</category><category domain="https://zhix.co/tags/%E7%AE%97%E6%B3%95/">算法</category><category domain="https://zhix.co/tags/%E4%BA%BA%E5%B7%A5%E6%99%BA%E8%83%BD/">人工智能</category><category domain="https://zhix.co/tags/%E9%81%97%E4%BC%A0%E7%AE%97%E6%B3%95/">遗传算法</category><category domain="https://zhix.co/tags/%E5%90%AF%E5%8F%91%E5%BC%8F%E6%90%9C%E7%B4%A2/">启发式搜索</category></item></channel></rss>