Python asíncrono para desarrollo web

La programación asíncrona es adecuada para tareas que incluyen leer y escribir archivos con frecuencia o enviar datos de un lado a otro desde un servidor. Asincrónico p...

La programación asíncrona es adecuada para tareas que incluyen leer y escribir archivos con frecuencia o enviar datos de un lado a otro desde un servidor. Los programas asincrónicos realizan operaciones de E/S sin bloqueo, lo que significa que pueden realizar otras tareas mientras esperan que los datos regresen de un cliente en lugar de esperar sin hacer nada, desperdiciando recursos y tiempo.

Python, como muchos otros lenguajes, adolece de no ser asíncrono por defecto. Afortunadamente, los rápidos cambios en el mundo de TI nos permiten escribir código asíncrono incluso usando lenguajes que originalmente no estaban destinados a hacerlo. A lo largo de los años, las demandas de velocidad superan las capacidades del hardware y empresas de todo el mundo se han unido al Manifiesto Reactivo para abordar este problema.

El comportamiento de no bloqueo de los programas asincrónicos puede generar importantes beneficios de rendimiento en el contexto de una aplicación web, lo que ayuda a abordar el problema del desarrollo de aplicaciones reactivas.

Cocinadas en Python 3 hay algunas herramientas poderosas para escribir aplicaciones asincrónicas. En este artículo, cubriremos algunas de estas herramientas, especialmente en lo que se refiere al desarrollo web.

Desarrollaremos una aplicación reactiva simple basada en aiohttp para mostrar las coordenadas celestes relevantes actuales de los planetas del Sistema Solar, dadas las coordenadas geográficas del usuario. Puedes encontrar la aplicación aquí, y el código fuente aquí.

Terminaremos analizando cómo preparar la aplicación para implementarla en Heroku.

Introducción a Python asincrónico

Para aquellos familiarizados con la escritura de código Python tradicional, dar el salto al código asíncrono puede ser conceptualmente un poco complicado. El código asíncrono en Python se basa en corrutinas, que junto con un bucle de eventos permiten escribir código que parece estar haciendo más de una cosa a la vez.

Las corrutinas se pueden considerar como funciones que tienen puntos en el código donde devuelven el control del programa al contexto de llamada. Estos puntos de "rendimiento" permiten pausar y reanudar la ejecución de rutinas, además de intercambiar datos entre contextos.

El bucle de eventos decide qué fragmento de código se ejecuta en un momento dado: es responsable de pausar, reanudar y comunicarse entre rutinas. Esto significa que partes de diferentes corrutinas podrían terminar ejecutándose en un orden diferente al que estaban programadas. Esta idea de ejecutar diferentes fragmentos de código fuera de orden se llama concurrencia.

Pensar en la concurrencia en el contexto de realizar solicitudes ‘HTTP’ puede ser esclarecedor. Imagina querer hacer muchas solicitudes independientes a un servidor. Por ejemplo, podríamos querer consultar un sitio web para obtener estadísticas sobre todos los deportistas en una temporada determinada.

Podríamos hacer cada solicitud secuencialmente. Sin embargo, con cada solicitud, podemos imaginar que el código puede pasar algún tiempo esperando que se entregue una solicitud al servidor y que se envíe la respuesta.

A veces, estas operaciones pueden tardar incluso varios segundos. La aplicación puede experimentar retrasos en la red debido a una gran cantidad de usuarios o simplemente debido a los límites de velocidad del servidor dado.

¿Qué pasaría si nuestro código pudiera hacer otras cosas mientras espera una respuesta del servidor? Además, ¿qué pasaría si solo volviera a procesar una solicitud determinada una vez que llegaran los datos de respuesta? Podríamos hacer muchas solicitudes en rápida sucesión si no tuviéramos que esperar a que finalice cada solicitud individual antes de continuar con la siguiente en la lista.

Las corrutinas con un bucle de eventos nos permiten escribir código que se comporta exactamente de esta manera.

asíncio

asíncio, parte de la biblioteca estándar de Python, proporciona un bucle de eventos y un conjunto de herramientas para controlarlo. Con asyncio podemos programar corrutinas para su ejecución y crear nuevas corrutinas (realmente objetos asyncio.Task, usando el lenguaje de asyncio) que solo terminarán de ejecutarse una vez que las corrutinas constituyentes terminen de ejecutarse.

A diferencia de otros lenguajes de programación asincrónicos, Python no nos obliga a usar el ciclo de eventos que viene con el lenguaje. Como Brett Cannon señala, las rutinas de Python constituyen una API asíncrona, con la que podemos usar cualquier bucle de eventos. Existen proyectos que implementan un ciclo de eventos completamente diferente, como curiosidad, o permiten colocar una política de ciclo de eventos diferente para asyncio (la La política de bucle de eventos es lo que administra el bucle de eventos "detrás de escena"), como bucle uv.

Echemos un vistazo a un fragmento de código que ejecuta dos rutinas al mismo tiempo, cada una de las cuales imprime un mensaje después de un segundo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# example1.py
import asyncio

async def wait_around(n, name):
    for i in range(n):
        print(f"{name}: iteration {i}")
        await asyncio.sleep(1.0)

async def main():
    await asyncio.gather(*[
        wait_around(2, "coroutine 0"), wait_around(5, "coroutine 1")
    ])

loop = asyncio.get_event_loop()
loop.run_until_complete(main())
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[correo electrónico protegido]local:~$ time python example1.py
coroutine 1: iteration 0
coroutine 0: iteration 0
coroutine 1: iteration 1
coroutine 0: iteration 1
coroutine 1: iteration 2
coroutine 1: iteration 3
coroutine 1: iteration 4

real    0m5.138s
user    0m0.111s
sys     0m0.019s

Este código se ejecuta en aproximadamente 5 segundos, ya que la corrutina asyncio.sleep establece puntos en los que el bucle de eventos puede pasar a ejecutar otro código. Además, le hemos dicho al ciclo de eventos que programe ambas instancias de wait_around para la ejecución simultánea con la función asyncio.gather.

asyncio.gather toma una lista de "awaitables" (es decir, corrutinas u objetos asyncio.Task) y devuelve un único objeto asyncio.Task que solo finaliza cuando todas las tareas/corrutinas que lo componen han finalizado. Las dos últimas líneas son repetitivas asyncio para ejecutar una rutina dada hasta que termine de ejecutarse.

Las corrutinas, a diferencia de las funciones, no comenzarán a ejecutarse inmediatamente después de que se invoquen. La palabra clave await es lo que le dice al bucle de eventos que programe una rutina para su ejecución.

Si quitamos await delante de asyncio.sleep, el programa finaliza (casi) instantáneamente, ya que no le hemos dicho al bucle de eventos que ejecute la rutina, que en este caso le dice a la rutina que haga una pausa durante un tiempo determinado.

Con una idea de cómo se ve el código Python asíncrono, pasemos al desarrollo web asíncrono.

Instalación de aiohttp {#instalación de aiohttp}

aiohttp es una biblioteca de Python para realizar solicitudes ‘HTTP’ asíncronas. Además, proporciona un marco para armar la parte del servidor de una aplicación web. Usando Python 3.5+ y pip, podemos instalar aiohttp:

1
pip install --user aiohttp

Lado del cliente: Realización de solicitudes

Los siguientes ejemplos muestran cómo podemos descargar el contenido HTML del sitio web "example.com" usando aiohttp:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# example2_basic_aiohttp_request.py
import asyncio
import aiohttp

async def make_request():
    url = "https://example.com"
    print(f"making request to {url}")
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            if resp.status == 200:
                print(await resp.text())

loop = asyncio.get_event_loop()
loop.run_until_complete(make_request())

Algunas cosas a destacar:

  • Al igual que con await asyncio.sleep, debemos usar await con resp.text() para obtener el contenido HTML de la página. Si lo dejamos fuera, la salida de nuestro programa sería algo como lo siguiente:
1
2
[correo electrónico protegido]local:~$ python example2_basic_aiohttp_request.py
<coroutine object ClientResponse.text at 0x7fe64e574ba0>
  • async with es un administrador de contexto que funciona con rutinas en lugar de funciones. En ambos casos en los que se usa, podemos imaginar que internamente, aiohttp está cerrando las conexiones a los servidores o liberando recursos.

  • aiohttp.ClientSession tiene métodos que corresponden a verbos HTTP. En el mismo
    way that session.get is making a GET request, session.post would make a POST request.

Este ejemplo por sí mismo no ofrece ninguna ventaja de rendimiento sobre la realización de solicitudes HTTP sincrónicas. La verdadera belleza de aiohttp del lado del cliente radica en realizar múltiples solicitudes simultáneas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# example3_multiple_aiohttp_request.py
import asyncio
import aiohttp

async def make_request(session, req_n):
    url = "https://example.com"
    print(f"making request {req_n} to {url}")
    async with session.get(url) as resp:
        if resp.status == 200:
            await resp.text()

async def main():
    n_requests = 100
    async with aiohttp.ClientSession() as session:
        await asyncio.gather(
            *[make_request(session, i) for i in range(n_requests)]
        )

loop = asyncio.get_event_loop()
loop.run_until_complete(main())

En lugar de hacer cada solicitud secuencialmente, le pedimos a asyncio que las haga al mismo tiempo, con asycio.gather.

Aplicación web PlanetTracker

A lo largo de esta sección, tengo la intención de demostrar cómo armar una aplicación que informe las coordenadas actuales de los planetas en el cielo en la ubicación del usuario (efemérides).

El usuario facilita su ubicación con la web API de geolocalización, que hace el trabajo por nosotros.

Terminaré mostrando cómo configurar un Procfile para implementar la aplicación en Heroku. Si planea seguirme mientras trabajo para armar la aplicación, debe hacer lo siguiente, asumiendo que tiene Python 3.6 y pip instalados:

1
2
3
[correo electrónico protegido]local:~$ mkdir planettracker && cd planettracker
[correo electrónico protegido]local:~/planettracker$ pip install --user pipenv
[correo electrónico protegido]local:~/planettracker$ pipenv --python=3

Efemérides del planeta con PyEphem

Las efemérides de un objeto astronómico son su posición actual en el cielo en un lugar y tiempo determinados en la Tierra. PyEphem es una biblioteca de Python que permite calcular con precisión las efemérides.

Es especialmente adecuado para la tarea en cuestión, ya que tiene objetos astronómicos comunes cocinados en la biblioteca. Primero, instalemos PyEphem:

1
[correo electrónico protegido]local:~/planettracker$ pipenv install ephem

Obtener las coordenadas actuales de Marte es tan simple como usar una instancia de la clase Observer para calcular sus coordenadas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import ephem
import math
convert = math.pi / 180.
mars = ephem.Mars()
greenwich = ephem.Observer()
greenwich.lat = "51.4769"
greenwich.lon = "-0.0005"
mars.compute(observer)
az_deg, alt_deg = mars.az*convert, mars.alt*convert
print(f"Mars' current azimuth and elevation: {az_deg:.2f} {alt_deg:.2f}")

Para facilitar la obtención de las efemérides de los planetas, configuremos una clase PlanetTracker con un método que devuelva el azimito y la altitud actuales de un planeta dado, en grados (PyEphem usa por defecto radianes, no grados, para representar ángulos internamente):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# planet_tracker.py
import math
import ephem

class PlanetTracker(ephem.Observer):

    def __init__(self):
        super(PlanetTracker, self).__init__()
        self.planets = {
            "mercury": ephem.Mercury(),
            "venus": ephem.Venus(),
            "mars": ephem.Mars(),
            "jupiter": ephem.Jupiter(),
            "saturn": ephem.Saturn(),
            "uranus": ephem.Uranus(),
            "neptune": ephem.Neptune()
        }

    def calc_planet(self, planet_name, when=None):
        convert = 180./math.pi
        if when is None:
            when = ephem.now()

        self.date = when
        if planet_name in self.planets:
            planet = self.planets[planet_name]
            planet.compute(self)
            return {
                "az": float(planet.az)*convert,
                "alt": float(planet.alt)*convert,
                "name": planet_name
            }
        else:
            raise KeyError(f"Couldn't find {planet_name} in planets dict")

Ahora podemos conseguir cualquiera de los otros siete planetas del sistema solar con bastante facilidad:

1
2
3
4
5
from planet_tracker import PlanetTracker
tracker = PlanetTracker()
tracker.lat = "51.4769"
tracker.lon = "-0.0005"
tracker.calc_planet("mars")

Ejecutar este fragmento de código produciría:

1
{'az': 92.90019644871396, 'alt': -23.146670983905302, 'name': 'mars'}

aiohttp del lado del servidor: rutas HTTP

Dada cierta latitud y longitud, podemos obtener fácilmente las efemérides actuales de un planeta, en grados. Ahora configuremos una ruta aiohttp para permitir que un cliente obtenga las efemérides de un planeta dada la geolocalización del usuario.

Antes de que podamos comenzar a escribir código, tenemos que pensar qué verbos HTTP queremos asociar con cada una de estas tareas. Tiene sentido usar POST para la primera tarea, ya que estamos estableciendo las coordenadas geográficas del observador. Dado que estamos obteniendo efemérides, tiene sentido usar GET para la segunda tarea:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# aiohttp_app.py
from aiohttp import web

from planet_tracker import PlanetTracker


@routes.get("/planets/{name}")
async def get_planet_ephmeris(request):
    planet_name = request.match_info['name']
    data = request.query
    try:
        geo_location_data = {
            "lon": str(data["lon"]),
            "lat": str(data["lat"]),
            "elevation": float(data["elevation"])
        }
    except KeyError as err:
        # default to Greenwich Observatory
        geo_location_data = {
            "lon": "-0.0005",
            "lat": "51.4769",
            "elevation": 0.0,
        }
    print(f"get_planet_ephmeris: {planet_name}, {geo_location_data}")
    tracker = PlanetTracker()
    tracker.lon = geo_location_data["lon"]
    tracker.lat = geo_location_data["lat"]
    tracker.elevation = geo_location_data["elevation"]
    planet_data = tracker.calc_planet(planet_name)
    return web.json_response(planet_data)


app = web.Application()
app.add_routes(routes)

web.run_app(app, host="localhost", port=8000)

Aquí, el decorador route.get indica que queremos que la rutina get_planet_ephmeris sea el controlador de una ruta variable GET.

Antes de ejecutar esto, instalemos aiohttp con pipenv:

1
[correo electrónico protegido]local:~/planettracker$ pipenv install aiohttp

Ahora podemos ejecutar nuestra aplicación:

1
[correo electrónico protegido]local:~/planettracker$ pipenv run python aiohttp_app.py

Cuando ejecutamos esto, podemos apuntar nuestro navegador a nuestras diferentes rutas para ver los datos que devuelve nuestro servidor. Si coloco localhost:8000/planets/mars en la barra de direcciones de mi navegador, debería ver una respuesta como la siguiente:

1
{"az": 98.72414165963292, "alt": -18.720718647020792, "name": "mars"}

Esto es lo mismo que emitir el siguiente comando curl:

1
2
[correo electrónico protegido]local:~$ curl localhost:8000/planets/mars
{"az": 98.72414165963292, "alt": -18.720718647020792, "name": "mars"}

Si no está familiarizado con rizo, es una herramienta de línea de comandos conveniente para, entre otras cosas, probar sus rutas HTTP.

Podemos proporcionar una URL OBTENER a curl:

1
2
[correo electrónico protegido]local:~$ curl localhost:8000/planets/mars
{"az": 98.72414165963292, "alt": -18.720718647020792, "name": "mars"}

Esto nos da las efemérides de Marte en el Observatorio de Greenwich en el Reino Unido.

Podemos codificar las coordenadas en la URL de la solicitud GET para que podamos obtener las efemérides de Mars en otras ubicaciones (tenga en cuenta las comillas alrededor de la URL):

1
2
[correo electrónico protegido]local:~$ curl "localhost:8000/planets/mars?lon=145.051&lat=-39.754&elevation=0"
{"az": 102.30273048280189, "alt": 11.690380174890928, "name": "mars"

curl también se puede usar para hacer solicitudes POST:

1
2
[correo electrónico protegido]local:~$ curl --header "Content-Type: application/x-www-form-urlencoded" --data "lat=48.93&lon=2.45&elevation=0" localhost:8000/geo_location
{"lon": "2.45", "lat": "48.93", "elevation": 0.0}

Tenga en cuenta que al proporcionar el campo --data, curl asume automáticamente que estamos realizando una solicitud POST.

Antes de continuar, debo señalar que la función web.run_app ejecuta nuestra aplicación de forma bloqueada. ¡Esto definitivamente no es lo que estamos buscando lograr!

Para ejecutarlo simultáneamente, tenemos que agregar un poco más de código:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# aiohttp_app.py
import asyncio
...

# web.run_app(app)

async def start_app():
    runner = web.AppRunner(app)
    await runner.setup()
    site = web.TCPSite(
        runner, parsed.host, parsed.port)
    await site.start()
    print(f"Serving up app on {parsed.host}:{parsed.port}")
    return runner, site

loop = asyncio.get_event_loop()
runner, site = loop.run_until_complete(start_async_app())
try:
    loop.run_forever()
except KeyboardInterrupt as err:
    loop.run_until_complete(runner.cleanup())

Tenga en cuenta la presencia de loop.run_forever en lugar de la llamada a loop.run_until_complete que vimos anteriormente. En lugar de ejecutar un número determinado de corrutinas, queremos que nuestro programa inicie un servidor que manejará las solicitudes hasta que salgamos con ctrl+c, momento en el que apagará el servidor correctamente.

Cliente HTML/JavaScript

aiohttp nos permite servir archivos HTML y JavaScript. Se desaconseja el uso de aiohttp para servir activos "estáticos" como CSS y JavaScript, pero para los fines de esta aplicación, no debería ser un problema.

Agreguemos algunas líneas a nuestro archivo aiohttp_app.py para mostrar un archivo HTML que hace referencia a un archivo JavaScript:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# aiohttp_app.py
...
@routes.get('/')
async def hello(request):
    return web.FileResponse("./index.html")


app = web.Application()
app.add_routes(routes)
app.router.add_static("/", "./")
...

La corrutina hello está configurando una ruta GET en localhost:8000/ que sirve el contenido de index.html, ubicado en el mismo directorio desde el que ejecutamos nuestro servidor.

La línea app.router.add_static está configurando una ruta en localhost:8000/ para servir archivos en el mismo directorio desde el que ejecutamos nuestro servidor. Esto significa que nuestro navegador podrá encontrar el archivo JavaScript al que hacemos referencia en index.html.

Nota: En producción, tiene sentido mover los archivos HTML, CSS y JS a un directorio separado que se sirve solo. Esto hace que el usuario curioso no pueda acceder a nuestro código de servidor.

El archivo HTML es bastante simple:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang='en'>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Planet Tracker</title>
</head>
<body>
    <div id="app">
        <label id="lon">Longitude: <input type="text"/></label><br/>
        <label id="lat">Latitude: <input type="text"/></label><br/>
        <label id="elevation">Elevation: <input type="text"/></label><br/>
    </div>
    <script src="/app.js"></script>
</body>

Sin embargo, el archivo JavaScript es un poco más complicado:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
var App = function() {

    this.planetNames = [
        "mercury",
        "venus",
        "mars",
        "jupiter",
        "saturn",
        "uranus",
        "neptune"
    ]

    this.geoLocationIds = [
        "lon",
        "lat",
        "elevation"
    ]

    this.keyUpInterval = 500
    this.keyUpTimer = null
    this.planetDisplayCreated = false
    this.updateInterval = 2000 // update very second and a half
    this.updateTimer = null
    this.geoLocation = null

    this.init = function() {
        this.getGeoLocation().then((position) => {
            var coords = this.processCoordinates(position)
            this.geoLocation = coords
            this.initGeoLocationDisplay()
            this.updateGeoLocationDisplay()
            return this.getPlanetEphemerides()
        }).then((planetData) => {
            this.createPlanetDisplay()
            this.updatePlanetDisplay(planetData)
        }).then(() => {
            return this.initUpdateTimer()
        })
    }

    this.update = function() {
        if (this.planetDisplayCreated) {
            this.getPlanetEphemerides().then((planetData) => {
                this.updatePlanetDisplay(planetData)
            })
        }
    }

    this.get = function(url, data) {
        var request = new XMLHttpRequest()
        if (data !== undefined) {
            url += `?${data}`
        }
        // console.log(`get: ${url}`)
        request.open("GET", url, true)
        return new Promise((resolve, reject) => {
            request.send()
            request.onreadystatechange = function(){
                if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
                    resolve(this)
                }
            }
            request.onerror = reject
        })
    }

    this.processCoordinates = function(position) {
        var coordMap = {
            'longitude': 'lon',
            'latitude': 'lat',
            'altitude': 'elevation'
        }
        var coords = Object.keys(coordMap).reduce((obj, name) => {
            var coord = position.coords[name]
            if (coord === null || isNaN(coord)) {
                coord = 0.0
            }
            obj[coordMap[name]] = coord
            return obj
        }, {})
        return coords
    }

    this.coordDataUrl = function (coords) {
        postUrl = Object.keys(coords).map((c) => {
            return `${c}=${coords[c]}`
        })
        return postUrl
    }

    this.getGeoLocation = function() {
        return new Promise((resolve, reject) => {
            navigator.geolocation.getCurrentPosition(resolve)
        })
    }

    this.getPlanetEphemeris = function(planetName) {
        var postUrlArr = this.coordDataUrl(this.geoLocation)
        return this.get(`/planets/${planetName}`, postUrlArr.join("&")).then((req) => {
            return JSON.parse(req.response)
        })
    }

    this.getPlanetEphemerides = function() {
        return Promise.all(
            this.planetNames.map((name) => {
                return this.getPlanetEphemeris(name)
            })
        )
    }

    this.createPlanetDisplay = function() {
        var div = document.getElementById("app")
        var table = document.createElement("table")
        var header = document.createElement("tr")
        var headerNames = ["Name", "Azimuth", "Altitude"]
        headerNames.forEach((headerName) => {
            var headerElement = document.createElement("th")
            headerElement.textContent = headerName
            header.appendChild(headerElement)
        })
        table.appendChild(header)
        this.planetNames.forEach((name) => {
            var planetRow = document.createElement("tr")
            headerNames.forEach((headerName) => {
                planetRow.appendChild(
                    document.createElement("td")
                )
            })
            planetRow.setAttribute("id", name)
            table.appendChild(planetRow)
        })
        div.appendChild(table)
        this.planetDisplayCreated = true
    }

    this.updatePlanetDisplay = function(planetData) {
        planetData.forEach((d) => {
            var content = [d.name, d.az, d.alt]
            var planetRow = document.getElementById(d.name)
            planetRow.childNodes.forEach((node, idx) => {
                var contentFloat = parseFloat(content[idx])
                if (isNaN(contentFloat)) {
                    node.textContent = content[idx]
                } else {
                    node.textContent = contentFloat.toFixed(2)
                }
            })
        })
    }

    this.initGeoLocationDisplay = function() {
        this.geoLocationIds.forEach((id) => {
            var node = document.getElementById(id)
            node.childNodes[1].onkeyup = this.onGeoLocationKeyUp()
        })
        var appNode = document.getElementById("app")
        var resetLocationButton = document.createElement("button")
        resetLocationButton.setAttribute("id", "reset-location")
        resetLocationButton.onclick = this.onResetLocationClick()
        resetLocationButton.textContent = "Reset Geo Location"
        appNode.appendChild(resetLocationButton)
    }

    this.updateGeoLocationDisplay = function() {
        Object.keys(this.geoLocation).forEach((id) => {
            var node = document.getElementById(id)
            node.childNodes[1].value = parseFloat(
                this.geoLocation[id]
            ).toFixed(2)
        })
    }

    this.getDisplayedGeoLocation = function() {
        var displayedGeoLocation = this.geoLocationIds.reduce((val, id) => {
            var node = document.getElementById(id)
            var nodeVal = parseFloat(node.childNodes[1].value)
            val[id] = nodeVal
            if (isNaN(nodeVal)) {
                val.valid = false
            }
            return val
        }, {valid: true})
        return displayedGeoLocation
    }

    this.onGeoLocationKeyUp = function() {
        return (evt) => {
            // console.log(evt.key, evt.code)
            var currentTime = new Date()
            if (this.keyUpTimer !== null){
                clearTimeout(this.keyUpTimer)
            }
            this.keyUpTimer = setTimeout(() => {
                var displayedGeoLocation = this.getDisplayedGeoLocation()
                if (displayedGeoLocation.valid) {
                    delete displayedGeoLocation.valid
                    this.geoLocation = displayedGeoLocation
                    console.log("Using user supplied geo location")
                }
            }, this.keyUpInterval)
        }
    }

    this.onResetLocationClick = function() {
        return (evt) => {
            console.log("Geo location reset clicked")
            this.getGeoLocation().then((coords) => {
                this.geoLocation = this.processCoordinates(coords)
                this.updateGeoLocationDisplay()
            })
        }
    }

    this.initUpdateTimer = function () {
        if (this.updateTimer !== null) {
            clearInterval(this.updateTimer)
        }
        this.updateTimer = setInterval(
            this.update.bind(this),
            this.updateInterval
        )
        return this.updateTimer
    }

    this.testPerformance = function(n) {
        var t0 = performance.now()
        var promises = []
        for (var i=0; i<n; i++) {
            promises.push(this.getPlanetEphemeris("mars"))
        }
        Promise.all(promises).then(() => {
            var delta = (performance.now() - t0)/1000
            console.log(`Took ${delta.toFixed(4)} seconds to do ${n} requests`)
        })
    }
}

var app
document.addEventListener("DOMContentLoaded", (evt) => {
    app = new App()
    app.init()
})

Esta aplicación se actualizará periódicamente (cada 2 segundos) y mostrará las efemérides del planeta. Podemos proporcionar nuestras propias coordenadas geográficas o dejar que la API de geolocalización web determine nuestra ubicación actual. La aplicación actualiza la geolocalización si el usuario deja de escribir durante medio segundo o más.

Si bien este no es un tutorial de JavaScript, creo que es útil para comprender qué están haciendo las diferentes partes del script:

  • createPlanetDisplay está creando dinámicamente elementos HTML y vinculándolos al Modelo de objetos de documento (DOM)
  • updatePlanetDisplay toma los datos recibidos del servidor y completa los elementos creados por createPlanetDisplay
  • get realiza una solicitud GET al servidor. El objeto XMLHttpSolicitud permite hacer esto sin recargar la página.
  • post realiza una solicitud POST al servidor. Al igual que con get, esto se hace sin recargar la página.
  • getGeoLocation usa la API de geolocalización web para obtener las coordenadas geográficas actuales del usuario. Esto debe cumplirse "en un contexto seguro" (es decir, debemos usar HTTPS no HTTP).
  • getPlanetEphemeris y getPlanetEphemerides realizan solicitudes GET al servidor para obtener efemérides para un planeta específico y para obtener efemérides para todos los planetas, respectivamente.
  • testPerformance realiza n solicitudes al servidor y determina cuánto tiempo lleva.

Introducción a la implementación en Heroku

Heroku es un servicio para implementar fácilmente aplicaciones web. Heroku se encarga de configurar los componentes web de una aplicación, como configurar proxies inversos o preocuparse por el equilibrio de carga. Para aplicaciones que manejan pocas solicitudes y una pequeña cantidad de usuarios, Heroku es un excelente servicio de alojamiento gratuito.

La implementación de aplicaciones de Python en Heroku se ha vuelto muy fácil en los últimos años. Básicamente, tenemos que crear dos archivos que enumeren las dependencias de nuestra aplicación y decirle a Heroku cómo ejecutar nuestra aplicación.

Un Pipfile se ocupa de lo primero, mientras que un Procfile se ocupa de lo segundo. Un Pipfile se mantiene usando pipenv: agregamos a nuestro Pipfile (y Pipfile.lock) cada vez que instalamos una dependencia.

Para ejecutar nuestra aplicación en Heroku, debemos agregar una dependencia más:

1
[correo electrónico protegido]local:~/planettracker$ pipenv install gunicorn

Podemos crear nuestro propio Procfile, añadiéndole la siguiente línea:

1
web: gunicorn aiohttp_app:app --worker-class aiohttp.GunicornWebWorker

Básicamente, esto le dice a Heroku que use gunicornio para ejecutar nuestra aplicación, usando el trabajador web especial aiohttp.

Antes de que pueda implementar en Heroku, deberá comenzar a rastrear la aplicación con Git:

1
2
3
[correo electrónico protegido]local:~/planettracker$ git init
[correo electrónico protegido]local:~/planettracker$ git add .
[correo electrónico protegido]local:~/planettracker$ git commit -m "first commit"

Ahora puede seguir las instrucciones en Heroku devcenter aquí para implementar su aplicación. Tenga en cuenta que puede omitir el paso "Preparar la aplicación" de este tutorial, ya que ya tiene una aplicación con seguimiento de git.

Una vez que su aplicación esté implementada, puede navegar a la URL de Heroku elegida en su navegador y ver la aplicación, que se verá así:

{.img-responsive}

Conclusión

En este artículo, nos sumergimos en cómo se ve el desarrollo web asíncrono en Python: sus ventajas y usos. Luego, construimos una aplicación reactiva simple basada en aiohttp que muestra dinámicamente las coordenadas celestes relevantes actuales de los planetas del Sistema Solar, dadas las coordenadas geográficas del usuario.

Al compilar la aplicación, la preparamos para su implementación en Heroku.

Como se mencionó anteriormente, puede encontrar tanto el código fuente como la demostración de la aplicación si es necesario.

Licensed under CC BY-NC-SA 4.0