南风


  • 首页

  • 分类

  • 归档

  • 标签

  • 搜索
close

Introducing Low-Latency HLS

发表于 2019-07-30   |   分类于 IT随笔

HLS 简介

HLS (HTTP Live Streaming)是Apple的动态码率自适应技术。主要用于PC和Apple终端的音视频服务。HLS的优点是性能高、兼容性好,缺点是实时性差。适合使用在点播和实时性要求不高的场景。苹果今年新发布了一个低延迟HLS规范,目前它是作为单独的草案来构建的。苹果新的低延时的HLS草案设计目标是1到2秒的延时。

常规HLS时延产生的原因

HLS 被设计为简单的和健壮的。但这种简单性是有代价的,HLS 通常会带来较高的延时。

切片和发现机制增加了延时

我们从框架开始,采集的数据首先进行编码,然后被放到一个segment里,苹果推荐6秒大小的segment。因为编码是实时的,这意味着在6秒内,CDN上没有任何数据。当segment编码完成,客户端需要知道这个segment完成了,现在的HLS采用轮询机制来获取最新的playlist,轮询会在(6,12)区间内获取到包含第一个切片的playlist。之后,客户端发起一个新的请求去获取真正的segment。这里的每一个请求都需要一个RTT,在移动网络上,这个可能增加数百毫秒的延迟。现在,在最坏情况下,延时已经达到了12秒,如果客户端需要2个以上的Segment才能播放,那延时会更大。

CDN的缓存机制加长了延时

如上图所示,源站已经前进到第四个segment,但是CDN边缘节点还缓存着上一个版本(只包含3个片段)的playlist,CDN无法知道该播放列表已在源站上更新。这个时候不能直接回源,如果每个端上的请求到达CDN边缘节点时都去找源站要最新版本,源站就可能会被流量冲垮。因此,CDN 必须缓存一段时间,这就是TTL,必须等TTL过期边缘节点才会回源,所以最差情况下又要多等待一个TTL。

苹果的低延时方案

对于像一些直播类到场景来说,延时的黄金标准是2到8秒。为了将延迟降低到2秒以下,以上问题都需要解决。但有一些因素需要考虑。首先,HTTP仍然是同时通过互联网向数十万人提供相同媒体的最佳方式。所以应该坚持HTTP。但是,这样做意味着我们必须坚持HTTP交付模型。这就是将离散的部分、离散的资源块分配给客户。如果我们要花6秒钟的时间来完成这部分工作,这已经超出了设计目标。那么我们通过HTTP分发的内容必须缩小,在某些情况下要缩短更多。其次,CDN本质上是HTTP代理缓存,它们将做缓存所做的事情。我们应该与之合作而不是反对使用缓存。最后,当播放进度离视频的最前沿太近时,我们只有一点点缓冲,在卡顿之前必须采取一些措施,比如切换码率,而且切换机制应该尽可能高效。基于这些考虑,苹果提出了5点改进:

  • 减少片段发布延迟
  • 优化片段发现机制
  • 消除片段请求时间
  • m3u8采用增量升级机制
  • 加速不同码率直播流切换速度

下面针对每个改进做一个简单介绍:

减少Segment发布延迟

减少发布延时的方式是允许提前发布Segment的一小部分。为了减少发布延迟,向HLS引入一个部分段的概念。这就是EXT-X-PART和EXT-X-PART-INF Tag。如下:

1
2
3
4
The new EXT-X-PART Tag
#EXTM3U
#EXT-X-TARGETDURATION:6.0
#EXT-X-PART-INF:PART-TARGET=0.5

一个部分段本质上只是常规段的一个子集。CMAF称之为FMP4内容的CMAF块。因此,可以使用CMAF块作为HLS中的部分段。您还可以为部分段使用少量传输流或任何其他已定义的HLS段格式。他们的主要特点是比较短小。例如,他们可能不到一个完整的GOP。所以这意味着你可以有半秒的部分片段,并且仍然保持你的两秒GOPs。每次创建新的部分片段时,都会将其添加到播放列表中。这意味着,如果您有半秒的部分片段,那么您可以在内容到达生产后端大约半秒后将其发布到您的CDN。这就是减少发布延迟的程度。部分片段与常规片段流并行添加到播放列表中,但它们不会在播放列表中停留很长时间。这是因为当你在播放的最前沿时部分片段起主要作用。它们允许客户在媒体到达时立即发现媒体。而且,这些部分段的细粒度可寻址性允许加入这些流的客户机将它们连接到更接近视频最前沿地方。

但是,当部分片段远离视频前沿并且它们的父片段在播放列表中建立之后,客户机实际上能更好地载入父片段。因此部分片段将从播放列表中删除。这有助于保持我们的播放列表紧凑。所以,它的工作方式是,当你产生你的片段时,你是并行地产生部分片段。过了一段时间,当这些部分段离活动边缘越来越远或足够远时,它们将被移除,并在视频前沿被新的部分段替换。让我们来看看在实际的HLS播放列表中的效果。我们要注意到的第一件事是,就像普通的播放列表一样,它有一个目标持续时间,这就是我们的片段可以持续多长时间。部分片段具有相同的类型,称为部分目标持续时间。这就是说,播放列表中的部分片段的最长持续时间是5秒半。也就是说部分段只用于描述视频的最前沿(Live edge),当部分段数据不再是最前沿的直播内容时就被合并删除(如下图),这就是我们使用部分片段来降低发布延迟的方法。

优化Segment发现机制

优化片段发现机制的方法是改变客户端更新Playlist的方式。方法时采用阻塞式m3u8加载。它的工作方式是服务器通过下发CAN-BLOCK-RELOAD=YES标记,来声明它能够处理阻塞式m3u8加载。当客户端看到这一点时,就知道它可以在实际准备就绪之前请求更新下一个m3u8。此时,服务器接收到一个请求,意识到它还没有一个被请求的播放列表更新,所以它会一直保持到完成为止。客户机使用HLS的一个特性,称为媒体序列号,向需要更新的服务器指定,它希望使用特定的播放列表更新其中的特定段。HLS播放列表中的每个片段都有一个唯一的序列号。播放列表第一段的序列号是该媒体序列标记的值。如图示,在这个例子中是1800。下一段的媒体序列号只是加1801。这意味着客户机持有这个播放列表,并且知道下一次更新的时候,下一段的序列号是什么。

例如,我们可以告诉服务器,“嘿,请给我一个播放列表更新,我想要一个包含媒体序列号1803的播放列表更新。”你可以看到它要求在M3U8上直播。我们有一个查询参数,_hls_msn=1803。这就是客户机告诉服务器的方式,我想要这个特定的播放列表更新,它包含这个媒体序列号。收到后,它会立即发送1804年的下一个更新请求。对于一个cdn来说,这些URL看起来完全不同,即使对于一个cdn来说只有一个查询参数是不同的,但它是一个完全不同的缓存实体。所以,这就给了我们高速缓存业务。

1
2
3
4
5
# Blocking Playlist Reload
# Block until Media Sequence Number 1803 is in Playlist
GET https://example.com/live.m3u8?_HLS_msn=1803
# Block until first part of Media Sequence Number 1803 is in Playlist
GET https://example.com/live.m3u8?_HLS_msn=1803&_HLS_part=0&_HLS_push=1

消除Segment请求时间

要消除Segment的RTT,就要采用Server push的方式。这是HLS协议升级的一个很大的改变,需要服务器支持HTTP/2。请求m3u8的时候就直接将Segment/part的内容一起push下来,减少一个RTT。

m3u8采用增量升级机制

对于一个3到5小时的m3u8文件,即使使用gzip也会变得很大。由于m3u8的请求可能高达每秒钟3-4次,为了减少网络传输开销,苹果引入了增量更新机制。工作的方式是,服务器再次向客户机声明它可以提供增量更新。它通过下发一个can-skip-until属性来实现这一点,该属性告诉客户如果需要增量更新,它将跳过所有段,直到距离视频前沿一定的秒数。

1
2
3
#EXT-X-SERVER-CONTROL:CAN-SKIP-UNTIL=36.0 
#EXT-X-SKIP:SKIPPED-SEGMENTS=1700
...

如果客户机看到了这一点,并且知道最后一次更新播放列表的时间,它就可以计算出增量更新,而不会错过任何信息。然后它可以在下次为增量更新播放列表时发出显式请求。这个更新只包含播放列表中的最后几个片段,这些片段最接近视频前沿。它跳过了客户机已经拥有的播放列表的早期部分。下面是一个例子:

1
2
# The new EXT-X-SKIP Tag
GET https://example.com/1M/live.m3u8?_HLS_skip=YES

提高不同码率直播流切换速度

前面提到,在视频即将卡顿之前我们应该采取一些措施,比如切换码率,而且切换机制应该尽可能高效。
提高不同码率直播流切换速度的实现方式是在m3u8的最后带上其它码率直播流的当前进展(Segment序列号和part序列号)和加载地址。

1
#EXT-X-RENDITION-REPORT:URI="/2M/live.m3u8",LAST-MSN=1801,LAST-PART=0

当客户端决定要切换到另一个直播流上的时候,不用发起新的连接,只要直接在原来的连接上请求即可。

1
2
# Requesting and receiving Rendition Reports
GET https://example.com/1M/live.m3u8?_HLS_report=/2M/live.m3u8

这就是苹果提出的低延迟HLS技术草案,苹果也提供了参考实现用于测试和演示。

Safari播放mp4失败的原因分析

发表于 2016-05-31   |   分类于 IT随笔

最近项目中用系统播放器播放MP4文件出了一个问题,用Safari播不了用Chrome可以播放,细查了原因,发现是服务器的问题,原来服务器不支持Content-Range特性。

Safari首先读取文件类型头来判断视频文件是否支持。

因此,对于不支持这个特性的 HTTP 服务器,Safari 就拒绝在线播放,Chrome无此限制。

解决方案:服务器加上这个特性的支持。

测试服务器是否支持:

curl --range 0-99 [MP4 URL]

如果返回的是100个字节的内容,说明服务器支持断点续传;否则返回整个文件。

参考Safari Web Content Guide。

Hexo多Mac同步

发表于 2016-05-21   |   分类于 IT随笔

场景

单位和家里两PC,同时都想更新blog。而由于hexo没有后台,而且全部文件都在本地生成,所以如果公司电脑上发表了A文章后回家又写了篇B文章,在家里上传后你会发现只有B文章而A文章没了(因为家里的PC上没有A文章的md文件),所以多台电脑同时用来写文章的时候,需要解决备份问题。

而常用的备份方案无非两种:

  • 百度云
  • Dropbox等网盘云备份

以百度云为例
优点:免费且操作简单
不足:

  • 备份后同步比较麻烦,每次另一台电脑上都需要手动下载备份文件夹手动覆盖。
  • 开启云端自动备份的时候,写blog的过程中如果保存了文件,会触发百度云的上传,而上传过程中产生的xxx.cfg文件会让hexo解析失败,导致hexo s生成的本地服务器进程停止,不方便边写边预览
    因此此方案作废

利用第三方的git服务备份

优点:部署完成后更新方便,hexo 更新完后只需要再更新全站到git即可
缺点:部署过程相对比较麻烦,对新手不友好(其实是由于对git的理解不深导致的)
国内外现在知名的git服务提供商主要有:
github、gitcafe、bitbucket、oschina、coding等

由于blog文件夹里有些插件配置文件会涉及比较敏感的隐私数据(云服务商的appsecret key之类的),所以建议放私有仓库(当然也可以把配置文件单独拿出来然后其余的全部扔到git的公众仓库,这个看人,本文重点也不在于git服务商的选择或者公有私有库之争)
上面提及的5家服务商里,github、gitcafe的私有库是收费的,而另外三家的私有库目前免费。各位可以自行选择,我个人选择了oschina

配置过程

git多网站多账户部署过程可以参考git多网站ssh部署方案

备份

这个操作建议在blog进度最新的PC上进行的,否则后面解决冲突会比较麻烦
在osc上添加公钥,建立新respo等过程略过不讲。

1、 删除文件夹内原有的.git缓存文件夹并编辑.gitignore文件

有些插件或者主题是git上下过来安装的话,每个文件夹下都会有对应的.git 文件夹,记得先删掉,否则会和blog仓库冲突
(.git默认是隐藏文件夹,需要先开启显示隐藏文件夹。##.git文件夹被删除后整个文件对应的git仓库状态也会被清空##)
.gitignore文件作用是声明不被git记录的文件,blog根目录下的.gitignore是hexo初始化带来的,可以先删除或者直接编辑,对hexo不会有影响。建议.gitignore内添加以下内容:

1
2
3
/.deploy_git
/public
/_config.yml

.deploy_git是hexo默认的.git配置文件夹,不需要同步
public内文件是根据source文件夹内容自动生成,不需要备份,不然每次改动内容太多
即使是私有仓库,除去在线服务商员工可以看到的风险外,还有云服务商被攻击造成泄漏等可能,所以不建议将配置文件传上去

2、初始化仓库

blog根目录下执行以下代码:

1
2
3
git init
git remote add origin <server>
<server>是指在线仓库的地址。origin是本地分支,remote add操作会将本地仓库映射到云端

3、添加本地文件到仓库并同步到git上

1
2
3
git add .  #添加blog目录下所有文件,注意有个`.`(`.gitignore`声明过的文件不包含在内)
git commit -m "first commit" #添加更新说明
git push -u origin master #推送更新到云端服务器

在执行这步之前一定要注意检查下.gitignore文件的内容,看看是否正确的把一些文件夹忽略掉了。如果加错了的话可以用

git rm -r --cached .

撤销添加操作。

到这里的时候,云端备份已经完成

同步到另一台电脑

假设之前将A电脑里的内容备份到git了,现在B电脑准备同步内容。

1
2
3
4
5
git init
git remote add origin <server> #将本地文件和云端仓库映射起来。这步不可以跳过
git fetch --all
git reset --hard origin/master
fetch是将云端所有内容拉取下来。reset则是不做任何合并处理,强制将本地内容指向刚刚同步下来的云端内容(正常pull的话需要考虑不少冲突的问题,比较麻烦。)

更新文章后的同步操作

假设在B电脑上写完了文章,也hexo d -g发布完了,这时候需要将新文章的md文件更新上去。(其实就是提交更新给git,会的可以无视了)
同一个bash界面下:

git add .

这时候可以用git status查看状态,一般会显示刚刚更改过的文件状态。如:

1
2
3
4
5
6
On branch master
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)

modified: db.json
new file: source/_posts/test.md

上面的输出状态即说明’db.json’文件做了更改,source/_posts目录下新增了’test.md’文件。

然后对更改添加说明并推送到远程仓库.

1
2
git commit -m '更新信息'
git push

当显示类似如下提示的时候,即表示备份成功

1
2
To git@git.oschina.net:xxxx/blog-backup.git
+ 2c77e1e...5616bc6 master -> master (forced update)

再到A电脑上的时候,只需要

git pull

即可同步更新

给git配置sock5代理

由于某些众所周知的缘故,所以github时不时的有时候速度会很慢,这种情况下本地代理就派上用场了。
这里以给git的SSH传输方式配置本地SS代理为例说下配置过程:

1、打开~/.ssh/config文件。
2、在Host github *.github.com下添加以下字段:

Proxycommand ssh -S 127.0.0.1:1080 %h %p

3、测试连接
保存退出后重启git bash。
输入ssh -vT git@github.com,当返回Hi username! You’ve successfully authenticated, but GitHub does not provide shell access.的时候即说明配置成功
之后github的所有流量都会走本地的ss代理。


原文

响应者链深入剖析

发表于 2016-05-20   |   分类于 IT随笔

一、事件分类

对于IOS设备用户来说,他们操作设备的方式主要有三种:触摸屏幕、晃动设备、通过遥控设施控制设备。对应的事件类型有以下三种:

1、触屏事件(Touch Event)

2、运动事件(Motion Event)

3、远端控制事件(Remote-Control Event)

今天以触屏事件(Touch Event)为例,来说明在Cocoa Touch框架中,事件的处理流程。首先不得不先介绍响应者链这个概念:

二、响应者链(Responder Chain)

先来说说响应者对象(Responder Object),顾名思义,指的是有响应和处理事件能力的对象。响应者链就是由一系列的响应者对象构成的一个层次结构。

UIResponder是所有响应对象的基类,在UIResponder类中定义了处理上述各种事件的接口。我们熟悉的UIApplication、 UIViewController、UIWindow和所有继承自UIView的UIKit类都直接或间接的继承自UIResponder,所以它们的实例都是可以构成响应者链的响应者对象。图一展示了响应者链的基本构成:

img

从图一中可以看到,响应者链有以下特点:

1、响应者链通常是由响应者对象构成的;

2、一个视图的下一个响应者是它视图控制器(UIViewController)(如果有的话),然后再转给它的父视图(Super View);

3、视图控制器(如果有的话)的下一个响应者为其管理的视图的父视图;

4、单例的窗口(UIWindow)的内容视图将指向窗口本身作为它的下一个响应者

需要指出的是,Cocoa Touch应用不像Cocoa应用,它只有一个UIWindow对象,因此整个响应者链要简单一点;

5、单例的应用(UIApplication)是一个响应者链的终点,它的下一个响应者指向nil,以结束整个循环。

三、事件分发(Event Delivery)

命中测试

第一响应者(First responder)指的是当前接受触摸的响应者对象(通常是一个UIView对象),即表示当前该对象正在与用户交互,它是响应者链的开端。整个响应者链和事件分发的使命都是找出第一响应者。

UIWindow对象以消息的形式将事件发送给第一响应者,使其有机会首先处理事件。如果第一响应者没有进行处理,系统就将事件(通过消息)传递给响应者链中的下一个响应者,看看它是否可以进行处理。

iOS系统检测到手指触摸(Touch)操作时会将其打包成一个UIEvent对象,并放入当前活动Application的事件队列,单例的UIApplication会从事件队列中取出触摸事件并传递给单例的UIWindow来处理,UIWindow对象首先会使用hitTest:withEvent:方法寻找此次Touch操作初始点所在的视图(View),即需要将触摸事件传递给其处理的视图,这个过程称之为hit-test view。

UIWindow实例对象会首先在它的内容视图上调用hitTest:withEvent:,此方法会在其视图层级结构中的每个视图上调用pointInside:withEvent:(该方法用来判断点击事件发生的位置是否处于当前视图范围内,以确定用户是不是点击了当前视图),如果pointInside:withEvent:返回YES,则继续逐级调用,直到找到touch操作发生的位置,这个视图也就是要找的hit-test view。
hitTest:withEvent:方法的处理流程如下:
首先调用当前视图的pointInside:withEvent:方法判断触摸点是否在当前视图内;
若返回NO,则hitTest:withEvent:返回nil;
若返回YES,则向当前视图的所有子视图(subviews)发送hitTest:withEvent:消息,所有子视图的遍历顺序是从最顶层视图一直到到最底层视图,即从subviews数组的末尾向前遍历,直到有子视图返回非空对象或者全部子视图遍历完毕;
若第一次有子视图返回非空对象,则hitTest:withEvent:方法返回此对象,处理结束;
如所有子视图都返回非,则hitTest:withEvent:方法返回自身(self)。

img

假如用户点击了View E,下面介绍hit-test view的流程:

1、A是UIWindow的根视图,因此,UIWindwo对象会首相对A进行hit-test;

2、显然用户点击的范围是在A的范围内,因此,pointInside:withEvent:返回了YES,这时会继续检查A的子视图;

3、这时候会有两个分支,B和C:

点击的范围不再B内,因此B分支的pointInside:withEvent:返回NO,对应的hitTest:withEvent:返回nil;

点击的范围在C内,即C的pointInside:withEvent:返回YES;

4、这时候有D和E两个分支:

点击的范围不再D内,因此D的pointInside:withEvent:返回NO,对应的hitTest:withEvent:返回nil;

点击的范围在E内,即E的pointInside:withEvent:返回YES,由于E没有子视图(也可以理解成对E的子视图进行hit-test时返回了nil),因此,E的hitTest:withEvent:会将E返回,再往回回溯,就是C的hitTest:withEvent:返回E—>>A的hitTest:withEvent:返回E。

至此,本次点击事件的第一响应者就通过响应者链的事件分发逻辑成功的找到了。

不难看出,这个处理流程有点类似二分搜索的思想,这样能以最快的速度,最精确地定位出能响应触摸事件的UIView。

说明

  • 如果最终hit-test没有找到第一响应者,或者第一响应者没有处理该事件,则该事件会沿着响应者链向上回溯,如果UIWindow实例和UIApplication实例都不能处理该事件,则该事件会被丢弃;

  • hitTest:withEvent:方法将会忽略隐藏(hidden=YES)的视图,禁止用户操作(userInteractionEnabled=YES)的视图,以及alpha级别小于0.01(alpha<0.01)的视图。如果一个子视图的区域超过父视图的bound区域(父视图的clipsToBounds 属性为NO,这样超过父视图bound区域的子视图内容也会显示),那么正常情况下对子视图在父视图之外区域的触摸操作不会被识别,因为父视图的pointInside:withEvent:方法会返回NO,这样就不会继续向下遍历子视图了。当然,也可以重写pointInside:withEvent:方法来处理这种情况。

  • 我们可以重写hitTest:withEvent:来达到某些特定的目的,当然实际应用中很少用到这些。


参考Event Handling Guide for iOS。

第一个Scrapy爬虫(二)

发表于 2016-05-18   |   分类于 爬虫系列

新建工程

scrapy startproject tutorial

如果没有报错,则创建成功

在Mac下如果报这个错误:

1
2
3
4
5
6
7
8
9
10
11
12
  Traceback (most recent call last):
File "/usr/local/bin/scrapy", line 7, in <module>
from scrapy.cmdline import execute
File "/Library/Python/2.7/site-packages/scrapy/__init__.py", line 34, in <module>
from scrapy.spiders import Spider
File "/Library/Python/2.7/site-packages/scrapy/spiders/__init__.py", line 10, in <module>
from scrapy.http import Request
File "/Library/Python/2.7/site-packages/scrapy/http/__init__.py", line 12, in <module>
from scrapy.http.request.rpc import XmlRpcRequest
File "/Library/Python/2.7/site-packages/scrapy/http/request/rpc.py", line 7, in <module>
from six.moves import xmlrpc_client as xmlrpclib
ImportError: cannot import name xmlrpc_client

尝试

export PYTHONPATH=/Library/Python/2.7/site-packages:$PYTHONPATH

或

echo "export PYTHONPATH=/Library/Python/2.7/site-packages:$PYTHONPATH" >> ~/.bashrc

然后重新创建工程

该命令将会创建包含下列内容的 tutorial 目录:

1
2
3
4
5
6
7
8
9
10
tutorial/
scrapy.cfg
tutorial/
__init__.py
items.py
pipelines.py
settings.py
spiders/
__init__.py
...

这些文件分别是:

  • scrapy.cfg: 项目的配置文件
  • tutorial/: 该项目的python模块。之后您将在此加入代码。
  • tutorial/items.py: 项目中的item文件.
  • tutorial/pipelines.py: 项目中的pipelines文件.
  • tutorial/settings.py: 项目的设置文件.
  • tutorial/spiders/: 放置spider代码的目录.

创建爬虫

在tutorial/spiders下创建dmoz_spider.py,代码:

1
2
3
4
5
6
7
8
9
10
11
from scrapy.spider import Spider
class DmozSpider(Spider):
name = "dmoz"
allowed_domains = ["dmoz.org"]
start_urls = [
"http://www.dmoz.org/Computers/Programming/Languages/Python/Books/",
"http://www.dmoz.org/Computers/Programming/Languages/Python/Resources/"
]
def parse(self, response):
filename = response.url.split("/")[-2]
open(filename, 'wb').write(response.body)

上面的class中,参数说明如下:

  • name是Scrapy 识别的爬虫名字,一定要唯一。
  • allowed_domains 是域名白名单
  • start_urls 即种子url (如果没有定义其他Rule的话,也就是只抓取这几页)
  • parse()是spider的一个方法。 被调用时,每个初始URL完成下载后生成的 Response 对象将会作为唯一的参数传递给该函数。 该方法负责解析返回的数据(response data),提取数据(生成item)以及生成需要进一步处理的URL的 Request 对象。

爬取数据

切换到tutorial目录,执行:

scrapy crawl dmoz

然后,发现目录下多了 Books 和 Resources 2个文件。

在执行上面的shell命令时,scrapy会创建一个scrapy.http.Request对象,将start_urls传递给它,抓取完毕后,回调parse函数。

刚才发生了什么?

Scrapy为Spider的 start_urls 属性中的每个URL创建了 scrapy.Request 对象,并将 parse 方法作为回调函数(callback)赋值给了Request。

Request对象经过调度,执行生成 scrapy.http.Response 对象并送回给spider parse() 方法。


参考Scrapy入门教程。

12…4
翻盖的乌龟

翻盖的乌龟

沙滩一躺三年半,大浪来时我翻身。

19 日志
3 分类
© 2019 翻盖的乌龟
由 Hexo 强力驱动
主题 - NexT.Mist