+ + + + + + + + + +
From da91819ebd9af201901023f426f74f959cc37949 Mon Sep 17 00:00:00 2001 From: hill-jiang <> Date: Sat, 13 Jan 2024 10:47:41 +0800 Subject: [PATCH] update from gridea: 2024-01-13 10:47:41 --- 404.html | 89 + about/index.html | 127 ++ api-content/index.html | 1 + api-info/index.html | 1 + archives/index.html | 449 ++++ atom.xml | 1558 +++++++++++++ favicon.ico | Bin 0 -> 215537 bytes friends/index.html | 137 ++ images/avatar.png | Bin 0 -> 215537 bytes index.html | 380 ++++ media/README.md | 1 + media/gridea-search/gridea-search.js | 230 ++ media/gridea-search/result-template.ejs | 33 + media/scripts/index.js | 1 + page/2/index.html | 344 +++ post-images/1696234529978.png | Bin 0 -> 29830 bytes post-images/1696234568616.png | Bin 0 -> 20042 bytes post-images/1696234585767.png | Bin 0 -> 45877 bytes post-images/1696234603967.png | Bin 0 -> 25259 bytes post-images/1696234619154.png | Bin 0 -> 28814 bytes post-images/1696234627836.png | Bin 0 -> 484115 bytes post-images/1696234636873.png | Bin 0 -> 5792 bytes post-images/1696234645381.png | Bin 0 -> 102085 bytes post-images/1696234654100.png | Bin 0 -> 6232046 bytes post-images/1696234691879.png | Bin 0 -> 117143 bytes post-images/1696234697310.png | Bin 0 -> 148015 bytes post-images/1696234706393.png | Bin 0 -> 6566 bytes post-images/1696234727044.png | Bin 0 -> 171787 bytes post-images/1696234733710.png | Bin 0 -> 236147 bytes post-images/1696234742850.png | Bin 0 -> 616336 bytes post-images/1696234755283.png | Bin 0 -> 218577 bytes post-images/1696234767981.png | Bin 0 -> 63729 bytes post-images/1696234775272.png | Bin 0 -> 384263 bytes post-images/1697334009505.png | Bin 0 -> 29675 bytes post-images/1697334037696.png | Bin 0 -> 1148773 bytes post-images/1697334054908.png | Bin 0 -> 86350 bytes post-images/1697334066905.png | Bin 0 -> 644820 bytes post-images/1697334073380.png | Bin 0 -> 488840 bytes post-images/1697334079970.png | Bin 0 -> 550823 bytes post-images/1697334087062.png | Bin 0 -> 35856 bytes post-images/1697334170660.png | Bin 0 -> 159929 bytes post-images/1697334186017.png | Bin 0 -> 183916 bytes post-images/1697334193433.png | Bin 0 -> 78585 bytes post-images/1697334204213.png | Bin 0 -> 32556 bytes post-images/1697334214081.png | Bin 0 -> 39091 bytes post-images/1697334228430.png | Bin 0 -> 43910 bytes post-images/1697334237964.png | Bin 0 -> 20003 bytes post-images/1697334246444.png | Bin 0 -> 18390 bytes post-images/1697334256791.png | Bin 0 -> 18478 bytes post-images/1697334262857.png | Bin 0 -> 68183 bytes post-images/1697334273781.png | Bin 0 -> 228666 bytes post-images/1697335133892.png | Bin 0 -> 62674 bytes post-images/1697335143083.png | Bin 0 -> 53991 bytes post-images/1701180976262.png | Bin 0 -> 145609 bytes post-images/1701180986892.png | Bin 0 -> 174630 bytes post-images/hello-gridea.png | Bin 0 -> 39271 bytes .../index.html" | 764 +++++++ .../index.html" | 433 ++++ .../index.html" | 196 ++ .../index.html" | 326 +++ post/about/index.html | 146 ++ .../index.html | 398 ++++ .../index.html | 391 ++++ post/git-sheng-ji-cai-keng/index.html | 180 ++ post/hello-gridea/index.html | 164 ++ .../index.html | 199 ++ post/playwright/index.html | 374 ++++ .../index.html | 406 ++++ .../index.html | 277 +++ .../index.html | 329 +++ .../index.html" | 242 +++ .../index.html" | 156 ++ .../index.html" | 284 +++ .../index.html" | 266 +++ .../index.html" | 176 ++ .../index.html" | 311 +++ search/index.html | 135 ++ styles/main.css | 1922 +++++++++++++++++ tag/-y9ZFT4MQ_/index.html | 157 ++ tag/4l_3RwoEe/index.html | 153 ++ tag/6xpVd9h-3/index.html | 178 ++ tag/At_GYpVA5S/index.html | 205 ++ tag/EaPdL6sj4/index.html | 153 ++ tag/PNIwzxfLqw/index.html | 207 ++ tag/Pm-QW_9AIk/index.html | 157 ++ tag/SNPxxRPwXa/index.html | 157 ++ tag/X1d9ds5kfn/index.html | 290 +++ tag/XYbRt1-8ic/index.html | 153 ++ tag/Z_aKsriYh/index.html | 153 ++ tag/cs3Ntl11C/index.html | 159 ++ tag/k0ez3Ah484/index.html | 157 ++ tags/index.html | 159 ++ 92 files changed, 13864 insertions(+) create mode 100644 404.html create mode 100644 about/index.html create mode 100644 api-content/index.html create mode 100644 api-info/index.html create mode 100644 archives/index.html create mode 100644 atom.xml create mode 100644 favicon.ico create mode 100644 friends/index.html create mode 100644 images/avatar.png create mode 100644 index.html create mode 100644 media/README.md create mode 100644 media/gridea-search/gridea-search.js create mode 100644 media/gridea-search/result-template.ejs create mode 100644 media/scripts/index.js create mode 100644 page/2/index.html create mode 100644 post-images/1696234529978.png create mode 100644 post-images/1696234568616.png create mode 100644 post-images/1696234585767.png create mode 100644 post-images/1696234603967.png create mode 100644 post-images/1696234619154.png create mode 100644 post-images/1696234627836.png create mode 100644 post-images/1696234636873.png create mode 100644 post-images/1696234645381.png create mode 100644 post-images/1696234654100.png create mode 100644 post-images/1696234691879.png create mode 100644 post-images/1696234697310.png create mode 100644 post-images/1696234706393.png create mode 100644 post-images/1696234727044.png create mode 100644 post-images/1696234733710.png create mode 100644 post-images/1696234742850.png create mode 100644 post-images/1696234755283.png create mode 100644 post-images/1696234767981.png create mode 100644 post-images/1696234775272.png create mode 100644 post-images/1697334009505.png create mode 100644 post-images/1697334037696.png create mode 100644 post-images/1697334054908.png create mode 100644 post-images/1697334066905.png create mode 100644 post-images/1697334073380.png create mode 100644 post-images/1697334079970.png create mode 100644 post-images/1697334087062.png create mode 100644 post-images/1697334170660.png create mode 100644 post-images/1697334186017.png create mode 100644 post-images/1697334193433.png create mode 100644 post-images/1697334204213.png create mode 100644 post-images/1697334214081.png create mode 100644 post-images/1697334228430.png create mode 100644 post-images/1697334237964.png create mode 100644 post-images/1697334246444.png create mode 100644 post-images/1697334256791.png create mode 100644 post-images/1697334262857.png create mode 100644 post-images/1697334273781.png create mode 100644 post-images/1697335133892.png create mode 100644 post-images/1697335143083.png create mode 100644 post-images/1701180976262.png create mode 100644 post-images/1701180986892.png create mode 100644 post-images/hello-gridea.png create mode 100644 "post/Linux\350\277\233\347\250\213\347\233\270\345\205\263\345\221\275\344\273\244\347\256\200\344\273\213/index.html" create mode 100644 "post/Python \350\277\233\347\250\213\351\227\264\351\200\232\344\277\241/index.html" create mode 100644 "post/Python\345\255\227\345\205\270\347\232\204\346\265\205\346\213\267\350\264\235\344\270\216\346\267\261\346\213\267\350\264\235/index.html" create mode 100644 "post/Streamlit\357\274\232\344\270\215\345\206\231\345\211\215\347\253\257\345\274\200\345\217\221WEB\345\272\224\347\224\250/index.html" create mode 100644 post/about/index.html create mode 100644 post/bao-wen-chuan-shu-zhong-de-url-bian-ma-wen-ti/index.html create mode 100644 post/bu-he-li-de-she-ji-shi-xian-shi-ru-he-yin-fa-xing-neng-wen-ti-de/index.html create mode 100644 post/git-sheng-ji-cai-keng/index.html create mode 100644 post/hello-gridea/index.html create mode 100644 post/jmeter-shi-xian-bing-xing-fa-song-xi-dai-lie-biao-can-shu-zhong-mou-yi-xiang-de-qing-qiu/index.html create mode 100644 post/playwright/index.html create mode 100644 post/rtmpflv-fu-wu-da-jian-yu-ji-yu-python-de-flv-liu-huo-qu/index.html create mode 100644 post/tls-shi-ru-he-dao-zhi-bao-wen-chang-du-peng-zhang-de/index.html create mode 100644 post/xian-cheng-chi-can-shu-she-zhi-bu-he-li-dao-zhi-de-xiao-xi-diu-shi-wen-ti/index.html create mode 100644 "post/\344\275\277\347\224\250Python\346\223\215\344\275\234SQLite/index.html" create mode 100644 "post/\345\215\232\345\256\242\346\224\266\351\233\206/index.html" create mode 100644 "post/\345\237\272\344\272\216PyQt\347\232\204\346\241\214\351\235\242\345\260\217\347\250\213\345\272\217\345\274\200\345\217\221/index.html" create mode 100644 "post/\345\256\211\345\215\223 APP \346\212\223\345\214\205\346\216\242\347\264\242/index.html" create mode 100644 "post/\346\267\273\345\212\240\342\200\234\345\234\250\346\255\244\345\244\204\346\211\223\345\274\200windowsTerminal\342\200\235\345\210\260\345\217\263\351\224\256\350\217\234\345\215\225/index.html" create mode 100644 "post/\350\257\201\344\273\266\346\240\241\351\252\214\350\247\204\345\210\231\347\240\224\347\251\266/index.html" create mode 100644 search/index.html create mode 100644 styles/main.css create mode 100644 tag/-y9ZFT4MQ_/index.html create mode 100644 tag/4l_3RwoEe/index.html create mode 100644 tag/6xpVd9h-3/index.html create mode 100644 tag/At_GYpVA5S/index.html create mode 100644 tag/EaPdL6sj4/index.html create mode 100644 tag/PNIwzxfLqw/index.html create mode 100644 tag/Pm-QW_9AIk/index.html create mode 100644 tag/SNPxxRPwXa/index.html create mode 100644 tag/X1d9ds5kfn/index.html create mode 100644 tag/XYbRt1-8ic/index.html create mode 100644 tag/Z_aKsriYh/index.html create mode 100644 tag/cs3Ntl11C/index.html create mode 100644 tag/k0ez3Ah484/index.html create mode 100644 tags/index.html diff --git a/404.html b/404.html new file mode 100644 index 0000000..58bcadf --- /dev/null +++ b/404.html @@ -0,0 +1,89 @@ + + +
+ + + +前段时间出了不少性能相关的市场反馈,某大型连锁店客户在使用云平台时频繁出现卡顿现象,基本不可用;甚至在其使用期间,由于大量占用了系统资源服务,导致后端接口响应时间明显边长,影响了其他用户的支持使用
+连锁店项目,所有设备放在同一个项目中进行管理
+每个一级区域对应一个城市,每个二级区域对应一个门店
+每个门店添加一个NVR,关联若干IPC通道
+设备 | +数量 | +
---|---|
总监控点数 | +10000 | +
NVR | +2000 | +
每个 NVR 下的通道数 | +1 到 16 | +
区域 | +数量 | +
---|---|
一级区域 | +500 | +
二级区域 | +每个一级区域下包含 1-50 个二级区域,共 2000 个二级区域 | +
假设有以下带有层级的树状区域结构:
+|-一级区域1
+ |-二级区域1-1
+ |-二级区域1-2
+ |-二级区域1-3
+ ...
+|-一级区域2
+ |-二级区域2-1
+ |-二级区域2-2
+ |-二级区域2-3
+ ...
+...
+
+有多个一级区域,且每个区域下有多个二级区域
+进入设备管理页面,前端会发送如下请求给后端:
+请求 | +请求参数 | +作用 | +
---|---|---|
getRootRegions | +- | +获取所有一级区域 | +
getRegionChildren | +rootRegionId | +获取特定一级区域下的所有二级区域 | +
getDeviceList | +regionId | +获取特定区域下的设备列表 | +
区域相关的所有请求,不分页,也就是 getRootRegions 会响应所有一级区域的信息,getRegionChildren 会响应所有特定区域下的子区域,不管响应结果会有多少个
+进入页面时,会完整请求所有区域,以及所有区域下的设备信息
+区域提供搜索功能,搜索由前端实现,所以要完整加载所有区域信息后才能实现搜索
+请求数量 = 1(getRootRegions) + 总一级区域数量(getRegionChildren) + 总区域数量(getDeviceList)
这样一来,一进入页面,就会发送多个请求,请求数量随区域数量的增加而线性增加,导致在区域数量很多时,并行发送了大量请求给后端
+从该客户的配置情况来看,前端会发送1+500+(500+2000)=3001
个请求,并行发大量请求带来的影响有:
浏览器一般会限制并行请求数量,前面的请求未得到响应时,后发的请求暂时阻塞,导致大量请求被阻塞,响应时间长,响应可能出现超时导致服务不可用
+并行请求数量太多,服务器资源占用突增,后端服务器响应时间变长
+该问题虽然可以认为是性能问题(配置少的情况下不会出现、配置多的情况下出现),但是从直接原因来看,是前后端设计实现不合理导致的,因此针对此页面提出如下几个改进方案:
+所有获取区域功能,添加分页接口,一次获取有限数量的信息,直到页面滚动或者用户手动点击加载更多才按需加载更多区域
+所有需要搜索区域的功能,添加搜索接口,搜索由后端实现
+所有请求设备的接口,只请求当前选中区域的设备、未选中区域的设备不请求
+如此一来,就能大大减少页面发送的请求数量,而对用户体验基本又不会有影响,后续采用此方案优化效果明显
+实际改动提测后,发现涉及到区域勾选+搜索的页面(设置角色权限时,可以批量勾选其拥有权限的区域),会遇到问题。
+因为当前是分页加载的,前端在加载完成之前并不知道有多少区域
+如果勾选了父区域,子区域也被选中,前端传参直接是父区域ID
+考虑如下场景:此时如果取消勾选某个子区域,父区域会变成半选状态,但是如果子区域过多、一页没有加载完,直接确认,前端传参就会变成这一页的所有勾选的子区域ID,剩下没加载的子区域因为前端不知道具体信息,所以不会传给后端,导致实际结果与预期不符
+进一步的,涉及勾选后再搜索、搜索后再清除某些已搜索的内容,都会因为前端没有获取过完整的树结构,导致一些不符合预期的结果(讨论过搜索清空已选的方案,认为与实际用户习惯不符,不能接受)
+这个问题相对比较棘手,讨论了很久都没有好的解决方案,于是去看了看竞品有没有类似的页面(树组织结构+父子层级+多选)
+海康云眸社区:https://www.hik-cloud.com/neptune/index.html
+有相似功能的有两个页面,其实现还不一样:
+父子组织树结构+搜索功能
+分页获取
+搜索后端完成
+父子组织树结构+勾选功能+搜索功能
+不分页直接获取完整组织树
+搜索前端完成
+参考这个实现,又想到是不是可以不分页、前端获取完整组织树、搜索前端完成
+之前出现性能问题的场景是,有多个根区域、每个根区域又有子区域,这时候获取完根区域之后再去遍历获取每个根区域的子区域,导致并行发送的请求数量很多
+可以考虑新增一个获取完整区域树结构的接口:
+输入 projectId,输出所有区域信息
+输出区域信息不包含层级关系,通过 regionBranch(regionId 完整路径) 来标示父子接哦故
+前端用区域信息来构造完整的区域树(一个请求搞定)
+搜索功能仍然有前端来完成,保留勾选状态
+父子组织树结构+勾选功能+搜索功能的页面使用频次很少,低频次的完整组织树获取也不会对服务有太大影响
+从竞品情况来看,3000+个区域,单个接口的响应大小在200K左右(其regionId为40位string,相对较长),单个接口的响应时间在100ms左右,都可以接受
+后端接口性能上,响应的都是数据库已有字段,projectId 已加索引,性能风险不大 SELECT regionId, regionName, regionBranch, order form region_info where projectId='xxx';
需要再评估一下前端性能
+POST /tums/resources/getRegionTree
+Request:
+{
+ "projectId": "123"
+}
+Response:
+{
+ "result": [
+ {
+ "regionId":"1",
+ "regionName": "一",
+ "regionBranch": "1-",
+ "order": 1
+ },
+ {
+ "regionId": "2",
+ "regionName": "二",
+ "regionBranch": "1-2-",
+ "order": 1
+ }
+ ],
+ "error_code": 0
+}
+
+遇到性能问题,先从设计实现的角度去看看,有没有实现不合理的地方,再去着手考虑优化
+前端资源按需加载,一次加载不必要的资源,不仅加大了服务压力,还导致响应时间延长影响用户体验
+一个问题有解决方案后,不一定对所有业务功能都通用,要灵活考虑
+最近项目新增了一个后端接口,功能大概就是传入区域的ID,响应该区域下所有的设备信息。
+针对这个新增接口做性能测试,需要测试响应成功率、最大吞吐量等指标,涉及到HTTP请求的并行发送和结果信息收集。这次打算不用之前自己写python脚本的方式,改用JMeter试试看。
+用JMeter实现上述测试需求的过程中,遇到了一些问题,自己摸索了一下解决方案,记录一下。
+使用JMeter自带的JSON提取器,从获取区域信息的响应中获取regionId的值后,被保存到了一个列表中,实际上是一组JMeterVariables。
+ +我需要遍历这个列表,再用这个列表中的每一项作为请求体来并行发送请求。比如获取到了50个regionId,并行发送50个请求,每个请求的请求体包含一个regionId的值,类似:
+for i in range(50):
+ payload = {"regionId": region_id[i]}
+
+一开始打算用JMeter的并行处理插件 parallel controller 和 forEach controller 来实现循环遍历和并行发送请求,但是发现无论是 parallel 在 forEach 控制器下,还是 forEach 在 parallel 下,都还是串行发送的请求。
+猜测这个问题可能和插件实现机制有关,每个 forEach controller 都还是在一个线程中串行创建的。
+不用 forEach,用 while 控制器 + 计数器来实现遍历,也是一样的结果。
+既然在同一个线程组里面没法用 parallel controller 来实现并行,那只能考虑用多个线程组来实现了,这就又涉及到了线程组之间的变量传递,我需要把上一个线程组中获取到的变量列表传递到下一个线程组中去。
+折腾了一圈,最终用 BeanShell取样器+CSV文件存储的方式实现了。
+__setProperty
方法怎么加上变量索引。FileWriter fstream = new FileWriter("F:/Documents/JMeter/result/src/regionId.csv",false);
+BufferedWriter out = new BufferedWriter(fstream);
+
+${__setProperty(regionNum,${regionIds_matchNr},)}
+
+for(int i=1;i<=${regionIds_matchNr};i++) {
+ String regionId_value = vars.get("regionIds_" + i);
+// log.info(regionId_value);
+ out.write(regionId_value+"\n");
+}
+out.close();
+fstream.close();
+
+创建一个线程组,线程数设置为${__property(regionNum)}
,表示并行运行 regionId 数组长度个线程。Ramp-Up 时间可以根据测试需要自行设置合适值。循环次数设置为 1 ,并不需要重复运行。
在新创建的线程组中,添加 CSV数据文件设置,读取在上一个线程组中写入的 csv 文件,保存到变量中,比如 regionId
,每个线程会自动把 csv 文件中当前线程数对应的行写入这个变量。
创建 HTTP 请求,请求参数设置为{"regionId": "${regionId}"}
即可。
++不要忘了在 Test Plan 中勾选“独立运行每个线程组”,否则所有线程组将一起启动。
+
可以看到,请求基本上都是并行发送的了。
+ +]]>最近升级了一下 pycharm,重启提示 git 版本不受支持,然后就找了个最新版本的 git 升级
+升级完之后,发现 git pull
报错,Permission denied (publickey).
这个报错之前见过,没配 SSH key 就是这个报错
+一开始以为是 git 升级完要重新配一下 SSH key,心想怎么这么不智能
+重新生成完了,在 gerrit 上也重新 Add 新生成的 SSH key 了,报错还是一样,重新 git clone
也是一样的报错
+眉头一皱,一搜,有大坑。
GIT 2.33.1 版本集成了最新的 OpenSSH v8.8p1版本,此版本放弃了历史相当悠久的 rsa-sha1的支持。
+2.33 以上的版本,如果再用 rsa 来生成 SSH Key,就会产生问题,需要更换密钥
如果你急需访问仓库,而暂时不想修改密钥,可以在密钥所在的. ssh 目录下的 config 文件(没有的话自行创建)添加如下配置即可访问。
+Host git.xxxxxx.com
+HostkeyAlgorithms +ssh-rsa
+PubkeyAcceptedAlgorithms +ssh-rsa
+
+重新生成更安全的密钥。
+在生成之前,要确定服务器是否支持相应的密钥加密算法。
+使用 ECDSA 或者 ED25519 算法替代 RSA 以一个不错的选择。
ssh-keygen -t ed25519 -C "your@example.email"
+
+回滚 git 版本
+windows 版本的 git 安装的时候,可以选择“use external OpenSSH”。这样可以不使用内置的 OpenSSH。可以指定一个可用的 OpenSSH 安装路径。
+最终确认 gerrit 支持更安全的密钥算法,就用方案二解决了。
以设备上线为例,设备首先和云平台的设备连接层建立socket连接交互,连接层模块会产生一个上线事件,发到kafka的topic上,下游模块消费后,经过业务处理,转发给云平台的设备管理模块,设备管理模块收到消息后修改对应的设备在线状态、触发消息提醒。
+participant 设备 as d
+participant 设备连接层 as c
+participant kafka as k
+participant 下游模块 as h
+participant 云平台 as b
+
+d->c: 建立连接
+note over d, c: 长连接
+c->k: 设备上线事件
+k->h: 消费消息
+note over h: 业务处理
+h->b: 消息转发
+note over b: 产生消息提示
+note over b: 修改设备在线状态
+
+
+根据日志,初步定位问题原因是云平台未收到设备上线的消息,导致无法正确修改设备在线状态。
+测试环境使用约 50 个虚拟设备反复触发离线、上线逻辑,挂载数小时可复现。
+继续添加日志定位问题:
+这两部分说明kafka消息队列没有问题,下游模块消费消息或是业务处理出现了问题。
+下游模块处理流程:
+2 打印正常、5 没有打印;在测试环境下远没有触发限流,3跳过;因此定位问题出在步骤4,业务线程池的处理出现了问题导致消息丢失。
+初始化时就建立好线程资源,避免反复创建新的线程造成的资源开销。
+优点
+threadPool:
+ corePoolSize: 20
+ maximumPoolSize : 2000
+ workQueue : 1000
+ keepAliveSeconds: 300
+
+线程池工作流程大致如下:
+graph LR
+A(开始) --> B[提交任务]
+B --> C{线程池状态是否Running?}
+C --> |是|D{线程数小于核心数?}
+D --> |是|E[添加工作线程并执行]
+E --> F(结束)
+C --> |否|G[任务拒绝]
+G --> F
+D --> |否|H{阻塞队列已满?}
+H --> |是|I{线程数小于最大线程数?}
+I --> |是|E
+I --> |否|G
+H --> |否|J[添加到阻塞队列, 等待工作线程获取执行]
+J --> F
+
+
+当线程数大于 maximumPoolSize 时,会执行拒绝策略,常见的拒绝策略有四种:
+CallerRunsPolicy(调用者运行策略)
+AbortPolicy(中止策略)
+DiscardPolicy(丢弃策略)
+DiscardOldestPolicy(弃老策略)
+实际配置的策略为 DiscardOldestPolicy,丢弃队列头部元素,即未执行的最旧的任务。
+所以当阻塞队列已满、并且线程数也已经达到了最大线程数的时候,就会执行拒绝策略,导致消息丢失。
+最终该问题定位的原因是线上消息压力过大,而配置的线程池参数过小、拒绝策略不合理,导致出现任务拒绝丢失消息。
+首先确定以下几个相关参数:
+根据这几个参数,可以算出核心线程数、任务队列长度、最大线程数、线程空闲时间的推荐值
+corePoolSize:常驻核心线程数
+corePoolSize = avgTasks * taskHandleTime
maximumPoolSize:最大线程数
+maximumPoolSize = maxTasks * taskHandleTime
workQueue:缓存队列长度
+workQueue = (maximumPoolSize - corePoolSize) * responseTime / taskHandleTime
keepAliveSeconds:最长线程空闲时间
+%
。 比如 \
,它的 ascii 码是 92,92 的十六进制是 5c,所以 \
的 url 编码就是%5c。RFC3986 文档规定,url 中只允许包含以下四种:
+-_.~
4 个特殊字符 ! * ' ( ) ; : @ & = + $, / ? # [ ]
所谓保留字符,就是在 url 中具有特定意义的字符。
+常见 url 编码转换表
+编码前 | +编码后 | +编码前 | +编码后 | +编码前 | +编码后 | +
---|---|---|---|---|---|
space | +%20 | +* | +%2A | +> | +%3E | +
! | +%21 | ++ | +%2B | +? | +%3F | +
" | +%22 | +, | +%2C | +@ | +%40 | +
# | +%23 | +- | +%2D | +[ | +%5B | +
$ | +%24 | +. | +%2E | +\ | +%5C | +
% | +%25 | +/ | +%2F | +] | +%5D | +
& | +%26 | +: | +%3A | +^ | +%5E | +
' | +%27 | +; | +%3B | +_ | +%5F | +
( | +%28 | +< | +%3C | +` | +%60 | +
) | +%29 | += | +%3D | +{ | +%7B | +
| | +%7C | +} | +%7D | +~ | +%7E | +
集中存储、监控点上墙等场景,传输的是 url 地址,需要重点关注用户名/密码包含所有保留字符的表现,因为后端在处理 url、从中提取用户名、密码、IP 等信息时,可能会遇到保留字符。
+设置设备名称等场景,原则上用户输入可以是任意字符,在HTTP传输过程中,为了确保数据的正确性和可靠性,通常需要对payload进行URL编码处理,这也就导致了所有需要进行HTTP传输的场景,客户端需要将原始用户输入进行URL编码传输、而将从服务端收到的数据进行URL解码显示,其中一个环节出现问题就可能导致传输或显示异常。
+此时需要关注所有 url 编解码相关的字符。
可能存在风险的字符:
+%
本身%20、%21
等Python:
+urllib.parse.quote
和urllib.parse.unquote
:这是Python标准库中提供的url编解码函数,可以将字符进行url编码或解码。lang=Python
+
+import urllib.parse
+
+# url编码
+url = 'https://www.example.com/search?q=Python 编程'
+encoded_url = urllib.parse.quote(url)
+print(encoded_url) # https%3A//www.example.com/search%3Fq%3DPython%20%E7%BC%96%E7%A8%8B
+
+# url解码
+decoded_url = urllib.parse.unquote(encoded_url)
+print(decoded_url) # https://www.example.com/search?q=Python 编程
+
+requests.utils.quote
和requests.utils.unquote
:这是requests库中提供的url编解码函数,与urllib.parse
类似。lang=Python
+
+import requests.utils
+
+# url编码
+url = 'https://www.example.com/search?q=Python 编程'
+encoded_url = requests.utils.quote(url)
+print(encoded_url) # https%3A//www.example.com/search%3Fq%3DPython%20%E7%BC%96%E7%A8%8B
+
+# url解码
+decoded_url = requests.utils.unquote(encoded_url)
+print(decoded_url) # https://www.example.com/search?q=Python 编程
+
+Java:
+java.net.URLEncoder
和java.net.URLDecoder
:这是Java标准库中提供的URL编解码类,可以将字符串进行URL编码或解码lang=Java
+
+import java.net.URLEncoder;
+import java.net.URLDecoder;
+
+// URL编码
+String url = "https://www.example.com/search?q=Java 编程";
+String encodedUrl = URLEncoder.encode(url, "UTF-8");
+System.out.println(encodedUrl); // https%3A%2F%2Fwww.example.com%2Fsearch%3Fq%3DJava+%E7%BC%96%E7%A8%8B
+
+// URL解码
+String decodedUrl = URLDecoder.decode(encodedUrl, "UTF-8");
+System.out.println(decodedUrl); // https://www.example.com/search?q=Java 编程
+
+org.apache.commons.codec.net.URLCodec
:这是Apache Commons Codec库中提供的URL编解码类,与java.net.URLEncoder
和java.net.URLDecoder
类似lang=Java
+
+import org.apache.commons.codec.net.URLCodec;
+
+// URL编码
+String url = "https://www.example.com/search?q=Java 编程";
+String encodedUrl = new URLCodec().encode(url);
+System.out.println(encodedUrl); // https%3A%2F%2Fwww.example.com%2Fsearch%3Fq%3DJava+%E7%BC%96%E7%A8%8B
+
+// URL解码
+String decodedUrl = new URLCodec().decode(encodedUrl);
+System.out.println(decodedUrl); // https://www.example.com/search?q=Java 编程
+
+C:
+C语言中没有标准库提供URL编解码函数,一般手动实现。
+lang=C
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+
+// URL编码函数
+char *urlencode(const char *str) {
+ const char *hex = "0123456789ABCDEF";
+ size_t len = strlen(str);
+ char *buf = malloc(len * 3 + 1), *pbuf = buf;
+ for (size_t i = 0; i < len; i++) {
+ if (isalnum(str[i]) || strchr("-_.~", str[i])) {
+ *(pbuf++) = str[i];
+ } else if (str[i] == ' ') {
+ *(pbuf++) = '+';
+ } else {
+ *(pbuf++) = '%';
+ *(pbuf++) = hex[(unsigned char) str[i] >> 4];
+ *(pbuf++) = hex[(unsigned char) str[i] & 15];
+ }
+ }
+ *pbuf = '\0';
+ return buf;
+}
+
+// URL解码函数
+char *urldecode(const char *str) {
+ size_t len = strlen(str);
+ char *buf = malloc(len + 1), *pbuf = buf;
+ for (size_t i = 0; i < len; i++) {
+ if (str[i] == '+') {
+ *(pbuf++) = ' ';
+ } else if (str[i] == '%' && isxdigit(str[i + 1]) && isxdigit(str[i + 2])) {
+ int ch;
+ sscanf(&str[i + 1], "%02x", &ch);
+ *(pbuf++) = ch;
+ i += 2;
+ } else {
+ *(pbuf++) = str[i];
+ }
+ }
+ *pbuf = '\0';
+ return buf;
+}
+
+]]>最近 4G 充电桩的项目遇到一个问题,实测设备每天消耗的 4G 流量,远远超出理论计算的值
+能够理解 TCP 头部、IP 头部、以太网头部等网络协议头导致报文长度增长,但从实际抓包情况来看,去掉这些网络协议头后的报文长度,也远超出原始明文长度:
+为了搞清楚原因,决定从 TLS 协议入手进行分析
+SSL/TLS 是一种密码通信框架,他是世界上使用最广泛的密码通信方法。SSL/TLS 综合运用了密码学中的对称密码,消息认证码,公钥密码,数字签名,伪随机数生成器等,可以说是密码学中的集大成者
+SSL(Secure Socket Layer)安全套接层,是 1994 年由 Netscape 公司设计的一套协议,并与 1995 年发布了 3.0 版本
+TLS(Transport Layer Security)传输层安全是 IETF 在 SSL3.0 基础上设计的协议,实际上相当于 SSL 的后续版本
+TLS 协议主要分为两层,底层是 TLS 记录协议,负责使用对称密码对消息进行加密
+上层是 TLS 握手协议,负责在客户端和服务端商定密码算法和共享密钥
+从抓包来看,我们的设备和基础云进行 TLS 握手时,经历了以下阶段:
+Client Hello,客户端向服务器端发送一个 client hello 的消息,包含下面内容:
+
可用版本号、当前时间、客户端随机数、会话 ID、可用的密码套件清单、可用的压缩方式清单
+Server Hello,服务器端收到 client hello 消息后,会向客户端返回一个 server hello 消息,包含如下内容:
+
+使用的版本号、当前时间、服务器随机数、会话 ID、使用的密码套件、使用的压缩方式
+可以看到,我们的设备和基础云协商的加密方式为 TLS_RSA_WITH_AES_128_CBC_SHA256
,压缩方式无
Certificate,服务端发送自己的证书清单,因为证书可能是层级结构的,所以除了服务器自己的证书之外,还需要发送为服务器签名的证书
+
Server Hello Done,服务器端发送 server hello done 的消息告诉客户端自己的消息结束了
+![image-20220820163300335](TLS 是如何导致报文长度膨胀的.assets/image-20220820163300335.png)
Client Key Exchange,公钥或者 RSA 模式情况下,客户端将根据客户端生成的随机数和服务器端生成的随机数,生成预备主密码,通过该公钥进行加密,返送给服务器端
+
Client Change Cipher Spec、Finish,客户端准备切换密码,表示后面的消息将会以前面协商过的密钥进行加密
+
Server Change Cipher Spec、Finish,服务端准备切换密码,表示后面的消息将会以前面协商过的密钥进行加密
+
TLS 记录协议主要负责消息的压缩,加密及数据的认证
+首先,消息被分割成多个较短的片段,然后分别对每个片段进行压缩,压缩算法需要与通信对象协商决定
+接下来,经过压缩的片段会被加上消息认证码,这是为了保证完整性,并进行数据的认证。通过附加消息认证码的 MAC 值,可以识别出篡改。与此同时,为了防止重放攻击,在计算消息认证码时,还加上了片段的编码。单项散列的函数的算法,以及消息认证码所使用的共享密钥都需要与通信对象协商决定
+再接下来,经过压缩的片段再加上消息认证码会一起通过对称加密进行加密。加密使用 CBC 模式,CBC 模式的初始向量 IV 通过主密码生成,而对称密码的算法以及共享密码需要与通信对象协商决定
+ +从握手阶段的 ServerHello 报文,我们知道了此次通信无压缩、使用的加密方式为 TLS_RSA_WITH_AES_128_CBC_SHA256
这个加密方式实际上分为了好几个部分:
+个没啥好分析的,握手阶段就已经通过 RSA 进行了 AES 密钥传输
+AES_128_CBC 是一种分组对称加密算法,即用同一组 key 进行明文和密文的转换,key 的长度为 128bit:
+以 128bit 为一组,128bit=16Byte,意思就是明文的 16 字节为一组,对应加密后的 16 字节的密文
+若最后剩余的明文不够 16 字节,需要进行填充,通常采用 PKCS7 进行填充。比如最后缺 3 个字节,则填充 3 个字节的 0x03;若最后缺 10 个字节,则填充 10 个字节的 0x0a
+若明文正好是 16 个字节的整数倍,最后要再加入一个 16 字节 0x10 的组再进行加密
+CBC 模式为:用初始向量和密钥加密第一组数据,然后把第一组数据加密后的密文重新赋值给 IV,然后进行第二组加密,循环进行直到结束
+那么通过 AES_128_CBC 加密原始明文,得到的最终长度一定是 16 字节的整数倍,会导致 1-16 字节的膨胀(密文长度比明文长度大 1-16 字节)
+对于任意长度的消息,SHA256 都会产生一个 256 位的哈希值,也就是 32 个字节。
+在 TLS 记录协议中,对压缩后的消息片段进行 MAC 值的计算用的散列函数就是 SHA256,详细的 MAC 值计算方法不展开了,反正最终 MAC 长度是 32 字节。
+回到 TLS 记录协议上,TLS 1.2 记录协议中,报文经过 ASE_CBC 块加密后的完整组成结构如下:
+struct {
+ opaque IV[SecurityParameters.record_iv_length];
+ block-ciphered struct {
+ opaque content[TLSCompressed.length];
+ opaque MAC[SecurityParameters.mac_length];
+ uint8 padding[GenericBlockCipher.padding_length];
+ uint8 padding_length;
+ };
+} GenericBlockCipher
+
+总长度 = 向量 IV 长度 + 明文压缩后的长度 + MAC 长度 + 填充长度
+再来看看设备发的心跳报文,原始明文为 34 字节,拆成了 64 字节和 96 字节两个包发送,猜测是这样的:
+至于为什么设备端总会发送数据之前发送一个内容为空的包,就涉及到具体设备端实现了,参考资料中有提到 发送 fragment 长度为 0 的应用数据在进行流量分析时是有用的
云端回复的报文,原始明文为 28 字节,实际加密后为 80 字节:
+length = 28(plainText) + 16(IV) + 32(MAC) + 4(padding) = 80
+至此,TLS 加密导致报文长度膨胀的原因基本弄清楚了
+设备和云端使用 TLS 加密协议进行通信,协商的通信方法是 TLS_RSA_WITH_AES_128_CBC_SHA256
,因为块加密填充、计算消息认证码等原因导致加密后的消息长度原大于原始明文长度
设备在发送消息时,总会把先发送一个原始明文长度为 0 的消息,与设备端实现有关
+具体到 4G 充电桩这个项目上,产品让步不再进行流量限制,后续如果有对使用流量极为敏感的设备,可从加密角度着手,优化通信方式和加密协议,减少 TLS 加密带来的报文长度膨胀
+++]]>一篇文章让你彻底弄懂 SSL/TLS 协议 - 知乎 (zhihu.com)
+对加密算法 AES-128-CBC 的一些理解 - 简书 (jianshu.com)
+https://halfrost.com/https_record_layer/
+
最近有个项目,用户可以在微信小程序上直接预览监控点,而不用额外下载 APP
+之前只能通过 APP 预览,基联 APP 的接口编写过模拟多路并行预览的工具
+但是小程序的鉴权方式完全不一致,而且预览流程也完全不一样(基于 HTTP+FLV),针对小程序的预览模拟,需要编写另外的脚本工具
+另外也立项了 RTMP 推流的需求,为了提前了解下相关协议,也为了方便脚本调试,尝试在本地搭建了相关服务并进行了脚本模拟拉流测试
+本地搭建的推流、拉流框架如下:
+ +nginx 本身是不支持流媒体功能的,开发者们为其添加了额外的流媒体功能,比如开源的 nginx-http-flv-module 但需要重新编译
+Windows 上源码编译 nginx 环境配置很麻烦,直接找编译好的包,解压就能使用
+万恶的 CSDN 上倒是有很多,但都要付费下载
+经过不懈努力终于在 github 上找到了一个编译好的包:https://github.com/chen-jia-hao/nginx-win-httpflv-1.19.0
+修改 conf/nginx.conf
worker_processes 1;
+
+#error_log logs/error.log;
+#error_log logs/error.log notice;
+#error_log logs/error.log info;
+#error_log logs/error.log debug;
+
+#pid logs/nginx.pid;
+
+events {
+ worker_connections 1024;
+}
+
+rtmp_auto_push on;
+rtmp_auto_push_reconnect 1s;
+rtmp_socket_dir temp;
+
+# 添加RTMP服务
+rtmp {
+ server {
+ listen 1935; # 监听端口
+
+ chunk_size 4000;
+ application live {
+ live on;
+ gop_cache on; # GOP缓存,on时延迟高,但第一帧画面加载快。off时正好相反,延迟低,第一帧加载略慢。
+ }
+ }
+}
+
+# HTTP服务
+http {
+ include mime.types;
+ default_type application/octet-stream;
+
+ #access_log logs/access.log main;
+
+ server {
+ listen 80; # 监听端口
+
+ location / {
+ add_header Access-Control-Allow-Origin *;
+ add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
+ add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
+
+ if ($request_method = 'OPTIONS') {
+ return 204;
+ }
+
+ root html;
+ }
+
+ location /live {
+ flv_live on; #打开HTTP播放FLV直播流功能
+ chunked_transfer_encoding on; #支持'Transfer-Encoding: chunked'方式回复
+
+ add_header 'Access-Control-Allow-Origin' '*'; #添加额外的HTTP头
+ add_header 'Access-Control-Allow-Credentials' 'true'; #添加额外的HTTP头
+ }
+
+ location /stat.xsl {
+ root html;
+ }
+ location /stat {
+ rtmp_stat all;
+ rtmp_stat_stylesheet stat.xsl;
+ }
+
+ location /control {
+ rtmp_control all; #rtmp控制模块的配置
+ }
+
+ }
+}
+
+
+start nginx -c conf/nginx.conf
+
+ffmpeg -stream_loop -1 -re -i 诸葛亮王朗.mp4 -vcodec libx264 -acodec aac -f flv rtmp://localhost:1935/live/123
+
+VLC 媒体-打开网络串流:
+http://localhost/live?port=1935&app=live&stream=123
+
+随后即可播放:
+ +因为目的是模拟多路并发预览,考虑用 Python 脚本实现多路并行获取 FLV 视频流,调研对比了多种实现方案
+OpenCV 库提供了简单的 API,可直接获取网络视频流保存到本地文件:
+import cv2
+
+def save_video(flv_url):
+ cap = cv2.VideoCapture(flv_url)
+ fps = cap.get(cv2.CAP_PROP_FPS)
+ size = (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
+ fourcc = cv2.VideoWriter_fourcc('F', 'L', 'V', '1')
+ video_file = 'video/test.flv'
+ out_video = cv2.VideoWriter(video_file, fourcc, fps, size)
+ rval, frame = cap.read()
+ while rval:
+ out_video.write(frame)
+ rval, frame = cap.read()
+ cv2.waitKey(1)
+ cap.release()
+ out_video.release()
+
+实测保存的本地文件可以用 VLC 或 ffplay 直接播放
+但我的需求是模拟多路并发预览,OpenCV 库提供的获取流方法是阻塞式的,没法套用已有的 async 协程框架,想要实现多并发得用多线程等方式实现
+为了复用之前的框架,同时也为了更深入地理解 FLV 协议,还是决定用 asyncio 直接建立 Socket 连接试试
+HTTP-FLV,即将音视频数据封装成 FLV,然后通过 HTTP 协议传输给客户端。
+建立连接后,需要发送 FLV 协议规定的 HTTP 请求头,比如用 VLC 拉流,抓包看到建立 TCP 连接后,发送的 HTTP 请求及响应如下:
+ +因为服务器并不知道流的长度,所以响应的 HTTP 头并没有携带 Content-Length
字段,而是携带 Transfer-Encoding: chunked
字段,这样客户端就会一直接收数据了
编写脚本用 asyncio 直接建立 Socket 连接,获取数据保存到本地文件:
+import asyncio
+import async_timeout
+from urllib.parse import urlparse
+
+async def save_video(flv_url):
+ video_file = "video/test.flv"
+ if os.path.exists(video_file):
+ os.remove(video_file)
+ flv_hostname = urlparse(flv_url).hostname
+ flv_port = "80"
+ flv_path = urlparse(flv_url).path
+ flv_query = urlparse(flv_url).query
+ try:
+ with async_timeout.timeout(20):
+ reader, writer = await asyncio.open_connection(flv_hostname, flv_port)
+ print("Flv Server Connected")
+ except asyncio.TimeoutError:
+ print("Connection Timeout!")
+ except ConnectionError:
+ print("Connection Failed!")
+ try:
+ header = """GET {}?{} HTTP/1.1
+Host: {}
+Accept: */*
+Accept-Language: zh_CN
+User-Agent: VLC/3.0.8 LibVLC/3.0.8
+Range: bytes=0-
+
+""".format(flv_path, flv_query, flv_hostname)
+ writer.write(header.encode())
+ await writer.drain()
+ recv_data = await reader.read(1024)
+ recv_header = recv_data.split(b'\r\n\r\n')[0]
+ print(recv_header.decode())
+ if 'HTTP/1.1 200 OK' in recv_header.decode():
+ print("Video Get Success")
+ if recv_data.split(b'\r\n\r\n')[1]:
+ flv_header_index = recv_data.split(b'\r\n\r\n')[1].find(b'\x46\x4C\x56')
+ flv_header = recv_data.split(b'\r\n\r\n')[1][flv_header_index:]
+ with open(video_file, 'wb') as fd:
+ fd.write(flv_header)
+ else:
+ recv_data = await reader.read(1024)
+ flv_header_index = recv_data.find(b'\x46\x4C\x56')
+ flv_header = recv_data[flv_header_index:]
+ with open(video_file, 'wb') as fd:
+ fd.write(flv_header)
+ while True:
+ recv_data = await reader.read(1024)
+ with open(video_file, 'wb') as fd:
+ fd.write(recv_data)
+ except ConnectionError:
+ print("Connection Failed!")
+
+其中 b'\x46\x4C\x56'
对应 FLV
,即 FLV 头部,从服务器响应的 FLV 头部开始的数据保存到文件中,但是保存下来的文件却无法通过 ffplay 或 VLC 播放
对比保存的文件内容,与抓包结果一致:
+ +再对比通过 OpenCV 保存的文件,虽然可以播放,但是与抓包结果的 FLV 头部却不一样:
+ +说明 OpenCV 在获取视频流数据、保存到文件的时候就对头部做了一些处理,让其可以正常播放
+而直接把通过 Socket 获取到的二进制数据保存到文件,其 FLV 头部并不是合法的格式,所以无法直接播放
+查找资料的时候发现,基于 requests 库可以直接用 get 方法获取 HTTP-FLV 数据,同样可以保存到文件:
+import requests
+
+def save_video_requests(flv_url):
+ video_file = "video/test_requests.flv"
+ if os.path.exists(video_file):
+ os.remove(video_file)
+ chunk_size = 1024
+ response = requests.get(flv_url, stream=True, verify=False)
+ with open(video_file, 'wb') as file:
+ for data in response.iter_content(chunk_size = chunk_size):
+ file.write(data)
+ file.flush()
+
+尝试了一下发现此方法保存的文件同样可以直接播放,对比抓包结果与文件内容如下:
+ +发现好像保存的文件就是去掉了抓包结果中的一些换行符(0d0a
),部分换行符前面还有一些数据,看来也是保存的时候底层做了一些处理。
其实换行符和部分换行符前面的数据是 HTTP 分块传输编码规则导致的:
+\r\n
)结尾的一行明文,用 16 进制数字表示长度;0\r\n\r\n
。所以我们只要在保存数据的时候,只保存 chunked data
,把 length
和换行符都过滤掉就可以了
这原理看起来简单,但真要直接处理二进制数据还比较复杂
+不过既然 requests 可以实现,那协程的 aiohttp 应该也可以吧
+import aiohttp
+
+async def save_video_aiohttp(flv_url):
+ video_file = "video/test_aiohttp.flv"
+ if os.path.exists(video_file):
+ os.remove(video_file)
+ chunk_size = 1024
+ conn = aiohttp.TCPConnector()
+ async with aiohttp.ClientSession(connector=conn) as session:
+ async with session.get(flv_url) as response:
+ with open(video_file, 'wb') as file:
+ while True:
+ data = await response.content.read(chunk_size)
+ if not data:
+ break
+ file.write(data)
+ file.flush()
+
+测试能通过 ffplay 和 VLC 正常播放,aiohttp 套入协程框架也很方便,最终就决定用这种方式了
+对于 Python 字典,如果直接用=
赋值,修改一个字典会同时修改另一个:
Python 3.7.4 (tags/v3.7.4:e09359112e, Jul 8 2019, 20:34:20) [MSC v.1916 64 bit (AMD64)] on win32
+Type "help", "copyright", "credits" or "license" for more information.
+>>> dict_1 = {'1': 1}
+>>> dict_2 = dict_1
+>>> dict_2['1'] = 2
+>>> dict_2
+{'1': 2}
+>>> dict_1
+{'1': 2}
+
+一般使用copy()
方法进行拷贝,在修改拷贝后的字典时,不会影响原来的字典:
Python 3.7.4 (tags/v3.7.4:e09359112e, Jul 8 2019, 20:34:20) [MSC v.1916 64 bit (AMD64)] on win32
+Type "help", "copyright", "credits" or "license" for more information.
+>>> dict_1 = {'1': 1}
+>>> dict_2 = dict_1.copy()
+>>> dict_2['1'] = 2
+>>> dict_2
+{'1': 2}
+>>> dict_1
+{'1': 1}
+
+但是,当字典超过一层时,修改copy()
后的键值时会同样修改原字典的键值:
>>> dict_3 = {'1': {'1': 3}}
+>>> dict_4 = dict_3.copy()
+>>> dict_4['1']['1'] = 4
+>>> dict_4
+{'1': {'1': 4}}
+>>> dict_3
+{'1': {'1': 4}}
+
+后来网上查了一下才知道,Python 字典的拷贝分为浅拷贝和深拷贝
+copy()
为浅拷贝,只拷贝字典的父级目录;而deepcopy()
为深拷贝,会将整个字典全都拷贝
使用deepcopy()
需要导入copy
模块
Python 3.7.4 (tags/v3.7.4:e09359112e, Jul 8 2019, 20:34:20) [MSC v.1916 64 bit (AMD64)] on win32
+Type "help", "copyright", "credits" or "license" for more information.
+>>> import copy
+>>> dict_3 = {'1': {'1': 3}}
+>>> dict_4 = copy.deepcopy(dict_3)
+>>> dict_4['1']['1'] = 4
+>>> dict_4
+{'1': {'1': 4}}
+>>> dict_3
+{'1': {'1': 3}}
+
+]]>最近在写商用云平台的虚拟充电桩设备,本来想着在之前虚拟 IPC 的基础上修改一些类型字段、加一些特定的适配协议和逻辑就可以了,后来发现还是有些不足的地方。
+现有的虚拟设备是基于 Python locust 框架写的,消息上报实现方案是预定义好上报的周期,比如每分钟上报一次,在 locust 中预构建一个子任务,每分钟调用一次消息上报接口。这样的方法不足是,没法模拟真实设备的场景,随时触发任意类型的消息进行上报。另外,如果在运行过程中,需要手动修改某些虚拟设备的配置信息,也无法实现,只能停止后修改再重新运行。
+换句话说,现有的虚拟设备工具,所有配置和逻辑都只能在运行前就预定义好,一旦运行之后就无法再介入修改了。
+需要实现的优化目标是,在虚拟设备工具运行过程中,外部触发某个条件,能够直接影响内部正在运行的任务,这就涉及到了 Python 进程间的通信。
+进程是操作系统分配和调度系统资源(CPU、内存)的基本单位。进程之间是相互独立的,每启动一个新的进程相当于把数据进行了一次克隆,子进程里的数据修改无法影响到主进程中的数据,不同子进程之间的数据也不能直接共享,这是多进程在使用中与多线程最明显的区别。
+常用的进程间通信方法有很多:
+具体到 Python,以上提到的进程间通信方法也都有对应实现。
+信号量是一个共享资源访问者的计数器,可以用来控制多个进程对共享资源的并发访问数。它常作为一种锁机制,防止指定数量的进程正在访问共享资源时,其他进程也访问该资源。每次有一个进程获得信号量时,计数器 -1,若计数器为 0 时,其他进程就停止访问信号量,一直阻塞直到其他进程释放信号量。
+示例:
+import os
+import time
+from multiprocessing import Process, Semaphore
+
+def handle(sem):
+ sem.acquire()
+ print(f"{int(time.time())}, {os.getpid()} 开始处理事件")
+ time.sleep(1)
+ print(f"{int(time.time())}, {os.getpid()} 结束处理事件")
+ sem.release()
+
+if __name__ == '__main__':
+ sem = Semaphore(4)
+ for i in range(5):
+ p = Process(target=handle, args=(sem,))
+ p.start()
+
+
+运行结果:
+1653378426, 216 开始处理事件
+1653378426, 3888 开始处理事件
+1653378426, 9064 开始处理事件
+1653378426, 13968 开始处理事件
+1653378427, 216 结束处理事件
+1653378427, 17160 开始处理事件
+1653378427, 9064 结束处理事件
+1653378427, 3888 结束处理事件
+1653378427, 13968 结束处理事件
+1653378428, 17160 结束处理事件
+
+可以看到,同时最多只有 4 个进程处理事件,当一个进程的事件处理结束释放信号量后,第 5 个进程才能开始处理事件。
+信号量常用于控制某共享资源的多进程并发访问者数量,并不适用于进程之间传输具体数据。
+信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
+进程间信号最先出现于 UNIX 系统,每个信号都有自己的系统调用,后续修改为统一的 signal()
与 kill()
调用。最初的进程间信号系统是异步的,而且没有队列的概念,即不同信号间很容易产生冲突,导致应用程序来不及处理前一个信号。POSIX 规范后来改进了这一设计,另外规定了实时信号,靠队列的方式避免了信号冲突的问题。
为了统一各系统下进程间信号与其整数的统一,POSIX 规范规定了 19 个信号及其对应整数与行为:
+Signal Value Action Comment
+───────────────────────────────────
+SIGHUP 1 Term Hangup detected on controlling terminal or death of controlling process
+SIGINT 2 Term Interrupt from keyboard
+SIGQUIT 3 Core Quit from keyboard
+SIGILL 4 Core Illegal Instruction
+SIGABRT 6 Core Abort signal from abort(3)
+SIGFPE 8 Core Floating point exception
+SIGKILL 9 Term Kill signal
+SIGSEGV 11 Core Invalid memory reference
+SIGPIPE 13 Term Broken pipe: write to pipe with no readers
+SIGALRM 14 Term Timer signal from alarm(2)
+SIGTERM 15 Term Termination signal
+SIGUSR1 30,10,16 Term User-defined signal 1
+SIGUSR2 31,12,17 Term User-defined signal 2
+SIGCHLD 20,17,18 Ign Child stopped or terminated
+SIGCONT 19,18,25 Cont Continue if stopped
+SIGSTOP 17,19,23 Stop Stop process
+SIGTSTP 18,20,24 Stop Stop typed at terminal
+SIGTTIN 21,21,26 Stop Terminal input for background process
+SIGTTOU 22,22,27 Stop Terminal output for background process
+
+在 Linux/UNIX 下,由于 SIGINT 与 SIGTSTP 信号较为常用,这两个信号可以分别使用 Ctrl+C
与 Ctrl+Z
快捷键触发,Windows 支持前者,但不支持后者。此外在 Linux/UNIX 下还有一个不常用的 Ctrl+\
快捷键,用于发送 SIGQUIT 信号。
首先介绍一个简单的方式,即异常捕获。Python 脚本运行过程中按下中断键(如 Ctrl+C)会触发一个 KeyboardInterrupt
异常,我们只要在需要处理中断的代码段外使用 try...except...
将其包裹起来即可,如下:
try:
+ # Some code
+except KeyboardInterrupt:
+ # Another code
+
+使用上文异常捕获的方式存在若干不足。一方面,对于一个庞大的系统来说,可能在不同的执行阶段对于退出有不同的处理方式;另一方面,尽管使用 Ctrl+C
热键触发 SIGINT
中断是最常见的方式,但并非所有 SIGINT
信号都是通过热键触发,也并非所有信号都是 SIGINT
。Python 为了实现信号的安装,引入了 signal
模块。下文以 SIGINT
与 SIGTERM
为例,简述该模块的使用。
import signal
+
+def bye(signum, frame):
+ print("Bye bye")
+ exit(0)
+
+signal.signal(signal.SIGINT, bye)
+signal.signal(signal.SIGTERM, bye)
+
+while True:
+ pass
+
+当执行过程中按下 Ctrl+C
或在其他终端窗口中输入 kill -2 [pid]
(2 的含义见上表)时,可以看到 bye(signum, frame)
函数被调用,并成功退出。
与信号量类似,信号仅用于进程间传递特定信号,并不适用于数据传输。
+管道常用来在两个进程间进行通信,两个进程分别位于管道的两端。
+Python multiprocessing 模块的 Pipe 方法返回(conn1, conn2)代表一个管道的两个端。Pipe 方法有 duplex 参数,如果 duplex 参数为 True(默认值),那么这个参数是全双工模式,也就是说 conn1 和 conn2 均可收发。若 duplex 为 False,conn1 只负责接收消息,conn2 只负责发送消息。send
和 recv
方法分别是发送和接受消息的方法。例如,在全双工模式下,可以调用 conn1.send
发送消息,conn1.recv
接收消息。如果没有消息可接收,recv
方法会一直阻塞。如果管道已经被关闭,那么 recv
方法会抛出 EOFError
。
示例:创建两个进程,一个子进程通过 Pipe 发送数据,一个子进程通过 Pipe 接收数据。
+from multiprocessing import Pipe, Process
+import os
+import time
+
+def proc_send(pipe, data):
+ for d in data:
+ print(f"{os.getpid()} send: {d}")
+ pipe.send(d)
+ time.sleep(1)
+
+def proc_recv(pipe):
+ while True:
+ print(f"{os.getpid()} recv: {pipe.recv()}")
+ time.sleep(1)
+
+if __name__ == '__main__':
+ pipe = Pipe()
+ p1 = Process(target=proc_send, args=(pipe[0], [i for i in range(5)]))
+ p2 = Process(target=proc_recv, args=(pipe[1],))
+ p1.start()
+ p2.start()
+
+输出如下:
+16132 send: 0
+16596 recv: 0
+16132 send: 1
+16596 recv: 1
+16132 send: 2
+16596 recv: 2
+16132 send: 3
+16596 recv: 3
+16132 send: 4
+16596 recv: 4
+
+消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
+Python Queue 是多进程安全的队列,可以使用 Queue 实现多进程之间的数据传递。
+put
方法用以插入数据到队列中, put
方法还有两个可选参数: blocked 和 timeout。如果 blocked 为 True(默认值),并且 timeout 为正值,该方法会阻塞 timeout 指定的时间,直到该队列有剩余的空间。如果超时,会抛出 Queue.full 异常。如果 blocked 为 False,但该 Queue 已满,会立即抛出 Queue.full 异常。
get
方法可以从队列读取并且删除一个元素。同样, get
方法有两个可选参数: blocked 和 timeout。如果 blocked 为 True(默认值),并且 timeout 为正值,那么在等待时间内没有取到任何元素,会抛出 Queue.Empty 异常。如果 blocked 为 False,有两种情况存在,如果 Queue 有一个值可用,则立即返回该值,否则,如果队列为空,则立即抛出 Queue.Empty 异常。
示例:创建两个进程,一个子进程通过 Queue 发送数据,一个子进程通过 Queue 接收数据。
+from multiprocessing import Queue, Process
+import time
+import os
+
+def send(q, data):
+ for d in data:
+ print(f"{os.getpid()} send: {d}")
+ q.put(d)
+ time.sleep(1)
+
+def recv(q):
+ while True:
+ if not q.empty():
+ print(f"{os.getpid()} recv: {q.get()}")
+ time.sleep(1)
+ else:
+ continue
+
+if __name__ == '__main__':
+ q = Queue()
+ p1 = Process(target=send, args=(q, [i for i in range(5)]))
+ p2 = Process(target=recv, args=(q,))
+ p1.start()
+ p2.start()
+
+输出如下:
+16564 send: 0
+9984 recv: 0
+16564 send: 1
+9984 recv: 1
+16564 send: 2
+9984 recv: 2
+16564 send: 3
+9984 recv: 3
+16564 send: 4
+9984 recv: 4
+
+管道和消息队列可用于进程间传输数据,但每次传递的数据大小受限,效率偏低。
+共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的进程间通信方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
+Python 标准库中实现共享内存通信的工具有 mmap,但是该库只能用于基本类型,且需要预先分配存储空间,对于自定义类型的对象使用起来有诸多不便。
+比较好用的第三方工具有 apache 开源的 pyarrow,不需要预先定义存储空间且任意可序列化的对象均可存入共享内存。但使用时需要注意:pyarrow 反序列化的对象为只读对象不可修改其值,想要修改对象可先通过对象 copy。
+Python 3.8 及以上版本支持 multiprocessing.shared_memory
,一般来说,进程被限制只能访问属于自己进程空间的内存,但是共享内存允许跨进程共享数据,从而避免通过进程间发送消息的形式传递数据。相比通过磁盘、套接字或者其他要求序列化、反序列化和复制数据的共享形式,直接通过内存共享数据拥有更出色性能。
shared_memory 的使用也非常简单,只需要定义一个 ShareableList
,就可以在多个进程之间访问同一对象。
ShareableList
提供一个可修改的类 list 对象,其中所有值都存放在共享内存块中。这限制了可被存储在其中的值只能是 int
, float
, bool
, str
(每条数据小于 10M), bytes
(每条数据小于 10M)以及 None
这些内置类型。它另一个显著区别于内置 list
类型的地方在于它的长度无法修改(比如,没有 append, insert 等操作)且不支持通过切片操作动态创建新的 ShareableList
实例。
a.py:
+from multiprocessing import shared_memory
+
+shared_list = shared_memory.ShareableList([None, 1, "haha", False, b'123'], name="test")
+while True:
+ pass
+
+
+b.py:
+from multiprocessing import shared_memory
+
+data = shared_memory.ShareableList(name="test")
+for d in data:
+ print(d)
+
+
+先运行 a.py
,再运行 b.py
,得到的运行结果为:
None
+1
+haha
+False
+b'123'
+
+相比于管道和消息队列,共享内存具有更高的性能和更大的数据传输量。
+socket 无疑是通信使用最为广泛的方式了,它不但能跨进程还能跨网络。在两个进程中创建 socket 连接,就能实现相互通信。
+基本的 socket 就是基于以太网的套接字,也是最常用的。server 端先创建一个套接字,绑定一个本地端口,等待 client 连接;client 也创建一个套接字,直接连接到 server 端就可以正常进行通信了。不过,传输的数据必须为 byte
类型。
server.py:
+import socket
+
+sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+sock.bind(("localhost", 2333))
+sock.listen()
+
+c, addr = sock.accept()
+print(f"Accept connect from {addr}")
+while True:
+ data = c.recv(1024).decode()
+ if data:
+ print(data)
+ c.send(f"{data} Reply".encode())
+
+
+client.py:
+import socket
+
+sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+sock.connect(("localhost", 2333))
+
+sock.send(b"Data")
+print(sock.recv(1024).decode())
+
+
+先运行 server.py
,再运行 client.py
,server 端输出:
Accept connect from ('127.0.0.1', 52410)
+Data
+
+client 端输出:
+Data Reply
+
+Unix domain socket:
+当同一个机器的多个进程使用普通套接字进行通信时,需要经过网络协议栈,这非常浪费,因为同一个机器根本没有必要走网络。所以 Unix 提供了一个套接字的特殊版本,它使用和套接字一摸一样的 api,但是地址不再是网络端口,而是文件。相当于我们通过某个特殊文件来进行套接字通信,不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。
+具体实现上,只需要在创建套接字的时候指定创建方式为 socket.AF_UNIX
即可:
server_addr = "./tmp_sock"
+sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+sock.bind(server_addr)
+
+不过 Unix 域套接字仅适用于 Unix 系统,在 Windows 系统下还是老实用以太网套接字绑定 localhost 吧。
+使用文件进行通信是最简单的一种通信方式,一个进程将结果输出到临时文件,另一个进程从文件中读出来。
+这个实现很简单,就不做示例了,不过感觉效率很低,频繁读写文件可能还存在同步和性能开销问题,不是很推荐使用。
+本文对 Python 中的各类进程间通信实现方案进行了介绍。
+使用总结:
+对各类进程间通信方案,只是做了简单的试用,并未涉及实现原理、冲突处理、锁机制、性能表现等,将在实际开发使用过程中继续深入了解。
+<6(Q!*E8sM9nnif5W1@d|~W5{lYoYky(Js3klu)3d(ktRl0gBD{4 ziH|}B@)$)DmkL9x661!O*N;nWpx^Y_jBJ_&D(xdAv*erzv`*>VU$84#bMx!nh^u)C zaDUGt;vjxoZL)~GTA_+XE}>6hlSeYtfJ%!fC4)xiK#R$2j~osyPiGd ~=>th9z;(H#X&1sdrHkPIH22%lr%0~@Rzxb&@7 EE9}<-4^}ij!j#nInZ2Oek%K1h!k*Z2G#zr%P2;)u-HSoShSz9M?ygz0u1| zl+`s4#2Eu_Nf(jh%bGhim^G@5ESbgH7xIj1l-G@t4?Y>(M^_w1kKNBz8w$2yn+xK8 z%~)>PT=p1Gj7W$Z!#|+;m+#)g;_-?K^)h@5v%>f~O#B` zt=VDyZm1D(bO_qGYdTL?q$1t9-~O2rBuU|%kN(h_>jx4mo$WGpvD|myv?^`-NHZB; z5c+$r`_Kn;HGUd36T*ofehm*xs7{fivWk!mkE1rd)HD(+r8A;i54c~Kh<+A|7d-En zE33|02QaX7+jux0SGE2QIBHxnNP)g=B(VD(ooQ}s?H3||TLnP;+9X}8_lscxu5r~+ z!J=-L^0joRY~zZ~S&|_wL#wJG)oPMOBtZ!;(3sU;_$GbIUxv<)N9e2#qEhIx=xxSO z{aSrqZRnUuE=w;LLQ94!;g<};lCtHSw{GP7X(owlJfFkQ>_BsvFDZKjq@=ZRV9pEv zB~w_q^LuR1nk>1%pIkW~L?ektN22+K+fC67NPq B>qVt`A>OOvvAa^clSmNA!|vzxY*FtDHaxH+sO)Zq>L_% z4|(}^`lh2;>Yf@$BF#jdT%#GbVln@5m6jd0n?|<1$;bABWKFQ1Vbz8ce^h^!Z_5L4 z$`If6eeK|Fs!OBx2~rz+?Zn!7Gy5;6vwgX_bHTxl9$KAB4&DM0Bb%{<9?2IS$F>L` zuBuY|aDL>g@70BRMy`Iozsc|CiBs>N%!cWIh0ER~9jE^N#b!Hi8%|g%?N)hZuf*^X zt%%zojlW}(0JF)X*x@?-`v_xs-7yj$Ta}HeqIcbWC6yxd0f2(qT6O{=qBAEW d`En6!JN~0Y(hU4v9kAs)jZmC%xY4?=P>{t&GN}KIHfK^SkE;*a$Bts;~2U72jT> z`c*| SeV$LRn*-mw7ukb?QNUI2EW Jw8gQto7a>;+#!vEr@zIxOweTXs>{Xow< zQqNEV%ZYGoV&bdWC?5aH)QJ>JAxX&!30SHcENQf$sANaJ`7_@lThvCsx2x^#)st=8 z$3&gNTUhBCl|RZsTb%HyS!tQq!uM;UdJNTXriC*PE2mhXIxNBFxsR}Dw+)@z7u!vV zM1%sf2R%J(Ub4Is$>OU5uJav|brqoWqXs6fN*s>BBJX!+sA5(K`tw)H5MPFoEfOQ= zF-z%cV5(q3hYG46?q~&g>o#SQ6i0F0g@92rQXLiGw-NgUPb03x4RQao7TkSkat<1# zNKqzOutgDY+5Qm1qO8`M!K0v|#n=_cFiCY6l1r)bjfY6xeZUu!nlv18dGC*#w}e!k zLK#Lzq=ptY*^lCs>*JTEp+lPpZ^!el^#;2o7?k+k;o9kF27(nSZdrWdts6A~4is~# z1a|bH_*W{PJPc2Cm^uNl2QY!vISPSqD+`rGNY5}!- DQ%m#m1t#WonhqZGT2TGS3po<{2>K!9oZ4 zGo&D55pp>Ed5tBpqzlVDZNg)asXBNXiwa)T{GdJ0suv0GgZ9r@MQY?uCwRMUg%0O~ zCTkmVS9y%^r;6>NM*5beHHB0@OL&J8Lc@s41@F*U0dN~vc2-LbEb(XfSN1;vWqw+w z>G(}${oy|;ntkUK7I|6fzV{0np96^qJzn?6&K|cnVPBoo*Nmg=Bhd^f6*UdOaK8}a zc6=+#WYhxAw_2XG{&8ZF5FB zUVeM=flnxFT -zvXDkhBgiu^#2qP*MWM~u$MP5 zZSv)V3z8HGh=qMJy8O?VYywSEXqwK8*Zo*a%{inO03r7FJ<~pUd8ofoNO`!)kx--+ z3+aNTLMLU~r^tO>SP~t@6TZ!BfBH SW4WFsZBOFeMp}KJ6WJnJw7YeK zSFXi?b2NT~y)@-@vMiGkWe>wsKzxReQRPG8q;|>6C27FvO}cen2p1R2z%%OXy=zx9 zlUF?817KvHTOH-#&d}btF=ay@C3wC&%@b~Knik^`2U1Z4*}HG@e(tB+qKQ(55^)n1 zc_*GJoH`iwRmxtQE;Z6Wgx;>Da{9Bm7I7eQ( >zjDq^w>L~Dje%jo<_cLRr zTW^}+f3yH{5 pU_@eD*YwkWKT#fevHX0JN8AQA$ zeccg#Yr6eQ>f6ugH6J|yRuLFPJ74Dbqdef@`YS;L)zOq$p%lyfPo%6wmyO37>U#hd zC)Lc)U5Yl3KbveJ9jiCi`Ff~h6O;WR{UM~fD9?nS8|^3Dz(npKQPJP>hM2y}vus_n z*tlt?H^mQPb{bW|D~n_v2$4|HL&^RdR#uia$MZG-W?t*D$ Gcp7XZa$=gxLD=I(zrAq;g6L8E605^{+4G|nSjrfL; zO*=zvQTOxC`_Au##=m3&b`wj793+FTk5^ayaQD+#!kiv)bd5$RWR{WJ#nbGEum|as zQt9wu=FhyD!Zn8}w7FZ9Q5M#pN&SDGy <;+t2Ej@R>i$I)0!K5q-JbklioWV9@}! z^bK+kELhm`5LN=kLc9F#5l*|!@lHYYF|SyEN5rf?%RURi8+{MHf+z7t`-}QXg(rG& zUvBybm6;3fnn}GW2v_j#lAoDrHeWKn(4WmAV_!5Q`U=?mdIO3xD9bGW(7A A4iAZs$&?$PV4vOswz^1$R{3Ax7@Y&_YNMH;kd2(A6~3i$1M}k}D2! zNNv6VTE_auW$nyN#0Re`VPmmX-fQn~K%0w<1B1j{Sr*>{t{wm?BM*N9nj)q;pGlm0 z1Ue>KT3J2}Cd&=jK;lkAF=3CN(eF&G71llj$(h&B)x)#*;c&PR!XY~XBKObZ&jqwa zr0U=kPlF~83wRb>iu|1Fx^4b8{<;dhq+7y{)z-K5_!df6J=$b3d)$IH778W%gaQ(? z#IHRJNI<;V{KMwKwI@0L_}y|cdS$wdR(8N o(bQ(c7c+a?cug8FPXW)Q`ez&OEkiYnLIJFOW5ba(idwG7uFNRl9xn z;%Vj&o*ctc*^cAXoU0KDe0f;Zqfq&PxveL59u>sn{Wf&VFnqQC-LS*8{{xmlX}_fg zqQI_3F$Yd>Ds{R<%?{DMUpYYEy#FBSx BK7qp; z6ctr5W>I)fbE>Vc)D@~)F<=6sqe`t#)q0CU)d&>LT2M6eDCx!*WAVg&t)_nF4?gpm zb%&@suf5iokI@^J7iT|c>fz;=?c2rba74L|sj#rjwp1~&1Svg4mn5cFMH9uioeKi4 z&>$}~DVfPrsnVciB1P?Hjq24^c7apj%4Xn(*cB>f-RR%AMI;JWD-bLE<#A(dH9K_r z^cf!30g@{e3Y1L6**-HCb2uX8$X+)%T_5>d3u6$TbGDvV6=FBFpXx-N6OoCxUK{6G z*!?|56k+2=%fy0+o2R`AhceXTqnprrO*}G<3B{lcCi(vNBEk{R6%iDtLD}R)c-C`C z0R8Lzwsp=P^xNXF=Hm6x7f(Q+mGj&K4{??6vVQ>oy%6nt$fCrV$Pu+u2%<-up2Z&V z^^WElvDD+)Z1!$b*Q>`*p1yf)ZEYNW=~m2aU4GeBGdH~Erf
1!@6X7 zd1cGfPd}qIT6LP3*v@308AypKWPIJikq8+VgIlT~{=%RY-k60JnMRzo3eakm$}RfB zKi)%g3k!7lWmj?}KQnWd2i5G{xs!(S1umu77Vz*$!C)M;Sn_aSQR3kB=(l7=Yp_w# zzLCJ6OX!gSd_-i?d;htq_h|hhLSN4UuwS~%hk@r{G8@Ej=|q}FM@RX%ut@}|i}?fH zK8cIXhgd73Fu+QpYy VN^{8B~@W!^dwnEKTiQ);9r@ZahKE|TC zT|2i@zK{?uhqzOqZyk MUN$BSjP5D|E41qV@@|wL Pj!VtzMB5G!-mu|42 z%nKAY@u)XDs5_Qwt=8$mq0uijI^hA&c8>kgAN?8E#%_D#4OzF _&5G4M`Wav7b5klkr_+zBr&vHq3-E+>O_8vLa)P9 z%pyTjM3$(D;?Zh#nb4uiMYLWnS7~`^g~>OUE3lFsVj|q=*}$Yv($NqOj#&HkLO! eJ<$( zvnYzg&-m~1q_H-ibFGfnFN;UqLfx+S`-zcW$AdQ%{TbUhc0_?3b#VlSN!;+77uN|| z|GHhDd)kr7Dc728PBXW-45Fz8LC{sWP0ns#arHH&E3drf_@$R$wshSqUbScc{{7cz zIt{I^Ht4A%Pji%r4b_!bUQV%?%_&@-OKyoFDJVVgtbj1!8Qq;WM$MX}8|bSyDex77 zNV`&N)8GC5KTx$=qbsktnuUfCel9JpGTsoFVXZT1Y#8tk2*|cguB9UbAX3)Rc-nKR z_Km{0jS+Y>6ec41$ZX~L-uTg)t_vXh86QOWAZgUUqfQ5qdS$J|sdpSdB5SOPP`8Wp zJPOs3CWF`C6Xh7JQ}n*#jS;{1u>})CQB$|L`~;FkN`HA}j?n&At(MqXao6r0bopg_ zX!oA&G&Go{Oe!vBI`F)Ke(k)U(Lr_!myzEeW)p2zqRoxSKwz^1n`nb7r8=EDb&BR@ z=BQb39Jb>2XRUbZf4}7|uU~#X{9fqKC857iq~!BnG6a73b*~$0cS^rmEiL`T-d$S? zm+YA!+fc|dLkfEhQWd;u`n}J1(`kr!`&Oa25pW$0H)k;+joWD;oC1O3M%|x82C*0m zdD&P3HA;9oBSMRDB+XOFRFxXdHm6@h)##{tY`W3z-4}Slm%1HyDsE~`U9$&g&rW}I zX6n?dVwO&O_im%%LWVrIPP*n&GGS372~rm~aUKB~cwDm++Yk|@`=nE|-4)-+OFgQY3znLJy=M*Ls ;!; KgZK ztTA3MGmxit+ik9uYh}xf`-@8}1I=bDR%^9;OU1QD5K>dsjaDL=XbcXH)W)}Noxc2v ztDb!IjW;&;?z{Akfn0XC?+1erDWdQC=_5yYQ#Cd=Lc=42JjE|E!!b>RQ@%K4527## zH=7+6H5!IPoXHPBf&n#KT{?Drnhqa1LaAhihKENvBZG*%v{s?T#U(!HY!=RG8QwTJ zmLWC}x=2m)`ykrjZdizD?Vr0g5uvZA(x3A?5t%;s_co^CpS6blx-`UlAavk>>ve8s zmJ*2s?cTkcuV)at$ky~D^InfIt3BETK>hCpn4}h!fW-TMz0Vxk2?+BvOjyJwo> zYSd|dah|59PE);6CLOw$giYJGZ=wDBCuqy~2n`NqsgTQ(X(Ep%G6^_N8x0`b-!DFd z7s9zS$Ius6;hPpUo9(_+B5|f{>hu|CJ?2#k|5~%`Kl-5`dc(={af6kFem+o?uW`W; z_{lfCAzt<>x1W6G@!!1ul~-=tzk7mWwnkpJ#e*e4kUA@Pq2yincUm7V!EeAmNwJqS z*I;28Do9XMjCC^%(IOSiOxDC$$WSH2vN=j_wAvI)BspTUoER-Gty0fdSQ%ZbwU%qu z#iXJ_W<@=LeT{yh_v%f`qs(Bs+>Lrmfhr4s0gg|b`B0OygE z*lK7NsfJ0NZpeSr$6ysG!~HJz6pH{q;PL@#i5peMQOgg%RuGC!gQE2#Y4r1|ED}S# z+_CMxNl0WiBW|29(cm_(4XIcbxAoi3)+6$0Gr &Wj+0Z8{hGJ9 z9iAHw5?xGD8grez` +H)vO0e&sKuvjacV>bk?|WT@0@bo80yRH~F|VqzEV z+_{5wAF$1Z7!K)j7B?E`i3Z^iJEn0;nFoO*Q(!Wo!&jhGuF=DfK1s_fMJB# kBg!lwbWx{2XMMVH6SZCo-k@CP>_~qNHb&afdToqMH{KAQ zn||Mzm4cuwDmg_&iR)}_WtCI!C`aV;dA?S#;lg#xk$MO2aOi?)_e(zTGJty?+Ra3U zMP$4Bnv4EM9dCR5zM&f2(+*KywNYmkd~t1wR*OqiTwP?_*s;+ZO-yW~ZCgia>y|Of z sjz}MlC!t8` wOI@{ zfe5SxPt<-rI8@Nul&0cZg<8!XtrW|Avqd@&7ON2WRLX6NJ4EAS1GInNP8uJ}lCGf? z;BkbWO~uy*OAv!W*^E@S5Rcg`^g)UcsZ^w|v3Y4X8{Ain8#kQ7P!>SlJMzAcL}|Ej zMG I>eSwocFw;ye^VAxaV)HQmY z;R=V3R=Y!OS8R&7#t*Oij!(5(monKb?b~}9-Sh)LcxG;X^}nvJR311yKfhEc6jX1% znsIH z$4{K1?c2B0==c^I zD&$3Z2(D#)6*VUsG)%)IZaIW^0$Q|?DPY)P%@^uaF4t&&VTB%k^l?^!@7=qXuK{dk zuvU?6DmFE+O$~jIVsV=WQb~#@Vq8b(&6+B_&7T`tN94TmJ;l%IuiwV^+w z4wl>>^`pqznAl?9J;zX=Vmi!Qlvytq9lqGMahyZ$<{ioF@gaN2D|G#qCi zHFgYpJ@D8w lCu5PyOw*dWn-Ho@t|Bto;pZWMl_MAKR z?LQnJ&3xdY`wpiDiz!J`*0d_wOh&Rvm_CH1YJd=c#!Aqve|1n&422*pW7NJaN_4iN zxse-3tO77#qX407tyIztAeEE`SmRC)x;Qv0>uY7{bYsk>%bPX2nAOOHo|pM~SU{Gf zQm*l#-?F7B(^KODyU_uZ+>;3@=FmTlNiqeZ8Dje;H#wc{RickdVWR(+kVv~l<}K&W z6A7t9cq5UNOj5^>uz}NEjpVR6H+lRU*-JXB{_ zS~-?wVwd5C85>m1;CK~h&=7TfN4yR1^}$1@4)k?1P{G%&LePH`+WTY9iZvXYQ^z)H zhhx^7YBlNy527Q$4r_#L9h`{8)m15NRv3hp%5_;UiENfdwrnlP{r5dA?|b5@3(;uo z=U;#0)XCrdPpSp*y cisORwPy35=L(r 1s0xHX xsX>8`j7|J z7kBMJOf*Saer8J@*>En vz2)EU2 zkj07(3nClv{bsby#7#?WDn06Sr$GYZ*?4x59UHsR@7QKy8wYyPm>aLwn k5s;C%pW`O%G1hUN z`a#_X2UZI_(pLY?7 VmWdx2;2ez5=#R!3xB^M>JSl@*&zVyaPCEis zE_{D@Y?SMVmrW$lW;KFVMT>j~+^cvF0qC0;bAymkTb~m<4!fW1e!=s_&v%A1I1bgU zXi0gqB<)5+R@atge*T7RY^+nX1}&0_$uXIlnw06ONe0?_>^cqzP{$gBc!>2uhCeh| z;5cRm9tc^u$f;3|aR5+JxOVNDT)24gM7>%6Z|P+A|9bxUkNm&}FSh{w9|f*%edu>v zfnWNiUn;C!IrpVPuKUG fL6^J9j >j!xA@sq{+SRQ<|YF%ND)&p3*$acRI=hz-aSVxKTL=g4{e> zU8z;7Qm<4QROHeb_BBxog_bl4OL=5f80*rJESwkE<(i4KrQHCNU*RtRJ RJ*I9~ zqv5@d9gRvy7vo^f2&*l~te4g{WPV{q21Z7p!M}3h;?-8Eys?_g72eu+@bI6E5AON) z$c~ZbfddD!2M^x9>%iXq|4Sy9xUbQaA`D5&n|1ldH@?C0@nerY=1SwZrp}2hC7dT; zU)_+U m&?>|_nky{Ayn~+;!{0ASz ze~{e`_Y>PXZ7sc1;uiDXnB0rSC^my?O16+EpyRV~yih;n@gpDuLCzf?I1;#*EA=|b zgm_%+e$^hm-q*NK_2roUY&+;Zs+|3K^cgS`3HdvC44k75OW?zxXX)~>Gnhh00zZl9hkN;c7?h)SnZ zBg0$WwAC1a(X;~_%6v0pQ>_qyMeuTSs0af1-~m)zu1 ?-Y51SI(%;|8Xp0ufM BR_7mj$?>7s``uB|>AdNb47)%r}cvV+6EDrvSV*=|PK_Au-EeL)- z-wV6kn6w%gE7j);0XlvL89D&nS}_a~1OdJ}^D(H8bfsF`;2y$1F>O=eGYr%@&C$^3 z9fCI?xgwJ;ZLD#0F }!m6tglqJRsRjLfTmC zMwNeXUbxPge4aY}Cr_V}tCz2=#N+XA<_Ge>TV9=g_0ymJbg#|eyKKa_GV~t>Lf!g| z?|TJ);giqot5?>4e{v-I%>KPo(Nwe{$ygK4*V1lQS@J@Lkwg^D8DZ@$5^VWUTLiA9 zV{Q(Pcd9^+Ua ze+Pn|Q-i~34M*g<%+P6Wl88w 0 zh$EVfS(!a3sk&^~03BsZX3Va%ZW}Oz9acCLOsKTyHccymGmF8vog*^YCe2cD+{vvP ziQ1X0^pC2cB<|CC{c`Vj`)@nWev^^jpV5&lKS(f;H)0a!>k$CL29qje01HP*1Y4ZI z9+k~%L&~)}+bXj+=9#H2E|n#j9hIHC4$Ax9{{eZ= xxz6+?W1e|AAh6OgAp|AqnFmpGC#j0uYTus zxp?uiY}>w5?!NmTDGUxcANrQW+-x~XZ!sa^4w)c=L 4~ zLIB@~R=Se;bOaNXC U=|1#4BFdn%*7 zZ-~b@AtE&u!hRuC>swr0ric6GE7xRpex7^>yuVP$vF(9u9b`wUVmv4S03ZNKL_t){ zuY>f8OdY=Mm`X_|Rk<#o&q^kpkY=kY%d5*Y;sNO~SIEoo@QB2dNtv6Olk?{ 18nOD9avfM2||GuH-)*FA=EAaErKR@2A&i(e#K>BA7?wKkM eTpu3DrwY TdiMg z<{@|x891PC*}_aV N%fR)sS>nb Dj-#Q`li)*s~z-@B>eGkzo4E=As(7WBNRB1i}nOP%1 zVqF0$XjMS_AOFpH`9xaQ*4E|JsWbA%>&I9czx$qhWNO o=-RSy?O5u@O@SS7+yB zW3wU~U{i`v#AomBJ+g1#9+{dPXNC??N4*BFZvgjPJ|iPT1@b4F-MU=8enplsArp-; zTc4bm; c+6cciuc^0UuATlsGL@GU_9VWZTo-~WBBz?Z)CrPSPo zH@|rLt=InLN1uN0#DU#Y(yEsvK_sYCOB@!S?BBs56pZUuSKuTWFtc5sTv1F*$^;W{ z*CDgoW%A4nkckIL2GeWzbBaXR5DgIc``m~eAAFA_rk|nCU+BGQKUzo25Zz-omKl8) zj$|r;!v2{f@3mF2L%NQ*l+UCklT8a`+I8mDd59yGCOHXP1QfII2CR)bh@l05~U9 z32}h!Yne6DG=Lau3PB*$%NsD?^x)is`R!hNP1CHVDxbH`VXxBxdXIybt`%MY+;;=? zeo(*JjRa?`pv-P<6OT3T_v`(#y=H|J^$&iY{bg6xppXf){f%q|3*K~=0D-kfIec}! zDwnS<$?W`!T)KQ+*2+~G8K07!dk@Lb=vIj*va)OMZF2YB4@xFCBx~zsW{vq=k$_&_ ztT6k>v;yrw17n>yiEI#=QGymc+*7i+xFp|x XM6`GNdoX3x5B&j z+J6p5;q1JEtlYa!P6tS$n6W2mQ=d<(G}&~;?A(H^t(C}l#~-X&Dy7u~RNN4p;^#~~ z4laGDKC#;2+Dq^}fN)GQ5s`txtc;Eg%J5)LQfag$bjqNKXO-h`=*uI}MZkc)2KKe6 zm_>MBfa|cdx+YhUNnf}mmoHwT!4uYYpg6?x`_AptEWr;A7CB~Gt&}Ae#WjdZHl1WZ z5l`w!BLID7W|rlBc)%l&K^p|4ZA%ODZRs>#F6Ik=kjW(f=DFwIodJIf(7!LZx%H+W z_6mIT@%taSF?;bpZW+(r{_q30%kJ$H(ynhxyIv7NNs3vGjU+*$v(AjCiOkY%%BlrN zG*^@-$b{w*iu0kCH^JVO=#dei3=L~eY}ojppV t?~&&3+GBFT6G+=!bv%?`4I;BiiZQvd%82;wgdvB|0NrNuWH9xoGeV z9N(hZZnKY_$>czc 0Jlp$)}!^ zdmngAVzI2W+EHmXpfQkkBt0 m;pmw uGbFz;_JvfXJ(k8gM>CX zJjA6mpY$+8*Pu*6IvfWx(6;rr`^0z`!Chz4ug**lfX>5l_nkd|^cU|$2%gAT(Uw4# zj`FmTKFPF?=y;x%-&@jjUIsWv5=z@m$!6m+GFp`J(V`5G49M_MN}nCHCNMIHX##R@ zG^jvE-fn16*X=@>34}Y?*MeKnmW^^nR@XOJJ-B@7s?5&LsRjinV-%3@x$`dBv17Xo z4-M!@TNH%2O=&fnlFLJSIVw|I$0Zhx$+ejUR(^0_V*N=vO(vyOT9u8}70G6jZ{*U+ z-z#md{M9f1;`8s&Kj5?X!``TG{r+1k@V!>xb07Wa?n{?X|NCxd{eutRe>lEx&!i*- z4{}WsD2I|vr5h4^qNb|W0ie4KjiqS?am<=Ca|Gadj4LgvD(K+d?Ay8CF0)%~f=(8e zE?|z`?PnVX)(8NauoMM|Fe5=mq~tJWddR*V0~a%_7!!&@=mtGb6P}>Anj0lUAJ-Ax z0Jbcdqde+ uOcz%&EweM-e&z+o`)+dQ~K7a=jl!jl{yVP zn8BM;o~JX &tTP;*8A9mu0=$l{@ZvL>_tUDLHi8J(A80u|))vm1bLeuPAXNJH|1AyoNG6 z?owC*r_yMfK=39aYpW$We*A=7x_Ct H|tJA zUpW=%$7_a;34VrWvRPK01(bVnEMVk=0Ejgi85?CEKbOsGpyP=HAt=-PCL~JsUoX2K zZWfF&c#L{VapZAmAZecWB&0Pl0@{$(l{FFrv1Y@g@FLI&7P8Ul9;d=g`0tjR$EQC6 z0<~C7hKI5;wRKF!MvIcm3#D86%;WLkW21_VCWXu3x)akR03(qNk;*251_gGAN3E=_ z@pzY4*X7d1YjXYijI6GeIeJ*F)Maw(Rs#B-ox5agd_*!C1du9O3v;Exe450(f#Qf1 z3VB)`udJ>I>Xl92do5XCUXn(=0-4~q5{bya??mc<_H#e?PGgr_fd0ME=GOOrs4MV= z=bs;0TAcfh8#gX~VgKHVp(BTP$oOzd;*o}y+v#=M<^Ia3nI;VoIBAg>LvaW^Z6J@i za E2y(i$k}e^+3otrv@Ipcniec-$U+TwWzSm49$)VvRXOq;j|H1aLbEya+&b8$ z$<{@mTt~E+iGv_UuWQF%r*AoQ!ZF^3IbCe xN`}i8D5QwnJU GhLRX~yJ{ z$3G+=e)`i=7@Uw)dO)J_lyus-JK*iE1|OQiVmvJ-Y4px%(xUqE7(2UuZB|}?{dF3z z96Wef_U}8y@;7gVL|ihdw6q$~MNo!3{u}oju9c0eQSc<5QlE6!BEk&=Vwv3&_&NY| zZegC8`o?CNWUrCoQ3fCgiV@71>YA~|!?L 9sx#vFFbMz=7U(=|*|LR!hE|ktt?>BQNmj)=!pNvv-Q>AJ?_p@5D5E0<8J{Rh zvEbTtAo@YI(e>PP_Cv1){);1TxURTIq3z#-f?tRIbduznEe05ng+?m{EtQ4&6}dJu zC)cjtkfoJXj?ksDS=qL2T6XW=O|u!a3~+rrO^gJ>yIpO5hlT+7QCVGYkS}4A3(HH3 z5|3f*wJbMgW*`!DY{!mmzn#gif3@F&^%kK2P~qy 0oRI0fzgY^{M&!E?d{Ko~cNMJ!q(RnR$uL zi0jy$>;Kz~z3Vsg>uy0cyV3r$FWiAl-8NG_=g{jqD5s+j>_*qf%J;-x`n}2h4Os(+ zkdwfYJc1bavUS2geIl0MUk`x9RRW-Z7U<`DH;VyzP};W=ZNHYdTLZgxL2JbIaD8wI zg98O;o{zN&oIy@dj(6<+AhTyVQ-dA-EDcCdy2i#1dsX<5qhi#Kre(d{mN!pakXMhL zlXQMU9(?pE`S8=9lKjA=R4P#IE7CCy0E~ $Dx!Il*JU>dhRBKri z8#;aF3^R80bYaI@$PcmqkCL(iAGGw9V5xy#M~Qrn4Uc1>z!0op*@nWi;J5)1t$^Ug zGI=n?VN)WgI4L;|fC=V3yv3K-)?|2SR7OCMOJ$|iL^(Yz?Pg8tjZFa;6eF(jn9xBD z<6>ju=$98HorWfYR$wAg1hOE_+H<8f3lmq;|5jcNkCDe2{=vDFYYjT^LA`II+>p}x zrmU`2WOim=uFcHLrAt?2ZGBxvMn>K2`iR_d#~m_0Hp$O#cWRQ(!~<7C6&*sGMF`*+ z*+bBU`x2h+YwN2Nn9AldFN}@ke|=@;%uD`A+bux K?dgRoJm;bns zi#~Sv&|W!mc%Kw9K ?@<6l6PRP05kCK-1m=nozD2zb7=naRHDA zauyiiJ9NC`tZ+!i(_`wr24MdCN@adF=a*~!^R{#KgBpK548(6%?(NU`L74L2?^hZa z^mudscAa{H23Vu9Tnn-zSj!l5OG>pBl}p!_ ebQ(q z*iT2fA0=1 NKH(P$!)hCmfS#&fQ_;<#?qi`U;-_i zskXI86)J|`tALK6!FuYv9`61`Us&1V8YJ4epE`Y-pN(;?@$pI7I<-v-g+Y#w6^liY zxK u8 zVt&9?!lKe>f^X9#dmR1pfkKMThS^+FiusIWvQWr#y?^W(5tOsQTmhgPm>ZxwF(n7) z_S{;WNUaNMvqM+DBeiOq!atkkCMO(9>lImES(oF-Ps*|5CmD>SGXU!X*ARj kBO)i}l%iA1`ET lc5w75L&8zOePu<&*z<;rh8x9yz=xciW*oGB}u)WCSzkO?sR|WvPv^ zGiQ+rP{pjUZbVkLGeFb_5vAOqoag4dwa*r2>D+*w2YS#G4>EiQRHj4XO2S~>`Wv}| zJWAzCj3bEAp-g*~rltj&n}MqWht0dYpScO3`v7C06qE(M%(EZ-HQNe%*7~_#MSyeT zVL24#QzhCNNlYscJ}}#71ObCve>+ASYcrs o+`Y^MO9(gAwO-V_q+LjY% zuF31iFUrDNOP>7DC*;|We_jg338~Zov`O}@0i*!Fa19jth%(xAj#Yd@akDn`TY|6s?JZHa0hX+|wZ=nt@9m!>r977u(%*)Vlo`8;+3vmUy zS$I@c0S+0usz=i6!L@+&9Tvno #|!HWG($ygy51Kbqz*3g+4`w+zT- {-ksv-f)UefN;%4e&&n8s~!&H_Un1*XFvm$qS)~N(+Dv zn_&f8`@KLY-VXv6Jg$R!253py8SyyRy~V{vIdS3?j{)SZsi|pl2~Z+mFPHdxQ1Tud z8W_V z;ksdR0RRu(e~8pHn+;0FqG|!tno4<7YUK^^P~Mhi>%VT)YX9vPp#R9>?5F+%BcJ@> zBcH!~@$A1kd~nz9J8s)8g99ndTS=!?k#ssLjoOA(Ha93&iBh{}-DdZwe}g>65->`W zZl+taQ!SC}e07T@HIkg{A#@U?Cf{n=ft99>oD%?v%j7wK?9H`PdKMxZ$`T4oq|PqX z04H>JvkYD<2*LLQ0#TnvySY#q`@9d(`$07X_5N|YaL$_>nNNCiGsJ2!sX>p=5%Bxh z2Cx8he*`HEpl%+R08|Y0^>5#*Q#bgV_4#f6jUUht@OHi=wz-!N{(hG2f8Bb sJf? @rDioecCsgAc z_Zxk<-NXk;ngNF+d{*?jsW6cb=r;Fly8a;0;Tl}Ma!uZN<0yUD4;{Kqrl+@2W4*Mw z!5%Sy27c>k3E X3CihB^YvKHZ3@wYc2qB!1SWM}V*>Jzt-> zB*|n;4j$Sg!vlj1DvASnmZ}p;cZK-;sD1>mtrx)Y84NP-Ff;dNI( >I7Ies0%1^VikOdvzCEtI02y*_hedi(Utw*dX89$$a73-HM&AG_o7#gqTX z!F@X)I&ff{j1FZbn~6)ixhc&?Rgwv0y$z{VHg&TBm`6~;hGLc*w;`LyWtohi-E1{V z@+eO=)NQKSLUymEH7fyvne3O5trTpfX| ?EYpip$m6lDzoZNm;KZ z =N2F-GVT2{O#T{q1kd>C@+B z&+dJ4 kH2lS_Tj|;^T~p zhIj7((D`qhbde5!AGj#0#~zW3m(ED7x-5J4ZkHX~w@ZNp9DSimk2}8Igok?AZX*fS z%dmAHd8aszvTprqTEAd|(_wJQ0?D1=KXkM$*g%F}sY$KTb@S^mdBOC+svJLYPEH&@ zP1V3yGA6ZJm1}~rHV_YY?b;#ZBf~oB0CFRwW8*Oy94d0O4Mg9~a+&u`1nr$>TUM49 za20P>u=>$%@V7qgr)LFz_Nk|~ojvo$AMM&U{=vKNIFK72PD>(&tOlCq4XM}4WS!DS z0&}HJloaDVfX=5s=y7#vQ;Lm<+S3hHwIY~vu2{>Bka>jVAvTq;> 6mY!Ai62`x6{6#^(aNo2UZ%L4g01`5C(bdVeY155nFJ)N$=Fv)pVqxRs(5 zie529SgH+J?4~4@9gtc(A+t*r`Nm7f 9G}zVCn@VIb3IeGNRj=Uw z&=F6oW?6<#tp) g&v|4;{Q+M#jga-e^cHnc!my zfGMr6$mS->z!2$CDN_in;16}uWBLs0GwT2y0iqi*RZ*2FS@QTh5sOk_rvOr0CPkAI zkls4oj;t)t%lyK1$!3$XclT}?9xhO64KJ(#&I|WS7_^7?iXS}O6wtZHc+X}qUgDl1 z?ZzWy(9u}>O2A=_it+6A*BVWh;o%RkS-}_`+#O@GzE+X*7p}_Lv*+c+sZ(4#Wa##q zn%W|V4j*8(0|HazCeV GIiM+gzXj)%)%`Fmd?6c8NwCoWWG4dP`dEx-=UVM-I_aADzsinxSK(ME1jtyk%-m z0P&VoHbEMx^1l@XlxYpZ4tjbZn3y~!Rss)#0=o6njP0%&ij9+DgCPJoQk;@%!t5C% zKt6Z{nVkcC06H=p8_zK#2>?1r!rZ3egBmj 3M}Z_dO+Z z*E+C%GC|(~x~_rCq|MyM8`bkOSarGcs|UT@10xx}qX+KJGvpd*>%@Bc{pa74t+~E8 zjsw@mf;N_gy)CRiFt7lizl`oz9SD5xT>Z@6*2CH(_PhP+gxch4zZqYL+^A-U326|} zmCYVY7G$Flmw)*733>Uovl7pZ%Y%=7Sf2dgGqPoJJInLwbd~{taSG0yJ14Kb_8OJi z9(?dYf*Oti#3*F$bF(*OdU{&!IC5A92EaJav-gUPpTzR620i|H=sGB USh=14ZShTs=I2FS#rdN)uk$i!Hhd^OMn&=;@Ol`AlF?TW0etx{?g ztZ|$() !&PT{TW4aOHfy9FkcPji{sa47RtX67{QLB+XnYU@o%jzG9bI`2B zw4%wBSE*H1ywVKTSl%IMxy1=I-a~Sny@w#(rIyD403ZNKL_t(#zUA~>Hs9%xBnQ?p z-mgSFEddhHZbFK)mVg5CyJ(}@VfC@z!_sirr^j0@Ll4WTYQ3t-d^oDqKelFoU>l)n zx7)c}Wy23@tg^t*HQQQmj@-}E!vMt@Xt;8|51d9~v-JaD;F=>NHny~Z_RYZC4|ME& zEZ`u|!UakF=U`v=0~#OLscQ~Uw;<0P{G6j7L{h+f2cT~@WAgg(OY-fb7iF>3k)8YR zl*ixyQMvElM_GE0MiUZ?rX-$7OD;Pg|Ic6iIZ0@D-hBr*cLbyWM=HX#S^~1%%*=I} zo*I|iZaX9+!^0AZbflQiG8n{|liKIHxpa5`naUjyn4~(_bEnV^xhF e-Tqfn9)r^4S2_)RoAQf7vZ z|BeXazFNIYt5lfneCIpgp` *N zH5IfcQ4!%3!L8S=75E{qz-K@E?C6b?C;n|R+Is$x`;KI{ZylCc2jd+zM$zV6hVqr! zKXMa~B_sh&V~W36nXV=EdbmLV(2d}RC>!NKvVJou0=l|OLA6XbB_qA*1Br4NT-R<6 z=(?dh)^qT{hS@1MJm*N}13G>;9xj#IbaS|l^kZ3bP;&NE+Wb me zyc?jSw5>f-vyN3CauS9dp!4&4N%#TZ;N1YdUlhjA-tphKzJBIw;A+5Y$8D_;e}>;O zC?C+VVQWAKpK7#L-r2jifT6cF5R&;UVA6AX_khVim;S(mzXO>mrYxL9Ft4qX4^{ne z2#25t8j(gMBUk21^6J}{<;2-J$qsFm2OfD+KJuYY$kf)Iu+El5svz0S0DG_h@WP8+ zyN4cmkoPXKF_1f<0ge|o@};FESzDQtef#&w^wd_CVgZD>=UYwHhu3w+y&jql@g8yd z1jdTzp;LM_tPZL8icOQKP3hUQ7v-fFU#0`v;Ujm*#Fi;(v>`5o(X|wN`PSFRdZ3>U zFo#hJRPIV^>&!y&H5^}YPziJZc#7&oK@v>L=ukn1hKe#VUX)}?Bo$W=b4c01(Kv?6 zUN<5ui)AwKq4N%hvmHBjuxC$oNPxBTZ|6B%d&U_w@%wx{VLz;!U{K{z{YVWU_&;60 z@LtxEy!xF7A)osXwPfbA5s9#mUahqi$ZHYKsaI-klI38^^6IOv$;FEoW!Ij4+&3^L zi1or}Avl?y-YN(8@0D%aw#Yy(%ZLU+7Hn7x#k8!huJZkQj<;Sv@CtOh-3SKzf8Zbd zDgA+G-h0op)zac0-F0~H&I7xrrI3wDx3MYp21qojISu72DmFRRGB o{A zZ^@lr(m|QqiPJbhH?orT<&EIw0}lH9eg `ZIJ53c_u~*3d7x<2d~6#x_p 0$I5WCJR)t9*e~hs%zh%JnwoBEuF*4H~ z(BM?PTI{}1pL75zt_@96mR9727haOHXD-Or?c3!32ObJX)0~r=U2m*^KAQ^yNd#Yb zhOHEjpk#h=AsAaj00Sqq(Xk=fwQWkqCq^jOS{%s8U|wAo;W~)P6+D(`Pt+@2dF$v2 z`P$dM#!@@TgM))b*|ldk=k+b9Mo Y#zux@&z@bfcjpco%OvA^T?U3=|Eel{Sl3&C{w^T<^{; {ZTFB;BYqeTwU?9KP>2y}B)#^qrm)rcp7rp@B zzFRL5`IXN+y?<`@@*i#)D?Ih!y+@+aR!tgJXNw9jL&=gXbYmE68IyD680%<4w4teH z2URVvtKK#kx+q<%Ub+H#MpDjvB8d_neh=go3H(m;+?8}y-qCGl^c3&y4cn%KUOQiX zn&EbFA>3gvi|&=p?#Amp6*kMx?fV8c{bhIi?B3rSlE?h-zcY&r$JX#ZsFx84`T(6l zhK=xea;gA=aD!6GTHD~+DhLF3Txr&G+Va8NfY1l~n?DdC3)^3pu+rd`-w_1u*zB`R z>ehhXfZbXP259zkekoro09snMS3jV?+e2*U#q|L=P;!m# 1TdM3WX6Aw`FyuB5$8KBf}#ja`^Tm^zY8*(*$n- zbT*yQwo?t(ykOIpWoGt@q!LMHr-OweOWwI`mLL<1Z{l8H)ho<26>`jdFa&h2mAlY1 z0O~3T@;+*dy#D%|@{O;5i_hDmkG)q41I1vrU&U~=ACJ#NMZrcwZEbO_z;(g13?Fv9 zx4c=B)zxJwZ>&nY10M$j6K&bMXNPQ^9+$nlcgXnIkfcC%gR&X}`G~aYQR=6^@WMAa z=7+I0{Jmr6PT9442WR)8Xoo*I2b8vX)=rr&lTnvC05}8gw(<@F@~kH`qtBfMZFo*} zf+oCqy!mxL7g1?JAcy|yBGpEhee`OzBee$hC6MS;m z*k(a%Wn+C+28#vRvulSOJhV@?OpG(YX?1Ecxn-Q!9QWESKzCQ;YhU|XD$~vsYVq1g zquSWs>UQp{*BkdlqmeDSTz)j2Ne`q_sZ_Vyjg$0It+bYxmutCnu39daOWkgFJ() 8Ic6it}z8#ZS`LfByNW#>*Sazm|?SfAN944vwS} z9Z5&K(yCWk8pOj6hA^@`S_>w%T#iSC>PYRDY_6lEj!Z|(tmusqSX?g& pcLw(|bX{`SZ1 zZU*MxD OdBvTxhndQ2?^5$tdeQ{3KDiPUt z@E-Z>=YK^G9lS$2-K3m5cU?}Oy&wk 20aEy6UlB??|~?qdCjp{N3Nl$rGpLi6@?vU3>RY8Smn`^Rm3MD4QE) zO4}kYAD iBh6O(?s~1dJ)O=> z4ULS9XENy&ZU)?-qhn*dPyiJG28bY3?AfRUq9V9~R#sM8tyXJwVQy}&)9zkRq>@*1 zx!jBKc=z>YvoQ1Y(+`z?l0f_2&gD~|`cyKqx%BMJmA8NI_QN}OKl (zUF6j2K+&f*(1kUhR_S@ z@9gmS$7%oV&z pvvccv6)U+V{Szx2)3@y4e5 zd)oRtdQ090=2)Np|F+*}eRMo;kmT(r+4(IFjug4pLI2-+U3y85c0YeNAP?4>v7l9X zAef#Ysf`}+xaUClY$Fq`OQVf|bwHLkMb2Dakdqf?<^0tp8JXBE&wT7Na`?!7oKrq? z`m&U3O?l6wkI8{U2c%ppNxe}fvl)C0*wuo#mcr*il`t+Oo?X6tSr+COWqf2@4jnoq zV IP*0c9qPZMe@^;G zE6O$o%x>O#`;`3EU;d302ZrUb$KESDcJHEfFDeeJ%PTTBKP%U-UgB*1f&F{s@Zp1! zFXovDu_^=ixt|7b@z3$k{_M|U&1Q2tpU)p@G#ZZ|+H>IcR;ROXXk>Ug8jYrLvyt49 zR()mwIyMDFgvO}FwcM;Ju$!reGBu;ps5X^>TdfgTaC3FL-GzFsex_QjzA-;D^LjiU zf4w1%3;ou2KS|f(x%a*A1FI|7e`j!@d*9=a-X+O+O``3(4CZs1nb))go-5|4rHmJD z{zkJdm0De@)w(pAN><|r>Yf`Ez(Ep14+($g^Eo%Rg%K$2V-Yx<9VRz1cbBu5ZW##P z=QeYtLxh n z_r}c?*?e-E9V3sY576y>2IzFi^2+S~oV^`aKcM>=w)N0$lMY$*&Tzw-efU{_a4ycn z8e9fHfG{w}*Q|A6frXXYaop&Mw(8+%mjC?t6^#DR?hO>|`f9-Bzt^e`KFIrPfo^M* zqhV0|ifWb~OG*?rx%AR*NCcyoiG)<^E!k{DrQAqJsTPq7*H`2_N6*N5H7 )4&90s8z3`GXU&ZG)W>NW~kqO`)w zs&f17w{wJ!Al_9eTC_qm0a8+=$;pPUh@9ZoX98?=Si7d8lF4AiG$CmaS)n}-Yf((B zIK?{bt+a%Nz)UhC@dSRiK0{>9XVU!KYE`*4rS-Cu%bV0uhp`JDYaU-z`_7vc%*^XF ziS8#E86FvwvEd== ;{2>!yLwrc7UsEEPHmlL zFoP-y&i|(d&_QJT@|VB7qt$9Xkw_#yHZ(AJ`=LXJwhavpC0VNQ)T$8iFdEH3^q*2z z1Zj+pfH+a9RwPOT4$Ow)!O)%%fFZ@XX?R$x3?MEHqEOsCrS)}`gXU&uXWnc!T7MR6 z$=^Kr?2|J;NkEQs`NT8N+;i#l(Lb1;9C-A|;i=f}oukrjmZeoMOFoqm=|rVnSKG)| zq^W)MF60rxhQ*jmgI?8y#fY0bN2UdD>Ue}Rs&FL&8`glpHVkYiM()ybs&lS#j!wv! znKkwSy00hh1A3TIgkxZqg_`A~fsFNWaZDIfQ()3jJ1eCJr8?IW)){8)Zw4Ul{IUJ_ z;+Srh(T161Z>c@#DYtsfR{DV-TI6K%Isuw~Ko37RB>b@wLANPX077qT%b@*pwG7?B zvM2L(pg~;nh
9mi0Kd|_2 zSvrpcrCC>%>2#y2;f?z?aLQ^+!U4f%wLyLNdM87nnHx)0dGo|YIelqf#;5km-UD~Y z9d|s)zbB@4NF54i@d!)p1A{qfwkpzY)LAx0e;j3ZC2+wyG|T-5AhoekVU~CL B{S#y!XCg;Qv|8Ha>JYZ3KV$2l>8YU=qdEG*G@ 0^*1xsKs>T3PDR003K6gl1w@w`D{kr;y7*Mf-Ypv z2m&DQ8fi;53sD@6OFO(C3XnKQd>#K?D#6eNqBm9bV~=3`Pe KK+C_!-DtZS aHC-XyB4gYDs_Y#;m;Z%1aa;>ULu?J~1gr4&Np_cklYi z2Xp}WSHAL zO&azW2~3^PRK!39f0M!7LirP&cs|uZX<9$uxtxLK%2#g%Kzqz#wBMgKvbXJ*_stb6 zs2qf3JpY{RzBD&E_?7!$=)14s_t%;=7Zim)?A&x+#$m_7 #+{ad>iYA3AZTi78{$Uz8F;2w&^v8{gq4=n zQe9@|OY-{LSESlb%Aq6o%11u-Y1wn|ZfOc^SfdhyAWH`G*)5T7gMu-|d_nS=EX`IZ z@fuT7DCWpl*ES>`OUpOE`J%k^;ww@rRphQa@21imGPr@kqExG!GCn@anP!ZU0myaV zQ~OjLKY$BsfRc3r^J2lIL|5uH9h*Zd43&VBC(g;p@R;mBw4b1d7xqd7 J%H3}8kBz5HBuhyp-Z7qym+FsZ-<4KiW~ ^y!ZdFpD1_hkzpFq*E0_R&!gqoS?K*Gsk9lrHo&Iy#?@@1MVPRsP`{ z-;%4>F3R2a9FfN!eSob2xK9og^9*<}+V=9xugdvzSEN*~QOkb!zP&%$fc~d{`ls6} zmHOlPOy=Y1RQjQOJ~KTrF(LQ^>%Dj?$(6OJQ6qW IlXdsY!$^M%%2kv>Gx; zZS&H-##DoJ8`IChI0=-LK-{RbFQ32oLakQ)<3_vs*Uvxy{MwH*o`3!mpO~JzarS>* zn7j1ceRuC4xc# mv|)2{?lf&BIRmX>YHG%>pZA!nqQo)zmUOun~CE`9|7W;azaXV>JyPih&vazWa z=E$kw)JvKR030E_19SuE@LmY*D=nK0%gud~sGi=1{=5RU{pkUJ#W|;_*Q NvUkMWPW**LNnLqO0sSDVfpZL zpOrfwcwEx?5ot#wl1!xlg%XP+8*fM|8Iys0j;djCO2lf0%C=7n1TSSxAA9?Zyzs4; znT V$G_E9 zmOCuTaTHdKbqMCvm_Q)Nu~et25LA8eek59`&_QqoR<2!^-sk5RWqD~$$`t_QsEmzI zF+jliX7hQj3qTz~1w7~h=x}`m(XLj}NgE~TA}G=427r#9ym1dyI495Q3D&e#Z}L6p z-D~>-K#uzYqil6qTd&L7>LxV{7ME6Nm%CnGle_LbAWyviVcD~1n?Uj)mrhAC8Ik3s zb-8r$DmfRY&s^Yf{=}Btzxvg$j+RQ52jY?FFD6r|2PY=RrpL#}BVag!A&GK{J(MUx z>E<1l4!O~}GM UO41zvfQk+>Fb!;+=h61prd8doy}+}Oz2{Y@B~z{2dj96x^i zc&%Rha=jV-FQ5C|vr9jYpiXJmxl8}-`qi_)wr|hW)ZKR;kcqJj?NDRgrbr8JYHe;r z3H1Nkaj7*Mv|j|EH>*0&nZU#6vz9T%i-#qfl5{%GQZ{?8Cdd*+e=pQLx9lnaW`xD= zWgE6-lEi|(#qQ7MO*g{8E+}QY60b4kZM-e~-HuG=0Npa7eo$AE`8!QP!VE(*d*9^5 z&$xZz7G1Z{e1_oX%8CYJ@94Eh0gxWZIUu+5v>++$>GztwgzKe1jq{`e8SOh!snRhn zK)$ktRc}B+qRULvq;c8X^`&R2#UBRCFumZpJHSU=0L^cEz7S*qXec2XWS;u_aS!o# zIqhwI=8ccgYoV&^*jrJt0FS|oS%ErRPppykjE#^-(GiwgwdDhMK=e-`1uM~{)N37C zDK+KI6W8SE$;;A?WaZBL-y=^w^J%&F!N(<*%F1S~%4`(eoFpbhQc-eHGAXA3hkGX~ zQmNGl=&?AI#+q_tc3J-6n=i^nsVb>VUR}{jW%kmiCMM<3;e$+S@Nc#A&~-3ZHr!K6 zCKL+i=%j y5V=IH1?Nedi7W9l@@F3KG^3t%y*D7C;O-re@(OKFub1V=^9zGxLabUvhOUv>gAC}pZ*FQQ&tD}i_KWUYy!TXGazqYF4;l8vf z09_*}>ZRBM!S~iTYO=NpTUFTVXfQl9T#&o&JSY!6j7fq?Z3nQO8xaW9!Od-XsU&Z{ z`Ia0zc1&P%`xDhe|E=Hpt>|5M-G5tDBEJ%e$VUbT2X{|SPDXLDsCCS8dYG{&A;U~7 zOaohQT Kf}Km5M?-go7~xj)*zb@cXw`*+BZLp!CqxhTb4T;fn-q5%Yg zA6$bNH#C|#Y;Gvi-Sy_;&L-7B4miL_osnr=Ak(ax#`+LreeGo%t(A$HNqU~leknD` zMqP!*2U;KSV=e;sfn2j0RRTkXeschD^Qs=8+h-UE>d*BO!mM3j{X(lYm<=h<$;-^S zdGu5ndgsu6$ydGi!b}`JaQ0H&JU)+4Le7G F50oG% zWPNsj&3m5TAcI#y5`S$qc)_-=EO8jHAW1+G3PPi;rpS7wE0<>0=)!hoW|@rlr=S14 zeDY_%AOph_vc6H`=vgWapzBID6O&>tBN?*>b 468pcXdvZgtU>z(sngZK+8 zIvujnDMuUE-g*GuQFB!b6Q_7AuCw4xz4u8hWXqsUlHBrYNvgF5rEKv#D|J{nt2h;o z+r*A=4CZ;yXCKBZ^`@YuMUkr(S}L1Tsg$`NfRTUo+GU>iU~zx}ED3#$Hd`5JcVGes zW4mnMH4$up7l6#ZC@^T*wtb82q1$6m001BWNkl G1N z0rJd^1v!2CteiS^;wJ~tzxvg$j}{8?C!{0)Je$qlGcr7y#YMr)o{n8;S&4eLEXBB+ z>>YsaW`nsw0qk|dHH}>w4CGYY0VE2rFb&LYV || zHOTQtwzv+E9`;Cgfq9OTGG)2;CX&0Bxq+yu_l}8`m_4ViEsxic9&x1D&{@T3N2Jn> z$kIkvUi{7pIeqS$L^C7uo+qA_=RWm$*>~V}i6%fs>q<5qmsBz$=~PVe*#tGs$@$?k zg)yiWD-hK>NLI?AK6><&oH=t|GPwa6AKSuA5;M+lYQy=Urw$O_K0VFZY^;kK{ivxC zb 9YHq}cETNLN-CL< zLQzMpw6v}*O}&R;JFL$;$0SK}P!ShosbIIKn8`yr?wwo|{~57sXVWqeT5>cgfr@Um z-jy3SZm^0lG&C3}$rT5o+81(j@bj!%pccciSmhj`GRJd^BIwbXe_RWY?4b4rdERU) z&9OdY{0IVYEs)HhKMztP)$^*TM5d&WA7beo+4;a=UUK=Qq|zYWg~_9Ka+HvV+aI^z z^75KoxpL_z1JM8KtABN{8;kz4p`oEq6pO{}Sh%fITX-Yje#H8tL_)y-^sUJJWl= z=cXk?EfIzS45GTpYiqpuM@Aqis67`%jQYI0pEK#nfaPgLkkHQ3ZZec(Ag$Q0YuO(z zMpv)T+-SDie^RYC{_wfyo|^rq4(gwH`jH(g3)g-(+U-8QXV><^?(JJ-YGP30-MX|J zn P9h*Vg*xC?NejT7v($Rr;J4_pxylcNb0aotz!BKpnioCF zxOBa9n*Q$Cx>y6}wnnp`N`)INeuwr&vpw_h{zx4IfmTx;^H}0nYhMl8H6SC`0srl$ z7?eb*efLHuD&=}oj-9$9uO2-uOKVNpciV%^&>wpr{L+W`JHV<2h@(OdPzMtn^VfFG z)z~8~<;{k4J8@|=qjKr;j2wOIge)wr%l2)%WNd6g)>cb$?b?i-J9j~+b}|{+y~B z+_{}&XJD3F?_Y0c-7oPqo#JtP0Is*%#hyBGO0HhL$}Du>{)3$SL6#nm!4yTaVpNf6 zh(d7~1a1+@W>aME;~0=N)AE%83Um=VEGzT5cI${zJC7{}W_(oANv)LX&{+8LgD(r( zHJDVAvL#eJFULAxD{XRaJy*z6Q4XE@bXM(Xn+*gr9i6;!RmAYwM#8MPqFKF4y|R@Q z$F&zZc@n &K39FW)&lW8qDGxGFc^8lWiYS!5vl$Tri8) zrRneEw!@nL*ek{T`JexJAujQUTHVh7SjZP18y_FbZr{F>!USLsT2_GhM!eKXNTWr( zELulTM )`nNECeirdPgShHeUnun}IXG=E}A{P=PR;cj4a19(-BU2|C zNdq3Mf+)!}1avzuC7VK+qn9mjeOqgjV8i03Pw8H@m?b3Q7+_8Ul{C2XwH%2H+Q31U zjBD;V1g5L)I4{_ot?Hxql>>G+(W3o#C99%W3N|-4)=Y-S0fHBP-b%16$0Kt@ Ou78%nFldNJyXBD}p?otSJk60*2jmlwZtOx`?sRZ_VTdFJDvmCyZmzarDy z_7W&EDHV`WuXCuFwS&rnMw2mVLAa!W3^GMo(Z!{8Iez?{T)aFZ1H};;8=E8p9?#wR z^B3gGl`GuOP#M7-^PW9>WNPacDHNc>mUh{BOL9Da1q^$!XY5FqAjX|KbxInwy6is) z5AQ9!MoP-WvBCyfoe=@(;K5{bDajYoY=b6KxDMJgCg*~YnUdyMU2)<+djF%n0zw>G z7!aaDMbDcH*C)jtX84?G|6lig3$~OjnIHgz*toK?N*P+TT9Qzl%w#2zNU}|$1UQT_ z(V8%@{AD$Z1WT>_rcyX+>=z2Y*<4gsmnw4h+! qdSy8jt %ZjQaZbTm4#3C#cp(h2oQ7jgeg>VBc z%rD8(@&d~txF7_Rc0;lmRVK5tUAXybzh1>?ct-_+gb(Po&5ANB(fey)WF$lO=&|D` zuSH|gKTc<(|M`h0p7^Iol#6`)BM*JJw0Py;Y@ZrB1S!J7LPlckmgH!S)RI;MpswXp zjO~;w@C0tKOwK7 0j-}gG`~(fSxU`t_Btm95U~0xQbZ-611@#$k{Wg6&wwUa34@A zIHLh0vot3P#6D=HLS?S 3Z-jDJt_M1d9 zE}aN6Yh8P2yq9ZH>BMq!?8HTR`Hhoub*3VB-T$!si~s32 zanoRKO^x(UUIo2`|O!)9@8}>w4wKXV`i53 zG{6;rJ~cHd+qP|$v2m?J;(Fk?&2kjS4Xa` zO z@3)e_Rt_{^1c8zM>2ySkO0%w+Gs^mxE?tq8 CN zF(`@2D%>;9(GGx)V}U9r+QyAmUB`QQOy(!|V+7=1|N7TQquuWNmll`4Fg`K<@Ywjo z@X+vp3=9ryG|!$-$XI9j&6U^nCRA3Z{Wty%aSfIPqwxF%b8v-00Tv2m2@p0Q&xZ{M z7X$1>_S(8_87$(0q$Gx3iaGRfJ{6I=3eMocu->GVh%916j|kZd#W7l4_RT=F0H|NO zbm@E|nf%`yt;m-@{NYDd{wZCiFMRGp4=l}G{rz2AijPf>6=ZC5L{iwhJ7E5UjR_ga zHpxN|mRMO`qh@hDks(P1UrVL4-0-QU*KFu!n{YOaN!;XUi|AQnhLsZs?#4&7s(>2$ zSqBfko8iX85wM^=6)+#rvD5p&Yik?;q_Z9MGxVPKo7jcR3=m)#p!?U$06n}1^}7Jl zFpJe5qE!g2Wb0QQfQFV0GpO{+?RL!eefv2*o?i1As}A9Qrr`~QOkz<gI0ySVn~%*~ Qa7e(l>_4h^aN0@*J~9FTUTUtZ0Dp>3YJa<4HXug1D c1ZCq5(57y=1hGE}?(deC>bu| *&26p1qNjZ1stc;G2$@b|T)Krhh5?nv56MM%H }6w&6V0M5Kx6WeT6-K|5>x%yk1Nr_F>!+ zZ3P9=rIj_gbonZk<%)yDGCVpV=~RZ-9>h7s{4jsgemtr-*n`}NVqh-id@0C7K)XQ` z5LA4?Ux30M*yxywz;iV@K1pCl{~pQW;9!<(QLkesX^}jL<4wf${=|!t$6fW1cLL5~ z5cy9DB)-?Nyzuug+?LEHe_?EF^pk_d;;zkVm5O0}@KY)NxJ$Hx3yVcX8KXe}R-z65 zU@_8J?T_JM08bU=9hB-3y;e4B%=V$Ogc})RFxXr$lUORPaq}4&8Iq~V2}k7coOVE# zs$(Wr2@mLjX|D(rD};?o wXzag1LbBZ@VT>%ym6h|0b94Wpn9TmkgCBTs z`FpL=kK+43|Cy(b3>9*}IaEkIlaJTZg&YJIQdG@?{xbSqs$~tkM9f!~$VQLGlTt_d z8paKA7kqVeb4cJCP*lQtoF<* X$$ms7jl%WmD`_`qj? z-Vf^b9w=N{R&Hf_e~n4labt=2x)10*$MP<2Z5$=;(Hhs^9{#MJe-LPR89UC~gmHS# z$veRCXWt3vTth0=Df<^|h~HymG;-eIP({mC%?wyk!28geLe{QLL)~boTMnOllvXnY z0 wqXLOHcFd1cT9Ibv|OAxlvj;Y4vae&Q~{s~o hcwr$(^GjIvRb;f8M z$ahF+u!bPPjSdeG2s2q|fV8BLP3f92*a1N6xSx_ =WX`ixw;Hp7X8q2Uo385v_`1NQ(bF~9-5nym(>575^) zZWa<0Z6oP^)@bS!icODHt2L=?lx1~wT~=4t7{YY=0mKdhnt9RVx7IGuf0hTTRI% zF&)w1Ug`(- ;exG~GG z?bxwRhKKTj@()LV@G!x 7uzsldi@heYk z# L^+Y|7!2t;vzV!Q|yBvVN9*V7LIt-R0R48ljQ=blNaohUL)YLB~2lx0-N zEG05hspBqBNhgt&*N$C~|MzcSkR5w&lYjX?|F*pMi4O|2EUa`-LT|UJZmoDe@W_F; zgTd@*k`)yIdaDWG7g<>?%h5Ma$kpp}GBP|Vg9D=iqY%5UNHUZN7nOFW#Z3Leg>!Q0 z@&!)bB$7#H*t>V{CSXGt1=o?E-2qFyC08zAljA3j$-aGiNv>O5nwPnmS@!5BC&y*$ z)-AGSa#BV{2BnzKs#beCL2K9~gm >Va%~PQSQdh&v_kDA$Stn7RH< zU%+fQ5I(Q&eF31mXWXg#Vf}F38!eGawI)}u&&s)Tm!z~&mTlX1$nIU>Gh`XyAt2JR zHaOlvPzhC7v|ysVUxR7`j$NSL4#6JR^1*pURPq%G8$@nif9*{wzYPu*DfJ4H;?O|B zIl@7KPJ;sm91N~;(Y5MBEdg)O`X>+QFTC(Vp;oJXY_Kr!rJcKWK8TreJh*t+Mn*@S zgrLogo6?nzM{&N7QLVSRx@- b=At|Yo z1g9HKi$IIbRYe!j>%$`%*YPrpj3koWDCQRzIOZ`rG9sg+!vu7I7KA0RKFAulDN~M7 z0iBU<+hv-Ld7jB*BX-ehFe@64vcc4$r(_*yqK7c)1G*39+_3vf?)a(x5;z|yr(0l~ zya9Ru{2rkD51?Oi_kXWlVk@6pFWo-h{sy4;2MNJ3xM0EVlHMu=WHTY9n~-kH^~h}= z-Nw>lakipJ7A5hFb|mDO%+Acq^&7L4&0H@7_%>LDL8S}<0Az7(J{R{!I+0}X4IO@f z=g?qLhKfbmIthYll+`gB)!=u~R<;$4mg0V3@WISSHSd{$q0-txP@_F{ih8&?X=}d( z+=;*dNHa{_djNQR9yVu|%M%HLl~xv%#SK9gWa_Bc`2<9)sezjI=r#BZ34&T_g02Ds z2av5WwE@6#pvB5aG$EVyrgUN{mfkPVuE@8Ko|elq%ktz?&&sd<>c5cfJNJ@ H0wa{S~ONyM` f5T3!F2&gX=rFf21iEeyFNHPD#=7nO63iC<)s&8cw|71 z+ F*ay)MW%n3n)G@vW@*hCnSr?n`L;%bl zm@BlapTN7lRM4r9XXDu=;~oG=g9L}?qg<&;7TI_wA{Q=Qk)ubCvB1A$$1aZf;j^%} zVZFegf}T94=eSqJZNGNa25RjqCZPh%imV&U#c?$oEuCd=sTRrk^Jn$ FO0p;i$qKe*%e$T_uRS~CqIc}|?z(nsro6RPc9l!EYh;_0L@P@aa{?nk z