【Linux】socket编程(二)

news2024/11/26 12:52:58

目录

前言 

TCP通信流程 

TCP通信的代码实现

tcp_server.hpp编写

tcp_server.cc服务端的编写

tcp_client.cc客户端的编写

整体代码


前言 

        上一章我们主要讲解了UDP之间的通信,本章我们将来讲述如何使用TCP来进行网络间通信,主要是使用socket API进行代码的实现。

        我们一共讲了5个socket API接口,分别为socket,bind,listen,accept,connect.但我们在讲解UDP通信时,只使用了socket和bind这两个接口就完成了。而TCP通信会使用后面这三个接口,我们将分别讲解.


TCP通信流程 

        同样地,TCP通信分为服务器端和客户端,它们的流程分别如下:

服务端通信流程:

  1. 创建套接字:使用socket函数创建一个套接字,指定协议族为AF_INET(IPv4)或AF_INET6(IPv6),指定类型为SOCK_STREAM(TCP)。

  2. 绑定套接字:使用bind函数将套接字与服务器的IP地址和端口号绑定在一起。这样服务器将使用指定的IP地址和端口号进行监听。

  3. 监听连接请求:使用listen函数开始监听连接请求。指定参数backlog,表示允许在队列中等待的最大连接数。

  4. 接受连接请求:使用accept函数接受客户端的连接请求。该函数会阻塞程序,直到有客户端连接时才返回一个新的套接字,用于与客户端进行通信。(新的套接字和旧套接字区别:新套接字负责服务建立的连接,包括通信等,旧套接字则一直负责监听连接.)

  5. 通信:使用新的套接字进行通信。可以使用readwrite函数进行数据的接收和发送。

  6. 关闭连接:当通信结束后,使用close函数关闭套接字,释放资源。

客户端通信流程:

  1. 创建套接字:使用socket函数创建一个套接字,指定协议族为AF_INET(IPv4)或AF_INET6(IPv6),指定类型为SOCK_STREAM(TCP)。

  2. 连接服务器:使用connect函数连接到服务器的IP地址和端口号。如果连接成功,返回0;否则返回错误码。

  3. 通信:使用已连接的套接字进行数据的发送和接收,可以使用readwrite函数。

  4. 关闭连接:当通信结束后,使用close函数关闭套接字,释放资源。


TCP通信的代码实现

依然是三个文件,分别为tcp_server.hpp(用来封装tcp socket),tcp_server.cc(服务器通信代码),tcp_client.cc(客户端通信代码).

tcp_server.hpp编写

首先我们要编写tcp_server.hpp,首先第一个接口initServer初始化服务端. 一共分为三步:

  • 1.创建套接字

利用socket函数创建新的套接字,并判断是否成功:

        listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (listensock < 0)
        {
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }
        logMessage(NORMAL, "create sock success,  listensock: %d", listensock);
  • 2.bind绑定

        bind将套接字和特定的ip和地址绑定在一起.用法我们上一章也说了,先创建一个sockaddr_in结构体,然后填入相关的数据:sin_family(协议族 AF_INET(IPv4)或AF_INET6(IPv6)),sin_port(端口号),sin_arr.s_addr(ip地址),然后再bind绑定并判断是否成功,代码如下:

        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());

        if (bind(listensock, (struct sockaddr *)&local, sizeof local) < 0)
        {
            logMessage(FATAL, "bind error", errno, strerror(errno));
            exit(3);
        }
  • 3.listen监听

        listen监听是否有新的连接,TCP与UDP不同的是,当客户端和服务端正式通信的时候,需要先建立连接,而UDP直接发送数据。所以要listen来监听是否有新链接.

        代码如下:

        // 3.因为TCP是面向连接的,意味着当我们正式通信的时候,需要先建立连接
        //第二个参数我们在讲TCP协议时会详细讲解,这里先暂且设为20
        if (listen(listensock, gbacklog) < 0)
        {
            logMessage(FATAL, "listen error", errno, strerror(errno));
            exit(3);
        }
        logMessage(NORMAL, "init server success");

第二个接口Start(),该接口主要负责获取连接,并进行通信.共分为两步:

  • accept获取到客户端连接

        这个我们同样的需要创建一个sockaddr_in结构体,用来存储客户端的连接信息,然后接收新的套接字,这个套接字是接下来我们通信要使用的。

            struct sockaddr_in src;
            socklen_t len = sizeof src;
            //servicesock(未来真正进行IO) vs listensock(主要任务:获取新链接)
            int servicesock = accept(listensock, (struct sockaddr *)&src, &len);
            if (servicesock < 0)
            {
                logMessage(ERROR, "accept error", errno, strerror(errno));
            }
  • 通信流程

这里可以提供两个版本的:一个是单进程版,即每一次只能处理一个客户端.

另一个是 多进程版,通过创建子进程来实现对多个客户端处理.

  • 单进程版

        紧接着上面说的,我们获取到客户端的连接信息后,我们需要对其进行解析,得到其ip地址和端口号:

            uint16_t client_port = ntohs(src.sin_port);//获得端口号
            string client_ip = inet_ntoa(src.sin_addr);//获得ip
            logMessage(NORMAL, "Link success, %d | %s : %d\n", servicesock,     client_ip.c_str(), client_port);

        然后直接执行对应的通信函数即可:

 service(servicesock,client_ip,client_port);
  • 多进程版: 

        利用fork函数实现,代码如下:后面的服务端通信和客户端通信都不用改动

            pid_t id = fork();
            assert(id != -1);
            if(id == 0)
            {
                //子进程
                close(listensock);
                service(servicesock,client_ip,client_port);
                exit(0);//僵尸状态
            }
            close(servicesock);

        通信函数service的实现:我们从sock中读取消息,客户端没有发消息时,服务端会阻塞在这里等待用户的输入。

static void service(int sock,const string& clientip,const uint16_t& clientport)
{
    //echo server
    char buffer[1024];
    memset(buffer, 0, sizeof(buffer));
    while(true)
    {
        //read && write
        ssize_t s = read(sock,buffer,sizeof buffer-1);
        if(s > 0)
        {
            buffer[s] = 0;//将发过来的数据当做字符串
            cout << clientip << " : " << clientport << "# "<< buffer << endl;
        }
        else if(s== 0)//对端链接关闭
        {
            logMessage(NORMAL,"%s : %d shutdown, me too!",clientip.c_str(),clientport);
            break;
        }
        else
        {
            logMessage(ERROR, "read socket error, %d:%s", errno, strerror(errno));
            break;
        }
        write(sock,buffer,strlen(buffer));
    }
    close(sock);
}

tcp_server.cc服务端的编写

这个就很简单了,只需要调用initServer初始化和Start开始就行了.

#include "tcp_server.hpp"
#include <memory>

static void usage(string proc)
{
    cout << "Usage: " << proc << "ServerPort\n" << endl;
}

//./tcp_server port
int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        usage(argv[0]);
        exit(1);
    }
    uint16_t port = atoi(argv[1]);
    unique_ptr<TcpServer> svr(new TcpServer(port));

    svr->initServer();
    svr->Start();

    return 0;
}

tcp_client.cc客户端的编写

  • 创建套接字:
    int sock = socket(AF_INET, SOCK_STREAM, 0);
  • 调用connect与服务端链接:利用命令行参数,将用户输入的ip地址和port端口号获取到,然后传入sockaddr_in结构体,最后进行connect
    uint16_t serverPort = atoi(argv[2]);
    string serverIp = argv[1];    

    struct sockaddr_in server;
    bzero(&server, sizeof server);
    server.sin_family = AF_INET;
    server.sin_port = htons(serverPort);
    server.sin_addr.s_addr = inet_addr(serverIp.c_str());
    if (connect(sock, (struct sockaddr *)&server, sizeof server) < 0)
  • 进行通信(send和recv)

   TCP的发送和接收消息不同于UDP的sendto和recvfrom,而是send和recv。我们分别看一下函数的用法:

send:

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  • sockfd:发送数据的套接字描述符。即想谁发送
  • buf:指向要发送数据的缓冲区的指针。
  • len:要发送的数据的长度(以字节为单位)。
  • flags:附加选项,通常设为0。
  • 作用:send()函数用于将数据从发送端发送到接收端。它返回已发送的字节数,或者在出现错误时返回-1。可以通过设置flags参数来指定传输数据的特定选项,例如设置为MSG_DONTWAIT非阻塞发送等。

recv:

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • sockfd:要接收数据的套接字描述符。即谁接收
  • buf:接收数据的缓冲区的指针。
  • len:接收数据的最大长度(以字节为单位)。
  • flags:附加选项,通常设为0。
  • 作用:recv()函数用于从套接字接收数据,并将其存储在指定的缓冲区中。它返回接收到的字节数,或者在出现错误时返回-1。可以通过设置flags参数来指定接收数据的特定选项,例如设置为MSG_DONTWAIT非阻塞接收等。

所以通信代码如下:

    while (true)
    {
        string line;
        cout << "Please Enter Message# ";
        getline(cin, line);
        send(sock, line.c_str(), line.size(), 0);
        char buffer[1024];
        ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
        if (s > 0)
        {
            buffer[s] = 0;
            cout << "server echo# " << buffer << endl;
        }
        else if (s == 0)
        {
            break;
        }
        else
        {
            break;
        }
    }

至此我们的TCP通信就完成了.

当我们使用多进程通信时,可以有多个客户端同时向服务端发送消息:

 至此,TCP的网络通信流程也完成了,这是完整的代码,可以直接 拷贝运行,可去掉logMessage相关的调试信息.

整体代码

注意运行服务器时,使用./tcp_server 端口号

运行客户端连接服务器时,使用./tcp_clinet 服务器ip 服务器端口号

tcp_server.hpp文件

#pragma once
#include <iostream>
#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
#include <string.h>
#include <memory>
#include <pthread.h>
#include <signal.h>
#include <cstring>
#include <ctype.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

using namespace std;

static void service(int sock,const string& clientip,const uint16_t& clientport)
{
    //echo server
    char buffer[1024];
    memset(buffer, 0, sizeof(buffer));
    while(true)
    {
        //read && write
        ssize_t s = read(sock,buffer,sizeof buffer-1);
        if(s > 0)
        {
            buffer[s] = 0;//将发过来的数据当做字符串
            cout << clientip << " : " << clientport << "# "<< buffer << endl;
        }
        else if(s== 0)//对端链接关闭
        {
            logMessage(NORMAL,"%s : %d shutdown, me too!",clientip.c_str(),clientport);
            break;
        }
        else
        {
            logMessage(ERROR, "read socket error, %d:%s", errno, strerror(errno));
            break;
        }
        write(sock,buffer,strlen(buffer));
    }
}

class TcpServer
{
public:
    const static int gbacklog = 20;

    TcpServer(uint16_t port, string ip = "")
        : _port(port), _ip(ip), listensock(-1)
    {
    }
    void initServer()
    {
        // 1.创建套接字
        listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (listensock < 0)
        {
            logMessage(FATAL, "%d:%s", errno, strerror(errno));
            exit(2);
        }
        logMessage(NORMAL, "create sock success,  listensock: %d", listensock);
        // 2.bind
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());

        if (bind(listensock, (struct sockaddr *)&local, sizeof local) < 0)
        {
            logMessage(FATAL, "bind error", errno, strerror(errno));
            exit(3);
        }
        // 3.因为TCP是面向连接的,意味着当我们正式通信的时候,需要先建立连接
        if (listen(listensock, gbacklog) < 0)
        {
            logMessage(FATAL, "listen error", errno, strerror(errno));
            exit(3);
        }
        logMessage(NORMAL, "init server success");
    }
    void Start()
    {
        //version2 :signal(SIGCHLD,SIG_IGN); //对SIGCHLD,主动忽略SIGCHLD信号,子进程退出的时候,会自动释放自己的僵尸进程
        while (true)
        {
            // sleep(1);
            // 获取连接
            struct sockaddr_in src;
            socklen_t len = sizeof src;
            // sock(未来真正进行IO) and _sock(主要任务:获取新链接)
            int servicesock = accept(listensock, (struct sockaddr *)&src, &len);
            if (servicesock < 0)
            {
                logMessage(ERROR, "accept error", errno, strerror(errno));
            }
            // 获取连接成功
            uint16_t client_port = ntohs(src.sin_port);
            string client_ip = inet_ntoa(src.sin_addr);
            logMessage(NORMAL, "Link success, %d | %s : %d\n", servicesock, client_ip.c_str(), client_port);
            // 开始进行通信服务
           
            // version 1 -- 单进程循环 -- 只能一次处理一个客户端,处理完一个,才能处理下一个
            // 显然是不能被直接使用的?为什么?单进程.
            service(servicesock,client_ip,client_port);
            // version 2 -- 多进程版本 -- 创建子进程,
            // 让子进程给新的连接提供服务,子进程能不能打开父进程曾经打开的文件fd呢? 答案是当然可以!
            pid_t id = fork();
            assert(id != -1);
            if(id == 0)
            {
                //子进程
                close(listensock);
                service(servicesock,client_ip,client_port);
                exit(0);//僵尸状态
            }
            //父进程
            close(servicesock);
        }
    }
    ~TcpServer()
    {
    }

private:
    uint16_t _port;
    string _ip;
    int listensock;
    unique_ptr<ThreadPool<Task>> _threadpool_ptr;
};

tcp_server.cc文件

#include "tcp_server.hpp"
#include <memory>

static void usage(string proc)
{
    cout << "Usage: " << proc << "ServerPort\n" << endl;
}

//./tcp_server port
int main(int argc, char* argv[])
{
    if(argc != 2)
    {
        usage(argv[0]);
        exit(1);
    }
    uint16_t port = atoi(argv[1]);
    unique_ptr<TcpServer> svr(new TcpServer(port));

    svr->initServer();
    svr->Start();

    return 0;
}

cline.cc文件

#include <iostream>
#include <string>
#include <cstdio>
#include <unistd.h>
#include <strings.h>
#include <stdlib.h>

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
static void usage(string proc)
{
    cout << "Usage: " << proc << "ServerIP ServerPort" << endl;
}
// ./tcp_clinet IP Prot
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        usage(argv[0]);
        exit(-1);
    }
    uint16_t serverPort = atoi(argv[2]);
    string serverIp = argv[1];

    int sock = socket(AF_INET, SOCK_STREAM, 0);

    if (sock < 0)
    {
        cerr << "sokcet error" << endl;
        exit(2);
    }
    // client 不需要显式的bind,OS会自动选择
    // 更不需要监听,但是需要连接的能力connect
    struct sockaddr_in server;
    bzero(&server, sizeof server);
    server.sin_family = AF_INET;
    server.sin_port = htons(serverPort);
    server.sin_addr.s_addr = inet_addr(serverIp.c_str());
    if (connect(sock, (struct sockaddr *)&server, sizeof server) < 0)
    {
        cerr << "connect error" << endl;
        exit(3);
    }
    cout << "connect success!" << endl;

    while (true)
    {
        string line;
        cout << "Please Enter Message# ";
        getline(cin, line);
        send(sock, line.c_str(), line.size(), 0);
        char buffer[1024];
        ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
        if (s > 0)
        {
            buffer[s] = 0;
            cout << "server echo# " << buffer << endl;
        }
        else if (s == 0)
        {
            break;
        }
        else
        {
            break;
        }
    }
    close(sock);
    return 0;
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/922459.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

whisper 语音识别项目部署

1.安装anaconda软件 在如下网盘免费获取软件&#xff1a; 链接&#xff1a;https://pan.baidu.com/s/1zOZCQOeiDhx6ebHh5zNasA 提取码&#xff1a;hfnd 2.使用conda命令创建python3.8环境 conda create -n whisper python3.83.进入whisper虚拟环境 conda activate whisper4.…

MyBatis的核心技术掌握---分页功能,详细易懂(下)

目录 一.前言 二.MyBatis 的分页 三.MyBatis 的特殊字符处理 一.前言 继上篇MyBatis 的文章&#xff0c;我们继续来学习MyBatis吧&#xff01;&#xff01;&#xff01; 上篇的博客链接&#xff1a; http://t.csdn.cn/5iUEDhttp://t.csdn.cn/5iUED 接下来进…

什么是梯度下降

什么是梯度下降 根据已有数据的分布来预测可能的新数据&#xff0c;这是回归 希望有一条线将数据分割成不同类别&#xff0c;这是分类 无论回归还是分类&#xff0c;我们的目的都是让搭建好的模型尽可能的模拟已有的数据 除了模型的结构&#xff0c;决定模型能否模拟成功的关键…

电商项目part05 分布式ID服务实战

背景 日常开发中&#xff0c;需要对系统中的各种数据使用 ID 唯一表示&#xff0c;比如用户 ID 对应且仅对应一个人&#xff0c;商品 ID 对应且仅对应一件商品&#xff0c;订单 ID 对应且仅对应 一个订单。现实生活中也有各种 ID&#xff0c;比如身份证 ID 对应且仅对应一个人…

XL74HC165 Parallel-2-Serail Controller

XL74HC165 Parallel-2-Serail Controller (SOP16) ( SN74LS165, CD74LS165 - DIP16 / SOP16 ) ( 不频繁存取, 可以考虑 I2C I/O Expender ) PCF8574/ T module (8bits Address *0x40~0x4E* ) PCF8574A module (8bit address *0x70~0x7E* )XL74HC165 fmax : VCC 3.3V &l…

冠达管理股票分析:首家!券商放大招,立马拉升

A股的“回购潮”&#xff0c;开始蔓延至券商行业。 广东研山私募证券投资&#xff08;百度搜索冠达管理)基金管理有限公司成立于2022年&#xff0c;是一家专注于私募基金管理的公司。8月23日盘后&#xff0c;国金证券发布公告称&#xff0c;收到控股股东长沙涌金&#xff08;集…

Fegin异步情况丢失上下文问题

在微服务的开发中&#xff0c;我们经常需要服务之间的调用&#xff0c;并且为了提高效率使用异步的方式进行服务之间的调用&#xff0c;在这种异步的调用情况下会有一个严重的问题&#xff0c;丢失上文下 通过以上图片可以看出异步丢失上下文的原因是不在同一个线程&#xff0c…

『PyQt5-基础篇』| 01 简单的基础了解

『PyQt5-基础篇』&#xff5c; 01 简单的基础了解 1 Qt了解1.1 支持的平台1.2 Qt Creator 2 PyQt52.1 PyQt5主要模块2.2 PyQt5主要类2.3 重要类的继承关系2.4 常用控件 1 Qt了解 跨平台C图形用户界面应用程序开发框架&#xff1b;既可以开发GUI程序&#xff0c;也可用于开发非…

JMeter分布式集群---部署多台机器进行性能压力测试

有些时候&#xff0c;我们在进行压力测试的时候&#xff0c;随着模拟用户的增加&#xff0c;电脑的性能&#xff08;CPU,内存&#xff09;占用是非常大的&#xff0c;为了我们得到更加理想的测试结果&#xff0c;我们可以利用jmeter的分布式来缓解机器的负载压力&#xff0c;分…

LVS集群 (四十四)

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 目录 前言 一、集群概述 1. 负载均衡技术类型 2. 负载均衡实现方式 二、LVS结构 三、LVS工作模式 四、LVS负载均衡算法 1. 静态负载均衡 2. 动态负载均衡 五、ipvsadm命令详…

npm报错:xxx packages are looking for funding run `npm fund` for details(解决办法)

报错信息&#xff1a;30 packages are looking for funding run npm fund for details 报错原因&#xff1a;这里是开发者捐赠支持的提示&#xff0c;打开一个github的链接之后&#xff0c;会显示是否需要打赏捐赠的信息。 解决方案&#xff1a;这个打赏是资源的&#xff0c;因…

Golang Gorm 一对多关系 关系表创建

一对多关系 我们先从一对多开始多表关系的学习因为一对多的关系生活中到处都是&#xff0c;例如&#xff1a; 老板与员工女神和添狗老师和学生班级与学生用户与文章 在创建的时候先将没有依赖的创建。表名称ID就是外键。外键要和关联的外键的数据类型要保持一致。 package ma…

投影标杆,旗舰实力,极米投影仪Z7X为用户创造影院级体验

2023年&#xff0c;在彩电消费市场复苏疲软的背景下&#xff0c;智能投影这个显示新品类却持续走红。今年第一季度&#xff0c;极米科技推出Z系列全新一代产品极米Z7X&#xff0c;和极米Z6相比&#xff0c;在保持轻薄体积不变的情况下将亮度提升了83%&#xff0c;达到600CCB 流…

五、linux分析命令

linux分析命令 一、服务器基础知识二、linux文件结构三、linux文件权限四、linux命令1、安装应用fedora家族: 如centosdebain家族&#xff1a;如ubuntu 2、获取帮助第一种&#xff1a;command --help第二种&#xff1a;man command第三种&#xff1a;info 3、服务器性能分析基础…

先加密后签名还是先签名后加密?

先签名后加密还是先加密后签名呢&#xff1f; 先说结论&#xff0c;通常情况下应该先签名后加密。 签名算法计算出来的签名是为了验证消息的完整性&#xff0c;签名算法有比如HMAC-SHA256&#xff0c;加密算法则是为了保证消息的机密性&#xff0c;类似AES-GCM、AES-CBC&#…

海马优化(SHO)算法(含开源MATLAB代码)

先做一个声明&#xff1a;文章是由我的个人公众号中的推送直接复制粘贴而来&#xff0c;因此对智能优化算法感兴趣的朋友&#xff0c;可关注我的个人公众号&#xff1a;启发式算法讨论。我会不定期在公众号里分享不同的智能优化算法&#xff0c;经典的&#xff0c;或者是近几年…

最小二乘法——参数估计过程推导

一 准备 1 给定数据集 D{(),(),...,()},其中假设X是一维的情况&#xff0c;即只有一个自变量 2 线性回归学习的目标&#xff1a;,使得 3 如何确定w和b&#xff1f;关键在于衡量f(x)和y之间距离的方法&#xff0c;此处使用的是‘均方误差’&#xff0c;其具有非常好的几何意义&a…

23款奔驰GLE450时尚型升级ACC自适应巡航系统,解放双脚缓解驾驶疲劳

有的时候你是否厌倦了不停的刹车、加油&#xff1f;是不是讨厌急刹车&#xff0c;为掌握不好车距而烦恼&#xff1f;如果是这样&#xff0c;那么就升级奔驰原厂ACC自适应式巡航控制系统&#xff0c;带排队自动辅助和行车距离警报功能&#xff0c;感受现代科技带给你的舒适安全和…

英特尔开始加码封装领域 | 百能云芯

在积极推进先进制程研发的同时&#xff0c;英特尔正在加大先进封装领域的投入。在这个背景下&#xff0c;该公司正在马来西亚槟城兴建一座全新的封装厂&#xff0c;以加强其在2.5D/3D封装布局领域的实力。据了解&#xff0c;英特尔计划到2025年前&#xff0c;将其最先进的3D Fo…

将公共组件提取到npm包中

多个前端项目中公共组件使用方案&#xff08;npm包方式&#xff09; - 简书