NULL on error underhood
… Ou como preparar o seu blog para escalar para milhões de visitas diárias, mas receber apenas algumas visitas… Da sua mãe, da sua tia, da sua avó, da prima e também da sua namorada :)
Inspirado no artigo Do things, write about it, resolvi escrever um pouco sobre como funciona o meu blog.
A princípio, optei por utilizar o App Engine por dois motivos: 1 - suporta python; e 2 - a quota gratuita é bem generosa para o meu humilde blog :)
Meu objetivo era criar algo extremamente simples, similar a essas ferramentas que geram arquivos estáticos. Um projeto bem popular e que faz isso é o Jekyll, mas como todo bom hacker, decidi reinventar a roda. Já havia feito outros projetos que rodam sob o AppEngine, inclusive a primeira versão do blog, e todos esses projetos seguiam os models da documentação, usando o webapp2 e a engine de renderização do django. Como o AppEngine possui um sistema de arquivos somente leitura, uma alternativa é salvar os dados no BigTable, o que não é um grande problema, uma vez que esses dados ficam no memcached na maior parte do tempo.
Porém, eu queria fazer algo diferente, queria estudar outros frameworks, e foi então que decidi usar o bottle e o jinja2 para renderização de templates. O resultado foi que aprendi muito, inclusive cheguei a escrever alguns filtros para o jinja2: um deles é o imgur, responsável por subir imagens no imgur quando encontra a tag imgur e depois troca pela tag img do html; e o outro, ao encontrar a tag code, invocava o pygments, que por sua vez fazia parser do código e o coloria, mas acabei descartando este filtro e deixando essa tarefa com o cliente, usando o prettify.
Como faço uso de uma quantidade considerável de javascript, pesquisei por algumas soluções de lazy loading, e a que mais me agradou foi o RequireJS. Vamos ver como ficou essa carga logo abaixo:
var require = {
baseUrl: "/static/js",
shim: {
'jquery': {
exports: '$'
},
'bootstrap': {
deps: ['jquery']
},
'prettify': {
init: function () {
prettyPrint()
}
},
'jquery.raptorize': {
deps: ['jquery']
},
'clippy': {
deps: ['jquery'],
init: function() {
clippy.BASE_PATH = '/static/agents/'
clippy.load('Clippy', function(agent) {
agent.show()
agent.speak('↑ ↑ ↓ ↓ ← → ← → B A')
window.agent = agent
window.setInterval(function() { agent.animate() }, 1500)
});
}
},
},
deps: [
'bootstrap',
{% for module in modules %}
'{{ module }}',
{% endfor %}
],
callback: function() {
$(function() {
{% block ready %}
{% endblock %}
})
}
}
Adicionar o RequireJS ao projeto é muito simples. Primeiro você configura os módulos que vai usar - quando digo módulos, estou me referindo ao arquivo de javascript, que, no meu caso, se encontram no diretório /static/js, armazenado na váriavel baseUrl.
Outra configuração importante é o shim, este array contém todos os módulos que eventualmente possam ser usados. Você pode indicar se algum módulo depende de outro, como no caso do jQuery.raptorize que depende do jQuery; isso vai garantir que o jQuery.raptorize será carregado depois que o jQuery já tiver sido carregado! Nesta mesma seção, podemos inserir um código na váriavel init que será executado logo após o módulo ser carregado. No caso do clippy temos o seguinte código:
init: function() {
clippy.BASE_PATH = '/static/agents/'
clippy.load('Clippy', function(agent) {
agent.show()
agent.speak(' ↑ ↑ ↓ ↓ ← → ← → B A')
window.agent = agent
window.setInterval(function() { agent.animate() }, 1500)
});
Ainda neste pequeno bloco de código javascript, temos algumas linhas com tags do jinja2, e um trecho de código que gostaria de destacar são as seguintes linhas:
deps: [
'bootstrap',
{% for module in modules %}
'{{ module }}',
{% endfor %}
]
Como podemos ver, pretendo apenas carregar o módulo bootstrap, e como o mesmo depende do jQuery, teremos apenas esses dois módulos carregados por padrão; é aí que entra o jinja2 - esse trecho de código está no arquivo layout.template, que será herdado por todos os outros arquivos de templates. Por padrão, a variável modules vem vazia, mas como podemos ver no template about a seguinte linha:
{% set modules = ['jquery.raptorize', 'clippy'] %}
E é neste momento que atribuo a variável modules com apenas o que vou usar, e não toda aquela tranqueira :)
Bom, isso é um pouco do que acontece no front-end, veremos agora o que se passa por trás das cortinas, no back-end.
Uma das coisas que ficou mais legal, modéstia à parte, foi a forma que o template correto é carregado e renderizado, sem ter que informar o nome do template para a função render, desta forma:
@route('/')
@memorize
def index():
return render(entries=db.Query(Entry).order('-published').fetch(limit=25))
@route('/entry/:slug')
@memorize
def entry(slug):
entry = db.Query(Entry).filter('slug =', slug).get()
if not entry:
from bottle import HTTPError
raise HTTPError(404)
else:
return render(entry=entry)
@route('/about')
@memorize
def about():
return render()
No trecho de código acima, temos 3 rotas, que são definidas pelo decorator route:
- index, que corresponde à url “/”
- entry que recebe o parâmetro “slug”, que nada mais é do que uma url amigavél
- about, que não recebe nenhum parâmetro
Nesse momento você deve estar se perguntando: mas como a função render sabe qual template carregar se essa informação não é passada? De uma forma bem simples, meu caro leitor, lembra-se da call stack? Pois então, fazendo uso da biblioteca inspect é possível examinar a call stack em tempo de execução, e assim, saber qual função chamou a função render. Com o nome da função, basta concatenar o nome dela com a extensão template e renderizar com o jinja2 repassando os parâmetros passados
def render(*args, **kwargs):
import inspect
callframe = inspect.getouterframes(inspect.currentframe(), 2)
template = jinja2.get_template('{}.template'.format(callframe[1][3]))
return template.render(*args, **kwargs)
Isso é que é levar o conceito Don’t Repeat Yourself a sério.
Mais tarde, durante uma entrevista de emprego, vim a saber que alguns frameworks fazem algo parecido, pórem usando exceptions… Bom saber que ainda existem programadores criativos
E por falar em DRY, uma tarefa que acabou ficando extremamente repetitiva foi a manipulação de valores no memcache. Na documentação do AppEngine temos o seguinte exemplo:
def get_data():
data = memcache.get('key')
if data is not None:
return data
else:
data = self.query_for_data()
memcache.add('key', data, 60)
return data
Contudo, como só pretendo fazer cache das chamadas get, decidi criar um decorator chamado memorize
from google.appengine.api import memcache
class memorize():
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
key = '{}/{}'.format(self.func.__name__, '/'.join(kwargs.values()))
result = memcache.get(key)
if result is None:
result = self.func(*args, **kwargs)
memcache.add(key=key, value=result)
return result
E adicionar em todas as rotas, como foi visto acima.
Basicamente o que esse decorator faz é gerar uma chave usando a url com seus parâmetros, assim, quando algum parâmetro é alterado, é forçada uma nova chamada à função, e logo após o retorno, é salvo no cache, usando a mesma chave gerada anteriormente. O uso do cache é importante para que o servidor não fique com uma leseira danada.
Para inserir uma nova entrada no blog, deve-se criar dois arquivos, cada um com uma extensão previamente estabelecida (poderia ser apenas um único arquivo e fazer um split usando uma determinada tag, mas achei que ficaria meio chunchado), então são dois arquivos com o mesmo nome, porém um deles é terminado em .entry, que contém o HTML que será exibido, e o outro, terminado em .meta é um YAML com as informações básicas, como título, data de publicação, tags e se é público ou não. Para automatizar essa inserção de dados, faço uso de post-receive hooks do github, ou seja, logo após um git push o github faz uma chamada POST com um json descrito na documentção do github; com esse json em mãos, basta fazer o parsing e ver quais arquivos foram modificados ou adicionados, com as extensões mencionadas acima:
for commit in payload['commits']:
for action, files in commit.iteritems():
if action in ['added', 'modified']:
for filename in files:
basename, extension = os.path.splitext(filename)
if extension in ['.entry', '.meta']:
Assim, basta montar a url completa com a função build_url:
github = {
'url' : 'https://raw.github.com',
'repository' : 'nullonerror-posts',
'user' : 'skhaz',
'branch' : 'master',
}
def build_url(filename):
return "%s/%s" % ('/'.join([v for k, v in github.iteritems()]), filename)
Note o “raw” na url, esse é o caminho para o arquivo crú, isso significa que posso baixar o arquivo no seu formato original, assim como foi mencionado acima, tenho dois tipos de arquivos que são tratados de forma especial, como visto abaixo:
from google.appengine.api import urlfetch
from utils import build_url
result = urlfetch.fetch(url = build_url(filename))
if result.status_code == 200:
entry = Entry.get_or_insert(basename)
if extension.endswith('.entry'):
entry.content = jinja2.from_string(result.content.decode('utf-8')).render()
else:
try:
import yaml
meta = yaml.load(result.content)
except:
logging.error('Failed to parse YAML')
else:
entry.title = meta['title']
entry.categories = meta['categories']
entry.published = meta['published']
entry.slug = basename
entry.put()
Assim como eu posso inserir ou atualizar as entradas, posso fazer o mesmo na hora de remover
elif action in ['removed']:
for filename in files:
basename, extension = os.path.splitext(filename)
entry = Entry.get_by_key_name(basename)
if entry: entry.delete()
No final do processo, faço um flush no memcached.
Outra coisa bacana no AppEngine, e que não é exclusividade do mesmo, é o PageSpeed. Esse módulo comprime todos os assets, recomprime todas as imagens para formato webp e as serve caso o navegador suporte, codifica em base64 e inclui no html assets muito pequenos para evitar requisições, unifica javascript e css, entre outras técnicas descritas no manual.
Além disso, uso o CloudFlare para CDN, que me ajuda a salvar um bocado de banda :)
Ainda falta implementar muita coisa, estive pensando em escrever um editor em Qt e usar a libgit2 para commitar e fazer push diretamente das alterações.
Enfim… É isso, obrigado por ter lido esse texto longo e chato :)