Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

C++ Socket套接字编程 Socket Programming In C++ (Windows)[进行中] #221

Open
WangShuXian6 opened this issue Nov 9, 2024 · 7 comments

Comments

@WangShuXian6
Copy link
Owner

C++ Socket套接字编程 Socket Programming In C++ (Windows)

以下是翻译:

  1. 介绍
    • 1.1 介绍

  2. 套接字介绍
    • 2.1 什么是套接字
    • 2.2 面向连接和无连接的套接字
    • 2.3 IP 地址(版本 4 和版本 6)
    • 2.4 阻塞与非阻塞套接字
    • 2.5 理解网络字节顺序
    • 2.6 开始前需要了解的重要结构

  3. 设置 Windows 开发环境
    • 3.1 Windows 上的套接字编程
    • 3.2 为套接字编程配置 Windows 操作系统
    • 3.3 测试系统准备情况的示例程序

  4. 套接字编程实践(TCP) - 单客户端套接字服务器
    • 4.1 打开一个套接字
    • 4.2 设置套接字选项
    • 4.3 设置套接字为阻塞或非阻塞
    • 4.4 将套接字绑定到本地端口
    • 4.5 接受来自客户端的新连接
    • 4.6 与已连接的客户端通信
    • 4.7 客户端套接字代码
    • 4.8 客户端服务器运行代码演示

  5. 套接字编程实践 - 多客户端处理
    • 5.1 打开一个新的套接字作为监听
    • 5.2 介绍 FD_SET 和准备 select 列表
    • 5.3 select 系统调用等待新客户端
    • 5.4 接受新客户端连接
    • 5.5 开始与新客户端通信
    • 5.6 客户端代码与服务器通信
    • 5.7 多客户端与单服务器通信演示

  6. UDP 套接字程序实践
    • 6.1 UDP 服务器代码
    • 6.2 UDP 客户端代码
    • 6.3 客户端与服务器之间的通信演示

  7. 剩余部分
    • 7.1 第 1 部分 - gethostname 和 gethostbyname 使用
    • 7.2 第 2 部分 - send 和 recv 标志参数
    • 7.3 第 3 部分 - 其他重要的 API

  1. Introduction
  2. Introduction

  1. Introduction To Sockets
  2. What Are Sockets
  3. Connection Oriented and Connection less sockets
  4. IP Addresses (Version 4 and Version 6)
  5. Blocking Vs Non Blocking sockets
  6. Understanding Network Byte Order
  7. Important Structures To know before you start

  1. Setting Up Windows Dev Environment
  2. Socket Programming On Windows
  3. Setting Up the Windows OS for Socket Programming
  4. Sample Program to test the readiness of your system

  1. Hands On With Socket Programming (TCP) - Single Client Socket Server
  2. Opening A Socket
  3. Setting Socket Options
  4. Make your socket blocking vs non blocking
  5. Binding your socket to local port
  6. Accepting The New connection from client
  7. Communicating with new client connected
  8. Client Socket Code
  9. Demo Of Client Server Running Code

  1. Hands on With Socket Programming - Multi client Handling
  2. Open a New Socket As Listener
  3. Introduction to FD_SET and Preparing the select list
  4. select system call waiting to new clients
  5. Accepting a new client connection
  6. Start Communication With New client
  7. Client Code To Communicate to Server
  8. Multiple Client and one Server Communication Demo

  1. UDP Socket Programs Hands On
  2. Code for UDP Server
  3. Code For UDP Client
  4. Demo Communication Between client and server

  1. Leftovers
  2. Part 1 - gethostname and gethostbyname use
  3. Part -2 send and recv flag argument
  4. Part - 3 Remaining Important APIs
@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

1. 介绍

1.1 介绍

课程介绍:C 语言与 Socket 编程

大家好,我是 Chaka,欢迎大家来到我的课程《C 语言与 Socket 编程》。让我先简单介绍一下我自己。我是一名软件专业人员,拥有 16 年的工作经验。我的主要工作领域包括 C++ 开发、Socket 编程和 Windows 基础知识。

我毕业于印度北方邦戈尔哈特技术大学,获得了计算机科学与技术专业的学位(2006 年)。此外,我还拥有数学学士学位(2003 年)。

课程目标

本课程面向初学者和中级学习者,内容将涵盖 Socket 编程 的基础知识,并帮助大家在掌握这些基础后,能够进一步提升到中级水平。只要您理解了课程中的概念,您就能够在此基础上进一步学习和发展。

在本课程中,我们将学习到以下内容:

1. Socket 编程基础

  • 理解 Socket 编程 的基础概念,包括如何通过网络在两台计算机之间传输数据。
  • 学习 IPv4 和 IPv6 地址机制,理解它们的工作原理。请注意,这部分内容将是理论性的,我们不会进行代码开发演示。

2. 网络字节顺序

  • 了解 网络字节顺序 及其在数据传输过程中的重要性。网络中的数据传输需要考虑字节顺序问题,因此在进行数据传输时,我们必须确保两台计算机使用相同的字节顺序。

3. 使用 TCP 的 Socket 编程

  • 学习 TCP Socket 编程,了解其作为连接导向协议的特性。
  • 我们将创建 单客户端和多客户端处理服务器,并使用 IPv4 地址 进行编程。

4. 使用 UDP 的 Socket 编程

  • 学习 UDP Socket 编程,了解其作为无连接协议(数据报协议)的特性。
  • 同样,我们将使用 IPv4 地址 进行 UDP 编程。

通过本课程的学习,您将获得必要的 Socket 编程技能,并能够在实际应用中开发网络程序。

祝大家学习愉快,感谢您的参与!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

2. 套接字介绍

2.1 什么是套接字

Socket 编程基础与概念

让我们从 Socket 编程的基本概念 开始,首先了解什么是 Socket 编程

什么是 Socket 编程?

Socket 编程 是一种连接两个程序的方式,使得它们能够相互通信、共享数据以及发送和接收消息。换句话说,如果你希望两个程序能够交换数据,那么就需要使用 Socket 编程

Socket 编程 中,有两个重要的角色:

  1. 服务器程序:这个程序会持续运行并监听来自客户端的请求,等待客户端的连接。
  2. 客户端程序:这个程序会连接到服务器并请求服务器提供的某些资源或服务。

服务器是一个“监听者”,它会不断地运行,并等待来自其他程序的请求。而客户端则通过连接到服务器来访问它提供的服务或资源。

服务器和客户端之间的通信

有时,服务器不仅仅是与一个客户端通信,它还可能在客户端之间充当中介,允许多个客户端之间的通信。例如,在聊天服务器的场景中,当你与朋友聊天时,并不是直接通过网络连接与朋友对话。事实上,聊天服务器在你和朋友之间充当了中介,促进了你们之间的通信。

因此,无论是服务器程序还是客户端程序,它们都是 Socket 程序。服务器程序的作用是让多个客户端能够相互通信,而客户端则是通过连接服务器来获取所需服务或资源。

Socket 是什么?

编程 中,Socket 类似于 文件描述符。就像你打开一个文件来读取或写入时,操作系统会为你创建一个流通道,用于读取或写入该文件。类似地,当两个程序需要通信时,它们会打开一个 通信通道,并且通过 Socket 描述符 来进行通信。

通过这个 Socket 描述符,两个程序就能够相互通信。Socket 描述符本质上是通信通道的唯一标识符,它使得程序能够通过指定的通信路径与其他程序交换数据。

Socket 的类型

在网络编程中,常见的有两种类型的 Socket

  1. 流式 Socket(连接导向):也称为 TCP Socket,用于建立可靠的、面向连接的通信通道。数据在这类 Socket 中以流的形式传输,因此它适用于需要保证数据完整性和顺序的应用。

  2. 数据报式 Socket(无连接):也称为 UDP Socket,用于无连接的、数据包导向的通信。它适用于对实时性要求较高的应用,如视频会议或在线游戏,因为它不需要建立连接,数据以独立的数据包形式传输。

在接下来的讲解中,我们将详细探讨这两种类型的 Socket,并了解它们的不同特性和应用场景。

结束语

感谢您的关注!在后续的课程中,我们将深入探讨 Socket 编程的各个方面,包括如何在实际应用中使用这些技术来进行网络通信。

2.2 面向连接和无连接的套接字

Stream Sockets 与 Datagram Sockets 的区别

Stream Sockets(流式套接字)

首先,让我们来了解 流式套接字。流式套接字是 面向连接的套接字,这意味着每当通信开始时,必须保持一个活动连接。如果连接丢失,通信将无法继续。因此,流式套接字的关键特性包括:

  1. 连接导向:流式套接字需要一个持续的连接。这意味着在通信过程中,两端的程序必须保持一个开放的、稳定的连接,以便能够交换信息。

  2. 双向可靠通信:流式套接字提供双向可靠的通信。这是通过 传输控制协议(TCP) 实现的。TCP 是一种 面向连接 的协议,确保数据能够可靠、顺序地从一个端传输到另一个端。

  3. TCP 保证通信可靠性:TCP 使用一个 96 位的 伪头部 来计算校验和,从而确保数据传输的完整性和准确性。伪头部并不作为实际数据包的头部发送,而是包含有 校验和,用于校验数据包是否传输正确。如果发送端和接收端的校验和相同,说明数据无误;如果校验和不同,说明数据包在传输过程中可能发生了错误,发送端会重新发送数据包,直到接收端的校验和匹配。

  4. 顺序保证:流式套接字保证数据的顺序。在传输过程中,如果发送端按照顺序发送了数据包(如包 1、包 2),接收端会按同样的顺序接收数据。无论数据包数量多少,流式套接字都会确保数据是 按顺序接收且无误差 的。

  5. 示例:一个典型的流式套接字应用是 Telnet。Telnet 是一个基于 TCP 的协议,它始终需要保持一个连接,以便持续的、可靠的双向通信。

Datagram Sockets(数据报套接字)

接下来,我们来看 数据报套接字。数据报套接字是 无连接的套接字,它不需要持续的连接来进行通信。每当数据包发送时,它会作为一个独立的包发送,而不需要先建立一个连接。它的关键特点包括:

  1. 无连接:数据报套接字不要求保持一个持续的连接。每个数据包都独立发送,不需要建立连接或等待确认。

  2. 不可靠:由于 UDP(用户数据报协议) 是无连接的,数据报套接字被认为是不可靠的。这意味着数据包可能不会按时到达接收端,或者即使到达,顺序可能也会被打乱。例如,如果发送端先发送数据包 1 然后发送数据包 2,接收端可能会先收到数据包 2 然后是数据包 1,导致顺序不一致。

  3. 数据包丢失:数据报套接字也可能会遇到数据包丢失的情况。发送的数据包可能根本没有到达接收端,这种丢失是数据报套接字的不可靠性的体现。因此,发送方无法确定接收方是否已经成功接收到数据包。

  4. 示例:一个典型的使用数据报套接字的应用是 FTP(文件传输协议)。尽管 UDP 本身是不可靠的,FTP 在传输过程中通常会利用 确认机制 来确保数据包的完整性和到达。这意味着,FTP 会要求接收端发送 确认信息,如果发送方没有收到确认,它将重新发送数据包,直到接收到确认。

总结

  • 流式套接字(Stream Sockets):面向连接、可靠、双向通信,确保数据的完整性、顺序和可靠传输,常用于 TCP 协议。

  • 数据报套接字(Datagram Sockets):无连接、不可靠、独立的数据包传输,适用于 UDP 协议,可以通过应用层的机制(如确认机制)来提高可靠性,常用于需要快速传输且对丢包不敏感的应用。

通过以上比较,你可以看到两者的主要区别在于 连接的有无数据的可靠性。流式套接字通过 TCP 提供可靠的通信,而数据报套接字则依赖于 UDP 提供快速但不可靠的通信。

2.3 IP 地址(版本 4 和版本 6)

IPV4 和 IPV6

什么是 IPV4?

首先,让我们了解一下 IPV4。IP 是 Internet Protocol(互联网协议) 的缩写,IP 地址用于在网络中路由数据包。IPV4 是一种 网络路由协议,它使用由四个字节(32 位)组成的地址。例如,常见的 IP 地址格式像 192.0.2.1,这种地址格式在你的系统、手机等设备上非常常见。

随着计算机、笔记本、手机以及其他设备数量的增加,IP 地址需求急剧增加。由于需求的增长,IPv4 地址(由四个字节组成)已经无法满足需求了。这个问题由 Vint Cerf 提出了。Vint Cerf 是一位美国网络先锋,被称为“互联网之父”,他在 1943 年出生,他预见到很快我们就会耗尽 IPv4 地址。

为什么需要 IPV6?

由于 IPV4 的地址空间已经接近枯竭,IPv6 应运而生。IPv6 使用 128 位 来生成 IP 地址,而 IPV4 仅使用 32 位。IPv6 地址的可用地址空间是 IPv4 的极大扩展,它能够生成约 340万万亿万亿个地址,这一数量足够满足全球范围内设备的需求。

IPV6 地址的格式

IPv6 地址通常采用 十六进制 的形式,地址之间用冒号(:)分隔。例如,一个典型的 IPv6 地址看起来会非常长,并且包含很多零。为了简化书写,IPv6 地址允许对多个连续的零进行压缩。例如,假设一个地址中有多个零段,它们可以通过双冒号(::)来表示,这样就可以使地址更加简洁。下面是一个压缩前和压缩后的 IPv6 地址示例:

  • 压缩前的地址:

    0:0:0:0:0:0:51:51
    
  • 压缩后的地址:

    ::51:51
    

这两者代表的是同一个地址。

循环地址(Loopback Address)

IPv6 也有 回环地址,类似于 IPv4 中的 127.0.0.1。IPv6 中的回环地址通常表示为 ::1,这可以与标准的回环地址表示方法互换使用,两个都是一样的。

总结

  • IPV4 使用 32 位地址,地址空间有限,随着互联网设备的不断增加,IPv4 地址逐渐用尽。
  • IPV6 使用 128 位地址,提供了几乎无限的地址空间,足以应对未来设备连接需求的增长。
  • IPV6 地址 采用十六进制表示,支持压缩表示多个连续的零段,简化地址书写。

如果你对 IPV6 与 IPV4 之间的差异、优势等有更深入的兴趣,建议你进一步查阅相关的互联网资源,如 JavaPoint 等网站。

2.4 阻塞与非阻塞套接字

阻塞与非阻塞套接字

什么是阻塞套接字?

在默认情况下,任何通过套接字 API 调用打开的套接字都是 阻塞套接字。这意味着,当你进行 输入输出操作,或者说进行数据发送和接收时,程序会被阻塞,直到操作完成。举个例子,如果你正在向服务器或客户端发送消息,程序将一直阻塞,直到消息成功传输并确认完成。

如果你作为 客户端,尝试连接到服务器,那么在连接建立之前,客户端线程也会被阻塞,直到服务器响应。这意味着在 阻塞模式 下,操作必须等到完成才能继续,程序无法执行其他任务,必须等待连接或数据传输完成。

非阻塞套接字

与阻塞套接字不同,非阻塞套接字允许程序在进行套接字操作时,不会停下来等待。以 浏览器 为例,当你打开浏览器并尝试连接到 google.com 或其他网站时,浏览器并不会在等待服务器响应时卡住。即使服务器没有响应,浏览器仍然可以进行其他操作。

如果你不希望在进行发送、接收或连接时让程序阻塞,应该使用非阻塞套接字。这样,如果连接没有立即建立,程序不会停下来等待,反而可以继续执行其他任务。

如何实现非阻塞套接字?

要将套接字设置为非阻塞,你需要使用特定的 API系统调用。这些方法可以使你的套接字不再阻塞,允许它在等待操作完成时执行其他任务。具体的实现方法会在后续的视频或实践中进一步讲解。

总结

  • 阻塞套接字 会在发送、接收或连接过程中阻塞程序的执行,直到操作完成。
  • 非阻塞套接字 允许程序在等待操作时执行其他任务,不会被单个操作所阻塞。

理解了这两种套接字的区别后,你可以根据实际需要选择使用阻塞还是非阻塞套接字。

2.5 理解网络字节顺序

网络字节顺序(Network Byte Order)

印度序(Indianness)

在理解 网络字节顺序 之前,我们首先需要了解 印度序(Indianness)的概念。印度序指的是在计算机中如何存储多字节数据的顺序。根据字节的存储顺序,通常有两种格式:大端格式(Big Endian)小端格式(Little Endian)

  1. 大端格式(Big Endian):将最重要的字节(最左边的字节)存储在最小的内存地址处。
  2. 小端格式(Little Endian):将最不重要的字节(最右边的字节)存储在最小的内存地址处。

大端格式与小端格式的示例

假设我们有一个整数 15,它的二进制表示为 00000000 00000000 00000000 00001111(在 32 位系统上)。在 大端格式 中,最重要的字节(00000000)存储在最低地址,而最不重要的字节(00001111)存储在最高地址。

小端格式 中,最不重要的字节(00001111)存储在最低地址,而最重要的字节(00000000)存储在最高地址。

例如:

  • 大端格式00000000 00000000 00000000 00001111(低地址 -> 高地址)
  • 小端格式00001111 00000000 00000000 00000000(低地址 -> 高地址)

网络字节顺序

网络字节顺序 中,使用的是 大端格式(Big Endian)。这意味着在通过网络传输数据时,数据会按照 大端格式 存储和传输,无论发送端和接收端的机器是采用什么样的字节顺序。

  • 网络字节顺序:网络协议如 TCP/IP 使用 大端格式 来存储和传输数据,确保即使不同机器使用不同的字节顺序,数据也能正确地传输和解释。

    TCP/IP 协议要求,数据在传输之前必须按照 大端格式(网络字节顺序)进行转换,即使机器本身使用的是 小端格式(如常见的 Intel 处理器)。如果接收端使用的是 小端格式,则接收端必须将数据从网络字节顺序转换回本机的字节顺序。

字节顺序转换函数

为了实现不同字节顺序之间的转换,套接字 API 提供了一些转换函数。例如:

  1. 从主机字节顺序转换为网络字节顺序(主机为小端格式时):

    • htons():将 16 位的主机字节顺序转换为网络字节顺序。
    • htonl():将 32 位的主机字节顺序转换为网络字节顺序。
  2. 从网络字节顺序转换为主机字节顺序

    • ntohs():将 16 位的网络字节顺序转换为主机字节顺序。
    • ntohl():将 32 位的网络字节顺序转换为主机字节顺序。
  3. 用于 IP 地址的转换

    • inet_addr():将点分十进制的 IP 地址(如 192.168.1.1)转换为网络字节顺序(大端格式)。
    • inet_ntoa():将网络字节顺序的 IP 地址转换为点分十进制表示。
  4. 其他平台的差异

    • Unix 系统 中,使用 inet_aton()inet_ntoa() 来处理 IP 地址的字节顺序转换。
    • Windows 系统 中,可能会有不同的 API 来进行相同的转换,具体细节将在后续的视频中讨论。

重要提示

  • 小端格式 的计算机上发送数据时,必须使用这些转换函数将数据转换为 大端格式(网络字节顺序),然后再通过网络发送。
  • 在接收端,如果接收端的计算机是 小端格式,则需要将数据从 大端格式 转换回 小端格式,以确保数据可以正确处理。

小结

  • 网络字节顺序 是一种 大端格式,它保证了不同硬件平台间的数据传输一致性。
  • 无论发送端和接收端的字节顺序如何,TCP/IP 协议都会确保数据以网络字节顺序进行传输。
  • 使用适当的函数进行字节顺序转换是确保网络通信数据正确传输的关键。

2.6 开始前需要了解的重要结构

重要的结构体和头文件

在开始编写套接字编程代码之前,了解一些关键的结构体是非常重要的。这些结构体将在您创建和配置套接字时起到关键作用。以下是几个在套接字编程中非常重要的结构体,您需要了解它们的用途和如何使用它们。

1. Socket 结构体

首先,您需要了解 Socket 结构体,它是与网络连接相关的核心结构之一。在 Windows 环境中,您可以在 MSDN 在线库中找到有关该结构体的详细信息。您可以访问 MSDN 了解更多内容。这个结构体很简单,且已经被详细解释,适合初学者理解。

Socket 结构体是套接字编程中最基本的结构之一,它定义了您如何与网络交互。您可以使用此结构来设置套接字、连接到远程服务器、监听特定端口等。

2. Socket_in 结构体

另一个重要的结构体是 Socket_in 结构体,它通常用于描述地址信息。特别是在 IPV4 环境中,您将使用此结构体来绑定套接字地址、连接到服务器或监听来自客户端的请求。

  • 在服务器端,您需要使用 Socket_in 来准备套接字,监听指定的端口,等待客户端的连接请求。
  • 在客户端,您使用 Socket_in 来配置服务器的地址和端口,以便连接到目标服务器。

3. Socket_in6 结构体

随着 IPV6 的引入,套接字相关的结构体也有所变化。对于 IPV6 网络,您需要使用 Socket_in6 结构体,它与 Socket_in 结构体非常相似,但是支持更大的地址范围(128 位)。因此,在使用 IPV6 网络时,您必须了解并使用 Socket_in6 结构体。

  • 如果您在工作中涉及到 IPV6 地址(例如连接到支持 IPV6 的服务器或设备),您将使用此结构体。

4. Addrinfo 结构体

另一个重要的结构体是 Addrinfo。它是套接字编程中用于存储与主机相关的网络信息的结构体,通常用于获取有关主机的详细信息。例如,它可以包含有关主机的 IP 地址、端口号等信息。这个结构体是由 getaddrinfo 函数使用的。

  • 如果您正在创建一个服务器,您将使用 Addrinfo 结构体来获取有关主机的信息。
  • 对于客户端来说,您可能也需要获取本地或远程主机的地址信息,Addrinfo 结构体将非常有用。

结构体总结

  • SocketSocket_in 结构体非常重要,尤其是在设置套接字进行连接和监听时。
  • Socket_in6 结构体是处理 IPV6 地址的关键。
  • Addrinfo 结构体可以帮助您获取主机的详细信息,特别是在动态配置主机地址时。

学习资源

这些结构体的具体定义和用法可以在 MSDN 在线文档中找到,建议您阅读这些资源,以便理解如何使用这些结构体进行套接字编程。

下一步

  • 在接下来的学习中,我们将通过实践编写一些代码,实际演示如何使用这些结构体进行套接字编程。
  • 掌握这些基础结构体将帮助您轻松地理解套接字编程的核心概念。

感谢您的阅读,接下来的部分将涉及到更多关于实际操作的讲解。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

3. 设置 Windows 开发环境

3.1 Windows 上的套接字编程

Windows环境中的套接字编程设置

在本讲中,我们将讨论如何在Windows环境中设置套接字编程开发环境。首先,我们需要做出一些决定,特别是选择使用的集成开发环境(IDE)。接下来,我们将了解与套接字编程相关的一些头文件和库文件。

1. 选择IDE

在这门课程中,我将使用 Visual Studio 2019 作为集成开发环境。如果您喜欢使用其他IDE,也完全可以,但请确保您熟悉所选IDE的使用方法。因为我整个课程中不会使用其他IDE,所以建议您确保能够熟练操作 Visual Studio 2019

2. 套接字编程所需的头文件

对于Windows平台上的套接字编程,有两个主要的头文件:

  • winsock.h
  • winsock2.h

这两个头文件都用于套接字编程,但在任何时刻您只能使用其中一个。您不能同时使用这两个头文件。winsock2.h 是较新的版本,它包含了 winsock.h 中的所有函数,因此我们推荐使用 winsock2.h

兼容性说明

如果您之前使用 winsock.h 写过程序,您可以轻松地将程序移植到使用 winsock2.h 的代码中。也就是说,winsock2.h 是向后兼容的。假设您在2010年或2011年用 winsock.h 写了某个程序,那么只需要将头文件改为 winsock2.h,代码就能继续编译并正常运行。这是因为 winsock2.h 包含了 winsock.h 中的所有内容,并且提供了更多的API和功能。

3. 套接字编程所需的库文件

接下来,我们需要了解套接字编程所使用的库文件。Windows中有两个主要的库文件:

  • wsock32.lib:当您使用 winsock.h 时,您需要使用此库文件。它包含了所有与 winsock.h 相关的函数定义。
  • ws2_32.lib:当您使用 winsock2.h 时,您需要使用此库文件。它包含了与 winsock2.h 相关的函数定义。

4. DLL文件的作用

除了静态库(lib文件),这些库还有对应的 DLL文件,它们在运行时提供函数的实现。具体来说:

  • 对于 wsock32.lib,其对应的动态链接库是 wsock32.dll,它包含了所有与 winsock.h 相关的函数定义。
  • 对于 ws2_32.lib,其对应的动态链接库是 ws2_32.dll,它包含了所有与 winsock2.h 相关的函数定义。

这意味着,您编写的程序在运行时依赖这些DLL文件来提供函数的实际实现。

5. 设置环境

在安装 Visual Studio 2019 后,相关的 DLL文件(如 wsock32.dllws2_32.dll)会被自动放置在正确的位置,通常是在 System32 文件夹或 Path 环境变量中。这意味着,在使用 Visual Studio 2019 时,您无需手动配置这些文件的位置,它们已经准备好了。

6. 总结

在Windows上进行套接字编程时,您需要了解以下几个关键点:

  • 选择IDE:建议使用 Visual Studio 2019,但您可以选择其他IDE,只要您熟悉其使用方法。
  • 头文件:推荐使用 winsock2.h,它是向后兼容的,包含了 winsock.h 的所有功能。
  • 库文件:对于 winsock.h 使用 wsock32.lib,对于 winsock2.h 使用 ws2_32.lib
  • DLL文件:运行时依赖的动态链接库分别是 wsock32.dllws2_32.dll,这些DLL会自动放置在正确的路径中。

在接下来的课程中,我们将演示如何在 Visual Studio 2019 中实际操作并进行套接字编程。

感谢您的阅读,敬请期待下一节内容!

3.2 为套接字编程配置 Windows 操作系统

在Visual Studio 2019中设置套接字编程环境

欢迎回来,在本视频中,我们将展示如何在 Visual Studio 2019 中为套接字编程设置开发环境。接下来,我将为您演示如何创建一个新项目并配置所需的头文件和库文件。

1. 创建新的C++控制台应用程序项目

首先,打开 Visual Studio 2019,并创建一个新的C++控制台应用程序项目。选择一个您希望存储项目的位置,并为项目命名为 UtilitySockets。点击创建按钮后,Visual Studio会自动生成一些代码文件。您应该熟悉这个过程,因为在使用 Visual Studio 时,IDE 会自动为您生成一些默认的代码。

2. 选择并添加适当的头文件

我们接下来要讨论的是在进行套接字编程时需要使用的头文件。在Windows环境下,您可以使用以下两种头文件:

  • winsock.h:这是较旧的版本。
  • winsock2.h:这是较新的版本,我们将在本课程中使用它。
如何使用这些头文件

如果您选择使用 winsock.h,请确保在代码中引用该头文件并编译代码:

#include <winsock.h>
using namespace std;

如果您使用 winsock.h,编译是成功的。但如果您尝试同时使用两个头文件 winsock.hwinsock2.h,编译会失败。原因在于这两个头文件中包含了一些相同的符号,因此它们不能在同一源文件中同时存在。

如何选择头文件
  • 如果您选择 winsock.h,那么程序可以正常工作,但这是较旧的版本。
  • 如果您选择 winsock2.h,它会提供更多的功能,因此我们建议您在本课程中使用 winsock2.h。如果您的项目需要更多功能或与最新的套接字编程兼容,建议选择使用 winsock2.h

3. 配置库文件

我们还需要为套接字编程配置适当的库文件。具体的库文件如下:

  • wsock32.lib:当您使用 winsock.h 头文件时,应该使用这个库文件。
  • ws2_32.lib:当您使用 winsock2.h 头文件时,应该使用这个库文件。
如何配置库文件

在使用 winsock.h 时,您需要引用 wsock32.lib,并且确保在项目设置中链接到该库文件。

如果您使用的是 winsock2.h,则需要引用 ws2_32.lib,并在项目设置中进行链接。

如何配置DLL文件
  • wsock32.dll:这个动态链接库与 winsock.h 一起使用,包含了所有套接字函数的实现。
  • ws2_32.dll:这个动态链接库与 winsock2.h 一起使用,包含了所有函数的实现。

这些 DLL文件 在您安装 Visual Studio 2019 时会自动放置在系统的正确位置(例如,System32 文件夹或 Path 环境变量中)。因此,您无需手动配置这些 DLL 文件。

4. 在项目设置中配置库文件

如果您不想在代码中明确指定库文件,也可以通过 Visual Studio 的项目设置来配置库文件的引用:

  1. 打开项目属性,选择 链接器 设置。
  2. 输入 选项中添加相应的库文件:
    • 对于 winsock.h,添加 wsock32.lib
    • 对于 winsock2.h,添加 ws2_32.lib

您还可以直接在代码中使用以下指令来指示链接器:

#pragma comment(lib, "ws2_32.lib")

如果您使用的是 winsock2.h,可以使用上面的代码来告诉编译器链接 ws2_32.lib

5. 代码编译与运行

一旦您配置好头文件和库文件,点击编译,您的程序将成功编译并准备好进行套接字编程。如果您遇到任何错误,请确保您引用了正确的头文件和库文件,并且确保项目中所有设置都正确。

6. 下一步:加载套接字DLL

在下一个视频中,我们将讨论如何加载套接字DLL文件。在Windows中,套接字API的使用不同于UNIX系统。与UNIX不同,Windows中的套接字编程需要在使用API之前先加载相关的DLL文件。我们将介绍如何执行这些操作,并确保套接字函数能够正确工作。

感谢您的观看,期待在下一个视频中与您继续讨论套接字编程的实践!

3.3 测试系统准备情况的示例程序

在Windows环境中使用套接字API的准备工作

欢迎回来!在本视频中,我们将讨论在开始使用套接字API之前需要做哪些准备工作。特别是,在Windows环境中,您需要调用一个特定的函数来加载套接字所需的库。

1. 使用 WSAStartup 初始化套接字API

在Windows环境下,使用套接字API之前,您需要调用 WSAStartup 函数。这是一个重要的步骤,因为它会加载必要的DLL文件,并使套接字功能在您的程序中可用。

WSAStartup 函数的使用方式
WSADATA wsaData;
int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
  • MAKEWORD(2, 2):指定要使用的Windows套接字版本。在这里,我们使用版本 2.2(即 winsock2)。
  • wsaData:一个 WSADATA 结构体,它用于存储与Windows套接字API相关的版本和信息。我们需要传递这个结构体的地址作为参数。
错误处理

如果 WSAStartup 函数调用失败,它会返回错误码(通常为 SOCKET_ERROR),并且套接字相关的API无法使用。在这种情况下,您应该检查返回值并进行相应的错误处理:

if (result != 0) {
    std::cout << "WSAStartup failed with error: " << result << std::endl;
    return 1;  // 返回失败状态
}

2. WSAStartup 的作用

调用 WSAStartup 函数的主要目的是加载Windows套接字库的动态链接库(DLL),例如 ws2_32.dll。这些DLL包含了所有与套接字操作相关的函数。如果 WSAStartup 成功执行,它会返回 0,表示套接字库已经正确加载到程序的内存中。否则,它会返回一个错误码。

3. 检查是否成功加载套接字库

如果 WSAStartup 成功返回0,您可以在控制台中打印出成功信息:

if (result == 0) {
    std::cout << "Successfully initialized socket library!" << std::endl;
} else {
    std::cout << "Failed to initialize socket library!" << std::endl;
    return 1;
}

4. 获取主机名称

在成功调用 WSAStartup 后,您可以开始使用套接字API。在本例中,我们将使用 gethostname API 获取主机名。

char hostname[32];
int hostname_len = sizeof(hostname);

int retval = gethostname(hostname, hostname_len);
if (retval == 0) {
    std::cout << "Hostname: " << hostname << std::endl;
} else {
    std::cout << "Unable to retrieve hostname!" << std::endl;
}
  • gethostname:此函数返回当前主机的名称。
  • 如果成功,它返回0,并将主机名存储在提供的数组中。
  • 如果失败,则返回-1,并且您可以根据此错误码处理异常。

5. 未调用 WSAStartup 的情况

如果您没有调用 WSAStartup 函数,而直接尝试使用 gethostname 等套接字API,那么这些API将无法工作,并且返回错误(通常是 -1)。

在没有调用 WSAStartup 的情况下运行代码,会得到以下结果:

int retval = gethostname(hostname, hostname_len);  // 错误,未初始化套接字库

此时,retval 会返回 -1,表示调用失败。

6. 示例代码和调试

  • 在上面的代码中,如果您没有先调用 WSAStartup,则 gethostname 会失败并返回 -1。您可以通过在调试器中设置断点并检查返回值来验证这一点。
  • 如果一切正常并且 WSAStartup 成功执行,您将看到主机名称正确显示。

7. 结论

在Windows环境中进行套接字编程时,调用 WSAStartup 是必不可少的步骤。这个函数会初始化套接字库,并使您能够调用任何套接字API。请务必记住,如果没有调用 WSAStartup,您的程序将无法使用套接字API。因此,每次在Windows平台进行套接字编程时,WSAStartup 函数必须在调用任何套接字API之前执行。

通过这些准备工作,您现在应该清楚为什么 WSAStartup 是必需的,以及如何正确设置Windows套接字编程环境。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

4. 套接字编程实践(TCP) - 单客户端套接字服务器

4.1 打开一个套接字

如何在套接字编程中打开套接字以开始通信

欢迎回来!在本视频中,我们将讨论如何在服务端和客户端程序中打开套接字以开始通信。无论您是在编写服务器端程序,还是客户端程序,首先都需要在调用 WSAStartup 后打开套接字。

目前我们将展示如何在服务器端打开套接字。

1. 创建并初始化套接字

首先,您需要创建一个变量来表示套接字。在服务器端,我们通常称它为 listener socket(监听套接字)。为了简化示例,我们将其声明为全局变量,虽然在实际编程中,最好使用面向对象的方式将其放在类中,作为私有成员变量。

SOCKET listenerSocket;

在此示例中,listenerSocket 是一个全局变量,这只是为了方便演示,但在实际项目中,您应避免使用全局变量,而是应该将其放入类中。

2. 打开套接字

接下来,我们通过调用 socket 系统调用来创建套接字。socket 函数的参数如下:

  • 地址族:我们可以使用 AF_INET 来表示 IPv4 地址族,或者 AF_INET6 来表示 IPv6 地址族。因为在本示例中我们使用 IPv4 地址,所以我们将使用 AF_INET

    AF_INET
  • 套接字类型:有两种常用的套接字类型:流式套接字(Stream socket)数据报套接字(Datagram socket)。在本示例中,我们使用流式套接字,因此传入 SOCK_STREAM

    SOCK_STREAM
  • 协议:在协议中,我们可以传入 0,表示让系统根据地址族和套接字类型选择合适的协议。也可以明确指定协议,例如 IPPROTO_TCP 表示使用 TCP 协议。

    IPPROTO_TCP

将这些值传递给 socket 函数:

listenerSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

3. 错误处理

当调用 socket 函数时,如果套接字创建成功,它将返回一个非负整数,表示套接字描述符。如果创建失败,它将返回 INVALID_SOCKET(通常为 -1)。因此,我们可以通过检查返回值来判断套接字是否成功创建:

if (listenerSocket == INVALID_SOCKET) {
    std::cout << "Socket failed to open!" << std::endl;
} else {
    std::cout << "Socket opened successfully!" << std::endl;
}

4. 进一步调试

为了验证套接字是否成功打开,您可以设置一个断点,在 socket 调用后查看返回值。如果套接字成功打开,您将看到类似于以下的输出:

Socket opened successfully!

如果调用失败,则会输出:

Socket failed to open!

5. 套接字的作用

套接字可以看作是一个文件描述符,类似于打开文件时获得的文件描述符。套接字描述符允许程序通过网络与其他程序进行通信。当您成功打开套接字时,您可以使用该描述符进行读写操作,就像与文件进行操作一样。

例如,您可以使用套接字描述符来接收和发送数据,建立连接等。

6. Windows与Linux的区别

需要注意的是,在 Windows 系统中,您必须先调用 WSAStartup 函数来初始化 Winsock 库,这是因为 WSAStartup 用于加载相应的 DLL 文件。而在 LinuxUnix 环境中,您不需要调用 WSAStartup,因为这些操作系统本身已经内置了套接字库。

7. 代码示例

以下是完整的示例代码,用于打开一个监听套接字:

#include <iostream>
#include <winsock2.h> // Windows平台需要此头文件

SOCKET listenerSocket;

int main() {
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);  // 初始化Winsock
    if (result != 0) {
        std::cout << "WSAStartup failed: " << result << std::endl;
        return 1;
    }

    listenerSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 创建套接字
    if (listenerSocket == INVALID_SOCKET) {
        std::cout << "Socket failed to open!" << std::endl;
    } else {
        std::cout << "Socket opened successfully!" << std::endl;
    }

    // 其他代码,例如绑定、监听等
    return 0;
}

8. 结论

在Windows环境中进行套接字编程时,打开套接字是一个必不可少的步骤。使用 socket 系统调用创建套接字后,您可以使用该套接字进行网络通信。记住,在Windows环境下,您必须先调用 WSAStartup 来初始化套接字库,否则套接字操作将无法正常工作。

4.2 设置套接字选项

套接字选项设置函数 setsockopt 讲解

欢迎回来!在本视频中,我们将介绍如何使用 setsockopt 函数设置已打开套接字的各种选项。使用此函数,您可以为套接字设置一些限制或约束,或者配置特定的套接字行为,通常是在套接字级别或协议级别。

1. setsockopt 函数概述

setsockopt 是用来配置套接字的系统调用。它可以对已经创建并打开的套接字进行设置,比如设置缓冲区大小、重用地址、设置超时等。该函数的调用格式如下:

int setsockopt(SOCKET socket, int level, int optname, const char *optval, int optlen);
  • socket:您要设置选项的套接字。
  • level:您希望设置选项的级别(如套接字级别或协议级别)。
  • optname:要设置的选项名称。
  • optval:选项的值。
  • optlen:选项值的大小(通常是 optval 的长度)。

2. 设置选项的级别

  • 套接字级别 (SOL_SOCKET):用于设置套接字本身的行为。
  • 协议级别 (IPPROTO_TCP):用于设置与协议相关的选项,例如 TCP 协议的行为。

例如,如果您希望设置套接字的行为,可以使用 SOL_SOCKET 作为 level,而如果需要设置 TCP 协议的行为,则使用 IPPROTO_TCP

3. 设置重用地址选项

在本示例中,我们设置的是 "重用地址" 选项。这个选项允许多个套接字绑定到相同的端口号,特别是在服务器程序关闭并重启时,端口仍然可以被快速重新使用。

我们使用 setsockopt 函数来设置这个选项。具体代码如下:

int error = setsockopt(listenerSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)&optVal, sizeof(optVal));

在这段代码中:

  • listenerSocket 是我们已经创建的套接字。
  • SOL_SOCKET 是套接字级别的设置。
  • SO_REUSEADDR 是选项名称,表示允许端口重用。
  • optVal 是设置的值,这里我们使用 1 表示允许重用地址。
  • sizeof(optVal) 表示选项值的大小(通常是 int 类型)。

4. 错误处理

如果 setsockopt 调用成功,它会返回 0,否则返回 -1。因此,您可以通过检查返回值来判断是否成功设置了套接字选项。

if (error < 0) {
    std::cout << "Failed to set socket options!" << std::endl;
} else {
    std::cout << "Successfully set socket options!" << std::endl;
}

5. 设置更多的套接字选项

除了 SO_REUSEADDR 之外,您还可以设置其他类型的套接字选项,例如:

  • SO_RCVBUFSO_RCVBUF:分别设置接收和发送缓冲区大小。
  • SO_RCVBUF:设置最大接收缓冲区大小,限制在接收时不会超出一定的字节数。
  • SO_RCVBUF:设置最大发送缓冲区大小。

这些选项可以根据您的需要来设置,以优化套接字的性能和行为。

6. 示例代码

以下是设置套接字选项的完整示例代码:

#include <iostream>
#include <winsock2.h> // Windows平台需要此头文件

SOCKET listenerSocket;

int main() {
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);  // 初始化Winsock
    if (result != 0) {
        std::cout << "WSAStartup failed: " << result << std::endl;
        return 1;
    }

    listenerSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 创建套接字
    if (listenerSocket == INVALID_SOCKET) {
        std::cout << "Socket failed to open!" << std::endl;
        return 1;
    } else {
        std::cout << "Socket opened successfully!" << std::endl;
    }

    // 设置套接字选项以允许重用地址
    int optVal = 1;
    int error = setsockopt(listenerSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)&optVal, sizeof(optVal));
    if (error < 0) {
        std::cout << "Failed to set socket options!" << std::endl;
    } else {
        std::cout << "Successfully set socket options!" << std::endl;
    }

    // 其他代码,例如绑定、监听等
    return 0;
}

7. 总结

通过 setsockopt 函数,我们可以设置套接字的各种选项,以优化套接字的行为。常用的选项包括重用地址、缓冲区大小等。为了确保套接字行为符合要求,我们可以在程序中根据需要灵活使用这些选项。

如果您想进一步了解 setsockopt 函数的所有可用选项,可以查阅 MSDN 文档。在 MSDN 中,您可以找到关于不同 leveloptname 的详细描述,帮助您根据需求选择合适的选项。

希望今天的讲解对您有所帮助!

4.3 设置套接字为阻塞或非阻塞

套接字模式控制

欢迎回来!在前面的部分中,我们已经了解了如何打开套接字,并且我们将其命名为 listener socket(监听套接字)。在服务器程序中,监听套接字用于接收来自客户端的连接请求。然后,我们使用了 setsockopt 函数在套接字级别设置了一些选项,特别是设置了重用端口地址的选项。

在本视频中,我们将讨论如何控制套接字的模式,特别是套接字的 输入输出模式,即 阻塞模式非阻塞模式

1. 阻塞模式与非阻塞模式

首先,我们需要回顾一下 阻塞模式非阻塞模式 的区别:

  • 阻塞套接字:当套接字处于阻塞模式时,所有与接收或发送数据相关的操作(如 recvsend 函数)都会被阻塞。这意味着,直到所有数据完全发送出去或者接收到完整数据,程序才会继续执行。也就是说,程序会在这些函数处停留,直到满足条件。

  • 非阻塞套接字:与阻塞模式相反,在非阻塞模式下,所有的接收和发送操作不会阻塞。即使数据未完全发送或接收,程序也会继续执行,而不是等待直到完成。这使得程序能够继续执行其他任务,而不必停在接收或发送操作上。

2. 如何设置套接字为阻塞或非阻塞

我们可以使用 ioctlsocket 函数来设置套接字的模式。具体来说,通过 ioctlsocket 函数可以设置套接字的输入输出操作模式。该函数的调用格式如下:

int ioctlsocket(SOCKET s, long cmd, u_long *argp);
  • s:表示要设置模式的套接字。
  • cmd:指定命令,这里我们会使用 FIONBIO 来设置套接字为非阻塞模式。
  • argp:指向 u_long 类型的指针,如果其值为零,则设置为阻塞模式;如果其值为非零,则设置为非阻塞模式。

3. 代码示例

下面是一个设置套接字为阻塞模式的代码示例:

#include <iostream>
#include <winsock2.h> // Windows平台需要此头文件

SOCKET listenerSocket;

int main() {
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);  // 初始化Winsock
    if (result != 0) {
        std::cout << "WSAStartup failed: " << result << std::endl;
        return 1;
    }

    listenerSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // 创建套接字
    if (listenerSocket == INVALID_SOCKET) {
        std::cout << "Socket failed to open!" << std::endl;
        return 1;
    } else {
        std::cout << "Socket opened successfully!" << std::endl;
    }

    // 设置套接字为阻塞模式
    u_long mode = 0;  // 设置为0表示阻塞模式
    int error = ioctlsocket(listenerSocket, FIONBIO, &mode);
    if (error < 0) {
        std::cout << "Failed to set socket mode!" << std::endl;
    } else {
        std::cout << "Socket mode set to blocking!" << std::endl;
    }

    // 其他代码,例如绑定、监听等
    return 0;
}

4. 代码解释

  1. WSAStartup:初始化 WinSock 库,确保网络功能可用。
  2. socket:创建一个 TCP 套接字,用于监听客户端的连接。
  3. ioctlsocket:我们使用 ioctlsocket 函数来设置套接字的输入输出模式。
    • FIONBIO:命令常量,用来设置套接字为非阻塞模式或阻塞模式。
    • mode:我们传递一个 u_long 类型的变量。如果 mode 为 0,则表示套接字处于 阻塞模式;如果为非零值,则表示套接字处于 非阻塞模式

5. 非阻塞模式的设置

如果需要将套接字设置为非阻塞模式,只需要将 mode 设置为非零值即可,例如:

u_long mode = 1;  // 设置为1表示非阻塞模式

然后调用 ioctlsocket 函数:

int error = ioctlsocket(listenerSocket, FIONBIO, &mode);

6. 错误处理

在代码中,我们检查了 ioctlsocket 函数的返回值:

  • 如果返回值小于 0,表示设置失败,我们输出错误信息。
  • 如果设置成功,我们输出成功信息,确认套接字已设置为阻塞模式。

7. 总结

通过 ioctlsocket 函数,我们可以轻松地设置套接字为阻塞模式或非阻塞模式。根据应用场景的不同,您可以选择合适的模式来优化套接字的操作。

  • 如果您的程序希望等待数据的完整接收或发送,则可以选择 阻塞模式
  • 如果您希望程序不被数据的接收或发送操作阻塞,而能继续执行其他任务,则选择 非阻塞模式

希望本视频能够帮助您理解如何设置套接字的输入输出模式!

4.4 将套接字绑定到本地端口

绑定套接字到本地端口

到目前为止,我们已经学习了如何打开一个套接字、设置各种套接字选项以及如何控制套接字的模式(阻塞或非阻塞)。现在我们准备开始监听来自客户端的新连接。在开始监听之前,我们需要将服务器绑定到一个本地端口。为了实现这一点,首先需要了解如何绑定服务器套接字。

1. 绑定套接字的基本概念

为了让客户端能够连接到我们的服务器,我们需要指定服务器的 IP 地址和端口号。每个系统都有一个唯一的 IP 地址,我们的服务器程序将使用这个 IP 地址来监听客户端的连接。同时,我们还需要选择一个端口号,客户端将通过这个端口号来连接我们的服务器。

2. 定义端口和绑定结构

首先,我们定义一个端口号,假设我们选择的端口为 19999

#define PORT 19999

接下来,我们需要定义一个结构来保存套接字的相关信息,这个结构名为 sockaddr_in,它包含了地址族、IP 地址和端口号等信息。结构的定义如下:

struct sockaddr_in srv;

然后,我们需要调用 bind 函数来绑定端口。该函数有三个参数:

  1. socket:即我们之前创建的监听套接字。
  2. sockaddr_in:包含了我们设置的服务器地址和端口的结构。
  3. addrlen:结构的大小。

3. 设置结构字段

在调用 bind 之前,我们需要设置结构 sockaddr_in 的各个字段。具体操作如下:

  • sin_family:这个字段需要设置为 AF_INET,表示我们使用的是 IPv4 地址。
  • sin_port:需要设置为我们之前定义的端口号,且要使用 htons 函数转换为网络字节序。由于计算机内部的字节序通常是小端字节序,而网络传输使用的是大端字节序,因此需要进行转换。
  • sin_addr:这里我们需要设置为服务器的 IP 地址。如果我们希望服务器监听所有可用的 IP 地址,可以使用 INADDR_ANY

具体代码如下:

srv.sin_family = AF_INET;                  // 设置为 IPv4
srv.sin_port = htons(PORT);                // 设置端口号(网络字节序)
srv.sin_addr.s_addr = INADDR_ANY;          // 绑定到所有可用的本地地址

4. 调用 bind 函数

在配置好结构体之后,我们就可以调用 bind 函数将套接字绑定到指定的地址和端口上了:

int result = bind(listenerSocket, (struct sockaddr*)&srv, sizeof(srv));
  • listenerSocket:监听套接字,已经在前面的步骤中创建。
  • (struct sockaddr*)&srv:传递绑定信息的结构体地址。
  • sizeof(srv):结构体的大小,用来确保正确传递结构体的大小。

5. 错误处理

如果 bind 调用失败,我们需要输出错误信息。如果绑定成功,我们就会输出成功消息。代码如下:

if (result < 0) {
    std::cout << "Failed to bind to local port!" << std::endl;
} else {
    std::cout << "Successfully bound to local port!" << std::endl;
}

6. 代码调试

在调试过程中,我们可以设置断点并逐步执行代码,以查看 bind 调用是否成功。如果失败,我们需要检查是否有错误,可能是端口已经被占用,或者其他配置错误。例如,可能在定义端口号时出现了错误:

#define PORT 19999

如果端口号定义正确,而调用 bind 失败,则可能是结构体的设置有问题。例如,结构体 sin_zero 的大小设置错误,应该使用 sizeof(sin_zero) 而不是 sizeof(srv),否则会导致整个结构体被置为零,从而导致绑定失败。

7. 绑定成功后

在调试过程中,经过修正后,如果 bind 调用成功,系统将会输出 Successfully bound to local port!。此时,服务器已经准备好监听客户端的连接。

8. 服务器端绑定总结

通过调用 bind 函数,我们将监听套接字绑定到本地的 IP 地址和端口上,使得服务器能够接收来自客户端的连接请求。以下是完整的代码流程:

#include <iostream>
#include <winsock2.h> // Windows 平台的 Winsock 库

#define PORT 19999 // 服务器监听端口

int main() {
    WSADATA wsaData;
    int result = WSAStartup(MAKEWORD(2, 2), &wsaData);  // 初始化 Winsock 库
    if (result != 0) {
        std::cout << "WSAStartup failed: " << result << std::endl;
        return 1;
    }

    SOCKET listenerSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);  // 创建套接字
    if (listenerSocket == INVALID_SOCKET) {
        std::cout << "Socket creation failed!" << std::endl;
        return 1;
    }

    struct sockaddr_in srv;
    srv.sin_family = AF_INET;                  // 设置 IPv4
    srv.sin_port = htons(PORT);                // 设置端口号(网络字节序)
    srv.sin_addr.s_addr = INADDR_ANY;          // 绑定到所有可用的本地地址

    // 绑定套接字
    int resultBind = bind(listenerSocket, (struct sockaddr*)&srv, sizeof(srv));
    if (resultBind < 0) {
        std::cout << "Failed to bind to local port!" << std::endl;
    } else {
        std::cout << "Successfully bound to local port!" << std::endl;
    }

    // 后续的代码,如监听和接收连接...

    return 0;
}

9. 结论

至此,我们已经完成了将服务器套接字绑定到本地端口的过程。接下来,在下一节中,我们将讨论如何接受客户端的连接请求,并与客户端建立通信。

希望你能通过这一部分了解如何正确地绑定套接字以及相关的错误处理。你也可以通过查阅 MSDN 库,进一步理解 bind 函数和 sockaddr_in 结构。

4.5 接受来自客户端的新连接

连接监听和接受连接的实现

现在我们将看到如何在特定端口上监听传入的连接。到目前为止,我们已经了解了如何将服务器绑定到特定端口、如何设置套接字为阻塞或非阻塞模式。还有一点需要记住,如果你没有调用函数(比如 EOC 套接字)来设置它为阻塞或非阻塞,那么你创建的套接字默认就是阻塞的。所以默认情况下,套接字是阻塞的,这是你必须记住的。

绑定端口后准备监听

在绑定到特定端口之后,我们准备开始监听新的传入连接。如果我们准备好监听并接收来自客户端的新连接,那么一旦有客户端连接,我们就能接受这个连接并开始与客户端进行通信。

在接受连接之前,我们需要先调用 listen 函数来开始监听。listen 函数接受两个参数。第一个参数是我们的套接字,即监听套接字;第二个参数是 backlog,表示在被服务器接受之前,最多允许多少个客户端连接等待。如果有多个客户端尝试连接,且超过了设置的 backlog 数量,后续的客户端连接请求将会收到“服务器忙碌”的消息。

backlog 参数解释

例如,如果我们将 backlog 设置为 95,那么当有超过 95 个客户端连接到服务器时,第 96 个及以后的客户端将会收到“服务器忙”的提示,直到之前的客户端连接被接受。这里我们设置为 5,意味着最多允许 5 个客户端连接等待。

int error = listen(socket, 5);
if (error < 0) {
    std::cerr << "Unable to listen" << std::endl;
} else {
    std::cout << "Started listening on port" << std::endl;
}

监听并接受连接

在调用 listen 后,服务器将开始等待客户端的连接。在此之前,程序并不会执行任何其他操作,直到有客户端尝试连接。

int client_socket = accept(server_socket, (struct sockaddr *)&client_address, &client_len);
if (client_socket < 0) {
    std::cerr << "Failed to accept new connection" << std::endl;
} else {
    std::cout << "Successfully connected with a new client" << std::endl;
}

客户端套接字

在调用 accept 函数后,我们将获得一个新的套接字,这个套接字用于与客户端进行通信。这个新的套接字是专门为与客户端通信而创建的。我们仍然可以使用原始的监听套接字来监听新的客户端连接,而使用新返回的套接字来进行数据通信。

例如:

int client_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_len);
if (client_socket >= 0) {
    // 用新套接字与客户端通信
    char buffer[1024];
    recv(client_socket, buffer, sizeof(buffer), 0);
    send(client_socket, "Hello Client", 13, 0);
}

获取客户端信息

你还可以通过 accept 函数获得连接到服务器的客户端信息。假设你希望获取客户端的 IP 地址,可以通过以下方式实现:

struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_len);

if (client_socket >= 0) {
    char *client_ip = inet_ntoa(client_addr.sin_addr);
    std::cout << "Client IP: " << client_ip << std::endl;
}

阻塞与非阻塞模式

至此,我们讨论的是阻塞套接字的实现。当套接字是阻塞的时,它会等待直到接收到新的连接或发生错误。如果我们将套接字设置为非阻塞模式,那么 accept 调用会立即返回。如果没有客户端连接,它将返回一个错误。

在非阻塞模式下,我们可能会使用 while 循环不断检查是否有客户端连接:

int flags = fcntl(server_socket, F_GETFL, 0);
fcntl(server_socket, F_SETFL, flags | O_NONBLOCK);

while (true) {
    int client_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_len);
    if (client_socket >= 0) {
        // 处理客户端
    } else {
        std::cout << "No client, retrying..." << std::endl;
    }
}

然而,循环检查非阻塞套接字可能会导致过多的 CPU 使用,影响系统性能。因此,如果没有特殊需求,阻塞套接字通常是更好的选择。

总结

  • 阻塞套接字:默认情况下,套接字是阻塞的,accept 会一直等待直到有客户端连接。
  • 非阻塞套接字:如果设置为非阻塞,accept 会立即返回,但如果没有连接,它将返回错误。为了持续检查客户端连接,通常需要将 accept 放在一个循环中,但这样会增加 CPU 使用,影响性能。

在编写服务器时,通常推荐使用阻塞套接字,除非有特殊需求(例如需要处理大量并发连接)。

4.6 与已连接的客户端通信

你好,欢迎回来。在上一段视频中,我们看到如何接受客户端的连接。而在这段视频中,我们将学习,在接受了客户端的连接之后,如何与客户端开始通信。与客户端开始通信意味着发送和接收消息,发送消息给客户端,并接收客户端发来的消息。好的,假设现在客户端已经连接,直到目前为止我们假设客户端已经连接。接受客户端连接后,我们可以读取客户端发送的任何消息。为了读取消息,我们需要使用 receive 函数。为了使用 receive 函数,我们需要一个缓冲区。所以首先,我们需要创建这个缓冲区数组。假设缓冲区的大小是 1024。然后我们可以查看接收到的内容,所以我们可以使用 receive API。现在,由于我们将从客户端接收消息,那么我们将使用哪个套接字呢?我们将使用这个套接字,也就是我们在调用 accept API 后获得的客户端套接字。

创建缓冲区并接收消息

因此,首先,我们可以创建一个缓冲区并设置为 1024 字节。然后,我们使用 receive 函数从客户端接收消息。你将传递给 receive 函数以下参数:

  1. 使用客户端套接字 (client_socket) 来接收消息;
  2. 使用缓冲区来存放接收到的消息;
  3. 设定缓冲区的长度,假设最大长度为 1024;
  4. 设置标志,目前不使用任何特定标志,直接使用 0。

接收到消息后,如果 receive 返回的字节数大于零,那么表示我们已经成功接收到了消息。如果 receive 返回的字节数大于零,我们就可以读取客户端发来的消息,并在屏幕上显示出来。然后,我们将回应客户端。以下是一个简单的实现示例:

client_socket = # 接受连接后获得的客户端套接字
buffer = bytearray(1024)  # 创建一个缓冲区,大小为 1024

# 接收消息
bytes_received = client_socket.recv(1024)  # 使用客户端套接字接收消息

if bytes_received > 0:
    message = buffer.decode('utf-8')  # 解码为字符串
    print(f"收到的消息: {message}")

    # 回复客户端
    response = "Acknowledged"
    client_socket.send(response.encode('utf-8'))  # 发送应答给客户端
else:
    client_socket.close()  # 如果接收失败,则关闭连接

发送应答

在接收到消息后,服务器将回复客户端一条确认消息。通过 send 函数,我们使用相同的客户端套接字发送确认消息。这里的消息内容是 "Acknowledged",并且我们指定消息的长度为 13 字节(即 "Acknowledged" 的字符长度)。此时,标志仍然设置为 0,因为我们暂时不需要使用特殊的标志。

response = "Acknowledged"
client_socket.send(response.encode('utf-8'))  # 发送应答给客户端

错误处理

如果在接收消息时发生错误,receive 函数会返回一个小于零的值,这意味着接收操作失败。在这种情况下,我们应该关闭套接字并释放资源。你可以使用 client_socket.close() 来关闭套接字连接,并将 client_socket 设置为 0。

if bytes_received < 0:
    client_socket.close()  # 关闭套接字
    client_socket = None  # 清空套接字对象

处理客户端和服务器通信的循环

目前,这个程序只接收一次消息并发送一次应答。如果你希望客户端和服务器之间进行持续的通信,可以将 receivesend 放入一个 while 循环中,这样可以不断地接收和发送消息,直到用户主动关闭应用程序。接下来,我们将在后续视频中讨论如何实现这种持续通信。

总结

目前的代码只是一个简单的例子,展示了如何接收客户端的消息并发送应答。如果你希望有一个持续的双向通信,可以将这个逻辑放入一个 while 循环中,以便不断进行数据交换。最后,在后续的视频中,我会展示如何通过客户端代码来测试这个服务器端代码。

代码示例

最后,你将能够从上一段视频中获得客户端和服务器端的完整代码,我将在那段视频的资源部分提供客户端和服务器代码的文本文件。希望你能够在此之前独立地理解和实践这些内容,不要直接依赖这些资源。

4.7 客户端套接字代码

服务器代码回顾

到目前为止,我们已经学习了如何编写一个服务器端的代码,使其能够接受客户端的连接,读取客户端发送的消息,并且将消息的确认信息返回给客户端。这一部分涉及到了服务器端的基本 socket 编程。然而在本节视频中,我们将编写客户端代码,并且在下一个视频中,我们将会看到客户端和服务器端同时运行,进行实际的通信。

创建客户端应用程序

在这一视频中,我们将创建一个控制台应用程序,用于作为客户端。首先,我将创建一个名为 Udemy Client 的项目。

项目设置

  1. 创建一个新的项目,命名为 Udemy Client
  2. 打开项目,并移除所有不必要的内容。
  3. 引入 Winsock2.h 库,并使用 std 命名空间。

配置客户端代码

接下来,我们将编写客户端的代码,下面是实现步骤:

包含必要的头文件

首先,确保代码中包含了所有必要的头文件和库:

#include <winsock2.h>
#include <stdio.h>
#include <iostream>
using namespace std;

定义端口和 SRV 结构

在客户端代码中,我们需要定义连接到服务器时使用的端口,以及填写服务器相关信息的结构:

#define PORT 9999
SOCKADDR_IN srv;

初始化 Winsock 库

然后,我们需要初始化 Winsock 库:

WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
    cout << "Winsock initialization failed!" << endl;
    return 1;
}

创建客户端 socket

接下来,创建一个套接字(socket)用于连接到服务器:

SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (clientSocket == INVALID_SOCKET) {
    cout << "Socket creation failed!" << endl;
    return 1;
}

填充服务器信息

在连接之前,我们需要将服务器的地址和端口信息填充到 srv 结构体中:

srv.sin_family = AF_INET;
srv.sin_port = htons(PORT);  // 网络字节序的端口
srv.sin_addr.s_addr = inet_addr("192.168.1.8");  // 服务器的 IP 地址

连接到服务器

我们使用 connect() 函数来连接服务器:

if (connect(clientSocket, (SOCKADDR*)&srv, sizeof(srv)) == SOCKET_ERROR) {
    cout << "Connection failed!" << endl;
    return 1;
}

发送消息

连接成功后,客户端就可以向服务器发送消息了:

const char* message = "Hello from client";
if (send(clientSocket, message, strlen(message), 0) == SOCKET_ERROR) {
    cout << "Send failed!" << endl;
    return 1;
}

接收服务器的响应

客户端在发送消息后需要接收服务器返回的确认信息:

char buffer[55];
int bytesReceived = recv(clientSocket, buffer, sizeof(buffer), 0);
if (bytesReceived == SOCKET_ERROR) {
    cout << "Receive failed!" << endl;
    return 1;
} else {
    buffer[bytesReceived] = '\0';  // 确保缓冲区是一个合法的字符串
    cout << "Server says: " << buffer << endl;
}

关闭 socket

完成通信后,关闭客户端套接字:

closesocket(clientSocket);
WSACleanup();

编译与链接

在编译代码时,如果遇到 deprecated 警告,可以通过添加以下宏来关闭警告:

#define _WINSOCK_DEPRECATED_NO_WARNINGS

确保链接时包含 Ws2_32.lib 库,或者在项目的链接器设置中添加此库。

客户端与服务器的完整代码

这段代码完成了客户端的所有工作,包括创建 socket、连接到服务器、发送消息和接收服务器的回应。在本视频的最后,我会将客户端和服务器的完整代码提供给你,作为资源文件,供你参考和下载。

编译过程中的错误修正

在构建过程中,如果出现 undefined reference 错误,确保链接了正确的库文件:

#pragma comment(lib, "Ws2_32.lib")

如果遇到类似 deprecated API 的警告,可以在项目设置中禁用这些警告,或者通过代码中的宏定义来关闭它们。

下一步:客户端与服务器的通信

在下一个视频中,我们将演示客户端和服务器的实际运行,展示它们是如何相互通信的。

总结

到这里,我们已经完成了客户端程序的编写。代码包括了连接到服务器,发送数据,并接收服务器的响应。你可以运行这个客户端程序,连接到你本地的服务器,或者将服务器地址替换为其他机器的 IP 地址进行远程连接。

最后,记得在下一个视频中,我们会看到客户端与服务器的完整演示,帮助你更好地理解和掌握 Socket 编程的实际应用。

4.8 客户端服务器运行代码演示

欢迎回来。在本节课中,我们将看到之前编写的客户端和服务器程序之间的通信。首先,这就是你现在看到的服务器代码。在这段代码中,我将做一个小的修改,其他部分都没有问题。我只是想打印出客户端的地址。为了打印出客户端的地址,我们定义了一个名为client的变量,它的类型是_in。在accept函数中我们是这样调用的。当从客户端接受到一个新的连接时,这个client变量将包含客户端的IP地址。这个client变量保存了客户端的IP地址,并将其存储在sin_addr中。

如果你想要打印客户端的IP地址,你需要使用inet_ntoa这个函数。inet_ntoa是一个用来将网络字节顺序中的IP地址转换为ASCII形式的函数。你可能已经在之前的视频中了解过,客户端和服务器通信时,发送的任何信息都是以网络字节顺序发送的。我们也在理论部分讲解过网络字节顺序。所以我希望你对此有所了解。inet_ntoa的作用是将收到的网络字节顺序的数据转换为主机字节顺序。所以,网络发送的信息是网络字节顺序,但如果你需要在程序中打印你从网络接收到的信息,例如IP地址或端口号,你就需要使用inet_ntoa。如果你不使用这个函数,直接打印客户端地址时,可能会得到一些垃圾值。

为了能够使用这个inet_ntoa函数,你还需要在客户端程序中使用宏定义。正如我们在前面的视频中讨论的那样,你需要选择这个预处理器选项,通过定义宏_WINSOCK_DEPRECATED_NO_WARNINGS来允许使用inet_ntoa函数。如果没有定义这个宏,inet_ntoa函数就无法被调用,编译时程序会失败,因此,如果你想成功编译并打印客户端地址,你必须使用这个宏。

代码调试

在本例中,这是我的服务器端代码,在服务器端我设置了一个断点在accept函数处。这是客户端程序,我也在客户端的connect函数处设置了一个断点,目的是通过这些断点逐步演示客户端和服务器之间如何进行发送和接收连接。

首先,我运行服务器程序。主要的目的是在accept函数处停止,看看服务器如何开始监听端口并准备接受新的连接。此时,服务器已经准备好接受连接,暂停在accept函数处。

接下来,我运行客户端程序。客户端程序将连接到服务器,所有的连接信息(包括服务器的IP地址和端口号)都已经在客户端程序中设置好了。我在同一台机器上运行客户端和服务器程序。所以,当我执行连接时,客户端就发送了连接请求。

连接建立与信息接收

当客户端成功连接到服务器时,服务器的accept函数继续执行,打印出一条连接成功的消息以及客户端的IP地址。因为客户端和服务器都在同一台机器上运行,所以客户端的IP地址与服务器的IP地址相同。但如果你在不同的系统上运行客户端和服务器,服务器端和客户端的IP地址将会不同。

当连接建立后,服务器开始接收从客户端发送的信息。客户端会发送一个固定的文本消息。服务器接收到该消息后,会显示在接收缓冲区中。如果你在服务器端打印这个消息,就能看到客户端发送的消息。

接下来,服务器向客户端发送一个确认消息。客户端收到确认消息后,会在屏幕上打印出来。

调试与错误处理

在过程中,偶尔会遇到程序暂停或“卡住”的情况,可能是由于我的系统配置问题。此时我会重新启动服务器和客户端程序,确保它们可以继续正常运行。在重新启动程序并连接之后,客户端会发送消息,服务器会接收并打印它,然后服务器会给客户端发送确认消息,客户端也会收到并显示这条确认消息。

总结

在整个过程中,你学习了客户端和服务器之间的连接建立、数据传输和确认机制。通过这个实验,你应该能够理解如何通过代码实现客户端与服务器的基本通信。下一步,我们将编写一个可以同时管理多个客户端的服务器程序,并且还会进行一些额外的实验来进一步丰富客户端的功能。

下一步

在下一节课中,我们将看到如何实现一个能够管理多个客户端的服务器程序,并为客户端编写相应的代码。虽然客户端代码基本保持不变,但我们会做一些实验,使其功能更加丰富。

如果你有两个不同的系统,建议你在不同的系统上分别运行客户端和服务器程序,这样你可以看到不同机器之间的通信,体验更真实的客户端-服务器通信过程。

好了,到这里为止,谢谢大家的收看,我们在下节课再见!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

5. 套接字编程实践 - 多客户端处理

5.1 打开一个新的套接字作为监听

欢迎回来。在这一部分中,我们将学习如何创建一个可以同时处理多个客户端的服务器。我要展示的程序是我之前为单个客户端处理的服务器程序的相似版本,但是我们将对这个程序进行一些修改,使其成为一个可以同时处理多个客户端的服务器。

程序的基本架构

首先,这个程序和我们之前编写的单客户端服务器程序是相似的。唯一的不同之处是,我们将在这个程序中做一些更改,使其能够处理多个客户端的连接。

  1. 添加依赖库

    • 像之前一样,我们需要引用winsock32.lib,这可以在代码中直接添加,或者你可以在链接器设置中添加对该库的引用。你可以选择直接在代码中包含该库或通过链接器设置来添加。这样,程序将可以使用Windows套接字功能。
  2. 套接字初始化

    • 我们会继续调用WSAStartup来初始化Windows套接字库。如果初始化成功,接下来我们会创建一个socket。这部分代码没有变化,和之前的单客户端处理的服务器一样。
  3. 设置服务器

    • 我们仍然会设置套接字为可复用地址,绑定端口等。这里的bind函数依然使用和之前一样的方式,指定了IPv4地址族、端口号以及将端口号转换为网络字节顺序。
  4. 监听请求

    • 然后,服务器开始监听客户端的连接请求。这里的listen函数调用依然是一样的。

多客户端服务器处理的关键修改

到这里为止,代码和单客户端处理的服务器代码是相同的。但从这一部分开始,我们需要做出修改,以支持同时处理多个客户端的连接。

  1. 单线程与多线程

    • 在原本的单客户端处理模式中,accept函数是阻塞的。也就是说,服务器只会接收到一个客户端连接,并在接收到这个连接后继续执行程序。而在多客户端服务器中,我们需要能够同时处理多个客户端的请求。因此,我们不能再直接使用accept进行阻塞式调用。
  2. 改变处理流程

    • 传统的单客户端服务器通常在调用accept函数时直接阻塞,直到接收到一个连接为止,然后处理该连接。为了支持多个客户端,我们需要创建新的机制来处理多个客户端的连接。最常见的做法是通过多线程或异步I/O来实现。

下一步

接下来的内容将在视频中进一步讨论如何修改代码来处理多个客户端请求。这将涉及如何管理并发的客户端连接,可能会使用线程池或多进程来同时处理多个客户端的连接请求。

总结

在这部分,我们回顾了多客户端服务器的基本架构,并介绍了需要更改的地方。接下来的部分将深入讨论如何实现多线程或异步机制来真正实现一个可以同时处理多个客户端的服务器。

希望你能理解这个程序的工作原理,并准备好下一步的多客户端处理。我们将在下节课继续讲解如何实现这一目标。

感谢观看,下次见!

5.2 介绍 FD_SET 和准备 select 列表

欢迎回来。在这一讲中,我们将讨论如何准备监听电路,使其能够准备好接受新的连接。同时,我们还将理解文件描述符集(FD Set)的概念。

文件描述符集(FD Set)的声明

在继续讲解文件描述符集(FD Set)之前,我们首先需要声明一个文件描述符集。可以通过类似以下的方式来声明:

fd_set fdr;

这个fdr就是我们的文件描述符集,用于读取从客户端传来的新连接或者已经连接的客户端发送的数据。为了实现这一点,我们需要创建一个文件描述符集,管理连接的套接字ID,就像监听套接字的ID一样。

文件描述符集的工作原理

fd_set是一个结构体,它实际上是一个数组,用来存储多个套接字的描述符。例如,监听套接字和客户端套接字都会有自己的描述符ID,所有这些描述符ID都将被放入到这个文件描述符集中。我们需要确保所有相关的套接字描述符在该集合中都能被正确地覆盖。

为此,我们维护一个变量max_fd,它代表当前最大的文件描述符值。假设现在我们只打开了监听套接字,那么max_fd就等于监听套接字的文件描述符。监听套接字的描述符是我们需要检查的最大值。

例如:

int max_fd = listener_socket;

通过max_fd,我们可以确保文件描述符集中的所有位都能被正确设置,以检查哪些套接字处于活动状态。这样,当我们调用select系统调用时,max_fd的值就会被用来限制检查的范围。

客户端套接字的管理

假设我们最多支持5个客户端连接,那么我们需要声明一个客户端套接字数组:

int client_sockets[5] = {0};

这里,client_sockets数组包含5个元素,每个元素表示一个客户端的套接字。初始时,所有客户端套接字值为0,表示没有客户端连接。

向文件描述符集中添加套接字

现在,我们需要将监听套接字以及已连接的客户端套接字添加到文件描述符集中。在处理过程中,如果有新的客户端连接,我们将其对应的套接字也加入到文件描述符集中,以便select系统调用能够监听所有的套接字。

首先,我们将监听套接字添加到fd_set中:

FD_ZERO(&fdr); // 清空文件描述符集
FD_SET(listener_socket, &fdr); // 将监听套接字添加到集

接下来,我们检查客户端套接字数组。如果某个客户端套接字不为0,则将其添加到fd_set中:

for (int i = 0; i < 5; i++) {
    if (client_sockets[i] != 0) {
        FD_SET(client_sockets[i], &fdr);
    }
}

运行中的服务器

服务器将持续运行,且需要不断检查是否有新的客户端连接或现有客户端是否发送了数据。为了实现这一点,我们将上述的代码放入一个无限循环中,这样服务器就能持续检查所有的套接字状态。

while (true) {
    // 设置文件描述符集并调用select
    FD_ZERO(&fdr);
    FD_SET(listener_socket, &fdr);
    
    // 将已连接的客户端套接字添加到文件描述符集中
    for (int i = 0; i < 5; i++) {
        if (client_sockets[i] != 0) {
            FD_SET(client_sockets[i], &fdr);
        }
    }

    // 调用select函数来检测哪个套接字准备好
    int activity = select(max_fd + 1, &fdr, NULL, NULL, NULL);

    // 处理activity返回的结果
    // 如果监听套接字有活动,接受新的连接
    // 如果客户端套接字有活动,处理接收到的数据
}

编译与测试

此时,代码实际上只是设置了文件描述符集,并将监听套接字和客户端套接字加入到集合中。到目前为止,我们并没有处理实际的客户端数据接收或新连接的接受。

我们将在下一节讲解select系统调用的使用,select系统调用可以帮助我们确定是否有客户端连接,或者已连接的客户端是否发送了消息。通过select,我们可以同时监听多个套接字,并根据不同的情况做出相应的处理。

总结

在这一讲中,我们讨论了如何准备文件描述符集来监听客户端连接和数据。我们通过创建文件描述符集,并将监听套接字和客户端套接字添加到集合中来实现这一点。接下来,我们将在下一讲中深入研究select系统调用,它能够帮助我们检测套接字的状态,决定是接受新连接还是处理已经连接的客户端发送的数据。

感谢观看,下次见!

5.3 select 系统调用等待新客户端

欢迎回来!在本讲中,我们将学习如何使用 select API。在前面的课程中,我们讲解了 Max FD(最大文件描述符)和 underscore set 的概念,该集合包含了所有的套接字描述符。它本质上是一个位集合,能够包含所有已连接客户端套接字和监听套接字的相关值。为了确保它能够包含所有可能的值,我们使用 Max FD,并将其设置为最大套接字描述符的值加 1。

1. 复习

在之前的讨论中,我们只有一个套接字,即监听套接字,因此将 Max FD 设置为该监听套接字的文件描述符 ID。这样做是因为目前我们没有其他客户端套接字,之后如果有客户端连接,我们会把它们的文件描述符添加到这个集合中。

2. 使用 Select API

在使用 select API 之前,我们需要定义一个变量,类型为 timeval。我们可以使用它来设置等待超时时间。这里我们将超时设置为 1 秒:

struct timeval tv;
tv.tv_sec = 1; // 设置超时为 1 秒
tv.tv_usec = 0; // 微秒部分为 0

然后,我们就可以使用 select 函数了,函数的调用如下:

int err = select(max_fd + 1, &rfds, NULL, NULL, &tv);

其中:

  • max_fd + 1 是最大文件描述符加 1,确保能够检查到所有的套接字描述符。
  • rfds 是读取套接字描述符集合,它包含了所有我们想要检测是否有新连接或是否有消息到达的套接字(如监听套接字和客户端套接字)。
  • 第二和第三个参数是写入套接字集合和异常套接字集合,但在当前的示例中我们不关心这些,因此设置为 NULL
  • tv 是超时时间,定义了 select 函数的等待时长。

3. 处理 Select 调用的返回值

select 调用会返回一个整数,表示状态。如果返回值大于 0,则表示至少有一个套接字准备好进行读操作,可能是新的连接请求,或者是已连接的客户端发送的消息。如果返回值为 0,则表示没有套接字准备好,我们可以打印 "No new connection or message" 之类的提示信息。如果返回值小于 0,则表示发生了错误,程序应该终止。

if (err > 0) {
    // 处理新连接或处理已连接客户端的消息
} else if (err == 0) {
    // 没有新的连接或消息
    printf("No new connection or message\n");
} else {
    // 出现错误,退出程序
    printf("Select failed, closing server\n");
    exit(EXIT_FAILURE);
}

4. 为什么要每次重新设置文件描述符集合

select 函数的一个重要细节是,它会在每次调用后重置传入的文件描述符集合。因此,我们必须在每次调用 select 前重新设置所有的文件描述符集合,确保它们被正确地检查。否则,第一次调用 select 后,集合的内容将被清空,下一次调用时将无法检测到任何套接字。

例如,如果我们仅在循环外部设置一次文件描述符集合:

// 错误的做法,文件描述符集合只设置一次

那么,在 select 调用后,文件描述符集合将被重置,导致之后的调用失败。你会看到返回值是 -1,表示没有套接字被检查到。为了避免这种情况,我们需要在每次 select 调用之前都重新设置文件描述符集合。

5. 演示

在代码中,我们会看到,如果我们每次不设置文件描述符集合,就会遇到错误:

// 错误的代码:未每次设置文件描述符集合

然后运行程序时,第一次返回 0,表示没有新连接或消息。但是第二次运行时,返回值将是 -1,因为文件描述符集合已经被 select 清空,因此无法正确检查套接字。

正确的做法是每次调用 select 时都设置文件描述符集合:

// 正确的做法:每次调用前设置文件描述符集合

这样程序就会正确运行,等待新连接或消息的到来。

6. 总结

在这节课中,我们学习了如何使用 select API 来检查套接字是否准备好接受连接或接收消息。重点是每次调用 select 时都要重新设置文件描述符集合,因为 select 会自动清除集合中的内容。理解这一点对于编写高效且正确的网络服务器程序非常重要。

7. 下节课预告

下一节课我们将深入探讨如何处理新连接以及从现有客户端接收消息。到那时,您将能够实现完整的服务器程序,可以接受新连接并与客户端交换数据。

5.4 接受新客户端连接

本视频概述

在本视频中,我们将了解如何在有新客户端尝试连接时,接受这个连接。

前置知识

在前一个视频中,我们理解了如何使用 select API 来检测文件描述符(或称套接字描述符)是否已准备好接受连接,或者是否有现有客户端正在向我们发送新消息。我们知道,select API 可以帮助我们判断某个套接字是否可以读取。

监听套接字和检测新连接

你可能已经看到过这种“无限循环”套接字,这是监听套接字的套接字 ID。我们会检查这个监听套接字的状态。如果它的“位”被设置了,意味着有新的连接正在等待接入。换句话说,当某人尝试连接到我们的服务器时,这个监听套接字的对应位会被设置,指示有一个新连接在等待。

检查监听套接字

要检查这个监听套接字的状态,我们可以使用宏 FD_ISSET。该宏用于检查某个特定的文件描述符(或者说是套接字描述符)是否已经设置。如果设置了,就意味着这个套接字准备好进行读取操作。所以在读取时,可能是一个新的连接,也可能是一个来自现有客户端的消息。在这里,我们主要关心的是新连接。

示例代码

if (FD_ISSET(listenerSocket, &rfds)) {
    // 说明有新的连接
}

在这段代码中,我们检查监听套接字是否已设置。如果设置了,表示有新连接需要接受。

接受新连接

为了接受新连接,我们需要编写一个函数,例如 AcceptNewConnection,用来接收客户端的连接。具体方法如下:

void AcceptNewConnection() {
    int clientSocket = accept(listenerSocket, NULL, NULL);
    if (clientSocket < 0) {
        std::cout << "Error accepting new connection" << std::endl;
        return;
    }
    // 成功接收到连接
}

限制最大客户端数

在实现中,我们限制最多只能接受 5 个客户端。具体做法是检查当前客户端的连接数,并在达到上限时返回“忙碌”消息。代码如下:

int clientSockets[5] = {0};  // 保存客户端套接字
int index = 0;
for (index = 0; index < 5; index++) {
    if (clientSockets[index] == 0) {
        break;
    }
}

if (index == 5) {
    std::cout << "Server is busy, try again later." << std::endl;
    return;
}

新客户端接入

如果没有达到最大客户端数,我们就可以接受新连接并分配一个可用的索引给它。

clientSockets[index] = clientSocket;

处理连接失败

如果 accept 调用返回小于零的值,表示连接失败,我们需要输出错误信息并结束函数:

if (clientSocket < 0) {
    std::cout << "Failed to accept new connection" << std::endl;
    return;
}

总结

通过上述步骤,我们成功地实现了接受新连接的功能。新的客户端连接通过 accept 调用被接受并与服务器进行通信。接下来,我们将在后续的视频中讨论如何处理来自已连接客户端的消息。

5.5 开始与新客户端通信

本视频概述

在前一个视频中,我们学习了如何在监听套接字被设置的情况下接受新的连接。今天,我们将了解如何检查是否有任何已经连接的客户端正在向我们发送消息。

接收来自已连接客户端的消息

为了检查现有客户端是否发送了消息,我们需要使用一个循环来检查每个客户端套接字是否有数据可读。这个过程的基本逻辑是:

  1. 遍历每个客户端套接字。
  2. 使用 FD_ISSET 函数检查每个客户端套接字是否有数据可以读取。
  3. 如果有数据,我们将调用一个函数来接收消息并处理。

遍历客户端套接字

我们首先定义一个循环,遍历客户端套接字数组,并使用 FD_ISSET 检查是否有客户端套接字已经准备好接收数据。

示例代码:

for (int index = 0; index < 5; index++) {
    if (FD_ISSET(clientSockets[index], &rfds)) {
        // 说明该客户端套接字有数据可以读取
        ReceiveOrSend(clientSockets[index]);
    }
}

在这段代码中,我们检查每个客户端套接字是否有数据准备好读取。如果有,则调用 ReceiveOrSend 函数处理消息的接收和响应。

接收消息的函数

我们定义了一个 ReceiveOrSend 函数,该函数用于接收客户端的消息,并根据接收到的消息执行后续的操作。以下是该函数的实现:

void ReceiveOrSend(int clientSocket) {
    std::cout << "Receiving message from client..." << std::endl;

    char buffer[255];  // 定义接收消息的缓冲区
    int bytesReceived = recv(clientSocket, buffer, sizeof(buffer), 0);

    if (bytesReceived < 0) {
        // 出现错误时关闭套接字
        std::cout << "Error receiving message from socket " << clientSocket << std::endl;
        close(clientSocket);
        clientSocket = 0;  // 清空该套接字
    } else if (bytesReceived == 0) {
        // 客户端关闭了连接
        std::cout << "Client disconnected, closing socket " << clientSocket << std::endl;
        close(clientSocket);
        clientSocket = 0;  // 清空该套接字
    } else {
        // 成功接收消息
        std::cout << "Message from client (" << clientSocket << "): " << buffer << std::endl;

        // 发送响应给客户端
        const char* response = "Acknowledgement from server";
        int bytesSent = send(clientSocket, response, strlen(response), 0);

        if (bytesSent < 0) {
            std::cout << "Error sending message to socket " << clientSocket << std::endl;
            close(clientSocket);
            clientSocket = 0;  // 清空该套接字
        } else {
            std::cout << "Acknowledgement sent to client (" << clientSocket << ")" << std::endl;
        }
    }
}

代码解析

  • 接收消息: recv 函数用于接收来自客户端的数据。我们为接收数据定义了一个大小为 255 字节的缓冲区。接收到的数据将存储在这个缓冲区中。
  • 错误处理: 如果 recv 函数返回小于 0 的值,表示接收数据时发生错误。此时,我们关闭套接字并将其从客户端套接字数组中移除。
  • 发送消息: 如果成功接收到客户端的消息,我们将使用 send 函数向客户端发送一个确认消息("Acknowledgement from server")。
  • 关闭套接字: 无论是接收失败还是客户端断开连接,都会关闭对应的套接字并将其在客户端套接字数组中标记为 0。

清理工作

在每次操作之后,我们清理并重置相应的客户端套接字数组中的套接字,以确保下次处理时没有留下无效的套接字。

if (clientSocket != 0) {
    // 清理客户端套接字
    clientSockets[index] = 0;
}

多客户端支持

本视频中的代码是为支持多个客户端连接设计的。每个客户端连接都会分配一个独立的套接字,我们通过循环检查每个客户端套接字的状态,以确保可以处理多个客户端同时发送的消息。

总结

在本视频中,我们学习了如何接收来自已连接客户端的消息,并向客户端发送响应。我们还讨论了如何处理套接字错误和关闭连接。我们的代码现在能够支持与多个客户端的通信。接下来,我们将在后续视频中继续编写客户端代码,并演示如何与服务器进行通信。

未来的计划

  • 在下一视频中,我们将编写客户端代码,演示如何与服务器进行通信。
  • 随后的视频将演示多个客户端如何同时与服务器进行通信,并展示如何管理多个客户端套接字。

感谢观看,本视频到此为止!

5.6 客户端代码与服务器通信

欢迎回来

我们已经准备好了用于处理多个客户端的套接字服务器代码。在这段代码中,我们编写了两个函数:一个用于接受新连接,另一个用于发送和接收消息。我在这两个函数上都设置了断点。一个断点设置在 receive_send_client_message 函数中,另一个则是在 accept_new_connection 函数中。现在,我们来看一下客户端的代码。由于客户端代码我们之前已经讨论过,这里我就不再详细解释。

客户端代码

这个客户端代码使用了相同的 9999 端口号,之前已经在讲解客户端与服务器通信时做过说明。这个代码我会在本节的最后视频中作为资源提供给你。在此,我只是简单过一遍,目的是让你了解客户端代码的基本结构。首先,客户端需要调用一些初始化方法,然后就像服务器端一样,客户端也会创建一个套接字,并设置相关的服务信息,如家庭、端口号、IP 地址等。这里我们硬编码了机器的 IP 地址,但实际上你应该避免硬编码,最好是从外部配置或数据库中读取服务器的 IP 地址。

客户端与服务器的连接

一旦客户端设置完成,它会尝试连接到服务器。连接成功后,客户端会发送一个固定的测试消息给服务器。当服务器接收到消息时,它会向客户端发送一个确认消息。我们在之前的视频中已经写了这个代码来处理服务器如何发送确认消息。如果你对这些内容有疑问,可以回到之前的视频查看。

运行服务器

我现在启动服务器。当服务器运行时,它会不断地等待连接,并显示没有新的连接消息。这个消息是因为 select API 返回了零(表示没有新的连接)。在服务器代码中,select API 被调用来检查是否有新的客户端连接。如果 select 返回零,则表示没有新的连接或消息。

启动客户端

现在,我启动客户端,客户端会在 connect 调用时停下来。当我连接到服务器时,服务器的 accept_new_connection 函数会触发,表明服务器已经接收到一个新的连接。此时,服务器会接受这个连接,并返回一个新的套接字描述符,用于与该客户端进行通信。

服务器端的代码继续循环运行,在客户端成功连接并发送消息后,服务器开始接收并处理来自客户端的消息。服务器在接收到客户端的消息后,会打印出接收到的消息(在本例中是“固定文本消息”)。然后,服务器会向客户端发送确认消息。

服务器和客户端的通信

客户端发送消息后,服务器接收并打印消息,并且发送一个确认消息。客户端收到这个确认消息后,显示“从服务器收到的确认消息”。服务器会在完成通信后关闭与客户端的连接。

服务器关闭连接

当客户端关闭连接时,服务器会收到一个错误,表示该客户端的连接已被关闭。服务器此时会关闭套接字,清理资源。

多客户端处理

在接下来的几期视频中,我们将继续扩展此示例,处理多个客户端同时与服务器通信。在下一期视频中,我将修改客户端代码,使其在循环中发送动态消息,这样客户端就能向服务器发送来自用户的输入消息。

记录客户端通信到文件

此外,我还计划修改服务器端代码,将接收到的消息以及相关信息(如客户端套接字)写入文件中,而不是仅仅在控制台打印。这将帮助我们跟踪每个客户端的通信,并且能够更好地调试和查看消息内容。

总结

到目前为止,我们已经完成了基本的服务器和客户端通信,能够处理单个客户端的消息并向客户端发送确认消息。在下一个视频中,我将继续扩展该示例,并添加多客户端支持以及日志记录功能。如果你有任何疑问,可以查看之前的视频,理解每个部分的工作原理。

下一个步骤

在下一期视频中,我将展示如何处理多个客户端,并在服务器端记录消息到文件。你可以通过下载代码,自己运行并调试代码,帮助你更好地理解这些概念。

感谢观看,我们下期再见!

5.7 多客户端与单服务器通信演示

修改客户端和服务器程序

我们将对客户端和服务器程序进行一些小的修改。在服务器程序中,我们将做出以下变化:之前在客户端接收到消息时,我们会直接打印出来,而现在,我们不再在屏幕上打印消息,而是将消息写入到文件中。

在文件中记录消息

为了实现这一点,我们将使用文件操作。首先,我们需要在程序中创建一个全局变量来存储文件指针,f b,并初始化为 NULL。然后,我们需要包含必要的头文件:

#include <stdio.h>

使用这个头文件,可以帮助我们打开文件并写入内容。我们将创建一个文件来存储客户端的消息。文件打开的位置将是在程序的某个位置,便于记录所有的消息。

程序启动时打开文件

程序启动时,我们将做如下操作:在初始化任何套接字之前,先打开文件。实际上,建议在执行 select 函数之前打开文件,因为在此之前无法保证程序是否会顺利运行,可能在 bindlisten 函数中就会失败。所以,一旦程序确保 bindlisten 成功,我们可以在进入循环之前打开文件。

打开文件的代码如下:

f = fopen("MSG_client.txt", "a");

我们打开文件时使用追加模式("a"),这样每次写入文件时不会覆盖原有内容,而是将新的消息追加到文件末尾。如果文件无法打开,程序会打印错误信息并退出。

if (f == NULL) {
    printf("Error opening file.\n");
    exit(1);
}

将客户端消息写入文件

每当接收到来自客户端的消息时,我们会将该消息写入到文件中。代码如下:

fprintf(f, "Message from client (socket %d): %s\n", client_socket, msg);

这里我们将客户端套接字和消息一起写入文件,以便之后追踪和查看哪个客户端发送了哪条消息。

客户端程序的修改

对于客户端,我们做了一些修改,使其能定期发送消息。在原有的程序中,我们每次都会提示用户输入消息,而现在我们修改为每隔2秒发送一个固定的消息。这样可以模拟多个客户端同时连接到服务器的场景。

while (1) {
    send_message("Fixed message");
    sleep(2);  // 每2秒发送一次消息
}

启动服务器和客户端

修改完成后,我们启动服务器和客户端进行测试。首先,运行服务器程序,然后打开多个客户端实例,每个客户端都将在2秒间隔内向服务器发送消息。服务器会响应每个客户端的消息,并将其记录到文件中。

查看日志文件

当服务器停止时,我们可以打开记录客户端消息的文件 MSG_client.txt。文件中会显示每个客户端的套接字ID和发送的消息内容。例如:

Message from client (socket 344): Hello from client 1
Message from client (socket 348): Hello from client 2
Message from client (socket 341): Hello from client 3

每条消息后面都会有客户端的套接字ID,帮助我们跟踪哪个客户端发送了哪条消息。

总结

通过这些改动,我们实现了将客户端发送的消息记录到文件中,这对于调试和日志记录非常有用。虽然写入文件会带来一定的性能开销,但在需要追踪网络通信的场景下,记录日志是非常有帮助的。

希望通过这个练习,您对套接字编程的理解能有所提高,并且能在实际开发中更好地处理客户端和服务器之间的通信。

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

6. UDP 套接字程序实践

6.1 UDP 服务器代码

欢迎回来,朋友们。在这个视频中,我们将看到如何创建一个基于UDP的服务器程序。到目前为止,我们已经看过了所有TCP的示例,现在我们将创建一个基于UDP协议的服务器,并在下一个视频中创建一个基于UDP协议的客户端程序。在理论部分,你可能已经了解了面向连接和无连接套接字之间的区别,你也可能理解了基于TCP和UDP的通信。那么,现在我们就来讨论基于UDP的通信,我们将在本视频中编写一个程序。

UDP协议的工作原理

在数据报套接字(datagram socket)中,每次都不需要保持一个活动的连接。所以在基于数据报的UDP服务器中,你不需要像在TCP中那样维护特定的客户端信息。在UDP中,任何客户端发送的数据都可以被接收,并且你可以立即对该客户端作出响应,告知数据已被接收。

通常,UDP套接字用于广播应用程序,或者是那种任何客户端可以连接一次并接收你想要发送的信息的应用程序。这些应用程序通常包括实时系统,比如在线电视节目等,多个客户端可以连接并接收信息。在这些应用程序中,服务器就像是广播信息给任何连接到它的客户端。

创建UDP服务器

我们来编写UDP服务器的代码。为了简化操作,我会使用和之前TCP程序相同的代码,并进行一些修改。首先,我包括了这些头文件:iostreamwinsock2.h等,并且使用了与之前相同的端口。

在TCP协议中,我们使用了SRB来创建套接字,这在UDP中也可以继续使用。不同的是,UDP套接字不需要像TCP那样保持一个客户端的数组,因为UDP是无连接的,每个客户端都可以独立地发送信息,并且可以即时接收回应。因此,我们需要一个变量来记录客户端的信息。我们可以使用KLEIN这个变量来存储客户端的相关信息。接下来,我将详细展示如何在程序中使用这些变量。

初始化和库引用

你需要引入相应的库,比如ws2_32.lib。你可以在链接器部分的输入中指定此库的名称,或者直接在代码中指定。这两种方法都可以。

主要的服务器代码

在服务器程序中,我们需要使用WSAStartup函数来初始化Winsock库,这是每次使用TCP或UDP套接字时都必须调用的函数。它会加载DLL文件,确保我们可以使用套接字API。直到调用了这个函数,我们才能开始使用套接字。

接下来,我们使用socket函数来创建一个UDP协议的套接字。与TCP套接字不同的是,我们在这里使用SOCK_DGRAM来表示数据报套接字,而不是SOCK_STREAM。这表明我们将接收数据包(datagrams),这些数据包就是UDP数据报。

套接字绑定

与TCP服务器一样,UDP服务器也需要将套接字绑定到特定的端口。通过调用bind函数,将套接字与网络地址(包括端口)绑定。这时,我们使用htons函数来确保端口号按照网络字节顺序排列。

if (bind(listenSocket, (SOCKADDR*)&localAddr, sizeof(localAddr)) == SOCKET_ERROR) {
    std::cerr << "Failed to bind to local port." << std::endl;
    return;
}

接收消息

为了接收来自客户端的消息,我们使用recvfrom函数。这个函数会在接收到消息之前阻塞,直到有数据到达。我们不需要主动保持与客户端的连接;只要有客户端发送数据,我们就会自动接收并处理这些数据。

int bytesReceived = recvfrom(listenSocket, buffer, 255, 0, (SOCKADDR*)&clientAddr, &clientAddrLen);
if (bytesReceived > 0) {
    // 处理接收到的消息
} else {
    std::cerr << "Error receiving data." << std::endl;
}

在这段代码中,我们指定了recvfrom的缓冲区、长度、标志和客户端地址。客户端地址存储在clientAddr中,这样我们就可以在后续的通信中向这个客户端发送回应。

发送回应

接收到消息后,服务器需要向客户端发送一个确认消息。使用sendto函数,我们可以把确认消息发送回客户端。

const char* ackMessage = "ACK from server";
sendto(listenSocket, ackMessage, strlen(ackMessage), 0, (SOCKADDR*)&clientAddr, clientAddrLen);

运行循环

由于我们是处理UDP通信,我们的服务器程序需要进入一个无限循环,随时准备接收来自任何客户端的消息。这意味着我们的程序会一直运行,直到被手动终止。

文件处理

除了接收和发送数据,服务器还可以把接收到的消息写入文件中。在这里,我使用fopen来打开一个文件,并在文件末尾追加消息。

FILE* fp = fopen("UDP_msg.txt", "a");
if (fp != NULL) {
    fprintf(fp, "Message from client: %s\n", buffer);
    fclose(fp);
} else {
    std::cerr << "Error opening file." << std::endl;
}

错误处理

在每个关键操作后,我们都会检查是否发生了错误。例如,在绑定、接收和发送数据时,我们都要检查操作是否成功。如果失败,我们会打印错误信息并退出程序。

代码示例

以下是服务器的完整代码示例(简化版本):

#include <iostream>
#include <winsock2.h>

#pragma comment(lib, "ws2_32.lib")

int main() {
    WSADATA wsaData;
    SOCKET listenSocket;
    sockaddr_in localAddr, clientAddr;
    char buffer[256];
    int clientAddrLen = sizeof(clientAddr);

    // 初始化 Winsock
    if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
        std::cerr << "WSAStartup failed." << std::endl;
        return 1;
    }

    // 创建 UDP 套接字
    listenSocket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    if (listenSocket == INVALID_SOCKET) {
        std::cerr << "Socket creation failed." << std::endl;
        WSACleanup();
        return 1;
    }

    // 设置本地地址
    memset(&localAddr, 0, sizeof(localAddr));
    localAddr.sin_family = AF_INET;
    localAddr.sin_addr.s_addr = INADDR_ANY;
    localAddr.sin_port = htons(8888);

    // 绑定套接字
    if (bind(listenSocket, (SOCKADDR*)&localAddr, sizeof(localAddr)) == SOCKET_ERROR) {
        std::cerr << "Binding failed." << std::endl;
        closesocket(listenSocket);
        WSACleanup();
        return 1;
    }

    // 接收数据
    while (true) {
        int bytesReceived = recvfrom(listenSocket, buffer, 255, 0, (SOCKADDR*)&clientAddr, &clientAddrLen);
        if (bytesReceived > 0) {
            buffer[bytesReceived] = '\0'; // Null-terminate the string
            std::cout << "Received message: " << buffer << std::endl;

            // 写入文件
            FILE* fp = fopen("UDP_msg.txt", "a");
            if (fp != NULL) {
                fprintf(fp, "Message from client: %s\n", buffer);
                fclose(fp);
            }

            // 发送确认消息
            const char* ackMessage = "ACK from server";
            sendto(listenSocket, ackMessage, strlen(ackMessage), 0, (SOCKADDR*)&clientAddr, clientAddrLen);
        }
    }

    // 清理
    closesocket(listenSocket);
    WSACleanup();
    return 0;
}

结论

在本视频中,我们创建了一个简单的UDP服务器,它能够接收来自客户端的消息,并且返回确认消息。你可以根据自己的需求进一步扩展此程序,比如加入更多的客户端处理逻辑、消息解析、错误处理等。

6.2 UDP 客户端代码

欢迎回来!今天我们将编写UDP客户端的代码。在之前的视频中,我们讨论了需要包含哪些库、如何定义端口号以及需要使用哪些头文件。这些内容在今天的视频中仍然适用,我们依然会使用相同的头文件和库。

接下来让我们进入主函数,并再次提供这个部分。你需要为此定义一个套接字,使用SRB(结构体变量),因为这个结构体包含了关于服务器的信息,我们的UDP客户端将与此服务器进行交互。在主函数内部,你可以看到我们做了同样的事情:启动程序,这对于启动网络请求非常重要,能够将程序加载到内存中,以便可以使用套接字API定义。启动是任何套接字程序中都必须的,无论你编写什么类型的程序,Windows环境下都需要启动。

现在,我们来看一下如何打开套接字。首先这是我的客户端套接字。我们正在打开此套接字,首先需要使用第一个参数作为无限,第二个参数应该为数据报(datagram)。然后,协议我们需要使用TCP/IP,但对于数据报套接字,必须使用UDP。因此,常量应该是IP_UDP,而不是TCP。虽然TCP流套接字需要使用TCP协议,但数据报套接字必须使用UDP协议。 如果套接字API调用失败,它将返回-1。如果返回-1,你可以显示错误信息并返回失败。

接下来,你可以像在服务器端程序中那样提供细节,但需要记住的是,如果你编写的是客户端程序并且不会将套接字绑定到特定端口,那么你需要使用整数来查找正确的IP地址,这个IP地址是你的服务器所在的机器地址,而这个你是已经知道的。

对于TCP协议或连接导向的套接字(如流套接字),我们通常需要与服务器建立连接,但在UDP中,我们不需要连接服务器。我们可以直接开始向服务器发送和接收信息。首先,我们可以声明一个变量,例如名为error,并发送一条消息。为了发送消息,我们可以使用sendto函数。如果我们需要发送消息,我们必须提供套接字、缓冲区、缓冲区的长度、标志以及套接字地址等信息。

例如:

sendto(socket, "test message", 13, 0, (struct sockaddr *)&serverAddr, sizeof(serverAddr));

这里,我们向服务器发送了一条长度为13的测试消息。如果发送失败,我们会收到一个返回值小于0的错误代码,表示发送失败。在这种情况下,我们可以显示错误信息并处理失败的情况。

接下来,我们需要接收来自服务器的确认信息。我们可以使用recvfrom来接收消息,但首先需要声明一个缓冲区,因为我们需要接收由服务器发送的数据。使用recvfrom时,我们需要传递的第一个参数是套接字,第二个是接收数据的缓冲区,第三个是缓冲区的长度。第四个参数是用来指定标志,通常设为0。

例如:

recvfrom(socket, buffer, 255, 0, NULL, NULL);

如果接收消息失败,同样我们会显示错误信息并退出程序。

关键代码结构:

  1. 初始化和套接字创建

    • 使用WSAStartup启动Windows套接字API。
    • 创建UDP套接字,使用socket函数。
  2. 发送消息

    • 使用sendto函数发送消息到服务器。
    • 如果发送失败,打印错误消息并退出。
  3. 接收消息

    • 使用recvfrom函数从服务器接收数据。
    • 如果接收失败,打印错误消息并退出。
  4. 错误处理

    • 检查每一步操作的返回值,如果有任何错误,打印相关信息并退出。

在下一集视频中,我们将演示如何发送和接收测试消息,展示客户端如何与服务器通过UDP协议进行通信。

今天的视频内容到这里为止。我们将在下一集展示如何通过UDP协议实现客户端和服务器之间的通信。

6.3 客户端与服务器之间的通信演示

欢迎回来!在本视频中,我们将展示如何运行之前编写的UDP客户端和服务器程序。你可以看到这是服务器程序,接下来我们将对其做一个小修改。在服务器端的while循环中,我在recvfrom函数使用了MSG_OOB标志,但现在我已经将其移除。因为我们将在调试时查看输出,而MSG_OOB标志在此情况下并不适用,因为我们只会等待服务器的输出,且所有数据包都需要一次性发送。所以,我将这个标志移除,以便以正确的方式查看输出。

服务器端和客户端的变化:

  1. 在客户端代码中,我在LS部分添加了打印代码,用于显示来自服务器的确认消息。也就是说,客户端将打印它从服务器接收到的消息。
  2. 在服务器端,我们将显示接收到的消息并将其保存到文件中。这是为了测试和确认信息是否正确接收到并正确响应。

运行流程:

  1. 首先,启动服务器程序。服务器将首先打开套接字并进行绑定,然后开始等待接收消息。
  2. 客户端将发送消息,并在recvfrom处等待从服务器返回的消息。
  3. 服务器接收到客户端发送的消息后,打印该消息并响应客户端。
  4. 客户端将接收到服务器的确认信息,并打印它。
  5. 在服务器端,我们将确认消息写入到一个文件中。每次运行客户端时,都会在文件中记录新的消息。

运行过程演示:

  1. 启动服务器

    • 服务器首先初始化套接字,绑定它,启动并等待来自客户端的消息。
    • 服务器在recvfrom函数处暂停,等待客户端消息。
  2. 启动客户端

    • 客户端向服务器发送一条消息。
    • 客户端在recvfrom处等待服务器的响应。
  3. 服务器响应客户端

    • 一旦服务器收到客户端消息,它将打印该消息并将确认消息发送回客户端。
    • 客户端接收到服务器的确认信息并显示。
  4. 打印消息到文件

    • 服务器将接收到的消息保存到文件中,文件路径通常为C:\UDP_messages.txt,并会刷新文件内容以确保实时更新。
  5. 检查文件

    • 打开文件查看存储的消息,确认消息是否已正确写入。

客户端-服务器多客户端测试:

  • 你可以运行多个客户端实例,向同一个UDP服务器发送消息,服务器将分别响应每个客户端。
  • 服务器会为每个客户端记录发送的消息并发送相应的确认消息。

文件操作和调试:

  • 如果文件正在被另一个进程使用,你可能会看到无法写入文件的情况。只需关闭文件,然后再重新打开,它应该就能正常显示消息。
  • 在程序中,确保在写入文件后调用flush()方法,这样可以立即将内容写入文件,而不是等待缓冲区填满。

运行多个客户端:

  • 你可以运行多个UDP客户端,就像我们为TCP多客户端示例所做的那样。客户端可以从不同的机器或同一台机器上启动,只要它们都连接到同一个UDP服务器。
  • 这使得你可以模拟多个客户端同时向服务器发送消息,服务器能够独立处理每个客户端的请求。

总结:

  • 你现在已经了解了如何通过UDP协议在客户端和服务器之间发送和接收消息。
  • 通过本次示范,你也掌握了如何使用sendtorecvfrom函数进行通信,并且能够在调试时检查数据流。
  • 在未来,你可以尝试更复杂的UDP服务器和客户端应用程序,甚至实现多客户端支持。

后续内容:

  • 在下一节视频中,我们将讨论一些剩余的概念,例如UDP协议中的sendreceive函数、标志位以及一些其他可能用到的API。
  • 本课程旨在帮助你从初学者到中级开发者,能够理解并实现TCP和UDP协议的客户端-服务器通信。

如果你按照本视频的示范进行了操作并成功实现了客户端和服务器的通信,你现在已经具备了中级编程的能力,能够处理基本的网络通信任务。

谢谢你的观看,我们在下一节视频中再见!

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Nov 9, 2024

7. 剩余部分

7.1 第 1 部分 - gethostname 和 gethostbyname 使用

欢迎回来!在本节中,我们将讨论一些在之前客户端和服务器程序示例中没有使用过,但它们非常有用的API。了解这些API的使用方法将非常有益,并且能帮助你更好地理解它们的作用及如何使用它们。今天我们将讨论两个API:

  1. gethostname:该API可以帮助我们获取当前运行程序的机器名称。
  2. gethostbyname:通过该API,我们可以根据机器名称获取机器的IP地址。

我们将一步步演示如何使用这些API,并在程序中实际应用它们。

相关设置和准备:

  1. 首先,您需要包含必要的库和头文件:

    • #include <winsock2.h>:这是Windows操作系统中与网络编程相关的库。
    • #include <ws2tcpip.h>:该库用于提供与IP地址和套接字的处理相关的功能。
    • 如果你在Windows中编写程序,还需要调用WSAStartup函数来初始化Winsock库。没有它,套接字相关的函数将无法加载。
  2. 你还需要确保在程序开始时正确地调用WSAStartup,否则你将无法加载ws2_32.dll,这对套接字的操作是必要的。

API详细讲解:

1. gethostname

  • 这个函数用于获取机器的主机名。
  • 示例代码中,我们使用了一个字符数组来保存主机名,并调用gethostname函数获取主机名。

2. gethostbyname

  • 这个函数根据主机名来查找主机的IP地址。
  • 它返回一个指向hostent结构体的指针,其中包含主机信息。
  • 我们通过此结构体的h_addr_list字段获取到主机的IP地址列表。
  • 接着,使用inet_ntoa将网络地址转换为标准的IP字符串格式。

代码演示:

#include <winsock2.h>
#include <ws2tcpip.h>
#include <stdio.h>

int main() {
    WSADATA wsaData;
    char hostname[255];
    struct hostent *he;
    struct in_addr **addr_list;
    
    // 初始化Winsock
    if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
        printf("Unable to load socket library\n");
        return 1;
    }

    // 获取主机名
    if (gethostname(hostname, sizeof(hostname)) == 0) {
        printf("Hostname: %s\n", hostname);
    } else {
        printf("Error getting hostname\n");
    }

    // 获取主机的IP地址
    he = gethostbyname(hostname);
    if (he == NULL) {
        printf("Unable to get host by name\n");
        return 1;
    }

    addr_list = (struct in_addr **)he->h_addr_list;
    printf("IP Address: %s\n", inet_ntoa(*addr_list[0]));  // 获取IPv4地址

    // 清理并退出
    WSACleanup();
    return 0;
}

代码分析:

  1. 初始化Winsock

    • 在Windows中,任何与网络相关的操作都必须先调用WSAStartup,它负责加载必要的库和初始化网络服务。
    • 如果WSAStartup失败,程序将输出错误信息并退出。
  2. 获取主机名

    • 使用gethostname函数,程序将获取本机的主机名并存储在hostname数组中。
    • 通过打印出主机名,我们可以确认获取的名称。
  3. 获取IP地址

    • 使用gethostbyname函数,程序将通过主机名查询到主机的IP地址。
    • gethostbyname返回一个hostent结构体指针,通过该结构体可以访问主机的IP地址列表(h_addr_list)。
    • 我们使用inet_ntoa将网络字节序的IP地址转换为点分十进制格式,并打印出来。

运行结果:

假设程序运行在名为laptop-gen4的计算机上,程序可能会输出如下内容:

Hostname: laptop-gen4
IP Address: 192.168.1.100

这些API的应用场景:

  • gethostname:你可以在编写服务器应用时使用它来动态获取本机的主机名,而不是手动输入主机名。这样程序的可移植性更强。
  • gethostbyname:如果你知道一个主机的名称,并且希望获取它的IP地址,可以使用此API来完成。这在客户端应用中非常有用,例如,你可以根据服务器的名称动态获取服务器的IP地址。

小结:

  • 在之前的客户端和服务器应用程序中,我们没有涉及到gethostnamegethostbyname,但它们非常有用,尤其是在动态获取主机信息时。
  • 通过这些API,你可以避免使用硬编码的主机名和IP地址,从而使应用程序更加灵活和可移植。

下一个视频中,我将讨论与recvsend相关的其他重要API和标志,进一步拓展我们的网络编程知识。谢谢观看,下次见!

7.2 第 2 部分 - send 和 recv 标志参数

欢迎回来。在本视频中,我们将讨论之前在前面的视频中使用过的两个API。在前面的视频中,我们讨论了如何在客户端和服务器之间进行通信。在此过程中,我们使用了 sendrecvsendtorecvfrom 等API。因此,在这段时间里,我们没有使用过flags参数,但它们是有意义的,我们将在本视频中讨论这些flags参数,因为它们可以改变这些API的行为。

Flags 参数

首先,让我们看看 flags 参数。可以看到,这里定义了两个常用的flag值:MSG_DONTROUTEMSG_OOB。如果你希望你的socket按照这两个值的行为来工作,可以使用按位或(bitwise OR)操作符来组合这两个值;否则,你可以选择只使用其中一个。

1. MSG_DONTROUTE

MSG_DONTROUTE 意味着你可以直接将数据发送到已经连接的网络或者流套接字网络,但如果在发送过程中有任何路由机制介入,它将不会进行路由。这意味着数据会直接发送到网络中,而不经过中间的路由。

需要注意的一点是,Windows Socket 服务提供者可以选择忽略这个flag,即使你传递了这个 MSG_DONTROUTE 值,Windows 的 Socket 服务提供者仍然可能会忽略这个值,继续进行路由。这取决于所使用的Windows Socket库的实现。

2. MSG_OOB (带外数据)

带外数据(Out-of-Band Data)指的是一种特殊的数据类型,它用于紧急传输的情况下。带外数据只适用于流套接字(stream sockets),而不适用于数据报套接字(datagram sockets)。因此,如果你需要在流套接字中发送带外数据,可以通过设置 MSG_OOB 来标明。

在流套接字中,数据是按顺序发送的(即队列顺序)。然而,有时你可能希望发送某些优先数据,并且希望这些优先数据能够绕过正常数据流,直接传输到目标端。在这种情况下,带外数据就发挥了作用。当你将数据标记为带外数据时,它会被单独处理并优先发送。

set_socket_options 函数

要启用带外数据的发送和接收功能,必须设置相应的套接字选项。你可以使用 setsockopt 函数来启用这个选项,并告知套接字在发送和接收时支持带外数据。

recv 函数中的 Flags 参数

recv 函数中,也有几个常用的 flags 参数,可以影响数据的接收方式。以下是常用的几种:

1. MSG_PEEK

当你使用 MSG_PEEK 标志时,recv 函数将返回一个数据副本,但不会将数据从接收队列中删除。这意味着你可以查看数据,但数据仍然保留在接收队列中,以便其他地方也可以使用这个数据。

举个例子,如果有数据包 D1D2D3 在接收队列中。当你调用 recv 且使用 MSG_PEEK 时,你将获取 D1 的副本,但 D1 仍然留在队列中。如果你没有使用 MSG_PEEK,那么当你调用 recv 后,D1 将会被删除,队列中将只剩下 D2D3

这个功能在某些情况下非常有用,比如你希望查看接收到的数据而不移除它,或者在需要回溯某些数据时使用。

2. MSG_OOB

我们之前讨论过的 MSG_OOB 也适用于接收函数。如果你想接收带外数据,你需要在 recvrecvfrom 中使用 MSG_OOB 标志。带外数据将绕过普通数据流,直接处理并优先接收。

3. MSG_WAITALL

MSG_WAITALL 是一个特殊的 flag,表示接收操作将一直阻塞,直到满足以下任一条件之一:

  • 接收到完整的数据块。
  • 连接被关闭。
  • 请求被取消或发生错误。

换句话说,如果你使用了 MSG_WAITALL,你的程序将阻塞,直到从网络接收了所有的数据,或者连接关闭,或者发生了某个错误。

需要特别注意的是,如果套接字是非阻塞的(non-blocking socket),使用 MSG_WAITALL 将会导致调用失败。因此,这个标志只能与阻塞套接字一起使用。

总结

  • MSG_DONTROUTE:避免通过路由机制发送数据。
  • MSG_OOB:发送或接收带外数据(只适用于流套接字)。
  • MSG_PEEK:查看接收到的数据,但不从接收队列中删除它。
  • MSG_WAITALL:阻塞直到接收到完整的数据块。

这些标志在实际开发中非常有用,尤其是在处理数据流和优先数据时,能够灵活地控制数据的发送和接收方式。

在接下来的视频中,我们将继续讨论其他一些重要的API,并在最后总结整个课程的内容。希望这些信息对你们有帮助!

7.3 第 3 部分 - 其他重要的 API

在本视频中,我将讨论一些在进行套接字编程时你应该了解的剩余 API。这些仅仅是一些附加内容,因此让我们开始了解一些你在进行套接字编程时会用到的 API。首先是关闭套接字函数。你知道,通过关闭套接字函数,我们可以断开连接,关闭与客户端连接的通信套接字。这个关闭套接字函数正是用于此。如果你关闭了套接字,现有的套接字将被关闭,之后将无法通过该套接字接收或发送消息。

除此之外,还有一个与套接字关闭相关的函数——shutdown socket。这两者之间是有区别的。在 shutdown socket 中,我们有两个参数。第一个参数是套接字,即你和客户端之间的通信通道,或者可以说是服务器和客户端之间的通信通道。第二个参数指定了你希望以何种方式关闭套接字。这个参数有三个值:

  • SD_RECEIVE:如果你在套接字 ID 后使用 SD_RECEIVE,那么在这种情况下,你的套接字将不再接收来自客户端或另一端的任何消息。
  • SD_SEND:如果你使用 SD_SEND,那么你将停止向另一端发送任何消息。
  • SD_BOTH:如果你使用 SD_BOTH,那么发送和接收操作都会停止。此时,shutdown socket 的行为与 close socket 相同,都会停止所有的接收和发送操作。

因此,如果你使用 shutdown socket 函数并传入 SD_BOTH,它将像 close socket 一样关闭一切,包括发送和接收消息的功能。所以,这就是 shutdown socket 的使用方法,它在使用 SD_BOTH 时表现得与 close socket 完全相同。

这些是你必须知道的两个重要 API。你可以访问 MSDN 网站,搜索 shutdownclose socket 函数,查看它们的详细文档。了解这些细节对你做出正确的函数选择非常有帮助,因为在编写程序时,选择合适的 API 是非常重要的。因此,在学习这些 API 时,千万不要错过注释部分。

除了这些函数,还有一个你应该知道的 API,那就是 getpeername。你知道在 Unix 系统中,有一些函数可以用来查找对端的名称,但在 Windows 中,有一个名为 getpeername 的函数。如果是在 Unix 系统中,你可以使用 getpeername 来获取对端的名称,但 Windows 系统没有类似的函数,像 ntohs 这样的函数不会有直接的对应。因此,如果你想要知道与自己连接的对端(即客户端或同行)名称,可以使用 getpeername 函数。

在使用 getpeername 时,它接受三个参数。第一个参数是套接字,即你与另一端建立的通信通道。就像在客户端和服务器的通信中,你的服务器通过套接字向客户端发送消息。在这种情况下,那个特定的套接字描述符会被用作 getpeername 的第一个参数。通常,当你使用 accept 函数时,系统会返回一个新的套接字描述符,用于与客户端通信。因此,如果你想知道对端是谁,想要知道对端的名称,可以使用这个函数 getpeername,然后传入套接字描述符。

第三个参数 name_len 表示存储对端信息的缓冲区大小,必须是一个指向整数的指针,表示缓冲区的大小。调用这个函数后,它将返回对端的地址和名称信息。你可以通过查看返回的描述来了解如何处理这些信息。

这些是你应该知道的三个重要函数。此外,还有一组其他的函数,如 gethostnamegethostbyname 等,你可以进一步了解它们。如果你已经掌握了本课程中的内容,那么你已经具备了成为套接字编程开发者的基础。你现在已经具备了使用套接字 API 编写程序的能力。

如果你完全理解了本课程的内容,那么你就具备了作为一个中级套接字 API 开发者的资格。如果你想要进一步了解更多高级内容,我会很快发布相关的课程。

至于 WCF 函数,如果你愿意深入了解,也可以阅读它们。但是对于本课程来说,你所学到的内容已经足够使你成为一名套接字编程开发者。你已经掌握了如何使用套接字 API 编写程序,能够独立完成相关的任务。

一个非常重要的函数是 WSACleanup。这个函数用于清理套接字资源。如果你在使用 WSAStartup 函数时加载了套接字 DLL,那么在关闭服务器或客户端时,使用 WSACleanup 函数可以清理所有的套接字 API DLL 相关的资源。在程序结束之前调用 WSACleanup,可以确保套接字相关的资源从进程的虚拟地址空间中完全清除。

总结一下,以上内容涉及了你在进行套接字编程时需要了解的主要 API。除了这些,如果你有其他问题,可以随时查阅 MSDN 文档。总之,这就是本视频的全部内容,谢谢大家观看。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant