《Unix 网络编程》11:名字和地址转换 - 樵仙
source link: https://www.cnblogs.com/lymtics/p/16320004.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
名字和地址转换
系列文章导航:《Unix 网络编程》笔记
域名系统主要用于主机名字和 IP 地址之间的映射。主机名可以是:
- 简单名字,如:centos01
- 全限定域名(FQDN[1]),如:xxx.com
记录 | 作用 |
---|---|
A | 指向IPv4 |
AAAA | 指向IPv6 |
PTR | 把IP地址映射为主机名 |
MX | 邮件记录 |
CNAME | 为二级域名指定域名或IP |
解析器和名字服务器
DNS替代方法
如果使用 DNS 查找主机名,则使用 /etc/resolv.conf
指定的 DNS
有如下替代方法:
- 静态主机文件,如
/etc/hosts
- 网络信息系统(NIS)
- 轻权目录访问协议(LDAP)
所有这些差异对应用开发人员是透明的,我们只需调用相关的解析器函数即可
IPv4 函数学习
域名和地址转换
gethostbyname
执行对 A 记录的查询,返回 IPv4 地址:
#include <netdb.h>
struct hostent *gethostbyname(const char * hostname);
// hostent:
struct hostent {
char *h_name; // 正式主机名
char **h_aliases; // 别名s
int h_addrtype; // AF_INET
int h_length; // 4 (32位IP地址)
char **h_addr_list; // IP地址s
}
错误情况
发生错误时,不设置 errno 变量,而是将全局整型变量 h_errno 设置为在头文件 netdb.h 中定义的如下常量之一:
- HOST_NOT_FOUND
- TRY_AGAIN
- NO_RECOVERY
- NO_DATA(等同于 NO_ADDRESS):表明主机在,但是没有 A 记录
多数解析器提供名为 hstrerror 函数,可以将某个 h_errno 代表的具体错误信息返回
案例
#include "unp.h"
int main(int argc, char **argv)
{
char *ptr, **pptr;
char str[INET_ADDRSTRLEN];
struct hostent *hptr;
while (--argc > 0)
{
// 遍历每一个域名
ptr = *++argv;
if ((hptr = gethostbyname(ptr)) == NULL)
{
// 错误信息
err_msg("gethostbyname error for host: %s: %s",
ptr, hstrerror(h_errno));
continue;
}
// 各种打印
printf("official hostname: %s\n", hptr->h_name);
for (pptr = hptr->h_aliases; *pptr != NULL; pptr++)
printf("\talias: %s\n", *pptr);
switch (hptr->h_addrtype)
{
case AF_INET:
pptr = hptr->h_addr_list;
for (; *pptr != NULL; pptr++)
printf("\taddress: %s\n",
Inet_ntop(hptr->h_addrtype, *pptr, str, sizeof(str)));
break;
default:
err_ret("unknown address type");
break;
}
}
exit(0);
}
[root@centos-5610 names]# ./hostent ethy.cn www.ethy.cn smtp.ethy.cn mail.ethy.cn
official hostname: ym.163.com
alias: ethy.cn
address: 117.147.199.37
gethostbyname error for host: www.ethy.cn: Unknown host
official hostname: cli.ym.ntes53.netease.com
alias: smtp.ethy.cn
alias: smtp.ym.163.com
address: 101.71.155.42
gethostbyaddr
与上一个的功能正好相反,查询 PTR 记录
#inlcude <netdh.h>
struct hostent *gethostbyaddr(const char *addr,
socklen_t len, // 对于 IPv4 为4
int family); // AF_INET
服务和端口转换
/etc/services
文件中保存了许多知名服务的端口和服务名称的映射,如下:
time 37/tcp timserver
time 37/udp timserver
rlp 39/tcp resource # resource location
rlp 39/udp resource # resource location
nameserver 42/tcp name # IEN 116
nameserver 42/udp name # IEN 116
nicname 43/tcp whois
nicname 43/udp whois
tacacs 49/tcp # Login Host Protocol (TACACS)
tacacs 49/udp # Login Host Protocol (TACACS)
re-mail-ck 50/tcp # Remote Mail Checking Protocol
re-mail-ck 50/udp # Remote Mail Checking Protocol
domain 53/tcp # name-domain server
domain 53/udp
whois++ 63/tcp whoispp
whois++ 63/udp whoispp
getservbyname
#include <netdb.h>
struct servent *getservbyname(const char* servname, const char *protoname);
// servent
struct servent {
char *s_name;
char **s_aliases;
int s_port;
char *s_proto;
}
几个案例:
getservbyname("domain", "udp");
getservbyname("ftp", "tcp");
getservbyname("ftp", NULL);
getesrvbyname("ftp", "udp");
如果没有指定协议,则会自动匹配(一般来说同一服务的 TCP 和 UDP 端口是相同的),但是如果指定的协议没有,则会报错
getservbyport
#include <netdb.h>
struct servent *getservbyport(int port, const char *protoname);
其中 port 参数必须为网络字节序,例如:
getservbyport(htons(53), "udp");
相同的端口上,不同的协议可能有不同的服务!
时间服务客户端改进
可以通过上面所学对时间服务的客户端进行改进:
int main(int argc, char** argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
struct sockaddr_in servaddr;
struct in_addr** pptr;
struct in_addr* inetaddrp[2];
struct in_addr inetaddr;
struct hostent* hp;
struct servent* sp;
if (argc != 3)
err_quit("usage: daytimetcpcli1 <hostname> <service>");
printf("%s:%s\n", argv[1], argv[2]);
// 获取域名对应的地址
if ((hp = gethostbyname(argv[1])) == NULL) {
// 获取失败, 猜测可能是用户输入了IP地址,所以进行转换
// 将 IP 地址从点分十进制转换为32位二进制
if (inet_aton(argv[1], &inetaddr) == 0) {
// 失败了
err_quit("hostname error for %s: %s", argv[1], hstrerror(h_errno));
} else {
// 保存
inetaddrp[0] = &inetaddr;
inetaddrp[1] = NULL;
pptr = inetaddrp;
}
} else {
// 直接转换出来的就是32位二进制
pptr = (struct in_addr**)hp->h_addr_list;
}
// 服务转换
if ((sp = getservbyname(argv[2], "tcp")) == NULL)
err_quit("getservbyname error for %s", argv[2]);
// 循环,对查询得到的所有IP进行访问
for (; *pptr != NULL; pptr++) {
sockfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = sp->s_port;
memcpy(&servaddr.sin_addr, *pptr, sizeof(struct in_addr));
printf("trying %s\n", Sock_ntop((SA*)&servaddr, sizeof(servaddr)));
// 只要有一个成功
if (connect(sockfd, (SA*)&servaddr, sizeof(servaddr)) == 0)
break; /* success */
err_ret("connect error");
close(sockfd);
}
if (*pptr == NULL)
err_quit("unable to connect");
// 输出
while ((n = Read(sockfd, recvline, MAXLINE)) > 0) {
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
exit(0);
}
getaddrinfo 函数
getaddrinfo 介绍
优势:
- 这个函数可以处理域名和地址的转换,以及服务和端口的转换
- 返回一个 sockaddr 列表,而不是上面的结构,这些 sockaddr 可以直接使用
- 把协议相关的内容隐藏了起来
#include <netdb.h>
int getaddrinfo(const char *hostname, // 主机名或地址串
const char *service, // 服务名或十进制端口号数串
const struct addrinfo *hints, // 填写对期望结果的暗示
struct addrinfo **result); // 返回的信息保存在这里
参数解释
-
hints
- hints 可以是一个空指针,也可以是一个指向某个 addrinfo 结构的指针
- 调用者在其中可以填写关于期望返回信息类型的暗示,前四个属性都可以设置
-
result
- 如果函数返回0,则会更新 result 对应的 addrinfo 链表
- 链表可能有多个项,这取决于 hostname 关联了几个地址,以及是否有不同的协议类型
- 链表是无序的,也就是说 TCP 未必会放在前面
- 返回的 addrinfo 中的信息可以用于 socket 的相关操作,如 connect、sendto、bind
-
如果 ai_flags 设置了
AI_CANONNAME
,那么返回的第一个 addrinfo 结构的 ai_canonname 指向所查找主机的规范名字
addrinfo 结构
// addrinfo
struct addrinfo {
int ai_flags; // 一些标志位,用来进行特殊的设置
int ai_family; // AF_XXX 如 AF_INET、AF_INET6
int ai_socktype; // SOCK_XXX 如 SOCK_STREAM SOCK_DGRAM
int ai_protocol; // 协议名称,如 IPPROTO_TCP,如果前面两项可以唯一确认,则此项可为0
socklen_t ai_addrlen;
char *ai_canonname;
struct sockaddr *ai_addr;
struct addrinfo *ai_next;
}
下面对各个结构进行详细的解释:
-
ai_flags
用来设置一些标志位可用的标志值和含义如下:请忽略点符号,这里只是为了方便阅读
标志值 作用 AI_PASSIVE 套接字将用于被动打开(如服务器端) AI_CAN.ON.NAME 返回主机的规范名称,保存在返回的链表的第一项的 ai_canonname
中AI_NUMERIC.HOST 防止任何类型的名字到地址映射,hostname 必须是一个地址串 AI_NUMERIC.SERV 放置任何类型的服务到端口映射,service 必须是十进制端口号数串 AI_V4.MAPPED 如果同时指定 ai_family 为 AF_INET6,又没有 AAAA 记录,就返回 IPv4 对应的 IPv6 地址 AI_ALL 如果同时指定上一项,则返回 IPv6 和 IPv4 对应的 IPv6 地址 AI_ADDR.CONFIG 按照所在主机的配置选择返回地址类型 -
family、socktype、protocol
已经在注释上说的比较清楚了,他们可以直接被使用来操作 socket -
ai_addrlen
ai_addr 套接字结构的大小 -
ai_canonname
在上表格中提到了,在需要的时候是主机的规范名称 -
ai_addr
指向套接字地址的指针,已经被函数填充好了,且类型自适应 -
ai_next
指向下一位
一个调用的案例:
struct addrinfo hints, *res;
bzero(&hints, sizeof(hints));
hints.ai_flags = AI_CANONNAME;
hints.ai_family = AF_INET;
getaddrinfo("freebsd4", "domain", &hints, &res);
一个可能的结果:
在调用 getaddrinfo
时,共有 6 个可以选择的参数组合:
- hostname、service
- ai_flags、ai_family、ai_socktype、ai_protocol
常见的组合方式如下所述
客户端
-
- 指定 hostname 和 service
- 返回后,针对返回的所有 IP 地址,逐一调用 socket 和 connect,直到有一个连接成功,或全部失败
-
-
返回后,调用 sendto 或 connect
-
如果客户能够判定第一个地址看起来不工作,则尝试其余的地址
两种判断方式:
- 已连接,获取到错误信息
- 未连接,等待消息超时
-
-
如果客户端清楚套接字的类型,则应该设置 hints 为合适的值
服务器
-
- 只指定 service,不指定hostname
- 在 hints 结构中指定 AI_PASSIVE 标志
- TCP 服务器随后调用 socket、bind、listen
-
- 调用 socket、bind、recvfrom
-
如果服务器清楚套接字的类型,则应该设置 hints 为合适的值
返回的 addrinfo 结构的数目
返回的 addrinfo 的数目和暗示信息中 ai_socktype 的对应关系:
gai_strerror
#include <netdb.h>
const char *gai_strerror(int error);
作用:对于 getaddrinfo 的非 0 错误码,将该数值作为参数,输出其对应的错误信息
freeaddrinfo
作用
getaddrinfo
返回的所有存储空间都是动态获取的(比如 malloc),包括:
- addrinfo 结构
- ai_addr 结构
- ai_canonname 字符串
这些存储空间通过 freeaddrinfo
返还给系统
#include <netdb.h>
void freeaddrinfo(struct addrinfo *ai);
注意
- 在头节点上调用,会释放整个 addrinfo 链表
- 注意如果你采用浅拷贝使用了某些属性,则再访问时可能会因为该地址已经被释放而出错
自己封装函数
host_serv
作用:简化 getaddrinfo 的步骤
struct addrinfo* host_serv(const char* host,
const char* serv,
int family,
int socktype) {
int n;
struct addrinfo hints, *res;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_flags = AI_CANONNAME; /* always return canonical name */
hints.ai_family = family; /* AF_UNSPEC, AF_INET, AF_INET6, etc. */
hints.ai_socktype = socktype; /* 0, SOCK_STREAM, SOCK_DGRAM, etc. */
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0)
return (NULL);
return (res); /* return pointer to first on linked list */
}
tcp_connect
作用:创建一个 TCP 套接字并连接到一个服务器
其步骤和上文最佳实践部分基本一致
int tcp_connect(const char* host, const char* serv) {
int sockfd, n;
struct addrinfo hints, *res, *ressave;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0)
err_quit("tcp_connect error for %s, %s: %s", host, serv,
gai_strerror(n));
ressave = res;
do {
sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sockfd < 0)
continue; /* ignore this one */
if (connect(sockfd, res->ai_addr, res->ai_addrlen) == 0)
break; /* success */
Close(sockfd); /* ignore this one */
} while ((res = res->ai_next) != NULL);
if (res == NULL) /* errno set from final connect() */
err_sys("tcp_connect error for %s, %s", host, serv);
freeaddrinfo(ressave);
return (sockfd);
}
时间程序客户端改进
这个类似于前面部分的,只不过把部分步骤封装在 tcp_connect 中了!
int main(int argc, char** argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
socklen_t len;
struct sockaddr_storage ss;
if (argc != 3)
err_quit("usage: daytimetcpcli <hostname/IPaddress> <service/port#>");
sockfd = Tcp_connect(argv[1], argv[2]);
len = sizeof(ss);
Getpeername(sockfd, (SA*)&ss, &len);
printf("connected to %s\n", Sock_ntop_host((SA*)&ss, len));
while ((n = Read(sockfd, recvline, MAXLINE)) > 0) {
recvline[n] = 0; /* null terminate */
Fputs(recvline, stdout);
}
exit(0);
}
tcp_listen
int tcp_listen(const char* host, const char* serv, socklen_t* addrlenp) {
int listenfd, n;
const int on = 1;
struct addrinfo hints, *res, *ressave;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_flags = AI_PASSIVE;
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0)
err_quit("tcp_listen error for %s, %s: %s", host, serv,
gai_strerror(n));
ressave = res;
do {
listenfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (listenfd < 0)
continue; /* error, try next one */
Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
if (bind(listenfd, res->ai_addr, res->ai_addrlen) == 0)
break; /* success */
Close(listenfd); /* bind error, close and try next one */
} while ((res = res->ai_next) != NULL);
if (res == NULL) /* errno from final socket() or bind() */
err_sys("tcp_listen error for %s, %s", host, serv);
Listen(listenfd, LISTENQ);
if (addrlenp)
*addrlenp = res->ai_addrlen; /* return size of protocol address */
freeaddrinfo(ressave);
return (listenfd);
}
时间程序服务器端改进
用 tcp_listen 代替部分步骤
int main(int argc, char** argv) {
int listenfd, connfd;
socklen_t len;
char buff[MAXLINE];
time_t ticks;
struct sockaddr_storage cliaddr;
if (argc != 2)
err_quit("usage: daytimetcpsrv1 <service or port#>");
listenfd = Tcp_listen(NULL, argv[1], NULL);
for (;;) {
len = sizeof(cliaddr);
connfd = Accept(listenfd, (SA*)&cliaddr, &len);
printf("connection from %s\n", Sock_ntop((SA*)&cliaddr, len));
ticks = time(NULL);
snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
Write(connfd, buff, strlen(buff));
Close(connfd);
}
}
再次改进
上述代码有一个问题:
- tcp_listen 的第一个参数是 NULL
- 而且 tcp_listen 内部指定的地址族为 AF_UNSPEC
- 两者结合可能导致 getaddrinfo 返回非期望地址族的套接字地址结构
用一个小技巧,可以指定,使用 IPv6 还是 IPv4:
int main(int argc, char** argv) {
int listenfd, connfd;
socklen_t len, addrlen;
char buff[MAXLINE];
time_t ticks;
struct sockaddr_storage cliaddr;
if (argc == 2)
listenfd = Tcp_listen(NULL, argv[1], &addrlen);
else if (argc == 3)
listenfd = Tcp_listen(argv[1], argv[2], &addrlen);
else
err_quit("usage: daytimetcpsrv2 [ <host> ] <service or port>");
for (;;) {
len = sizeof(cliaddr);
connfd = Accept(listenfd, (SA*)&cliaddr, &len);
printf("connection from %s\n", Sock_ntop((SA*)&cliaddr, len));
ticks = time(NULL);
snprintf(buff, sizeof(buff), "%.24s\r\n", ctime(&ticks));
Write(connfd, buff, strlen(buff));
Close(connfd);
}
}
测试案例:
[root@centos-5610 names]# ./daytimetcpsrv2 0::0 daytime
connection from [fe80::5054:ff:fe4d:77d3]:37428
[root@centos-5610 names]# ./daytimetcpsrv2 0.0.0.0 daytime
connection from 10.0.2.15:52178
udp_client
这个套接字地址结构的大小在 lenp 中返回,不允许是一个空指针(而TCP允许),因为 sendto 和 recvfrom 调用都需要直到套接字地址结构的长度
int udp_client(const char* host,
const char* serv,
SA** saptr,
socklen_t* lenp) {
int sockfd, n;
struct addrinfo hints, *res, *ressave;
bzero(&hints, sizeof(struct addrinfo));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_DGRAM;
if ((n = getaddrinfo(host, serv, &hints, &res)) != 0)
err_quit("udp_client error for %s, %s: %s", host, serv,
gai_strerror(n));
ressave = res;
do {
sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sockfd >= 0)
break; /* success */
} while ((res = res->ai_next) != NULL);
if (res == NULL) /* errno set from final socket() */
err_sys("udp_client error for %s, %s", host, serv);
*saptr = Malloc(res->ai_addrlen);
memcpy(*saptr, res->ai_addr, res->ai_addrlen);
*lenp = res->ai_addrlen;
freeaddrinfo(ressave);
return (sockfd);
}
协议无关时间获取客户程序(UDP)
这里协议无关指的是 IPv4 or IPv6
int main(int argc, char** argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
socklen_t salen;
struct sockaddr* sa;
if (argc != 3)
err_quit("usage: daytimeudpcli1 <hostname/IPaddress> <service/port#>");
sockfd = Udp_client(argv[1], argv[2], (void**)&sa, &salen);
printf("sending to %s\n", Sock_ntop_host(sa, salen));
Sendto(sockfd, "", 1, 0, sa, salen); /* send 1-byte datagram */
n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
recvline[n] = '\0'; /* null terminate */
Fputs(recvline, stdout);
exit(0);
}
udp_connect
- 由于 connect 会保存相关的端口和端口信息,所以我们只需要知道返回的描述符就可以了
- 和 tcp_connect 不同,UDP 的错误在发送一个数据报才能知晓(没有三次握手的过程)
int main(int argc, char** argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
socklen_t salen;
struct sockaddr* sa;
if (argc != 3)
err_quit("usage: daytimeudpcli1 <hostname/IPaddress> <service/port#>");
sockfd = Udp_client(argv[1], argv[2], (void**)&sa, &salen);
printf("sending to %s\n", Sock_ntop_host(sa, salen));
Sendto(sockfd, "", 1, 0, sa, salen); /* send 1-byte datagram */
n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
recvline[n] = '\0'; /* null terminate */
Fputs(recvline, stdout);
exit(0);
}
udp_server
int main(int argc, char** argv) {
int sockfd, n;
char recvline[MAXLINE + 1];
socklen_t salen;
struct sockaddr* sa;
if (argc != 3)
err_quit("usage: daytimeudpcli1 <hostname/IPaddress> <service/port#>");
sockfd = Udp_client(argv[1], argv[2], (void**)&sa, &salen);
printf("sending to %s\n", Sock_ntop_host(sa, salen));
Sendto(sockfd, "", 1, 0, sa, salen); /* send 1-byte datagram */
n = Recvfrom(sockfd, recvline, MAXLINE, 0, NULL, NULL);
recvline[n] = '\0'; /* null terminate */
Fputs(recvline, stdout);
exit(0);
}
和上面 tcp_server 一样,可以通过那个小技巧来决定使用 IPv4 还是 IPv6
getnameinfo
- 是
getaddrinfo
的互补函数 - 以一个套接字地址为参数,返回描述其中的主机的一个字符串和描述其中的服务的另一个字符串
- 协议无关,函数内部自行处理
#include <netdb.h>
int getnameinfo(const struct sockaddr *sockaddr, socklen_t addrlen,
char *host, sockelne_t hostlen, // 调用者预先分配
char *serv, socklent_t servlen, // 调用者预先分配
int flags);
flags:
常值 | 说明 | 备注 |
---|---|---|
NI_DGRAM | 数据报服务 | 如果知道是UDP,则应设置,以免部分服务端口的冲突 |
NI_NAME.REQD | 若不能从地址解析出名字则返回错误 | |
NI_NO.FQDN | 只返回FQDN的主机名部分 | 如a.foo.com,将截断为a |
NI_NUMERIC.HOST | 以数串格式返回主机字符串 | 不要调用 DNS, |
NI_NUMERIC.SCOPE | 以数串格式返回范围标识字符串 | |
NI_NUMERIC.SERV | 以数串格式返回服务字符串 | 服务器通常应该设置这个标识 |
可重入函数
可重入函数主要用于多任务环境中,一个可重入的函数简单来说就是可以被中断的函数,也就是说,可以在这个函数执行的任何时刻中断它,转入 OS 调度下去执行另外一段代码,而返回控制时不会出现什么错误;而不可重入的函数由于使用了一些系统资源,比如 全局变量区, 中断向量表 等,所以它如果被中断的话,可能会出现问题,这类函数是不能运行在多任务环境下的。
在使用时需要注意:
-
gethostbyname
、gethostbyaddr
、getservbyname
、getservbyport
都是不可重入的,因为它们都返回指向同一个静态结构的指针 -
inet_pton
、inet_ntop
总是可重入的 -
因为历史原因,
inet_ntoa
是不可重入的,不过部分实现提供了使用线程特定数据的可重入版本 -
getaddrinfo
可重入的前提是由它调用的函数都可重入,这就是说,它应该调用可重入版本的 gethostbyname 和 getservbyname -
getnameinfo
可重入的前提是由它调用的函数都可重入,这就是说,它应该调用可重入版本的 gethostbyaddr 和 getservbyport -
errno
在每一个进程各有一个副本,但是多线程下也会发生被其他线程修改的情况
解决方案
-
不使用函数的不可重入版本
-
就 errno 例子而言,可以使用类似如下的代码进行避免:
void sig_alrm(int signo) { int errno_save; errno_save = errno; if (write( ... ) != nbytes) { fprintf(stderr, "Errno = %d\n", errno); } errno = errno_save; }
可重入版本
gethostbyname_r
gethostbyaddr_r
具体描述暂略
本文没有提到的
- 作废的IPv6地址解析函数
- 其他网络相关信息
由于此两章暂时用不到,故略
Full Qualified Domain Name ↩︎
__EOF__
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK