iOS ping - SimplePing 源碼解讀

ping 的功能

ping 程序?qū)τ陂_發(fā)人員來說應(yīng)該是不會陌生的, ping 通常用來探測主機(jī)到主機(jī)之間是否可以通信抹镊。如果可以 ping 通刀闷,意味著可以和該主機(jī)建立網(wǎng)絡(luò)連接辈灼,就像這樣的帽蝶。

?  ~ ping www.qq.com
PING www.qq.com (182.254.34.74): 56 data bytes
64 bytes from 182.254.34.74: icmp_seq=0 ttl=53 time=22.996 ms
64 bytes from 182.254.34.74: icmp_seq=1 ttl=53 time=36.688 ms
64 bytes from 182.254.34.74: icmp_seq=2 ttl=53 time=25.390 ms
64 bytes from 182.254.34.74: icmp_seq=3 ttl=53 time=25.516 ms

如果不能 ping 通称近,那就意味著無法和該主機(jī)建立網(wǎng)絡(luò)連接,就像下面這樣的傅蹂。

?  ~ ping www.google.com
PING www.google.com (66.220.147.47): 56 data bytes
Request timeout for icmp_seq 0
Request timeout for icmp_seq 1
Request timeout for icmp_seq 2
Request timeout for icmp_seq 3
Request timeout for icmp_seq 4

Apple 的 SimplePing 封裝了 ping 的功能纷闺,它利用 resolve host,create socket(send & recv data), 解析 ICMP 包驗(yàn)證 checksum 等實(shí)現(xiàn)了 ping 功能份蝴。并且支持 iPv4 和 iPv6犁功。

ICMP 協(xié)議

ping 功能使用是 ICMP 協(xié)議(Internet Control Message Protocol),ICMP 協(xié)議定義了一組錯(cuò)誤信息婚夫,當(dāng)路由器或者主機(jī)無法成功處理一個(gè)IP 封包的時(shí)候浸卦,能夠?qū)㈠e(cuò)誤信息回送給來源主機(jī),ICMP 常見的錯(cuò)誤如下案糙。

  1. 傳輸線路或者節(jié)點(diǎn)故障導(dǎo)致無法到達(dá)目的地主機(jī)
  2. 路由器封包重組失敗
  3. 封包存活時(shí)間(Time To Live,TTL)變成 0 (防止封包在網(wǎng)絡(luò)中永無止境得繞圈)
  4. IP 首部的錯(cuò)誤檢查碼發(fā)現(xiàn)錯(cuò)誤

iOS SimplePing 的使用

    // 1. 利用 HostName 創(chuàng)建 SimplePing
    SimplePing *pinger = [[SimplePing alloc] initWithHostName:@"www.apple.com"];
    self.pinger = pinger;
    // 2. 指定 IP 地址類型
    if (isIpv4 && !isIpv6) {
        pinger.addressStyle = SimplePingAddressStyleICMPv4;
    }else if (isIpv6 && !isIpv4) {
        pinger.addressStyle = SimplePingAddressStyleICMPv6;
    }
    // 3. 設(shè)置 delegate,用于接收回調(diào)信息
    pinger.delegate = self;
    // 4. 開始 ping
    [pinger start];

SimplePing 的使用還是非常簡單的限嫌,

  1. 利用 HostName 創(chuàng)建 SimplePing
  2. 指定 IP 地址類型
  3. 設(shè)置 delegate,用于接收回調(diào)信息
  4. 開始 ping

delegate 的回調(diào)方法體現(xiàn)了 ping 的過程。

// 解析 HostName 拿到 ip 地址之后时捌,發(fā)送封包
- (void)simplePing:(SimplePing *)pinger didStartWithAddress:(NSData *)address
{
    NSLog(@"pinging %@", displayAddressForAddress(address));
    [self sendPing];
}
// ping 功能啟動(dòng)失敗
- (void)simplePing:(SimplePing *)pinger didFailWithError:(NSError *)error
{
    NSLog(@"failed: %@", shortErrorFromError(error));
    [self stop];
}
// ping 成功發(fā)送封包
- (void)simplePing:(SimplePing *)pinger didSendPacket:(NSData *)packet sequenceNumber:(uint16_t)sequenceNumber
{
    NSLog(@"#%u sent", sequenceNumber);
}
// ping 發(fā)送封包失敗
- (void)simplePing:(SimplePing *)pinger didFailToSendPacket:(NSData *)packet sequenceNumber:(uint16_t)sequenceNumber error:(NSError *)error
{
    NSLog(@"#%u send failed: %@", sequenceNumber,shortErrorFromError(error));
}
// ping 發(fā)送封包之后收到響應(yīng)
- (void)simplePing:(SimplePing *)pinger didReceivePingResponsePacket:(NSData *)packet sequenceNumber:(uint16_t)sequenceNumber
{
    NSLog(@"#%u received, size=%zu", sequenceNumber, packet.length);
}
// ping 接收響應(yīng)封包發(fā)生異常
- (void)simplePing:(SimplePing *)pinger didReceiveUnexpectedPacket:(NSData *)packet
{
    NSLog(@"unexpected packet, size=%zu", packet.length);
}

SimplePing 的流程

流程圖

上圖是 SimplePing 執(zhí)行一次 ping IPv4 地址的流程圖怒医,
ping 的實(shí)現(xiàn)并不負(fù)責(zé),一共有以下幾個(gè)步驟

  1. 解析傳入的 HostName匣椰,獲取第一個(gè)可用 IP 地址
  2. 創(chuàng)建傳輸/接收數(shù)據(jù)的 socket
  3. 發(fā)送數(shù)據(jù)裆熙,封裝一個(gè) ICMP 包
  4. 解析目標(biāo) IP 傳回的 ICMP 包

HostName 的解析

關(guān)于 HostName 的解析端礼,SimplePing 采用 CFHost 這個(gè)異步 API 方案禽笑,通過CFHost解析主機(jī)名主要有以下幾個(gè)步驟:

  1. 通過調(diào)用 CFHostCreateWithName 創(chuàng)建一個(gè) CFHostRef 對象。
  2. 調(diào)用 CFHostSetClient 并且提供一個(gè)上下文對象和回調(diào)函數(shù)蛤奥,這個(gè)回調(diào)函數(shù)在解析結(jié)束的時(shí)候會被調(diào)用佳镜。
  3. 調(diào)用 CFHostScheduleWithRunLoop 用于在 RunLoop 中執(zhí)行具體的解析操作。
  4. 調(diào)用 CFHostStartInfoResolution 來告訴解析器開始解析凡桥,把它的第二個(gè)參數(shù)設(shè)置為 kCFHostAddresses 表明你想要返回一個(gè) IP 地址蟀伸。
  5. 等待解析器調(diào)用你的回調(diào)函數(shù),通過你的回調(diào)函數(shù),調(diào)用 CFHostGetAddressing 函數(shù)來獲取解析結(jié)果啊掏。這個(gè)函數(shù)返回 CFDataRef 對象的一個(gè)數(shù)組蠢络,其中的每一個(gè)都包含一個(gè) POSIX 的 sockaddr 結(jié)構(gòu)體。

下面的這段代碼執(zhí)行的是 1 - 4 過程

- (void)start {
    Boolean             success;
    CFHostClientContext context = {0, (__bridge void *)(self), NULL, NULL, NULL};
    CFStreamError       streamError;
    
    assert(self.host == NULL);
    assert(self.hostAddress == nil);

    self.host = (CFHostRef) CFAutorelease( CFHostCreateWithName(NULL, (__bridge CFStringRef) self.hostName) );
    assert(self.host != NULL);
    
    CFHostSetClient(self.host, HostResolveCallback, &context);
    
    CFHostScheduleWithRunLoop(self.host, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
    
    success = CFHostStartInfoResolution(self.host, kCFHostAddresses, &streamError);
    if ( ! success ) {
        [self didFailWithHostStreamError:streamError];
    }
}

在系統(tǒng)解析 HostName 成功之后會調(diào)用 HostResolveCallback 這個(gè)回調(diào)迟蜜,這個(gè)回調(diào)的作用相當(dāng)于重定向刹孔,將內(nèi)容從 c 轉(zhuǎn)成適當(dāng)?shù)?Objective-C 內(nèi)容。

static void HostResolveCallback(CFHostRef theHost, CFHostInfoType typeInfo, const CFStreamError *error, void *info) {
    // This C routine is called by CFHost when the host resolution is complete. 
    // It just redirects the call to the appropriate Objective-C method.
    SimplePing *    obj;
    obj = (__bridge SimplePing *) info;
    assert([obj isKindOfClass:[SimplePing class]]);
    // 省略代碼 ......
    if ( (error != NULL) && (error->domain != 0) ) {
        [obj didFailWithHostStreamError:*error];
    } else {
       // 在這個(gè)方法獲取 HostName 對應(yīng)的地址
        [obj hostResolutionDone];
    }
}

調(diào)用 CFHostGetAddressing 函數(shù)來獲取解析結(jié)果娜睛,這個(gè)函數(shù)返回一個(gè)數(shù)組髓霞,從這個(gè)數(shù)組中取得 HostName 對應(yīng)的 IP。從服務(wù)端的角度來說畦戒,為了實(shí)現(xiàn)負(fù)載均衡方库,一個(gè)域名是可以對應(yīng)多個(gè) IP 的,但是從客戶端的角度來說障斋,一個(gè)域名就是對應(yīng)一個(gè) IP纵潦。

- (void)hostResolutionDone {
    Boolean     resolved;
    NSArray *   addresses;
    
    // Find the first appropriate address.
    
    addresses = (__bridge NSArray *) CFHostGetAddressing(self.host, &resolved);
    if ( resolved && (addresses != nil) ) {
        resolved = false;
        for (NSData * address in addresses) {
            const struct sockaddr * addrPtr;
            
            addrPtr = (const struct sockaddr *) address.bytes;
            if ( address.length >= sizeof(struct sockaddr) ) {
                switch (addrPtr->sa_family) {
                    case AF_INET: {
                        if (self.addressStyle != SimplePingAddressStyleICMPv6) {
                            self.hostAddress = address;
                            resolved = true;
                        }
                    } break;
                    case AF_INET6: {
                        if (self.addressStyle != SimplePingAddressStyleICMPv4) {
                            self.hostAddress = address;
                            resolved = true;
                        }
                    } break;
                }
            }
            if (resolved) {
                break;
            }
        }
    }

    // We're done resolving, so shut that down.
    
    [self stopHostResolution];
    
    // If all is OK, start the send and receive infrastructure, otherwise stop.
    
    if (resolved) {
        [self startWithHostAddress];
    } else {
        [self didFailWithError:[NSError errorWithDomain:(NSString *)kCFErrorDomainCFNetwork code:kCFHostErrorHostNotFound userInfo:nil]];
    }
}

Socket 操作

HostName 解析成功拿到對應(yīng)的 IP 之后,SimplePing 調(diào)用startWithHostAddress 創(chuàng)建 socket 配喳。

  1. 使用 CFSocketCreateWithNative 創(chuàng)建一個(gè) CFSocket
  2. 使用 CFSocketCreateRunLoopSource 為 CFSocket 創(chuàng)建一個(gè) CFRunLoopSourceRef酪穿,
  3. 使用 CFRunLoopAddSource 將 CFRunLoopSourceRef 添加到 RunLoop 的 kCFRunLoopDefaultMode 模式中。
- (void)startWithHostAddress {
 // 省略代碼 ......
        CFSocketContext         context = {0, (__bridge void *)(self), NULL, NULL, NULL};
        CFRunLoopSourceRef      rls;
        id<SimplePingDelegate>  strongDelegate;
        
        // Wrap it in a CFSocket and schedule it on the runloop.
        
        self.socket = (CFSocketRef) CFAutorelease( CFSocketCreateWithNative(NULL, fd, kCFSocketReadCallBack, SocketReadCallback, &context) );
        assert(self.socket != NULL);
        
        // The socket will now take care of cleaning up our file descriptor.
        
        assert( CFSocketGetSocketFlags(self.socket) & kCFSocketCloseOnInvalidate );
        fd = -1;
        
        rls = CFSocketCreateRunLoopSource(NULL, self.socket, 0);
        assert(rls != NULL);
        
        CFRunLoopAddSource(CFRunLoopGetCurrent(), rls, kCFRunLoopDefaultMode);
    
        CFRelease(rls)晴裹;
 // 省略代碼 ......
}

在 CFSocketCreateWithNative 的官方文檔描述中有提到被济,CFSocketCreateWithNative 在創(chuàng)建 socket 的時(shí)候是有一個(gè)復(fù)用機(jī)制的。

The new CFSocket object, or `NULL` if an error occurred. 
If a CFSocket object already exists for `sock`, 
the function returns the pre-existing object instead of creating a new object; 

封裝 ICMP 包

在 socket 創(chuàng)建完成之后涧团,接下來就要開始組裝 IP 封包并發(fā)送了只磷。組裝 IP 封包并發(fā)送的過程需要我們手動(dòng)在這個(gè)回調(diào)方法觸發(fā)。

- (void)simplePing:(SimplePing *)pinger didStartWithAddress:(NSData *)address
{
// 調(diào)用 - (void)sendPingWithData:(NSData *)data 
}

sendPingWithData 這個(gè)方法做的操作是組裝 IP 封包然后發(fā)送封包泌绣,調(diào)用這個(gè)過程對應(yīng)的回調(diào)方法钮追。發(fā)送封包的過程是調(diào)用 sendto 方法

- (void)sendPingWithData:(NSData *)data {
    // 省略代碼 ......

    // Send the packet.
    
    if (self.socket == NULL) {
        bytesSent = -1;
        err = EBADF;
    } else {
        bytesSent = sendto(
            CFSocketGetNative(self.socket),
            packet.bytes,
            packet.length, 
            0,
            self.hostAddress.bytes, 
            (socklen_t) self.hostAddress.length
        );
        err = 0;
        if (bytesSent < 0) {
            err = errno;
        }
    }
    // 省略代碼 ......

組裝 IP 封包是調(diào)用下面這個(gè)方法來完成,這個(gè)方法把數(shù)據(jù)按照 ICMPHeader 結(jié)構(gòu)體的格式進(jìn)行初始化并返回 IP 封包阿迈,關(guān)于 ICMPHeader 的結(jié)構(gòu)這里就不再累贅元媚,通過 ICMPHeader 結(jié)構(gòu)體的定義就可以明白。

- (NSData *)pingPacketWithType:(uint8_t)type 
                   payload:(NSData *)payload 
                  requiresChecksum:(BOOL)requiresChecksum 苗沧;

解析 ICMP 包

完成了發(fā)送操作之后刊棕,接下來就是等待 ping 的響應(yīng)了。當(dāng) socket 收到 ping 響應(yīng)的時(shí)候回調(diào) SocketReadCallback 待逞,這個(gè)回調(diào)的作用相當(dāng)于重定向甥角,將內(nèi)容從 c 轉(zhuǎn)成適當(dāng)?shù)?Objective-C 內(nèi)容,SocketReadCallback 里面調(diào)用了 readData 方法识樱。

static void SocketReadCallback(CFSocketRef s, CFSocketCallBackType type, CFDataRef address, const void *data, void *info) 

在 readData 方法里面做的工作就是讀取響應(yīng)數(shù)據(jù)嗤无,驗(yàn)證響應(yīng)的數(shù)據(jù)正確性震束,執(zhí)行相應(yīng)的回調(diào)方法。與sendto對應(yīng)当犯,讀取數(shù)據(jù)使用的是 recvfrom 方法垢村。驗(yàn)證響應(yīng)數(shù)據(jù)調(diào)用的是下面的方法。

- (BOOL)validatePing4ResponsePacket:(NSMutableData *)packet sequenceNumber:(uint16_t *)sequenceNumberPtr 

這個(gè)方法接收 ping 響應(yīng)數(shù)據(jù)的時(shí)候嚎卫,會對 ICMP 包進(jìn)行校驗(yàn)肝断,會跳過 IP 頭,畢竟 IP 首部對于 ping 功能來說并不重要驰凛,重要的是 ICMP 協(xié)議的內(nèi)容胸懈,其中主要驗(yàn)證的字段是 checksum 和 sequenceNumber(iPv6 只需要驗(yàn)證 sequenceNumber)。
停止 ping 的時(shí)候需要做一些清理工作恰响,包括 socket 和 CFHost 對應(yīng)的銷毀趣钱。
到這里,整個(gè) ping 的基本流程就結(jié)束了胚宦。

總結(jié)

ICMP 協(xié)議規(guī)定首有,目的主機(jī)必須返回 ICMP 回送應(yīng)答消息給源主機(jī),如果源主機(jī)在一定時(shí)間內(nèi)收到應(yīng)答枢劝,則認(rèn)為主機(jī)可達(dá)井联,而 ping 功能使用的是 ICMP 協(xié)議。
SimplePing 實(shí)現(xiàn) ping 操作的原理步驟是這樣的您旁,先解析出 HostName 對應(yīng)的 IP 地址烙常,這個(gè)才知道數(shù)據(jù)包要發(fā)送給哪個(gè)目的主機(jī),接著構(gòu)造符合 ICMP 協(xié)議格式的數(shù)據(jù)包并發(fā)送鹤盒,等待目的主機(jī)響應(yīng)蚕脏。一段時(shí)間過后,目的主機(jī)響應(yīng)數(shù)據(jù)到達(dá)源主機(jī)侦锯,源主機(jī)接收響應(yīng)數(shù)據(jù)包驼鞭,驗(yàn)證數(shù)據(jù)包,然后去掉數(shù)據(jù)包的 IP 首部尺碰,拿到 ICMP 數(shù)據(jù)挣棕。由于個(gè)人水平有限,文章若有不對之處懇請指出亲桥,我稍作修改洛心,大家共同進(jìn)步。

參考

  1. https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/NetworkingTopics/Articles/ResolvingDNSHostnames.html#//apple_ref/doc/uid/TP40012543-SW1
  2. https://github.com/iOS-Developer-Documents-Chinese/iOS-Developer-Documents-Chinese/blob/master/Socket/DNS%E4%B8%BB%E6%9C%BA%E5%90%8D%E7%9A%84%E8%A7%A3%E6%9E%90.md
  3. https://www.cnblogs.com/cuihongyu3503319/archive/2012/07/09/2583129.html
  4. http://blog.163.com/qhj4433210@126/blog/static/165975282201592251248584/
  5. http://blog.csdn.net/inject2006/article/details/2139149
  6. https://zhaoxinyu.me/2017-04-12-simple-ping/
  7. https://en.wikipedia.org/wiki/IPv4_header_checksum
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末两曼,一起剝皮案震驚了整個(gè)濱河市皂甘,隨后出現(xiàn)的幾起案子玻驻,更是在濱河造成了極大的恐慌悼凑,老刑警劉巖偿枕,帶你破解...
    沈念sama閱讀 206,311評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異户辫,居然都是意外死亡渐夸,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評論 2 382
  • 文/潘曉璐 我一進(jìn)店門渔欢,熙熙樓的掌柜王于貴愁眉苦臉地迎上來墓塌,“玉大人,你說我怎么就攤上這事奥额∩淮保” “怎么了?”我有些...
    開封第一講書人閱讀 152,671評論 0 342
  • 文/不壞的土叔 我叫張陵垫挨,是天一觀的道長韩肝。 經(jīng)常有香客問我,道長九榔,這世上最難降的妖魔是什么哀峻? 我笑而不...
    開封第一講書人閱讀 55,252評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮哲泊,結(jié)果婚禮上剩蟀,老公的妹妹穿的比我還像新娘。我一直安慰自己切威,他們只是感情好育特,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,253評論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著先朦,像睡著了一般且预。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上烙无,一...
    開封第一講書人閱讀 49,031評論 1 285
  • 那天锋谐,我揣著相機(jī)與錄音,去河邊找鬼截酷。 笑死涮拗,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的迂苛。 我是一名探鬼主播三热,決...
    沈念sama閱讀 38,340評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼三幻!你這毒婦竟也來了就漾?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 36,973評論 0 259
  • 序言:老撾萬榮一對情侶失蹤念搬,失蹤者是張志新(化名)和其女友劉穎抑堡,沒想到半個(gè)月后摆出,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,466評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡首妖,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,937評論 2 323
  • 正文 我和宋清朗相戀三年偎漫,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片有缆。...
    茶點(diǎn)故事閱讀 38,039評論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡象踊,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出棚壁,到底是詐尸還是另有隱情杯矩,我是刑警寧澤,帶...
    沈念sama閱讀 33,701評論 4 323
  • 正文 年R本政府宣布袖外,位于F島的核電站菊碟,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏在刺。R本人自食惡果不足惜逆害,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,254評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望蚣驼。 院中可真熱鬧魄幕,春花似錦、人聲如沸颖杏。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,259評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽留储。三九已至翼抠,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間获讳,已是汗流浹背阴颖。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留丐膝,地道東北人量愧。 一個(gè)月前我還...
    沈念sama閱讀 45,497評論 2 354
  • 正文 我出身青樓,卻偏偏與公主長得像帅矗,于是被迫代替她去往敵國和親偎肃。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,786評論 2 345

推薦閱讀更多精彩內(nèi)容

  • 1.這篇文章不是本人原創(chuàng)的浑此,只是個(gè)人為了對這部分知識做一個(gè)整理和系統(tǒng)的輸出而編輯成的累颂,在此鄭重地向本文所引用文章的...
    SOMCENT閱讀 13,037評論 6 174
  • 簡介 用簡單的話來定義tcpdump,就是:dump the traffic on a network凛俱,根據(jù)使用者...
    保川閱讀 5,941評論 1 13
  • 個(gè)人認(rèn)為紊馏,Goodboy1881先生的TCP /IP 協(xié)議詳解學(xué)習(xí)博客系列博客是一部非常精彩的學(xué)習(xí)筆記料饥,這雖然只是...
    貳零壹柒_fc10閱讀 5,051評論 0 8
  • 1, 網(wǎng)絡(luò)是什么 計(jì)算機(jī)網(wǎng)絡(luò)的組成組件: 節(jié)點(diǎn) (node):節(jié)點(diǎn)主要是具有網(wǎng)絡(luò)地址 (IP) 的設(shè)備之稱。 服務(wù)...
    求閑居士閱讀 1,466評論 0 3
  • 一日一景 露從今夜白瘦棋,月是故鄉(xiāng)明。 玉良畫才女暖哨,青丹展后人赌朋。 潘玉良《月是故鄉(xiāng)明》藝術(shù)作品展在江蘇美術(shù)館陳列館(國...
    吉光片羽_9bc2閱讀 217評論 0 4