文档

ALB使用WebSocket协议实现信息实时推送

更新时间:

WebSocket是一种在单个TCP连接上提供全双工通信渠道的网络协议。WebSocket的设计使得客户端和服务器之间可以实现持久连接,都能够主动地向对方发送数据或接收数据,减少了频繁建立连接的开销和延迟,这通常比传统的HTTP请求和响应模式更高效。WebSocket主要应用于需要实时通信功能的场景,ALB默认支持WebSocket协议。

WebSocket简介

为什么使用WebSocket

随着互联网技术的迅速发展,Web应用的多样化趋势日益显著,其中不少应用场景,例如直播间聊天室、实时弹幕等,都需要服务器具备实时推送数据的能力。传统的实现方式是通过轮询技术,即客户端浏览器在固定的时间间隔(例如每1秒)向服务器发起HTTP请求,服务器随后将最新数据返回给客户端。然而,这种模式存在显著的不足,客户端需要频繁地发起请求,而HTTP请求的头部信息通常较长,有效数据却相对较少,这不仅增加了服务器的负担,也造成了带宽资源的极大浪费。

为了解决这些问题,HTML5引入了WebSocket协议,它为服务器与客户端之间的通信提供了更为高效的解决方案。WebSocket协议支持全双工通信,这意味着服务器和客户端可以同时进行数据的发送和接收,从而允许服务器在有新数据时主动推送给客户端,无需客户端不断轮询。这种双向实时通信机制显著提高了数据传输的效率,减少不必要的网络请求,有效节省服务器资源和带宽,同时为用户带来更为流畅和实时的交互体验。

WebSocket协议的特性

WebSocket通信前,首先客户端与服务器要进行TCP三次握手连接,然后进行一次叫做“握手”的特殊HTTP请求进行协议升级,完成协议升级后原始的HTTP连接被升级到WebSocket连接。在协议升级后,客户端和服务器之间的通信将使用WebSocket协议进行而不再是HTTP,可以在同一个WebSocket连接上进行双向通信。

WebSocket连接一旦经过握手协商成功建立,便能维持活跃状态,使得双方能如同使用原始套接字(Socket)那样进行连续不断的双向数据传输,而不必为每个通信回合重新发起连接或等待确认。通过WebSocket,客户端和服务器之间得以建立一种持久、低延迟的连接,极大地提升了数据交换效率。

image

WebSocket通过数据帧进行通信,它有自己的帧协议格式,头信息更简洁,数据可以作为文本或二进制传输。这种方式减少了持久连接上额外的协议开销,允许更高效的网络交互,能够在节省服务器资源和带宽的同时,提供更优质的实时互动体验。

关于WebSocket协议的更多信息,可参考官方文档The WebSocket Protocol

WebSocket应用场景

WebSocket主要适用于需要快速、实时的双向通信的应用场景,例如AI应用、在线聊天室、实时通知系统、多人在线游戏、实时市场信息推送等。

场景示例

某公司需要在阿里云上部署Web在线聊天应用,用户可以通过访问域名来接入后端服务进行实时交流。该应用由于其即时通讯的特性,要求用户之间的信息传递必须具备低延迟、高效率和双向实时的特点。

该公司的网站服务面临的挑战是高并发与长连接管理。随着用户数量的增长,传统的HTTP模式无法满足大量用户同时在线并保持实时通信的需求,因为每次通信都需要重新建立连接,这会导致服务器压力剧增且性能低下。

在这个场景下,选用ALB结合WebSocket协议,能够有效解决高并发下的长连接管理问题。通过后端服务器组多服务器部署WebSocket应用程序,并使用Redis进行消息同步,确保服务的高可用性,从而为在线聊天室应用提供了一个可靠、高效的实时信息推送解决方案。

image

注意事项

ALB的HTTP监听默认支持WebSocket协议。ALB默认支持热更新,即配置变更时不会影响已有长连接。

使用时需要注意如下事项:

  • 若ALB与后端服务器的连接采用某个版本的HTTP协议(例如HTTP/1.1),建议后端服务器采用支持同样HTTP协议版本的Web Server。

  • HTTP监听的默认连接请求超时时间为60秒,即如果ALB与后端服务超过60秒无消息交互,会主动断开连接。

    • 如果60秒无法满足您的需求,您可以通过修改监听的连接请求超时时间字段,调整该时间值。

    • 如果需要维持连接一直不中断,需要主动实现保活机制,每60秒内进行一次报文交互。

前提条件

  • 已创建公网ALB实例。具体操作,请参见创建应用型负载均衡

  • 已准备3个云服务器ECS01、ECS02、ECS03。

    • ECS01、ECS02用于部署WebSocket应用程序,ECS03用于部署Redis。

    • 本文示例中服务器操作系统统一为CentOS7.9。

    • ECS01、ECS02、ECS03建议处于同一安全组中。如果分配了不同的安全组,注意互相放行服务器需要通信的端口。

  • 已经注册域名并完成备案。具体操作,请参见注册阿里云域名ICP备案

操作步骤

步骤一:部署服务

您需要在您的ECS03服务器中部署Redis,在ECS01、ECS02服务器中部署WebSocket应用程序。

本文以CentOS 7.9为示例,演示使用Python快速部署一个简易的在线聊天室测试服务。示例仅供参考,实际使用过程中以您自己开发的程序和服务为准。

在ECS03部署Redis服务

  1. 登录ECS03服务器后台。

  2. 复制粘贴如下命令,并执行,完成Redis部署与配置。

    # 安装 EPEL (Extra Packages for Enterprise Linux)
    sudo yum install epel-release -y
    
    # 安装 Redis
    sudo yum install redis -y
    
    # 启动并启用 Redis 服务
    sudo systemctl start redis
    sudo systemctl enable redis
    
    # 检查并编辑 Redis 配置文件,允许远程连接
    sudo sed -i 's/^bind 127.0.0.1$/bind 0.0.0.0/' /etc/redis.conf
    sudo sed -i 's/^protected-mode yes/protected-mode no/' /etc/redis.conf
    
    # 重启 Redis 服务以使更改生效
    sudo systemctl restart redis
    
    # 检查 Redis 是否运行
    sudo systemctl status redis
    
  3. 命令执行无报错,并且命令运行完成后,返回如下信息并显示Redis服务为active(running)状态,表示部署与配置成功。

    image

在ECS01中部署WebSocket应用程序

  1. 登录ECS01服务器后台。

  2. 执行sudo pip3 install flask flask-socketio flask-cors redis,安装依赖库。

  3. 执行vi ECS01_ws.py,按i键进入编辑模式。

  4. 复制并粘贴如下代码:

    部署测试服务代码参考

    说明

    注意第13行的redis_url中的IP地址,需要修改为Redis服务器的IP地址,即ECS03的IP地址。

    import os
    import redis
    from flask import Flask, render_template, request
    from flask_cors import CORS
    from flask_socketio import SocketIO, emit, disconnect
    
    app = Flask(__name__)
    app.config['SECRET_KEY'] = 'secret!'
    # 启用跨域资源共享(CORS)
    CORS(app)
    
    # 配置 Redis 作为消息队列和状态存储
    redis_url = "redis://192.168.*.*:6379/0"  # 替换为你的 Redis 服务器 IP
    redis_client = redis.StrictRedis.from_url(redis_url)
    
    # 日志级别增加为 DEBUG 以便于调试
    socketio = SocketIO(app, message_queue=redis_url, manage_session=True, logger=True, engineio_logger=True, cors_allowed_origins="*")
    
    SESSION_PREFIX = "session:"
    
    
    def set_session_data(session_id, key, value):
        redis_client.hset(f"{SESSION_PREFIX}{session_id}", key, value)
    
    
    def get_session_data(session_id, key):
        return redis_client.hget(f"{SESSION_PREFIX}{session_id}", key)
    
    
    def delete_session_data(session_id):
        redis_client.delete(f"{SESSION_PREFIX}{session_id}")
    
    
    @app.route('/')
    def index():
        return render_template('index.html')
    
    
    @socketio.on('connect')
    def handle_connect():
        try:
            session_id = request.sid  # 获取客户端的 session ID
            print(f"Session {session_id} connected.")
            welcome_message = "您已进入聊天室!"
            emit('message', welcome_message)
            set_session_data(session_id, "username", '')  # 初始化用户名为空
        except Exception as e:
            print(f"Error during connection: {str(e)}")
    
    
    @socketio.on('disconnect')
    def handle_disconnect():
        try:
            session_id = request.sid
            username = get_session_data(session_id, "username")
            if username:
                username = username.decode()
                leave_message = f"{username} 已离开聊天室。"
                emit('message', leave_message, broadcast=True)
                print(leave_message)
            delete_session_data(session_id)
            print(f"Session {session_id} disconnected.")
        except Exception as e:
            print(f"Error during disconnection: {str(e)}")
    
    
    @socketio.on('set_username')
    def handle_set_username(username):
        session_id = request.sid
        set_session_data(session_id, "username", username)
        print(f"客户端 {session_id} 的用户名设置为 {username}")
        emit('message', f"您的用户名已设置为:{username}")
    
    
    @socketio.on('message')
    def handle_message(msg):
        session_id = request.sid
        username = get_session_data(session_id, "username")
        if username:
            username = username.decode()
            formatted_message = f"{username}:{msg}"
            emit('message', formatted_message, broadcast=True)
            print(formatted_message)
        else:
            warning_message = "发送消息失败:请先设置用户名。"
            emit('message', warning_message)
            print(warning_message)
    
    
    if __name__ == '__main__':
        # 确保存在 templates 目录
        if not os.path.exists('templates'):
            os.makedirs('templates')
    
        # 使用 Flask 模板(index.html)
        html_code = '''<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>聊天室</title>
        <style>
            body {
                font-family: Arial, sans-serif;
                display: flex;
                flex-direction: column;
                align-items: center;
                margin: 0;
                padding: 0;
                background-color: #f0f0f0;
            }
            h1 {
                color: #333;
            }
            .chat-container {
                width: 90%;
                max-width: 600px;
                background: white;
                padding: 20px;
                border-radius: 8px;
                box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
            }
            .user-container, .message-container {
                display: flex;
                margin-bottom: 10px;
            }
            .user-container input, .message-container input {
                flex: 1;
                padding: 10px;
                margin-right: 10px;
                border: 1px solid #ccc;
                border-radius: 4px;
            }
            .message-container {
                margin-top: 10px;
            }
            button {
                padding: 10px;
                background-color: #0056b3;
                color: white;
                border: none;
                border-radius: 4px;
                cursor: pointer;
            }
            button:hover {
                background-color: #004099;
            }
            #messages {
                border: 1px solid #ccc;
                padding: 10px;
                height: 300px;
                overflow-y: scroll;
                margin-bottom: 10px;
                border-radius: 4px;
                background-color: #f9f9f9;
            }
        </style>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
        <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    </head>
    <body>
        <h1>在线聊天室</h1>
        <div class="chat-container">
            <div class="user-container">
                <input type="text" id="username" autocomplete="off" placeholder="请输入用户名">
                <button onclick="setUsername()">设置用户名</button>
            </div>
            <div id="messages"></div>
            <div class="message-container">
                <input type="text" id="myMessage" autocomplete="off" placeholder="请输入消息...">
                <button onclick="sendMessage()">发送</button>
            </div>
        </div>
        <script>
            var socket = io({ transports: ['websocket', 'polling', 'flashsocket'] });
            var usernameSet = false;
            socket.on('connect', function() {
                console.log("Connected to the server!");
                socket.on('message', function(msg){
                    $('#messages').append($('<div>').text(msg));
                    $('#messages').scrollTop($('#messages')[0].scrollHeight);
                });
            });
            function setUsername() {
                var username = $('#username').val();
                if (username) {
                    socket.emit('set_username', username);
                    usernameSet = true;  // 设置用户名标识
                } else {
                    alert("用户名不能为空!");
                }
            }
            function sendMessage() {
                if (usernameSet) {
                    var message = $('#myMessage').val();
                    if (message) {
                        socket.send(message);
                        $('#myMessage').val('');
                    } else {
                        alert("消息不能为空!");
                    }
                } else {
                    alert("请先设置用户名!");
                }
            }
        </script>
    </body>
    </html>
    '''
    
        # 将模板保存到文件
        with open('templates/index.html', 'w') as file:
            file.write(html_code)
    
        socketio.run(app, host='0.0.0.0', port=5000)
    

  5. Esc键,输入:wq保存修改。

  6. 执行sudo python3 ECS01_ws.py命令,运行脚本。

  7. 当最后显示如下执行结果时,表示WebSocket应用程序已启动,端口为5000。

    Server initialized for threading.
     * Serving Flask app 'ECS01_ws' (lazy loading)
     * Environment: production
       WARNING: This is a development server. Do not use it in a production deployment.
       Use a production WSGI server instead.
     * Debug mode: off
     * Running on all addresses.
       WARNING: This is a development server. Do not use it in a production deployment.
     * Running on http://192.168.*.*:5000/ (Press CTRL+C to quit)
    

    如果出现启动失败,需要排查下端口是否已被占用,或者命令及代码是否复制粘贴错误。

在ECS02中部署WebSocket应用程序

  1. 登录ECS02服务器后台。

  2. 执行sudo pip3 install flask flask-socketio flask-cors redis,安装依赖库。

  3. 执行vi ECS02_ws.py,按i键进入编辑模式。

  4. 复制并粘贴如下代码:

    部署测试服务代码参考

    说明

    注意第13行的redis_url中的IP地址,需要修改为Redis服务器的IP地址,即ECS03的IP地址。

    import os
    import redis
    from flask import Flask, render_template, request
    from flask_cors import CORS
    from flask_socketio import SocketIO, emit, disconnect
    
    app = Flask(__name__)
    app.config['SECRET_KEY'] = 'secret!'
    # 启用跨域资源共享(CORS)
    CORS(app)
    
    # 配置 Redis 作为消息队列和状态存储
    redis_url = "redis://192.168.*.*:6379/0"  # 替换为你的 Redis 服务器 IP
    redis_client = redis.StrictRedis.from_url(redis_url)
    
    # 日志级别增加为 DEBUG 以便于调试
    socketio = SocketIO(app, message_queue=redis_url, manage_session=True, logger=True, engineio_logger=True, cors_allowed_origins="*")
    
    SESSION_PREFIX = "session:"
    
    
    def set_session_data(session_id, key, value):
        redis_client.hset(f"{SESSION_PREFIX}{session_id}", key, value)
    
    
    def get_session_data(session_id, key):
        return redis_client.hget(f"{SESSION_PREFIX}{session_id}", key)
    
    
    def delete_session_data(session_id):
        redis_client.delete(f"{SESSION_PREFIX}{session_id}")
    
    
    @app.route('/')
    def index():
        return render_template('index.html')
    
    
    @socketio.on('connect')
    def handle_connect():
        try:
            session_id = request.sid  # 获取客户端的 session ID
            print(f"Session {session_id} connected.")
            welcome_message = "您已进入聊天室!"
            emit('message', welcome_message)
            set_session_data(session_id, "username", '')  # 初始化用户名为空
        except Exception as e:
            print(f"Error during connection: {str(e)}")
    
    
    @socketio.on('disconnect')
    def handle_disconnect():
        try:
            session_id = request.sid
            username = get_session_data(session_id, "username")
            if username:
                username = username.decode()
                leave_message = f"{username} 已离开聊天室。"
                emit('message', leave_message, broadcast=True)
                print(leave_message)
            delete_session_data(session_id)
            print(f"Session {session_id} disconnected.")
        except Exception as e:
            print(f"Error during disconnection: {str(e)}")
    
    
    @socketio.on('set_username')
    def handle_set_username(username):
        session_id = request.sid
        set_session_data(session_id, "username", username)
        print(f"客户端 {session_id} 的用户名设置为 {username}")
        emit('message', f"您的用户名已设置为:{username}")
    
    
    @socketio.on('message')
    def handle_message(msg):
        session_id = request.sid
        username = get_session_data(session_id, "username")
        if username:
            username = username.decode()
            formatted_message = f"{username}:{msg}"
            emit('message', formatted_message, broadcast=True)
            print(formatted_message)
        else:
            warning_message = "发送消息失败:请先设置用户名。"
            emit('message', warning_message)
            print(warning_message)
    
    
    if __name__ == '__main__':
        # 确保存在 templates 目录
        if not os.path.exists('templates'):
            os.makedirs('templates')
    
        # 使用 Flask 模板(index.html)
        html_code = '''<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>聊天室</title>
        <style>
            body {
                font-family: Arial, sans-serif;
                display: flex;
                flex-direction: column;
                align-items: center;
                margin: 0;
                padding: 0;
                background-color: #f0f0f0;
            }
            h1 {
                color: #333;
            }
            .chat-container {
                width: 90%;
                max-width: 600px;
                background: white;
                padding: 20px;
                border-radius: 8px;
                box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
            }
            .user-container, .message-container {
                display: flex;
                margin-bottom: 10px;
            }
            .user-container input, .message-container input {
                flex: 1;
                padding: 10px;
                margin-right: 10px;
                border: 1px solid #ccc;
                border-radius: 4px;
            }
            .message-container {
                margin-top: 10px;
            }
            button {
                padding: 10px;
                background-color: #0056b3;
                color: white;
                border: none;
                border-radius: 4px;
                cursor: pointer;
            }
            button:hover {
                background-color: #004099;
            }
            #messages {
                border: 1px solid #ccc;
                padding: 10px;
                height: 300px;
                overflow-y: scroll;
                margin-bottom: 10px;
                border-radius: 4px;
                background-color: #f9f9f9;
            }
        </style>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
        <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    </head>
    <body>
        <h1>在线聊天室</h1>
        <div class="chat-container">
            <div class="user-container">
                <input type="text" id="username" autocomplete="off" placeholder="请输入用户名">
                <button onclick="setUsername()">设置用户名</button>
            </div>
            <div id="messages"></div>
            <div class="message-container">
                <input type="text" id="myMessage" autocomplete="off" placeholder="请输入消息...">
                <button onclick="sendMessage()">发送</button>
            </div>
        </div>
        <script>
            var socket = io({ transports: ['websocket', 'polling', 'flashsocket'] });
            var usernameSet = false;
            socket.on('connect', function() {
                console.log("Connected to the server!");
                socket.on('message', function(msg){
                    $('#messages').append($('<div>').text(msg));
                    $('#messages').scrollTop($('#messages')[0].scrollHeight);
                });
            });
            function setUsername() {
                var username = $('#username').val();
                if (username) {
                    socket.emit('set_username', username);
                    usernameSet = true;  // 设置用户名标识
                } else {
                    alert("用户名不能为空!");
                }
            }
            function sendMessage() {
                if (usernameSet) {
                    var message = $('#myMessage').val();
                    if (message) {
                        socket.send(message);
                        $('#myMessage').val('');
                    } else {
                        alert("消息不能为空!");
                    }
                } else {
                    alert("请先设置用户名!");
                }
            }
        </script>
    </body>
    </html>
    '''
    
        # 将模板保存到文件
        with open('templates/index.html', 'w') as file:
            file.write(html_code)
    
        socketio.run(app, host='0.0.0.0', port=5000)
    

  5. Esc键,输入:wq保存修改。

  6. 执行sudo python3 ECS02_ws.py命令,运行脚本。

  7. 当最后显示如下执行结果时,表示WebSocket应用程序已启动,端口为5000。

    Server initialized for threading.
     * Serving Flask app 'ECS02_ws' (lazy loading)
     * Environment: production
       WARNING: This is a development server. Do not use it in a production deployment.
       Use a production WSGI server instead.
     * Debug mode: off
     * Running on all addresses.
       WARNING: This is a development server. Do not use it in a production deployment.
     * Running on http://192.168.*.*:5000/ (Press CTRL+C to quit)
    

    如果出现启动失败,需要排查下端口是否已被占用,或者命令及代码是否复制粘贴错误。

步骤二:配置服务器组

  1. 登录应用型负载均衡ALB控制台

  2. 在顶部菜单栏,选择实例所属的地域。

  3. 在左侧导航栏,选择服务器组

  4. 单击创建服务器组。在创建服务器组页面配置以下信息,其他参数可保持默认值或根据实际情况修改。完成后单击创建

    配置

    说明

    服务器组类型

    选择服务器类型

    VPC

    选择后端服务器即ECS01、ECS02所在的VPC。

    后端服务器需要与ALB实例处于同一VPC中。

  5. 在弹出的服务器组创建成功窗口中,单击添加后端服务器

  6. 添加后端服务器配置页面,选中ECS01与ECS02并完成添加,注意端口需要配置为WebSocket应用程序端口。本文代码示例中WebSocket应用程序端口为5000。

步骤三:配置HTTP监听

  1. 登录应用型负载均衡ALB控制台

  2. 在顶部菜单栏,选择实例所属的地域。

  3. 在左侧导航栏,选择实例

  4. 实例页面,找到目标实例,然后在操作列单击创建监听

  5. 配置监听页面配置以下信息,其他参数可保持默认值或根据实际情况修改。完成后单击下一步

    配置

    说明

    选择监听协议

    选择HTTP

    监听端口

    本文配置端口5000。

  6. 选择服务器组页面配置以下信息,其他参数可保持默认值或根据实际情况修改。完成后单击下一步

    配置

    说明

    选择服务器组

    选择此前已创建好的服务器组。

  7. 配置审核页面,检查配置参数是否有误,无误的话单击提交,等待监听创建完成。

步骤四:配置域名解析

  1. 登录应用型负载均衡ALB控制台

  2. 在顶部菜单栏选择地域。

  3. 选择要进行域名解析的ALB实例,复制其对应的DNS名称。

  4. 完成以下步骤来添加CNAME解析记录。

    1. 登录域名解析控制台

    2. 域名解析页面单击添加域名

    3. 添加域名对话框中输入您的主机域名,然后单击确认

      重要

      您的主机域名需已完成TXT记录验证。

    4. 在目标域名的操作列单击解析设置

    5. 解析设置页面单击添加记录

    6. 添加记录面板配置以下信息完成CNAME解析配置,然后单击确认

      配置

      说明

      记录类型

      在下拉列表中选择CNAME

      主机记录

      您的域名的前缀。

      解析请求来源

      选择默认。

      记录值

      输入域名对应的CNAME地址,即您复制的ALB实例的DNS名称。

      TTL

      全称Time To Live,表示DNS记录在DNS服务器上的缓存时间,本文使用默认值。

步骤五:结果验证

准备2个不同IP地址的能够访问公网的终端电脑,通过在浏览器输入聊天消息并查看效果,验证ALB使用WebSocket协议实现信息实时推送。

  1. 在浏览器中输入http://域名:5000,访问在线聊天室应用。

    页面访问成功示例:

    image

    如果您打开了浏览器开发者工具,您可以在网络Network页签看到,浏览器已经在使用WebSocket协议进行通信。

    image

  2. 输入用户名用于后续聊天交互,完成后单击设置用户名

  3. 在不同终端电脑中,分别输入多条聊天消息并单击发送,进行测试。

    所有浏览器中,均可以实时收到消息。

    image

  4. 如上验证过程表明,通过ALB使用WebSocket协议实现了信息实时推送,并且实现了高可用。

常见问题

如何使用WebSocket Secure协议?

WebSocket Secure是WebSocket协议的加密版本。

HTTPS监听默认支持WebSocket Secure协议。您在配置监听时,选择使用HTTPS监听,即可使用WebSocket Secure协议。

使用WebSocket收费吗?

WebSocket和WebSocket Secure协议不额外收取费用。

哪些地域支持WebSocket?

ALB支持的所有地域,都已支持WebSocket和WebSocket Secure。

相关文档

本文的示例中使用了在ECS部署Redis的简单方式,便于您做业务测试,如果Redis服务器出现问题可能造成系统单点故障。在实际生产环境中,建议您使用云数据库Redis版,提升应用系统整体高可用性。云数据库Redis版如何快速入门?