Django: 将子进程连续输出到 HTML 视图

9
我需要在我的Django应用程序中创建一个HTML页面,以加载并显示脚本的连续输出,且具有可滚动的框。这种情况是否可能?
我目前使用子进程来运行Python脚本,但是HTML页面要等到脚本运行完成后(可能需要约5分钟)才会加载。我希望用户能看到正在发生的事情,而不仅仅是旋转的圆圈。
我的代码还将脚本的完整输出通过文本中的"\n"卸载出来;如果可能的话,我想每次输出一行新的内容。
我的代码如下:
Views.py:
def projectprogress(request):
    GenerateProjectConfig(request)
    home = os.getcwd()
    project_id = request.session['projectname']
    staging_folder = home + "/staging/" + project_id + "/"
    output = ""
    os.chdir(staging_folder)
    script = home + '/webscripts/terraformdeploy.py'
    try:
        output = subprocess.check_output(['python', script], shell=True)
    except subprocess.CalledProcessError:
        exit_code, error_msg = output.returncode, output.output
    os.chdir(home)
    return render(request, 'projectprogress.html', locals())

projectprogress.html:

<style>
  div.ex1 {
  background-color: black;
  width: 900px;
  height: 500px;
  overflow: scroll;
  margin: 50px;
}
</style>

<body style="background-color: #565c60; font-family: Georgia, 'Times New Roman', Times, serif; color: white; margin:0"></body>
    <div class="ex1">
        {% if output %}<h3>{{ output }}</h3>{% endif %}
        {% if exit_code %}<h3> The command returned an error: {{ error_msg }}</h3>{% endif %}
    </div>
    <div class="container">
        <a class="button button--wide button--white" href="home.html" title="Home" style="color: white; margin: 60px;">
            <span class="button__inner">
          Home
        </span>
        </a>
    </div>
</body>
</html>

3
可以实现。我建议使用服务器端事件(Server Side Events)来完成此操作,因为你只需要单向通信,而WebSocket会引入新的协议,并且过于复杂。以下是我给出的一个简单介绍如何创建SSE的答案:https://dev59.com/3Ggu5IYBdhLWcg3wFDJr#62077516 - Nick Brady
@NickBrady 非常有趣!我需要一个ASGI服务器,还是普通的WSGI服务器就可以胜任? - Mario Orlandi
就像之前的用户所说,如果您可以接受从客户端轮询的话,REST API也可以工作,并且是最简单的路线。您只需要接受事件不会“立即”发生的事实。关于WSGI和ASGI的问题很好。它可以在WSGI上运行,但我认为while循环将阻塞WSGI进程,因此您可能希望将其作为线程在您的进程上运行。我建议您进行一些测试,并根据您的需求进行一些负载测试和更多研究。我没有在大规模上实现SSE,只是在一个请求负载较低的小应用程序中使用,因此我不需要太担心。 - Nick Brady
1
抱歉,我不会。SEE使用HTTP协议,所以你只需要发送一个通用的文本/流响应即可。不要过多考虑Django或非Django。研究如何返回文本流,然后按照我的示例类似地执行即可。别忘了在线程上运行它 :) - Nick Brady
1
@RobTheRobot16 很高兴听到这个消息!请确保将您的解决方案发布在此处作为答案;)似乎是一个受欢迎的问题。如果需要,可以随时通过我的个人联系方式(可以在我的简介中找到一个简单的小网站)与我联系。 - Nick Brady
显示剩余3条评论
2个回答

5
你可以使用StreamingHttpResponsePopen来简化你的任务:
def test_iterator():
    from subprocess import Popen, PIPE, CalledProcessError

    with Popen(['ping', 'localhost'], stdout=PIPE, bufsize=1, universal_newlines=True) as p:
        for line in p.stdout:
            yield(line + '<br>') # process line here

    if p.returncode != 0:
        raise CalledProcessError(p.returncode, p.args)

def busy_view(request):
    from django.http import StreamingHttpResponse
    return StreamingHttpResponse(test_iterator())

StreamingHttpResponse 需要一个迭代器作为其参数。迭代器函数是指具有 yield 表达式(或生成器表达式)的函数,其返回值是一个生成器对象(迭代器)。

在此示例中,我只是回显 ping 命令以证明它可以正常工作。

['ping', 'localhost'] 替换为列表(如果您需要传递参数给命令,则必须使用列表 - 在这种情况下是 localhost)。您原来的 ['python',脚本] 应该也能正常工作。

如果您想了解更多关于生成器的内容,我建议观看 Trey Hunner 的 talk,并强烈推荐阅读《Fluent Python》一书的第14章。两者都是很棒的资源。

免责声明:

性能考虑

Django设计用于短暂请求。流式响应将会占用整个工作进程的时间。这可能导致性能下降。

一般来说,你应该在请求-响应周期之外执行昂贵的任务,而不是使用流式响应。


4
您需要的是WebSockets,或者在Django中被称为Channels。

https://channels.readthedocs.io/en/latest/

这使您能够在后端向前端发送消息,而无需在前端拉取消息或重新加载页面。
值得一提的是,您还可以将输出流式传输到多个客户端,并向后端发送命令。

适合您代码的方法

请注意,由于我无法访问您的代码,因此此代码未经测试,因此您可能需要进行一些微小的调整,但我相信提供的代码应该可以说明概念。

Settings.py

INSTALLED_APPS = (
#Other installed Apps
       'Channels',
)
CHANNEL_LAYERS = {
      "default": {
          "BACKEND": "asgiref.inmemory.ChannelLayer",
            "ROUTING": "django_channels.routing.channel_routing",
      },
}

routing.py(将文件添加到与settings.py相同的文件夹中)

from django_channels_app.consumers import message_ws, listener_add, listener_discconect

channel_routing = [
      route("websocket.receive", message_ws),
      route("websocket.disconnect", listener_discconect),
      route("websocket.connect", listener_add),
]

在你的模块中:
import threading
from channels import Group

class PreserializeThread(threading.Thread):
    def __init__(self, request, *args, **kwargs):
        self.request = request
        super(PreserializeThread, self).__init__(*args, **kwargs)

    def run(self):
        GenerateProjectConfig(request)
        home = os.getcwd()
        project_id = request.session['projectname']
        staging_folder = home + "/staging/" + project_id + "/"
        output = ""
        os.chdir(staging_folder)
        script = home + '/webscripts/terraformdeploy.py'
        try:
            output = subprocess.check_output(['python', script], shell=True)
            Group("django_channels_group").send({
                "text": output,
            })

            # NOTICE THIS WILL BLOCK; 
            # You could try the following, untested snippet


#    proc = subprocess.Popen(['python', script], shell=True, #stdout=subprocess.PIPE)
#    
#    line = proc.stdout.readline()
#    while line:
#        line = proc.stdout.readline()
#        Group("django_channels_group").send({
#                        "text": line,
#                    })
#    Group("django_channels_group").send({
#        "text": "Finished",
#    })
        except subprocess.CalledProcessError:
            exit_code, error_msg = (
                output.returncode,output.output)
        os.chdir(home)

def listener_add(message):
    Group("django_channels_group").add(
        message.reply_channel)

def listener_discconect(message):
    Group("django_channels_group").discard(
        message.reply_channel)

def message_ws(message):
    Group("django_channels_group").send({
          "text": "My group message",
     })

def projectprogress(request):
    ProgressThread(request).start()
    return render(request, 'projectprogress.html', locals())

HTML

<style>
  div.ex1 {
  background-color: black;
  width: 900px;
  height: 500px;
  overflow: scroll;
  margin: 50px;
}
</style>

<body style="background-color: #565c60; font-family: Georgia, 'Times New Roman', Times, serif; color: white; margin:0"></body>
    <div id="output">
        
    </div>
    <div class="container">
        <a class="button button--wide button--white" href="home.html" title="Home" style="color: white; margin: 60px;">
            <span class="button__inner">
          Home
        </span>
        </a>
    </div>
</body>
</html>

<script>
socket = new WebSocket("ws://127.0.0.1:8000/"); #Or your server IP address
socket.onmessage = function(e) {
    const data = JSON.parse(e.data);
    document.querySelector('#ouput').value += (data.message + '\n');
}
socket.onopen = function() {
    socket.send("Test message");
}
</script>

更通用的答案

后端:

chat/consumers.py:

import json
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer

class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.room_name = self.scope['url_route']['kwargs']['room_name']
        self.room_group_name = 'chat_%s' % self.room_name

        # Join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name,
            self.channel_name
        )

        self.accept()

    def disconnect(self, close_code):
        # Leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name,
            selfreturn render(request, 'projectprogress.html', locals()).channel_name
        )

    def send_message(self, event):
        message = event['message']

        # Send message to WebSocket
        self.send(text_data=json.dumps({
            'message': message
        }))

mysite/settings.py:

# Channels
ASGI_APPLICATION = 'mysite.routing.application'
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}

mysite/routing.py:

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
import chat.routing

application = ProtocolTypeRouter({
    # (http->django views is added by default)
    'websocket': AuthMiddlewareStack(
        URLRouter(
            chat.routing.websocket_urlpatterns
        )
    ),
})

chat/routing.py:

from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/chat/(?P<room_name>\w+)/$', consumers.ChatConsumer),
]

前端:

<script>
const chatSocket = new WebSocket(
            'ws://'
            + window.location.host
            + '/ws/chat/'
            + roomName
            + '/'
        );

        chatSocket.onmessage = function(e) {
            const data = JSON.parse(e.data);
            document.querySelector('#chat-log').value += (data.message + '\n');
        };
</script>

谢谢,考虑到我完全是Django和Channels的新手,我得做一些研究。您知道我应该把子进程命令放到哪里吗?是在consumers.py文件中的某个地方吗? - RobTheRobot16
1
@RobTheRobot16 很抱歉之前没有回复你的评论,我会更新我的答案告诉你如何做到这一点。 - alexisdevarennes
1
@RobTheRobot16 我已经更新了我的答案,如果你已经看到了更新的内容,你可能需要再次检查一下,因为我纠正了一些拼写错误。 - alexisdevarennes
1
添加了一条注释,关于使其流式输出而不是等待其完成。 - alexisdevarennes
谢谢,@alexisdevarennes!我不得不更新我的设置,包括“ASGI_APPLICATION ='django_forms.routing.application'”(根据https://dev59.com/tFMH5IYBdhLWcg3wpgte),但现在我有点困惑ProtocolTypeRouter在您特定示例的routing.py文件中起到什么作用。在通用答案中,您使用URL模式,但在更具体的答案中,您只是列出路由。您能解释一下区别吗? - RobTheRobot16

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接