Streamando dados no Django

Recentemente na Loadsmart, houve a necessidade de lidar com um cenário onde se faz necessário acessar uma view que retorna um CSV de tamanho considerável, gerado a partir de parâmetros dinâmicos, no melhor esquema "imprima um relatório".

Embora o Django seja desenhado para requisições curtas, existe a possibilidade de fazer streaming de grandes arquivos CSVs através da classe StreamingHttpResponse.

Vale a nota: Esse artigo é sobre CSVs, mas você também pode "streamar" outros tipos de dados através desse método supimpa.

Motivação

Em determinados cenários, precisamos retornar arquivos relativamente grandes para o usuário. O exemplo acima, uma requisição com parâmetros que irão gerar um relatório em CSV, é um caso bem comum.

O comportamento de resposta padrão do Django é retornar uma instância de HttpResponse. O problema é que nesse modo carregaremos o arquivo inteiro para a memória do servidor, e só depois enviamos o arquivo para o cliente. Além de prejudicarmos o Time To First Byte (TTBT), podemos gerar picos de memória na máquina hospedeira (que podem afetar demais requisições sendo processadas) e timeouts de conexão.

Um exemplo de como faríamos esse retorno através do HttpResponse:

import csv
from django.http import HttpResponse
from django.view import View


class CsvReportView(View):
    def get(self, request, *args, **kwargs):
        # Abstraindo a logica de filtro por parametros do request
        cargoes = Cargoes.objects.filter_by_request_parms(request)

        response = HttpResponse(content_type='text/csv')
        response['Content-Disposition'] = 'attachment; filename="filtered_cargoes.csv"'

        writer = csv.writer(response)

        for row in cargoes.values_list('id', 'commodity', 'weight'):
            writer.writerow(row)

        return response

Se você tem certa intimidade com geradores, deve estar supondo que essa pode ser uma maneira de solucionar esse problema... e você está certo!

Como funciona o StreamingHttpResponse?

O StreamingHttpResponse funciona com a ajuda do conceito de generators. Achei uma excelente definição no Stackoverflow:

(...) is simply a function which returns an object on which you can call next, such that for every call it returns some value, until it raises a StopIteration exception, signaling that all values have been generated. Such an object is called an iterator.

Leia mais sobre iteráveis e geradores.

Para tanto, o uso da palavra reservada yield se faz necessário. Logo, ao invés de termos o exemplo abaixo:

def my_view(request):
    message = 'Hello, there!'
    response =  HttpResponse(message)
    response['Content-Length'] = len(message)

    return response

Teremos:

def hello():
    yield 'Hello,'
    yield 'there!'

def my_view(request):
    return StreamingHttpResponse(hello)

Leia mais no excelente "How does Django's StreamingHttpResponse work, exactly?".

Voltando ao exemplo do CSV, teremos:

import csv
from django.db import models
from django.http import StreamingHttpResponse
from django.views.generic import View

class Echo(object):
    """
    Interface parecida com a que usamos para escrever arquivos.
    """

    def write(self, value):
        return value


class CsvReportView(View):
    def get(self, request, *args, **kwargs):
        # Abstraindo a logica de filtro por parametros do request
        cargoes = Cargoes.objects.filter_by_request_parms(request)

        # Usamos a mesma interface de buffer utilizada para escrever
        # um arquivo. No entanto, apenas damos um `return value`
        # aos inves de persistirmos o valor
        pseudo_buffer = Echo()
        writer = csv.writer(pseudo_buffer)

        # Construimos o nosso gerador
        gen_func = (writer.writerow(row) for row in cargos.values_list('id', 'commodity', 'weight'))

        response = StreamingHttpResponse(gen_func, content_type='text/csv')
        response['Content-Disposition'] = 'attachment;filename="filtered_cargoes.csv"'

        return response

Antes mesmo de termos todo o CSV montado, já estamos retornando dados ao usuário.

Veja o exemplo da documentação ofical do Django.

É a melhor maneira de resolver esse problema?

Caso a sua única opção seja retornar o dado através de uma view Django, sim. Além de um uso mais eficiente de memória, o retorno do StreamingHttpResponse vai impedir que, por exemplo, um load balancer feche a conexão do seu usuário pelo fato de o serviço Django estar levando muito tempo para montar a resposta.

"O Django com StreamingHttpResponse fica mais biznis (thelegomovie.wikia.com)"
O Django com StreamingHttpResponse fica mais biznis (thelegomovie.wikia.com)

Mas segundo a recomendação da própria documentação do StreamingHttpResponse, já que essa é uma "operação bloqueante", o ideal seria realizá-la em um background job (por exemplo) e deixar com que o usuário acesse essa informação quando ela já estiver pronta.

O django-report-builder entrega essa modalidade de "asynchronous reports" através do Celery.

Considerações finais

É fato que a "mágica pesada" fica no lado do WSGI, como você pode ver aqui. O Django "faz o que pode" quando o assunto é lidar com grandes volumes de dados. Mas o ideal é sempre deixar essa carga de processamento fora do ciclo de vida da requisição do usuário.

Mesmo assim, o StreamingHttpResponse vem como uma boa alternativa para resolver alguns problemas de views com tempos de resposta e consumo de recursos muito altos, podendo ser a solução ideal para alguns relatórios que você emite em sua aplicação.

Até a próxima.

Referências