月度归档:2013年04月

Cross-Origin Resource Sharing

受浏览器的同源策略限制,JavaSript只能请求本域内的资源。跨域资源共享(Cross-Origin Resource Sharing, CORS)是为解决Ajax技术难实现跨域问题而提出的一个规范,这个规范试着从根本上解决安全的跨域资源共享问题。在此之前,解决此类问题的途径往往是服务器代理、JSONP等,治标不治本。目前基本所有浏览器都已经支持该规范。

一个域是由schema、host、port三者共同组成,与路径无关。所谓跨域,是指在http://example-foo.com/域上通过XMLHttpRequest对象调用http://example-bar.com/域上的资源。CORS约定服务器端和浏览器在HTTP协议之上,通过一些额外HTTP头部信息,进行跨域资源共享的协商。服务器端和浏览器都必需遵循规范中的要求。

CORS把HTTP请求分成两类,不同类别按不同的策略进行跨域资源共享协商。

1. 简单跨域请求。
当HTTP请求出现以下两种情况时,浏览器认为是简单跨域请求:

1). 请求方法是GET、HEAD或者POST,并且当请求方法是POST时,Content-Type必须是application/x-www-form-urlencoded, multipart/form-data或着text/plain中的一个值。
2). 请求中没有自定义HTTP头部。

对于简单跨域请求,浏览器要做的就是在HTTP请求中添加Origin Header,将JavaScript脚本所在域填充进去,向其他域的服务器请求资源。服务器端收到一个简单跨域请求后,根据资源权限配置,在响应头中添加Access-Control-Allow-Origin Header。浏览器收到响应后,查看Access-Control-Allow-Origin Header,如果当前域已经得到授权,则将结果返回给JavaScript。否则浏览器忽略此次响应。

2. 带预检(Preflighted)的跨域请求。
当HTTP请求出现以下两种情况时,浏览器认为是带预检(Preflighted)的跨域请求:

1). 除GET、HEAD和POST(only with application/x-www-form-urlencoded, multipart/form-data, text/plain Content-Type)以外的其他HTTP方法。
2). 请求中出现自定义HTTP头部。

带预检(Preflighted)的跨域请求需要浏览器在发送真实HTTP请求之前先发送一个OPTIONS的预检请求,检测服务器端是否支持真实请求进行跨域资源访问,真实请求的信息在OPTIONS请求中通过Access-Control-Request-Method Header和Access-Control-Request-Headers Header描述,此外与简单跨域请求一样,浏览器也会添加Origin Header。服务器端接到预检请求后,根据资源权限配置,在响应头中放入Access-Control-Allow-Origin Header、Access-Control-Allow-Methods和Access-Control-Allow-Headers Header,分别表示允许跨域资源请求的域、请求方法和请求头。此外,服务器端还可以加入Access-Control-Max-Age Header,允许浏览器在指定时间内,无需再发送预检请求进行协商,直接用本次协商结果即可。浏览器根据OPTIONS请求返回的结果来决定是否继续发送真实的请求进行跨域资源访问。这个过程对真实请求的调用者来说是透明的。

XMLHttpRequest支持通过withCredentials属性实现在跨域请求携带身份信息(Credential,例如Cookie或者HTTP认证信息)。浏览器将携带Cookie Header的请求发送到服务器端后,如果服务器没有响应Access-Control-Allow-Credentials Header,那么浏览器会忽略掉这次响应。

这里讨论的HTTP请求是指由Ajax XMLHttpRequest对象发起的,所有的CORS HTTP请求头都可由浏览器填充,无需在XMLHttpRequest对象中设置。以下是CORS协议规定的HTTP头,用来进行浏览器发起跨域资源请求时进行协商:
1. Origin。HTTP请求头,任何涉及CORS的请求都必需携带。
2. Access-Control-Request-Method。HTTP请求头,在带预检(Preflighted)的跨域请求中用来表示真实请求的方法。
3. Access-Control-Request-Headers。HTTP请求头,在带预检(Preflighted)的跨域请求中用来表示真实请求的自定义Header列表。
4. Access-Control-Allow-Origin。HTTP响应头,指定服务器端允许进行跨域资源访问的来源域。可以用通配符*表示允许任何域的JavaScript访问资源,但是在响应一个携带身份信息(Credential)的HTTP请求时,Access-Control-Allow-Origin必需指定具体的域,不能用通配符。
5. Access-Control-Allow-Methods。HTTP响应头,指定服务器允许进行跨域资源访问的请求方法列表,一般用在响应预检请求上。
6. Access-Control-Allow-Headers。HTTP响应头,指定服务器允许进行跨域资源访问的请求头列表,一般用在响应预检请求上。
7. Access-Control-Max-Age。HTTP响应头,用在响应预检请求上,表示本次预检响应的有效时间。在此时间内,浏览器都可以根据此次协商结果决定是否有必要直接发送真实请求,而无需再次发送预检请求。
8. Access-Control-Allow-Credentials。HTTP响应头,凡是浏览器请求中携带了身份信息,而响应头中没有返回Access-Control-Allow-Credentials: true的,浏览器都会忽略此次响应。

Reference:
[1] HTTP access control (CORS). https://developer.mozilla.org/en-US/docs/HTTP/Access_control_CORS.
[2] Server-Side Access Control. https://developer.mozilla.org/en-US/docs/Server-Side_Access_Control.
[3] CORS In Action. http://arunranga.com/examples/access-control/.

--EOF--

『牧羊少年奇幻之旅』

这是一个关于追寻梦想的故事。

少年圣地亚哥梦想着了解世界,因而不顾家人反对,放弃成为神甫,选择了可以四处游荡的牧羊人。他认为不会说话的羊群能告诉他这个世界所有的秘密,牧羊的时光一直持续到他连续做了几个同样的梦,梦中有人告诉他金字塔旁埋着宝藏,醒来之后圣地亚哥觉得那是某种“指示”,机缘巧合之下,他遇见老人撒冷之王,老人告诉圣地亚哥,天命是一个人一直期望去做的事,他已经到了人生中一个能完成天命的阶段,这个阶段里,一切都很明朗,没有做不到的事情,人们梦想着完成他们一生中喜欢做的一切事情,但是,渐渐地会有一股神秘力量开始阻碍人们完成自己的天命,让人觉得上帝不公平,生活不公平,那神秘力量表面有害无益,实际上它却在教人如何完成天命,培养精神和毅力。

保罗·柯艾略的寓意再明显不过了:不论是谁,不论做什么,当渴望得到某种东西的时候,最终一定能够得到,因为这愿望来自灵魂深处。任何完成愿望过程遇到的挫折,都是对人的一种磨练,不应为这种磨练感到痛苦,没有一颗心会在追求梦想的时候感到痛苦。当然,为了鼓励人们追寻自己的梦想,会有神秘力量通过“良好的开端”、“新手的运气”等方式,引领人们走上正确的道路。

圣地亚哥是幸运的,因为他碰到了很多玄之又玄的于他有缘的人,吉普赛妇人、撒冷之王、水晶店老板、英国人、炼金术士、预言家、沙漠女子法蒂玛……这些人给过他帮助,带给他希望。然而圣地亚哥决定寻找梦想,踏出follow heart的第一步才是一系列幸运的开端。这是最重要的。这一步很多人迈不出去,比如水晶店老板梦想去圣城麦加朝圣,当初因为太穷决定先搁置朝圣计划。慢慢地,平庸的岁月消磨了意志,当初的梦想变成了活下去的希望,再后来,他反倒开始害怕实现梦想,实现梦想意味着失去活下去的动力,他宁愿永远不去麦加,依靠对麦加的憧憬而活。再比如金字塔旁边的难民,当时神秘力量给过他们指示,遥远的西班牙郊外田野教堂边无花果树下埋着属于他们的宝藏,然而仅一念只差,他们搁浅下的天命最终成为了别人的天命。

原来梦想会一直在生活的地方等着你。

--EOF--

虾米收藏歌曲下载器

使用虾米的时间不是很长,但多少也积累了一些收藏歌曲,很想把它们都下下来,趁这两天有空,就开始付诸实践,顺便可以巩固下Python。现在基本功能已经实现,代码已上传到GitHub:https://github.com/fengchj/xiami-favsong-downloader

虾米收藏歌曲下载器的基本功能包括:
1. 下载指定用户收藏的歌曲。
2. 自定义歌曲下载目录。
3. 格式化歌曲ID3信息。

后续会在本地曲库中维护一个元数据文件,加入判断当前歌曲是否已经在本地曲库中的功能,避免每次运行程序都从头开始下载。另外可能会加入下载收藏专辑、收藏歌手的Top100歌曲的功能。

用法:
1. 启动程序: python downmusic.py。
2. 输入用户在虾米网的userid。userid是一串数字,进入用户首页,如http://www.xiami.com/u/3270716, 3270716就是userid。
3. 输入歌曲的下载目录。默认是程序所在目录。

下载完成后,会分别显示下载成功和失败的次数:

这里先说说虾米收藏歌曲下载器的基本思路:
1. 获取用户收藏的歌曲列表。获取用户收藏的歌曲列表目的是拿到歌曲ID列表。虾米无需登陆就能查看任意用户收藏的歌曲列表,查看地址为:

1
http://www.xiami.com/space/lib-song/u/{userid}/page/{pageno}

{userid}表示用户id,{pageno}表示指定页的收藏歌曲列表。这个地址返回的是个HTML文件,其中每首歌都以"<a title="Set Fire to the Rain" href="/song/1769915737">Set Fire to the Rain</a>"的格式呈现,我们目的是拿到歌曲ID,所以只需正则匹配出"/song/1769915737"部分就可以了,其他信息可以从歌曲的元信息XML中拿到。遍历用户的所有收藏歌曲页面,匹配"/song/\d{1,20}",直到匹配结果为空结束,得到用户收藏的歌曲ID列表。

2. 获取歌曲元信息(包含歌曲名、歌手和链接地址等)。在试听一首歌时,虾米先到http://www.xiami.com/song/playlist/id/{song_id}/object_name/default/object_id/0请求一个包含歌曲元信息(歌名、歌手、专辑名和加密链接地址等)的XML文件,这个地址对所有歌曲有效,用的时候只需把{song_id}替换成歌曲的ID即可。

比如,Adele的『Set Fire to the Rain』歌曲页面为:http://www.xiami.com/song/1769915737,歌曲ID为1769915737,元信息XML地址为:

1
http://www.xiami.com/song/playlist/id/1769915737/object_name/default/object_id/0

在浏览器打开它,返回的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
<?xml version="1.0" encoding="utf-8"?>
<playlist version="1" xmlns="http://xspf.org/ns/0/">
<trackList>
<track>
<title><![CDATA[Set Fire to the Rain]]></title>
<song_id>1769915737</song_id>
<album_id>253320</album_id>
<album_name><![CDATA[21]]></album_name>
<object_id>1</object_id>
<object_name>default</object_name>
<insert_type>1</insert_type>
<background>http://img.xiami.com/res/player/bimg/bg-5.bak.jpg</background>
<grade>-1</grade>
<artist><![CDATA[Adele]]></artist>
<location>
4h%2Fxit2522%519383.t3Ffi.%3%5%2E7179%mtA%1an24235F565_25pp%2.meF8F3E%_9724E3
</location>
<ms></ms>
<lyric>http://img.xiami.com/./lyric/upload/37/1769915737_1356124042.lrc</lyric>
<pic>http://img.xiami.com/images/album/img85/23485/2533201341353998_1.jpg</pic>
</track>
</trackList>
<type>default</type>
<type_id>1</type_id>
<clearlist></clearlist>
</playlist>

用正则匹配找出歌曲名(title节点),歌手(artist节点),歌曲链接(location节点)。

3. 获取歌曲的真实链接地址。从XML中location节点得到的地址是加过密的,不过虾米只对歌曲链接进行了简单的加密,网上已有大把分析文章,很容易破解。歌曲链接采用的是栅栏加密算法,将明文以列顺序写为几行,然后按照行的顺序读出来生成密文。
比如明文"attack-at-once"的加密过程为:

1
2
3
4
5
1) 将attack-at-once以列序写成三行。
aa--e
tcao
tktc
2) 按行读取生成密文"aa--etcaotktc"。

知道了栅栏加密的原理后,只需得到加密时的行数,就能从密文中反解出明文。虾米用location的第一个字符表示行数,因此歌曲『Set Fire to the Rain』的location反解过程分为:

1
2
3
4
5
6
7
8
9
10
11
1) 将密文分行:
密文:4h%2Fxit2522%519383.t3Ffi.%3%5%2E7179%mtA%1an24235F565_25pp%2.meF8F3E%_9724E3
 
4
h%2Fxit2522%519383.
t3Ffi.%3%5%2E7179%m
tA%1an24235F565_25p
p%2.meF8F3E%_9724E3
 
2) 按列读取明文:
http%3A%2F%2Ff1.xiami.net%2F23485%2F25332%5E%2F%5E5_1769915737_289243%5E.mp3

将明文进行URLDecoder后,得到:

1
http://f1.xiami.net/23485/25332^/^5_1769915737_289243^.mp3

将链接中的'^'符号替换成'0'后,就得到了最终的歌曲下载地址:

1
http://f1.xiami.net/23485/253320/05_1769915737_2892430.mp3

4. 格式化歌曲的ID3信息。通过程序下载下来的虾米歌曲ID3信息丢失严重,对于喜欢从手机或者电脑客户端通过Last.FM Scrobbler同步音乐播放记录的人来说,这是个严重问题。解决这个问题的方法是从歌曲元信息XML文件中得到信息较为准确的歌手和歌名信息,再以标准方式写入音频文件。

以上是基本思路,接下是Python代码层面上处理的一些问题:
1. 使用urllib2模块模拟浏览器操作,获取HTTP响应内容,它提供的API可以像读取本地文件一样获取网络上的数据。程序中获取用户收藏歌曲列表和获取歌曲元信息XML都用到这个模块的API。

1
2
3
4
5
6
7
headers = {
    'User-Agent' : 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) Chrome/24.0'
}
request = urllib2.Request(address, headers = headers)
response = urllib2.urlopen(request)
text = response.read()
print text

HTTP Header中User-Agent信息必不可少,少了它服务器会返回一个503: Service Temporarily Unavailable,估计虾米用它来挡掉一部分无聊的流量吧。

2. 下载歌曲使用了urllib模块的urlretrieve API。这里也能用urllib2 API来实现,两个模块虽有一些区别,但是可以用任何一个实现下载文件功能。选择urllib因为用它代码少:

1
urllib.urlretrieve(location ,path)

其中,location为歌曲的下载地址,path为本地歌曲的存放路径。

3. 正则匹配。从网上抓数据少不了正则匹配(re模块)。程序中有两个地方用到正则匹配,一是从歌曲元信息XML中获取感兴趣的信息:歌名、歌手、歌曲链接地址。二是从用户收藏歌曲页面获取歌曲ID列表。

1
2
3
4
5
6
7
8
9
10
11
##1. 匹配歌曲元信息
song_title_reg=r"<title><\!

</span>CDATA<span style=" />

!
(.*)</span><span style=" />></title>"
match = re.search(song_title_reg, text) if match: song_title = match.group(1) print song_title   ##2. 获取用户收藏歌曲ID列表 song_reg=r"\"/song/(\d{1,20})\"" result = re.findall(song_reg, text, re.S) print result

re.findall API返回一个列表,因为匹配模式中只有一个分组,所以返回的result就是歌曲ID列表。正则匹配要仔细考虑贪婪和非贪婪、单行匹配和多行匹配问题,re模块都有相应的解决方案。

4. 写入ID3信息。Python有很多处理音频文件ID3信息的第三方模块,这里用mutagen,因为它支持的文件类型多,接口较丰富。因为同步Last.FM只需要歌名和歌手信息准确就可以,所以也没必要用mutagen那些高级的API,用EasyID3类就足够。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from mutagen.easyid3 import EasyID3
 
def write_id3(path, song_name):
    #write id3 info to song.
    try:
        audio = EasyID3(path)
    except Exception, e:
        #while source track doesn't have ID3 structure. malloc new EasyID3().
        audio = EasyID3()
    song_info = song_name.split(' - ')
    artist = song_info[0]
    title = song_info[1]
    audio["artist"] = unicode(artist, 'utf-8')
    audio["title"] = unicode(title, 'utf-8')
    audio.save(path)

mutagen在构造EasyID3对象时,如果发现音频文件中缺少ID3信息,会抛出一个异常,因此需要在异常处理中new一个EasyID3对象,然后填入歌手和歌名信息,存到文件中。

5. 反解栅栏加密算法。歌曲的真实链接地址被栅栏加密算法加密过,前面已经分析过栅栏加密算法的原理,很容易写出反解算法。在已知行数row的情况下,如果字符串长度能被行数整除,则每行字符数相等;如果字符串长度无法被行数整除,假如余数为r,则前r行的字符数比剩下(row-r)行字符数多1,根据这些规律构造二维数组,按列读取即可。核心代码如下:

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
##行数已知,计算密文长度、列数、余数等。
encypt_loc_len = len(encypt_location)
column = encypt_loc_len/row 
remainder = encypt_loc_len% row
 
src_list = []
unencypt_loc = ''
iter = 0
##前余数行的二维数组构造方法。
for i in range(0, remainder):
	sub_list = encypt_location[iter:iter + column + 1]
	iter = iter + column + 1
	src_list.append(sub_list)
 
##剩下的(row-余数)行的二维数组构造方法。
for i in range(0, row - remainder):
	sub_list = encypt_location[iter:iter+column]
	iter = iter + column
	src_list.append(sub_list)
 
#二维数组构造出来后,按列读取。
for i in range(0, column + 1):
	for j in range(0, row):
		if i < len(src_list[j]):
			unencypt_loc += src_list[j][i]
print unencypt_loc

6. 下载失败重试。这里的失败主要是指反解出的歌曲真实链接地址不正确。其实能出现这个错误也算幸福的烦恼,在获取歌曲元信息XML文件时,如果间隔太短,虾米服务器返回的信息是无效的(格式仍合法),这样反解出的歌曲真实链接地址就有问题。本来是单线程,大部分时间程序应该阻塞在下载歌曲的API(urllib.urlretrieve)上,但是如果网速太快(=.=!!),就会造成请求歌曲元信息XML间隔太短。现在采用的方法是失败重试,最多重试三次,每次重试之前有个退避时间,退避时间算法为2失败次数秒。失败重试的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#if errors ocurs during the download processing, re-try 3 times.
RETRY_TIME_LIMIT  = 3
isFail = True
trytime = 0
while isFail and  trytime < RETRY_TIME_LIMIT:
    try:
        (location, song_title, artist) = parse_xml(song_id)
        target = parse_location(location)
        download_music(target, artist + ' - ' + song_title, base_dir)
        count = count + 1
        isFail = False
    except Exception, e:
        print e
        time.sleep(math.pow(2, trytime))
        isFail = True
        trytime = trytime + 1

--EOF--

Log4j配置文件热更新

默认情况下Log4j配置修改后要重启才能生效,而产品上线以后不能轻易停服,有时候遇到线上bug需要调整日志级别,或者磁盘空间满了要调整日志输出路径,这时就要用到apache log4j提供的热更新配置文件接口:PropertyConfigurator.configureAndWatch()DOMConfigurator.configureAndWatch()。前者用来读取和热更新log4j.properties文件,后者用来读取和热更新log4j.xml文件。

用法:
1. 在ServletContextListener或者Tomcat LifecycleListener中调用configureAndWatch API:

PropertyConfigurator.configureAndWatch(log4jConfigFilename, 60000);

configureAndWatch API接受两个参数,log4jConfigFilename表示log4j.properties配置文件所在文件,后一个参数表示下次查看配置文件的间隔时间,默认是60秒(FileWatchdog.DEFAULT_DELAY)。程序中调用configureAndWatch API后,Log4j会单独起一个线程定期查看配置文件,如果发现配置文件有过修改,会启用新的配置。

2. 添加jvm shutdown hook,当jvm退出时可以清理现场。

Runtime.getRuntime().addShutdownHook(new Thread() {
    @Override
    public void run() {
        LogManager.shutdown();
    }
});

configureAndWatch API创建的线程不会在应用取消部署(undeploy)的时候自己结束,因此需要添加jvm shutdown hook来清理。

References:
[1]. Changing Log4j logging levels dynamically. http://blogs.justenougharchitecture.com/?p=185.
[2]. Automatically reload log4j configuration in tomcat. http://janvanbesien.blogspot.jp/2010/02/reload-log4j-configuration-in-tomcat.html.

--EOF--

apache+mod_jk+tomcat集群

目的:用apache (Apache/2.2.16 (Debian) )作为前端负载均衡,将客户端的请求反向代理到后端的tomcat集群。
步骤:
1. 在apache mods-enable目录中添加jk.load配置,加载mod_jk模块,指定workers.properties文件位置。
2. 修改apache的VirtualHost配置。加入:

1
2
3
<IfModule mod_jk.c>
JkMount /* myworker
</IfModule>

apache会将此虚拟主机接收到的请求全部反向代理到myworker。这里的myworker是一个虚拟出来的用来处理具体请求的实例名称,可以是一个tomcat实例,也可以是一个tomcat集群。本例中代表tomcat集群,详细信息在workers.properties文件中定义。

3. 修改各个tomcat实例的conf/server.xml文件,指定AJP端口号和jvmRoute,保证不冲突。apache推荐使用AJP协议,因为AJP直接走tcp协议和socket通道,效率高。jvmRoute是server.xml配置文件Engine节点的一个属性值,主要用来session绑定。本例中指定tomcat实例一的AJP端口8010,jvmRoute名称woker1,tomcat实例二的AJP端口8011,jvmRoute名称worker2。

4. 配置wokers.properties。wokers.properties用来定义集群中的tomcat实例,以及负载均衡策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
worker.list=mywoker
 
worker.worker1.port=8010
worker.worker1.host=localhsot
worker.worker1.type=ajp13 
worker.worker1.lbfactor=2
worker.worker1.connection_pool_size=256
worker.worker1.connection_pool_timeout=600
worker.worker1.socket_keepalive=1
worker.worker1.socket_timeout=300
 
worker.worker2.port=8011
worker.worker2.host=localhost
worker.worker2.type=ajp13
worker.worker2.lbfactor=8
worker.worker2.connection_pool_size=256
worker.worker2.connection_pool_timeout=600
worker.worker2.socket_keepalive=1
worker.worker2.socket_timeout=300
 
worker.mywoker.type=lb
worker.mywoker.sticky_session=false
worker.mywoker.balance_workers=worker1, worker2
worker.mywoker.method=B

wokers.properties文件中的属性值必需以“woker.”开头,worker.list列出可以处理请求的实例列表,mod_jk就是将请求代理到列表中的这些实例,多个实例用逗号分隔。接下的配置格式均为worker.<worker name>.<directive>=<value>的形式定义。<worker name>是内部或外部配置文件中用到的实例名,<directive>代表一个属性,<value>代表属性对应的值。
* type属性的可选值为ajp13, ajp14, jni和lb等,常用ajp13和lb,lb表示集群类型实例,ajp13表示一个通过AJP访问的tomcat实例。
* port和host定义了tomcat实例的socket。
* connection_pool_size属性是AJP协议的tcp连接池大小。
* connection_pool_timeout表示连接池中非活动连接距离被回收的超时时间。
* socket_keepalive属性设为true告诉操作系统定时向tomcat实例发送keepalive心跳包,避免防火墙将连接关闭。
* socket_timeout表示mod_jk与tomcat实例建立socket连接的超时时间。
* lbfactor属性表示本实例在集群中的负载权重,值越大,则mod_jk代理到它的请求数相对越多。
* balance_workers是集群中的tomcat实例列表,用逗号分隔,如果需要绑定session,则这里的实例名称必须与tomcat配置文件中的jvmRoute值相同,同时置sticky_session值为true,这样mod_jk就可以把相同sessionid的请求转发到同一个tomcat实例。
* method属性是负载均衡采用的策略,可选值有R[equest]、S[ession]、 N[ext]、T[raffic]和B[usyness]。本例中设置method=B,该策略会根据tomcat实例的当前负载,分别除以各个实例的lbfactor值,选择值最小的tomcat实例,将请求转发过去。

关于woker.properties文件中的各个参数的更详细介绍参见Tomcat官方文档:『The Apache Tomcat Connector Reference Guide - workers.properties configuration』

--EOF--