맵리 듀스 상식 요약

A, 맵리 듀스 아이디어

대용량 데이터를 다루는 좋은 맵리 듀스, 왜 그러한 힘을 가지고 있습니까? 이 맵리 듀스 디자인 아이디어를 찾을 수 있습니다. 맵리 듀스는 아이디어에있다 " 분할 통치 ."

  (1) 매퍼를 다루는 여러 "간단한 작업"으로 "분", 즉 복잡한 작업을 담당합니다. "단순 태스크는"세 의미를 포함 첫째, 데이터 또는 크게 감소 될 원래 작업의 상대 크기를 계산하는 단계; 셋째, 이러한 작은 두 번째는 태스크 노드를 계산하는데 필요한 데이터를 저장하기 위해 할당 될 것 근처의 컴퓨팅 원칙 병렬 컴퓨팅 작업, 서로 사이에 거의 의존.

  결과지도 단계에 대한 책임 (2) 감속기가 집계됩니다. 필요한 감속기의 수가 특정 문제에 따라 사용자, 파라미터 mapred.reduce.tasks mapred-site.xml의 구성 파일, 디폴트 값의 값을 설정하여.

두, 사양의 맵리 듀스 준비

1, 사용자 프로그램은 세 부분으로 작성 : 매퍼, 감속기, 드라이버 (신청 클라이언트 프로그램이 MR을 실행)

도 2를 참조하면, 입력 데이터 형태 매퍼 쌍에 KV (KV 맞춤형 타입)

도 3을 참조하면, 출력 데이터 형태 매퍼 쌍에 KV (KV 맞춤형 타입)

4 맵 내의 매퍼 로직 () 메소드

각 <K, V> 번이라고 5지도 () 메소드 (maptask 처리)

6 입력 데이터 감속기 매퍼의 형태뿐만 아니라, KV에서, 출력 데이터 타입의 종류에 대응

도 7에서, 로직 감속기 내의 () 메소드를 줄일

8 번이라고 Reducetask 프로세스는 동일한 K의 각 세트 <K, V> 그룹 () 메소드를 줄일

9 매퍼 및 감속기 사용자 정의 클래스는 자신의 아버지를 상속한다

(10)는, 전체 프로세스는 제출 다양한 개체에 필요한 작업 정보에 대한 설명을 제출하는 Drvier 필요

셋째, 프로세스 맵리 듀스 인스턴스는 때 분산 실행

1, MRAppMaster : 프로그램 예약 및 상태의 조정의 전 과정에 대한 책임

2 Yarnchild : 전체 데이터 처리 흐름지도 스테이지 책임

3, Yarnchild는 : 프로세스의 전체 데이터 MapTask에 대한 책임과 ReduceTask 두 단계 처리 단계가 MapTask 및 ReduceTask이 같은 YarnChild에서 실행 대답, YarnChild 있습니다 감소보다 더 많은 흐름

실행 맵리 듀스의 넷째, 공정

프로그램 미스터 시작될 때 1, 현재 작업에 대한 설명 정보를 시작한 후 MRAppMaster, MRAppMaster을 시작하고, 원하는 maptask 예를 계산할 수, 기계 가공 maptask의 클러스터 번호에 대응하는 애플리케이션을 시작

(2) 프로세스는 주어진 데이터 조각에 따른 데이터 처리, maptask 시작 후 (이 범위에있는 오프셋 파일) 범위의 주요 흐름으로서 :

   InputFormat RecordReader 지정된 A, 이용 고객이 읽은 데이터 KV로 입력 형성 취득

  KV 맵 B 및 입력 고객 정의 () 메소드에 전달 논리 연산을 수행하고, 캐시 맵 () 메소드 KV 출력 수집

  C는 종류에 따라 캐시 파티션 K에 대한 KV 디스크 파일에 오버플로 계속

3 MRAppMaster은 작업 완료의 과정을 모니터링하는 모든 maptask 후, 고객 reducetask에 따라 지정된 매개 변수의 적절한 수를 시작합니다 (사실 일부 maptask 처리 공정이 완료되면, 그것은 데이터 maptask를 가져옵니다은 reducetask에서 시작 완료된 것으로) 프로세스 및 데이터의 범위를 다루는 방법을 알려 reducetask (데이터 분할)

4 Reducetask 프로세스가 시작되면, 데이터 통지 MRAppMaster 위치 대만 maptask의 개수가 몇 maptask 출력 파일을 실행하는 컴퓨터에서 획득하는 위치 처리하고, 일종의 로컬 병합 다시, 다음 용 KV 동일한 키를 따르 그룹은, 고객이 정의한 호출 논리 연산을 수행하는 () 메소드를 줄이고, KV를 수집하는 동작의 결과를 출력 한 다음, 고객이 지정한 OutputFormat 외부 저장 장치에 결과 데이터를 출력하는 전화

다섯, 병렬 maptask의 정도

하둡은 MapTask에 병렬기구를 결정하는 단계를 포함한다. 실행 맵리 듀스 프로그램에서 더 나은 MapTask 없습니다. 우리는 기계와 데이터의 양을 구성하는 방법을 고려해야합니다. 데이터의 양이 작은 경우, 작업까지의 처리 시간을 초과 데이터 시작하는 시간 일 수도있다. 동일하지 낫다.

그래서 어떻게 슬라이스?

우리가 파일 300M이 경우, HDFS의 세 가지로 절단됩니다. 0-128M, 128-256M, 256-300M. 그리고 서로 다른 노드에 위치 위로 이동합니다. 맵리 듀스 작업에서는이 3 블록은 세 MapTask 주어집니다.

작업 슬라이스 범위는하지만,이 범위는, 논리적 개념 블록의 물리적 파티션과는 아무 상관이없는 경우 MapTask 실제로 할당. 그러나 실제로는, 읽기 데이터가 기계를 실행하지 MapTask하다면, 네트워크를 통해 성능에 매우 큰 영향을 데이터 전송을 수행해야합니다. 따라서, 정책들은 서브 절단 MapTask 각 데이터 가능한 기계 판독 MapTask되도록 스토리지 블록을 가지고있다.

블록이 매우 작은 경우, 당신은 MapTask에 여러 개의 작은 블록을 넣을 수 있습니다.

그래서 잘라 MapTask 하위 프로세스는 상황에 따라 달라집니다. 디폴트의 ​​구현은 블록 크기에 따라 분할 될 것입니다. MapTask은의 책임 클라이언트 (우리는 주요 방법을 쓰기) 슬라이스. 슬라이스는 MapTask 예에 해당한다.

여섯, 병렬 메커니즘의 정도를 결정 maptask

작업을 제출할 때 위상지도 병렬 작업은 클라이언트에 의해 결정된다.

그리고 클라이언트는 계획 단계지도 병렬 처리의 기본 논리는 다음과 같다 :

논리 데이터를 실행하면, 슬라이스를 처리하는 각각의 병렬 처리 mapTask의 분할 예를 할당 (즉, 슬라이스는 특정 크기에 따라, 데이터는 논리적으로 분할 된 복수의 처리로 함)

슬라이스 메커니즘 :

슬라이스 기본 메커니즘 FileInputFormat

1, 단순 파일 콘텐츠의 길이에 따라 구분

2, 타일 크기, 기본 블록 크기는 동일

도 3에서, 슬라이스는 전체 데이터 세트로 간주되지 않고, 각 데이터 파일에 대한 슬라이스하여 하나의 슬라이스는 이러한 두 개의 파일로서 처리 할 :

  은 File2.txt 200M

  File2.txt 100M

슬라이스는 다음의 정보를 처리하는 방법) (getSplits 후에 형성된다 :

은 File2.txt-split1 0-128M

은 File2.txt-split2의 129M-200M

File2.txt-split1 0-100M

 
FileInputFormat에서 슬라이스 매개 변수의 크기

 FileInputFormat, 계산 로직 슬라이스 크기의 소스를 분석하여 롱 splitSize를 computeSplitSize = (블록 크기, MINSIZE,이 maxSize)는 무엇 번역이 세 값의 중간 값을 계산

슬라이스 동작은 주로 다음 값에 의해 결정 :

블록 크기 : 기본값은 128M입니다 dfs.blocksize에 의해 변경 될 수 있습니다

최소 매개 변수 : 기본값은 mapreduce.input.fileinputformat.split.minsize에 의해 변경 될 수 있으며, 1

MAXSIZE : 기본값은 Long.MaxValue입니다 mapreduce.input.fileinputformat.split.maxsize에 의해 변경 될 수 있습니다

큰 조각의 블록 크기보다 더 톤을 MINSIZE 않을 경우 비율 블록 크기 MAXSIZE 톤 블록 크기보다 작은 경우에 따라서 슬라이스는, 그러나의 블록 크기하지보다 더 큰 것 없이 톤 매개 변수를 허용하지 않는 방법을 여러 개의 작은 파일 "분류"분할

 

 

세븐, reducetask 병렬

Reducetask 또한 동시 효율 전체 작업의 병렬도 영향을 주지만, 다른 부분의 수에 의해 결정 동시 maptask의 수는, 결정 수를 직접 수동으로 설정 ReduceTask : job.setNumReduceTasks (4);

기본값은 1입니다

수동으로 네 ReduceTask를 실행 보여주는, 4로 설정,

그것은 즉, 어떤 실행 reduceTask 작업을 의미하는 0 만 매퍼 단계를 더 감속기 단계를 설정하지

데이터가 편재되는 경우, 데이터는 위상 기울기를 감소 생성 할 수있다

참고 : 번호 reducetask가 설정되지 않을뿐만 아니라 비즈니스 로직의 요구를 고려, 어떤 경우에는, 당신은 하나의 reducetask을 가질 수, 전체 집계 결과를 계산해야

너무 많은 reducetask를 실행하지보십시오. 대부분의 작업의 경우, 클러스터의 수 rduce 제일과 평면 또는 슬롯 클러스터를 줄이기보다 작게 줄일 수 있습니다. 이 작은 클러스터의 경우, 특히 중요하다.

여덟, reducetask 병렬 메커니즘의 정도를 결정

1 job.setNumReduceTasks (번호);
2 job.setReducerClass (MyReducer.class);
3 job.setPartitioonerClass (MyPTN.class);

다음과 같은 상황에 대해 토론

숫자 1과 2는 사용자 정의 감속기로 설정되어있는 경우는 1, reduceTask의 수는 1
자기 공명 프로그램에 관계없이 파티션 설정을 설정하지 않은 사용자의 글을 작성하고 지구 협의회가 작동하지 않습니다

번호가 설정되지 않고, (2)는 감속기를 정의하도록 설정되어있는 경우 (2)는, 수 reduceTask 1은
1보다 큰 수를 모두 정상 수행없이 약간의 관계없이 사용자가 설정 한 개수의 기본 분할 어셈블리의 영향 가.
파티션 사용자 정의 구성 요소를 설정할 때 경우에주의해야합니다
수 당신은 reduceTasks이 ==== 최대 + 1의 파티션 수 있어야합니다 설정
최상의 시나리오 : 파티션 번호가 연속이다.
이어서 reduceTasks의 최대 총 수 = 구획 번호의 구획 번호 = 1 +

수> = 2, 2 인 경우 3, 사용자 정의 감속기 reduceTask 번호의 번호로 설정되어있는 것은
기본 데이터 파티션의 조립 작업을 기본값으로

당신은 수의 수를 설정하지만, 사용자 정의 감속기를 설정하지 않은 경우 (4), 다음 프로그램이 더 감속기 단계 맵리 듀스 의미하지 않습니다
으로 출력 : 실제 감속기 논리를, 그것을 구현 로직의 기본 상위 클래스의 감속기를 호출하는 것입니다
의 reduceTask 숫자는 숫자입니다

미스터 프로그램, 감속기 단계를 원하지 않는 경우, 5. 그래서 당신이 작동 할 수 있습니다 무엇 :
job.setNumberReudceTasks (0)
전체 MR 프로그램은 단계 매퍼. 어떤 감속기 단계 없습니다.
그리고 더 셔플 단계가 없다

(91)의 효과의 분할기

맵리 듀스 계산하는 동안, 때로는 지방의 구분에 따라, 예를 들어, 다른 파일로 최종 출력 데이터를 입력 할 필요가 파일에 같은 지방의 데이터를 필요로하며 섹스 이야기에 따라 같은 성별 데이터를 필요로 파일에. 우리는 알고 감속기 작업에서 최종 출력 데이터가. 하나 개 이상의 파일을 얻고 싶은 경우에 따라서, 이는 감속기 작업 실행 같은 수를 의미한다. 작업의 데이터 매퍼 감속기 작업은 매퍼 작업이 서로 다른 데이터 감속기를 실행하는 다른 작업에 할당 된 데이터를 분할했다. 작업의 부문에 프로세스 매퍼 데이터 파티션이라고합니다. 분할은 파티션 설정이라는 데이터 형식을 구현하기위한 책임이 있습니다.

 

 

텐, combinar 역할

컴 사실로 인해 대역폭 제한, 최적화 기법을 속하고 감소 사이의 데이터 전송의 수를 매핑하는 시도해야합니다. 그것은 감소와 일치하는 동일한 키 값 쌍과 결합 계산, 계산 규칙을 ​​끝낼지도, 그래서 결합기는 특별한 감속기로 볼 수있다.

결합기는 프로그램 (job.setCombinerClass (myCombine.class) 맞춤 결합기 동작을 이용하여 프로그램)을 제공 개발자 결합기 필요한 작업을 수행한다.

감속기 어셈블리 최종 최종 요약 글로벌 요약을 확인하는 데 사용되는 결합기 구성 요소는 그것의 mapTask 집계되어, 요약 부분을 확인하는 데 사용된다.

 일레븐, 셔플 댓글을 맵리 듀스

. (1)의 MapReduce는, 데이터 처리의 단계는 매퍼 스테이지, MapReduce의 프레임 워크는 가장 중요한 처리 방법 감속기에 전달하고,이 프로세스는 셔플이라고

(2) 셔플 : 데이터 셔플 - (코어기구 : 데이터 분할이 정렬 부분 중합 버퍼 당기고 정렬 병합)

도 3은 구체적으로 : 데이터 처리의 결과 ReduceTask 분산 파티션 설정 요소에 의해 확립 된 규칙들에 따라, 출력 MapTask하는 것이며, 유통 과정에서, 데이터는 분할되고, 키를 정렬

도입의 맵리 듀스 프로세스를 셔플 :

더 무작위, 불규칙 더 나은 정기적으로 데이터의 특정 세트로 데이터 세트를 변환하는 셔플, 셔플, 수의 원래 의미를 잘 섞습니다. 셔플 셔플처럼 맵리 듀스는 일정한 규칙을 가진 데이터 세트에 임의의 데이터 세트를 변환하려고하는, 반대 과정이다.

왜 셔플 과정을 맵리 듀스 컴퓨팅 모델을 필요로? 우리 모두는 맵리 듀스 계산 모델은 일반적으로 두 가지 주요 단계를 포함 알고 :지도는 매핑, 유통에 대한 책임을 필터링 데이터를, 법령이 데이터를 병합에 대한 책임이 계산됩니다 줄일 수 있습니다. 감소, 셔플하여 데이터를 획득 할 필요를 줄이기 위해 입력되는,지도의 출력으로부터 데이터를 줄인다.

입력으로부터 출력 매핑은 전체 공정을 크게 셔플 지칭 될 수 줄인다. 지도 및 셔플 끝 끝 끝이지도 유출 과정에서 구성을 통해 감소하고 그림과 같이 정렬 복사, 끝이 공정을 포함 감소 :

유출 절차 :

유출 프로세스는 도시 된 바와 같이, 출력 순서 작성 오버 플로우 등을 병합하는 단계를 포함한다 :

수집

메모리의 환형 구성 데이터 구조에 대한 출력 데이터의 형태로 연속적으로 각 맵 태스크. 메모리에 많은 데이터를 배치하는 메모리 공간을보다 효율적으로 사용하기 위해 데이터를 사용하는 환형 구조.

이 데이터 구조는 이름이 정의를 알 수 있듯이, 실제로 Kvbuffer라는 바이트 배열이지만, 데이터 만 배치뿐만 아니라, 지역의 인덱스 데이터가 Kvbuffer의 영역에서는, Kvmeta의 별칭에서 배치 할 인덱스 데이터의 수를 배치되지 있습니다 IntBuffer 입고에 조끼 (바이트 시퀀스는 플랫폼 자체 엔디안에서 사용된다). 데이터 영역과 데이터 영역 Kvbuffer 인덱스가 두 분할 컷오프 포인트와 겹치지 않는 두 영역에 인접하는 경계 지점을 변경할 수 없지만, 각 유출 후에 갱신된다. 0은 도시 된 바와 같이, 상향 성장 방향에 기억되어있는 데이터가 저장되는 인덱스 데이터의 방향이 아래쪽으로 성장 초기 컷오프 점이다 :

Kvbuffer bufIndex 포인터 저장 후 막힘 머리 위로 성장 초기 값 0 bufIndex 예, 지능이 종료 키 형 성장 bufIndex (4), 완료 후 INT 타입 값, 8 bufIndex 성장을 갖는다.

索引是对在kvbuffer中的索引,是个四元组,包括:value的起始位置、key的起始位置、partition值、value的长度,占用四个Int长度,Kvmeta的存放指针Kvindex每次都是向下跳四个“格子”,然后再向上一个格子一个格子地填充四元组的数据。比如Kvindex初始位置是-4,当第一个写完之后,(Kvindex+0)的位置存放value的起始位置、(Kvindex+1)的位置存放key的起始位置、(Kvindex+2)的位置存放partition的值、(Kvindex+3)的位置存放value的长度,然后Kvindex跳到-8位置,等第二个和索引写完之后,Kvindex跳到-32位置。

Kvbuffer的大小虽然可以通过参数设置,但是总共就那么大,和索引不断地增加,加着加着,Kvbuffer总有不够用的那天,那怎么办?把数据从内存刷到磁盘上再接着往内存写数据,把Kvbuffer中的数据刷到磁盘上的过程就叫Spill,多么明了的叫法,内存中的数据满了就自动地spill到具有更大空间的磁盘。

关于Spill触发的条件,也就是Kvbuffer用到什么程度开始Spill,还是要讲究一下的。如果把Kvbuffer用得死死得,一点缝都不剩的时候再开始Spill,那Map任务就需要等Spill完成腾出空间之后才能继续写数据;如果Kvbuffer只是满到一定程度,比如80%的时候就开始Spill,那在Spill的同时,Map任务还能继续写数据,如果Spill够快,Map可能都不需要为空闲空间而发愁。两利相衡取其大,一般选择后者。

Spill这个重要的过程是由Spill线程承担,Spill线程从Map任务接到“命令”之后就开始正式干活,干的活叫SortAndSpill,原来不仅仅是Spill,在Spill之前还有个颇具争议性的Sort。

Sort:

先把Kvbuffer中的数据按照partition值和key两个关键字升序排序,移动的只是索引数据,排序结果是Kvmeta中数据按照partition为单位聚集在一起,同一partition内的按照key有序。

Spill:

Spill线程为这次Spill过程创建一个磁盘文件:从所有的本地目录中轮训查找能存储这么大空间的目录,找到之后在其中创建一个类似于“spill12.out”的文件。Spill线程根据排过序的Kvmeta挨个partition的把数据吐到这个文件中,一个partition对应的数据吐完之后顺序地吐下个partition,直到把所有的partition遍历完。一个partition在文件中对应的数据也叫段(segment)。

所有的partition对应的数据都放在这个文件里,虽然是顺序存放的,但是怎么直接知道某个partition在这个文件中存放的起始位置呢?强大的索引又出场了。有一个三元组记录某个partition对应的数据在这个文件中的索引:起始位置、原始数据长度、压缩之后的数据长度,一个partition对应一个三元组。然后把这些索引信息存放在内存中,如果内存中放不下了,后续的索引信息就需要写到磁盘文件中了:从所有的本地目录中轮训查找能存储这么大空间的目录,找到之后在其中创建一个类似于“spill12.out.index”的文件,文件中不光存储了索引数据,还存储了crc32的校验数据。(spill12.out.index不一定在磁盘上创建,如果内存(默认1M空间)中能放得下就放在内存中,即使在磁盘上创建了,和spill12.out文件也不一定在同一个目录下。)

每一次Spill过程就会最少生成一个out文件,有时还会生成index文件,Spill的次数也烙印在文件名中。索引文件和数据文件的对应关系如下图所示:

在Spill线程如火如荼的进行SortAndSpill工作的同时,Map任务不会因此而停歇,而是一无既往地进行着数据输出。Map还是把数据写到kvbuffer中,那问题就来了:只顾着闷头按照bufindex指针向上增长,kvmeta只顾着按照Kvindex向下增长,是保持指针起始位置不变继续跑呢,还是另谋它路?如果保持指针起始位置不变,很快bufindex和Kvindex就碰头了,碰头之后再重新开始或者移动内存都比较麻烦,不可取。Map取kvbuffer中剩余空间的中间位置,用这个位置设置为新的分界点,bufindex指针移动到这个分界点,Kvindex移动到这个分界点的-16位置,然后两者就可以和谐地按照自己既定的轨迹放置数据了,当Spill完成,空间腾出之后,不需要做任何改动继续前进。分界点的转换如下图所示:

Map任务总要把输出的数据写到磁盘上,即使输出数据量很小在内存中全部能装得下,在最后也会把数据刷到磁盘上。

Merge

Map任务如果输出数据量很大,可能会进行好几次Spill,out文件和Index文件会产生很多,分布在不同的磁盘上。最后把这些文件进行合并的merge过程闪亮登场。

Merge过程怎么知道产生的Spill文件都在哪了呢?从所有的本地目录上扫描得到产生的Spill文件,然后把路径存储在一个数组里。Merge过程又怎么知道Spill的索引信息呢?没错,也是从所有的本地目录上扫描得到Index文件,然后把索引信息存储在一个列表里。到这里,又遇到了一个值得纳闷的地方。在之前Spill过程中的时候为什么不直接把这些信息存储在内存中呢,何必又多了这步扫描的操作?特别是Spill的索引数据,之前当内存超限之后就把数据写到磁盘,现在又要从磁盘把这些数据读出来,还是需要装到更多的内存中。之所以多此一举,是因为这时kvbuffer这个内存大户已经不再使用可以回收,有内存空间来装这些数据了。(对于内存空间较大的土豪来说,用内存来省却这两个io步骤还是值得考虑的。)

然后为merge过程创建一个叫file.out的文件和一个叫file.out.Index的文件用来存储最终的输出和索引。

一个partition一个partition的进行合并输出。对于某个partition来说,从索引列表中查询这个partition对应的所有索引信息,每个对应一个段插入到段列表中。也就是这个partition对应一个段列表,记录所有的Spill文件中对应的这个partition那段数据的文件名、起始位置、长度等等。

然后对这个partition对应的所有的segment进行合并,目标是合并成一个segment。当这个partition对应很多个segment时,会分批地进行合并:先从segment列表中把第一批取出来,以key为关键字放置成最小堆,然后从最小堆中每次取出最小的输出到一个临时文件中,这样就把这一批段合并成一个临时的段,把它加回到segment列表中;再从segment列表中把第二批取出来合并输出到一个临时segment,把其加入到列表中;这样往复执行,直到剩下的段是一批,输出到最终的文件中。

最终的索引数据仍然输出到Index文件中。

Map端的Shuffle过程到此结束。

Copy:

Reduce任务通过HTTP向各个Map任务拖取它所需要的数据。每个节点都会启动一个常驻的HTTP server,其中一项服务就是响应Reduce拖取Map数据。当有MapOutput的HTTP请求过来的时候,HTTP server就读取相应的Map输出文件中对应这个Reduce部分的数据通过网络流输出给Reduce。

Reduce任务拖取某个Map对应的数据,如果在内存中能放得下这次数据的话就直接把数据写到内存中。Reduce要向每个Map去拖取数据,在内存中每个Map对应一块数据,当内存中存储的Map数据占用空间达到一定程度的时候,开始启动内存中merge,把内存中的数据merge输出到磁盘上一个文件中。

如果在内存中不能放得下这个Map的数据的话,直接把Map数据写到磁盘上,在本地目录创建一个文件,从HTTP流中读取数据然后写到磁盘,使用的缓存区大小是64K。拖一个Map数据过来就会创建一个文件,当文件数量达到一定阈值时,开始启动磁盘文件merge,把这些文件合并输出到一个文件。

有些Map的数据较小是可以放在内存中的,有些Map的数据较大需要放在磁盘上,这样最后Reduce任务拖过来的数据有些放在内存中了有些放在磁盘上,最后会对这些来一个全局合并。

Merge Sort:

这里使用的Merge和Map端使用的Merge过程一样。Map的输出数据已经是有序的,Merge进行一次合并排序,所谓Reduce端的sort过程就是这个合并的过程。一般Reduce是一边copy一边sort,即copy和sort两个阶段是重叠而不是完全分开的。

Reduce端的Shuffle过程至此结束。

추천

출처www.cnblogs.com/zhangfuxiao/p/11374546.html