【计算机网络】UDP实战

news2024/9/24 7:20:40

其实经过这几天写的几种不同的UDP的简易客户端与服务端,还是很有套路的,起手式都是非常像的。
更多的难点对我来说反而是解耦,各种各样的function一用,回调函数一调,呕吼,就会懵一下。
对于这篇文章,我主要是把那些起手式,还有我觉得有点难得解耦方式稍微进行一下说明,方便我自己回顾,当然如果可以帮助更多的小伙伴那自然是更好啦。

目录

  • Echo
    • 服务端起手式
    • 服务端LOOP
    • 客户端起手
    • 客户端LOOP
    • 验证
  • Dict
    • 设计思想
    • 验证
  • Chat
    • 服务端的修改
    • 客户段的修改
    • 效果展示

Echo

服务端起手式

这个echo的代码是为了熟悉起手式,因为几乎没有业务的附带,所以是很简单的。
而它的功能就是你向服务器中发送消息,你的服务端会重新发给你。

注意:日志真的很重要,可以让你知道你的程序在哪一步出错了,很快的定位。

首先大概的看一下起手式接口:
因为我们的网络要通信需要IP + 端口号才能定位到具体主机内具体进程,而IP + port就是我们说的套接字,关于套接字在UDP中需要知道2个接口,但是这两个接口中我们注意到有一个sockaddr 结构体,因此我们需要看一下结构体。

// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,
socklen_t address_len);

在这里插入图片描述
首先,这组接口不仅可以实现网络通信,也可以实现主机内通信。
其中sockaddr_in是网络,sockaddr_un是主机内,那么为什么bind接口内是sockaddr?

因为我们会填16位地址类型,所以当我们将对应的结构体强转为sockaddr *时,函数内部就会根据16位地址类型判断究竟是哪一种,这也就是C语言层面的多态!

首先,我们的server是面向对象的,代码中没有定义就出现变量都是私有成员。根据名字都可以知道大概意思,在最后会有完整代码。

 int fd = ::socket(AF_INET, SOCK_DGRAM, 0);
 if (fd < 0)
 {
     exit(1);
 }
 _socketfd = fd;
 // 将Ip Port与套接字绑定
 struct sockaddr_in addr;
 memset(&addr, 0, sizeof(addr));
 addr.sin_family = AF_INET;
 // 不要忘记转为网络序列!
 // addr.sin_addr.s_addr = inet_addr(_ip.c_str());
 addr.sin_addr.s_addr = INADDR_ANY;
 addr.sin_port = htons(_port);
 // bind
 int n = ::bind(_socketfd, (struct sockaddr *)&addr, sizeof(addr));

再来解释一下代码

第一个参数:

因为我们是网络通信,所以16位网络地址选择使用AF_INET

第二个参数:

我们选择的是UDP
在这里插入图片描述
也就是无连接,不可靠,数据报。

第三个参数:

表示希望使用的协议,我们通常设置为0,系统会根据情况自己处理。

返回值:

socket返回的是一个文件描述符在这里插入图片描述
为什么返回文件描述符?

我们在此感性的理解
因为我们网络的通信是建立在网卡上的,而linux中一切皆文件,所以就相当于我们返回的是网卡的文件描述符。

于是我们的socket就创建好了,但是还要与IP与port进行绑定起来。
那么就先要创建一个sockaddr_in的结构体填参

sin我们可以理解为socket Internet。
其中我们只关心图中的框起来部分,sin_zero是作为将结构体补齐用的,新的网络编程库中甚至都见不到这个字段了。
在这里插入图片描述

我们逐个分析一下这个要填的3个字段


在这里插入图片描述
第一个参数的形式是一个宏,在预处理阶段会进行处理,进行替换得到sa_family_t sin_family##在预处理阶段会将两边的字符串进行拼接)。
而这个填的就是AF_INET,与套接字对应。


第二个参数是一个无符号短整型,uint16_t的类型,我们填入自定义端口即可。
注意:由于我们要注意网络序列与主机序列的转换,自己进行判断的话过于麻烦,OS也提供了一组接口方便我们进行转换。在这里插入图片描述


第三个参数是IP
注意这个IP可是有很大讲究的,
首先他是结构体内嵌套结构体,填的时候要注意
其次我们刚开始肯定觉得绑定自己的公网IP,或者局域网IP,又或者是本地环回。
但是如果填一个具体的IP,那么就意味着你以后只能从这一个向指定的IP中获取信息,但是你的主机IP有多个,反而不能全部利用,因此这里我们选择填入INADDR_ANY(0)。
在这里插入图片描述

此时我们就可以接收多个IP+端口号发送来的信息了。

注意:我们一般在进行网络测试时,一般会使用本地环回IP测试,也就是127.0.0.1,当你的客服端向127.0.0.1这个IP发送时,那么就不会在网络中传输,而是在本机。
在这里插入图片描述
那么此时我们就完成起手式,socket的创建与绑定了。

服务端LOOP

while (true)
{
    char buffer[1024];
    sockaddr_in peer;
    socklen_t len = sizeof(peer);
    int n = recvfrom(_socketfd, buffer, sizeof(buffer) - 1, 0, (sockaddr*)&peer, &len);
    if (n != -1)
    {
        buffer[n] = 0;
        
        int m = sendto(_socketfd, buffer, n, 0, (struct sockaddr*)&peer, sizeof(peer));
        if (m == -1)
        {
            perror("发送错误");
            break;
        }
    }
}

我们的服务端肯定是要进行接收消息的,然后在做一些加工返回给客服端。
这里就不得不说两个函数了。
在这里插入图片描述
在这里插入图片描述
他们的参数都非常的类似,在接收时我们传入一段缓冲区,填入大小,即可得到客户端发来的消息了,因为我们接收后还要发送给对方,所以后边的两个参数是输入型参数,会得到对方的sockaddr信息。

对于发送时,我们也是如此。
另外:它们的 flags 参数是用来控制函数行为的标志位,允许程序员指定一些特殊的选项或操作模式。flags 参数通常是多个标志的按位或(OR)组合,但大多数情况下,这些标志并不是必需的,因此绝大多数会传递0作为默认值。

客户端起手

与服务端有很大的不同。

int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd == -1)
{
    exit(1);
}

先说结论:我们在服务端只需要创建socket即可,肯定需要bind,但无需显示bind,因为会在sendto中OS会自主绑定。

为什么不需要自己指定IP + 端口号?
OS肯定是知道你的IP,那么OS给你绑定也说得过去,那么端口号为什么不自主绑定?
我们举一个例子,一个主机上的端口号是有限的,如果客户端是自主定义,那么可能不同客户端会出现重复!比如抖音用端口号8888,那么快手绑定8888是势必不成功,因为IP + port标识一个唯一进程。所以这个由OS自主分配即可。

客户端LOOP

while (true)
{
    std::string buffer;
    std::cout << "Please write msg:";
    std::getline(std::cin, buffer);
    
    // 处理sockaddr结构体 + 发送数据到服务端
    struct sockaddr_in peer;
    peer.sin_addr.s_addr = inet_addr(ip.c_str());
    peer.sin_port = htons(port);
    peer.sin_family = AF_INET;
    socklen_t len = sizeof(peer);
    int n = sendto(fd, buffer.c_str(), buffer.size(), 0, (struct sockaddr *)&peer, len);
    if (n != -1)
    {
        char inbuffer[1024];
        struct sockaddr_in temp;
        socklen_t len;
        int m = recvfrom(fd, inbuffer, n, 0, (struct sockaddr *)&temp, &len);
        if (m != -1)
        {
            inbuffer[m] = 0;
            std::cout << inbuffer << std::endl;
        }
    }
}

我们在服务端绑定时不需要填真正的IP,发送和接收时也直接使用现成的,但是在客户端我们需要手动填写,但是我们现在有的是一个字符串,我们压迫将他转为4字节,还要转为网络序列,这也是很繁琐的,因此也有一批函数用来转化。
在这里插入图片描述
可以看到我们放入一个字符串地址即可得到网络序列4字节IP,非常的方便,但是这里可以改进(我们最后说,一般使用inet_pton)。

所以我们现在也就没啥干货了,起手式已经完成!

验证

由于上图代码都是非常简略的,并没有将如何封装写出,但是还是要验证一下的
在这里插入图片描述
完整代码在Gitee链接中给出。
在这里插入图片描述

Dict

设计思想

我们在以上的基础上增加一些业务,这里也就开始涉及一些解耦的设计了。

我们的理想效果为输入一个单词返回他的意思。

其实服务器与客户端大的逻辑仍旧是不变的,但是这里进行设计解耦的思想是很好的,要学习!
首先我们要改变的就是服务端,我们在recvfrom到字符串单词后可以使用回调函数,将这个单词交给外部来做,返回汉语意思字符串sendto。这样就很好的完成了解耦,因为我们要用到回调函数,所以也就意味着在构造服务端对象时要传入可调用对象。

此时我们就可以利用function进行包装,包装出一个可调用对象类型。

using func_t = std::function<std::string(std::string)>; 

关于这里我其实还想补充几点
我们命名时可以看到func_t中的func代表这是函数,t代表typename表示类型,这样别人一看就知道这是一个函数类型。
而命名空间时也有这样的讲究,比如我们有一个日志类,使用log_ns域封装起来,ns就是namespace的缩写,也是一目了然。

另外就是关于function的一些点了,实际上我们的function绑定时与被绑定的函数类型并不需要完全相符,就像下图这样的代码甚至可以编过,我一点都不理解…
在这里插入图片描述
但是我认为还是最好保证一样。


回到主线:
由于我们希望解耦,因此function中我们也就没有必要传引用,就解耦解的结结实实(但实际上传也是可以的,还避免了拷贝)。
随后我们再编写一个字典类,最终要的是要有支持翻译的功能!

我们可以选择搞一个配置文件的形式,创建对象时进行加载即可~
在这里插入图片描述

const std::string sep = ": ";// 单词与翻译的分隔符

class Dict
{
private:
    void Load()
    {
        std::ifstream in(_path.c_str());
        if (!in.is_open())
        {
            exit(1);
        }

        std::string line;
        while (std::getline(in, line))
        {
            std::string key;
            std::string value;
            auto pos = line.find(sep);
            if (pos == -1)
                continue;
            key = line.substr(0, pos);
            value = line.substr(pos + sep.size());
            _map.insert(std::make_pair(key, value));
        }
    }

public:
    Dict(const std::string & path)
        : _path(path)
    {
        Load();
    }
    std::string GetChinese(std::string word)
    {
        auto ite = _map.find(word);        
        if (ite != _map.end())
            return ite->second;
        else
            return "None";
    }
    ~Dict()
    {}
private:
    std::unordered_map<std::string, std::string> _map; 
    std::string _path;
};

其中GetChinese函数就是我们未来在服务端回调的函数!

但是此时要注意,我们不能直接将这个函数传入服务器的构造函数中,因为这是一个静态成员函数!所以我们需要将这个函数bind一下,让this指针隐式写入即可!

我觉得这就是最精髓的地方了。
代码见链接。

验证

在这里插入图片描述

Chat

这是一个聊天室项目,我觉得是还算挑战性的,但实际上只是套的层数有点的,好多整合在一起。
听说Java那更喜欢各种封,各种套,什么结构啥的,害怕~

但是对于当前的chat聊天室来说最重要的搞清楚整体的大框架。
不仅仅是对于当前的聊天室,甚至可以说是任何比较嵌套的,只要把结构搞清楚了,那么就会轻松很多。
在这里插入图片描述
我们现在直接使用以上的服务端 + 客户端进行改进即可。

线程池的代码

服务端的修改

我们在字典中已经学到了在服务端使用回调进行业务处理,我们当然也可以使用回调完成转发!

我们进行转发需要3个元素。
sockfd描述符, message消息体,sockaddr结构体。
因此我们的回调设置为这样子即可。

这里的Inet就是我们带码云中封装过的转化类,

using service_t = std::function<void(int, const std::string &, const Inet &)>;

在服务器端上一个版本原本调用处理获取翻译的地方更改一下即可~

所以又到了设计转发类的时候了,我们一般喜欢在应用层中把这个工作叫做路由。
也就是设计一个路由类。

我们这样设计:当服务端回调到路由模块时,我们就得到了sockfd,message,addr,

  1. 首先检查当前ip + port是否在在线列表中,不在就add,在了就不管。
  2. 当消息为QUIT或者Q时,将在线列表中的user删除
  3. 转发我们只需要遍历一遍在线用户列表即可

也就是转发时使用线程池。

class Route
{
public:
    Route()
    {}
    void CheckOnlineUsers(const Inet &inet_addr)
    {
        _online_users.insert(inet_addr)
    }
    void Offline(const Inet &inet_addr)
    {
        LOG(DEBUG, "%s offline\n", inet_addr.AddrStr().c_str());
        _online_users.erase(inet_addr);
    }
    void ForwardHelper(int socket, const std::string &message)
    {
        for (auto &user : _online_users)
        {
            sockaddr_in peer = user.Sockaddr();
            socklen_t len = sizeof(peer);
            int n = ::sendto(socket, message.c_str(), message.size(), 0, (struct sockaddr *)&peer, len);
        }
    }
    void Forward(int socket, const std::string &message, const Inet &inet_addr)
    {
        CheckOnlineUsers(inet_addr);

        if (message == "Q" || message == "QUIT")
        {
            Offline(inet_addr);
        }
        // 转发模块,线程池去执行
        std::function<void()> f = std::bind(&Route::ForwardHelper, this, socket, send_message);
        ThreadPool<std::function<void()>>::GetInstance()->Equeue(f);
    }
    ~Route()
    {}

private:
    std::set<Inet, Route_ns::comp> _online_users;
    pthread_mutex_t _mutex;
};

注意到我们的线程池中只需要push进去一个可调用对象即可,所以我们进行bind一下以进行适配线程池模板。

而我们在进行构造客户端时传入Route类中的Forward函数即可~
依旧和Dict服务器一样的方法套路。

这样服务端就设计好了

客户段的修改

我们当前的客户端首先是有问题的,因为我们当前只有一个线程同时进行收和发,当我们多起几个客户端时,如果客户端A进行发消息,其他的客户端其实都不会显示的,因为只有别的客户端进行sendto时才会收到消息,否则就一直阻塞在sendto中。

所以这里我们也是用多线程进行一下修改,一个线程一直读,一个一直进行发送。

main函数中我们创建2个线程,分别执行各自的读和写,这里就没什么细节了。

int ClientInit()
{
    int fd = socket(AF_INET, SOCK_DGRAM, 0);
    if (fd == -1)
    {
        LOG(FATAL, "create socket err");
        exit(1);
    }
    return fd;
}

void receiver(const std::string &name, int socketfd)
{
    while (true)
    {
        char inbuffer[1024];
        struct sockaddr_in temp;
        socklen_t len;
        int n = recvfrom(socketfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&temp, &len);
        if (n >= 0)
        {
            inbuffer[n] = 0;
            std::cerr << inbuffer << std::endl;
        }
    }
}

void sender(const std::string &name, int socketfd, const std::string &ip, uint16_t port)
{
    while (true)
    {
        std::string buffer;
        std::cout << "Please input msg:";
        std::getline(std::cin, buffer);
        // 处理sockaddr结构体 + 发送数据到服务端
        struct sockaddr_in peer;
        peer.sin_addr.s_addr = inet_addr(ip.c_str());
        peer.sin_port = htons(port);
        peer.sin_family = AF_INET;
        socklen_t len = sizeof(peer);
        int n = sendto(socketfd, buffer.c_str(), buffer.size(), 0, (struct sockaddr *)&peer, len);
    }
}

int main(int args, char *argv[])
{
    // 处理命令行行参数
    if (args != 3)
    {
        std::cerr << "Usage:" << argv[0] << " Ip Port" << std::endl;
        exit(1);
    }
    std::string ip = argv[1];
    uint16_t port = std::stoi(argv[2]);

    int socketfd = ClientInit();

    MyThread t1("thread-receiver", std::bind(receiver, std::placeholders::_1, socketfd));
    MyThread t2("thread-sender", std::bind(sender, std::placeholders::_1, socketfd, ip, port));

    t1.Start();
    t2.Start();

    t1.Join();
    t2.Join();


    return 0;
}

代码链接

效果展示

在这里插入图片描述

完~~

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

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

相关文章

成功解决7版本的数据库导入 8版本数据库脚本报错问题

我 | 在这里 ⭐ 全栈开发攻城狮、全网10W粉丝、2022博客之星后端领域Top1、专家博主。 &#x1f393;擅长 指导毕设 | 论文指导 | 系统开发 | 毕业答辩 | 系统讲解等。已指导60位同学顺利毕业 ✈️个人公众号&#xff1a;热爱技术的小郑。回复 Java全套视频教程 或 前端全套视频…

大数据面试SQL(七):累加刚好超过各省GDP40%的地市名称

文章目录 累加刚好超过各省GDP40%的地市名称 一、题目 二、分析 三、SQL实战 四、样例数据参考 累加刚好超过各省GDP40%的地市名称 一、题目 现有各省地级市的gdp数据,求从高到低累加刚好超过各省GDP40%的地市名称&#xff0c;临界地市也需要。 例如&#xff1a; 浙江省…

设计模式-延迟加载(Lazy Load)

概念 一个对象&#xff0c;它虽然不包含所需要的所有数据&#xff0c;但是知道怎么获取这些数据。 加载一个对象会引起大量相关对象的加载&#xff0c;这样会损害系统的性能。延迟加载会暂时终止这个加载过程。 运行机制 四种实现延迟加载的方法&#xff1a; 延迟初始化&am…

ThreadLocal —— 让每个线程拥有自己的变量副本

在多线程编程中&#xff0c;数据共享是一个常见的话题。很多时候我们需要多个线程共享同一个数据&#xff0c;以实现数据的一致性和通信。然而&#xff0c;在一些场景下&#xff0c;我们并不希望数据被多个线程共享&#xff0c;而是希望每个线程都拥有自己独立的一份数据。这时…

【C++类和对象(中)】—— 我与C++的不解之缘(四)

前言&#xff1a; 接下来进行类和对象中的学习&#xff0c;了解类和对象的默认成员函数 一、类和对象默认成员函数 默认成员函数就是用户没有显示实现&#xff0c;编译器会自动生成的成员函数。 一个类&#xff0c;我们不显示实现的情况下&#xff0c;编译器就会默认生成一下留…

无人机开启农林植保新篇章

嘿&#xff0c;小伙伴们&#xff0c;你们知道吗&#xff1f;无人机已经悄悄在农业领域大展拳脚&#xff0c;成为现代农业的“黑科技”新宠儿啦&#xff01; 想象一下&#xff0c;广袤的田野上空&#xff0c;无人机如同勤劳的蜜蜂&#xff0c;精准高效地完成着各项任务&#xff…

unity 使用 compute shader的步骤

这里详细的记载使用步骤&#xff0c;我这个例子是让一个立方体上下不停的动 创建一个compute shader 一个普通shader 一个材质 一个C# 先挨个写上类容 这里kernel 指定main函数入口&#xff0c;RWStructuredBuffer就是数组&#xff0c;具体size是在外部指定的&#xff0c;可能…

C#使用CEFSharp获取动态网页源码

CEF 全称是Chromium Embedded Framework&#xff08;Chromium嵌入式框架&#xff09;&#xff0c;是个基于Google Chromium项目的开源Web browser控件&#xff0c;支持Windows, Linux, Mac平台。CEFSharp就是CEF的C#移植版本。 访问以下链接可以获取CEF的详细介绍 chromiumem…

c++版opencv长文指南

c版opencv长文指南 1、配置opencv库1.1 下载1.2 配置1.2.1 配置包含目录1.2.2 配置库含目录1.2.3 配置链接器1.2.4 配置系统环境变量 2、学习路线3、入门知识3.1 图像读取与显示3.2 图像色彩空间转换3.2 图像对象的创建与赋值3.2.1 图像对象的创建3.2.2 图像对象的赋值 3.3 图像…

轻松搞定小程序生成短链接/二维码,你学会了吗?

朋友们&#xff0c;大家有没有遇到过这样的困扰&#xff1a;小程序由于不是链接&#xff0c;在短信或者其他平台里根本没法推广&#xff0c;导致小程序的用户量很难涨起来。 那小程序转成链接真的就没办法实现吗&#xff1f;当然不是&#xff01; 现在有一款超实用的工具——…

zigbee笔记:十、ZStack(2.3.0-1.4.0)的OSAL使用分析

zigbee笔记&#xff1a;九中&#xff0c;我们已经学会了利用模板&#xff0c;定制自己的个性开发工程&#xff0c;本文为协议栈&#xff08;ZStack-CC2530-2.3.0-1.4.0&#xff09;代码使用分析笔记&#xff0c;来进一步掌握协议栈的使用。 一、协议栈使用知识点 1、协调器、路…

python从入门到精通:基础语法讲解

1、字面量 字面量&#xff1a;在代码中&#xff0c;被写下来的固定的值&#xff0c;称之为字面量。 python中常用的几种数据类型&#xff1a; 类型描述说明数字&#xff08;Number&#xff09; 整数&#xff08;int&#xff09; 浮点数&#xff08;float&#xff09; 复数&a…

Graph-Cot:图上迭代推理

Graph-Cot&#xff1a;图上迭代推理 提出背景GRAPH-COT 对比 MindMapGRAPH-COT 和 MindMap 多链推理方法结合案例一&#xff1a;复杂症状的诊断案例二&#xff1a;罕见病的诊断案例三&#xff1a;治疗方案的制定 解法拆解目的问题解法 GRAPH-COT 医学问诊 论文&#xff1a;http…

(BO)Bayes-CNN多变量时序预测 基于贝叶斯算法-卷积神经网络多变量时序预测(多输入单输出)Matlab代码

Bayes-CNN多变量时序预测 基于贝叶斯算法-卷积神经网络多变量时序预测&#xff08;多输入单输出&#xff09;Matlab代码 程序已经调试好&#xff0c;无需更改代码替换数据集即可运行&#xff01;&#xff01;&#xff01;数据格式为excel&#xff01;(如下) 需要其他的都可以…

CSS3下拉菜单实现

导航菜单&#xff1a; <nav class"multi_drop_menu"><!-- 一级开始 --><ul><li><a href"#">Power</a></li><li><a href"#">Money</a></li><li><a href"#"…

【数模修炼之旅】02 多目标规划 深度解析(教程+代码)

【数模修炼之旅】02 多目标规划 深度解析&#xff08;教程代码&#xff09; 接下来 C君将会用至少30个小节来为大家深度解析数模领域常用的算法&#xff0c;大家可以关注这个专栏&#xff0c;持续学习哦&#xff0c;对于大家的能力提高会有极大的帮助。 1 多目标规划介绍及应…

百度智能云发布3款轻量级+2款场景大模型

文心大模型ERNIE 3.5是目前百度智能云千帆大模型平台上最受欢迎的基础大模型之一。针对用户的常见通用的对话场景&#xff0c;ERNIE 3.5 在指令遵循、上下文学习和逻辑推理能力三方面分别进行了能力增强。 ERNIE Speed作为三款轻量级大模型中的“大个子”&#xff0c;推理场景…

解决连接不上Linux和服务器中的Nacos(Windows中能连接但是Linux中却不行)

报错 com.alibaba.nacos.shaded.io.grpc.StatusRuntimeException: UNKNOWN: Uncaught exception in the SynchronizationContext. Re-thrown. at com.alibaba.nacos.shaded.io.grpc.Status.asRuntimeException(Status.jav 2024-08-13T10:21:52.93708:00 ERROR 27764 --- …

ArduPilot开源代码之FMU+IOMCU设计

ArduPilot开源代码之FMUIOMCU设计 1. 源由2. 设计概念3. FMU & IOMCU特点3.1 FMU&#xff08;Flight Management Unit&#xff09;的主要功能3.2 IOMCU&#xff08;Input/Output Microcontroller Unit&#xff09;的主要功能3.3 主要差异 4. 主/辅助(MAIN/AUX) PWM输出5. 软…

【北京仁爱堂】痉挛性斜颈的早期症状,你了解吗?

在日常生活中&#xff0c;您可能很少听到“痉挛性斜颈”这个名词&#xff0c;但它却是一种不容忽视的疾病。今天&#xff0c;就让我们一起来了解一下痉挛性斜颈的早期症状&#xff0c;以便能够及时发现并采取相应的措施。 痉挛性斜颈是一种局限性肌张力障碍疾病&#xff0c;主要…