2022年 11月 4日

Python开发实战

一、搭建开发环境

搭建开发环境:

首先,确认系统安装的Python版本是3.7.x:

  1. $ python3 --version
  2. Python 3.7.0

然后,用pip安装开发Web App需要的第三方库:

异步框架aiohttp:

$pip3 install aiohttp

前端模板引擎jinja2:

$ pip3 install jinja2

MySQL 5.x数据库,从官方网站下载并安装,

安装完毕后,请务必牢记root口令。为避免遗忘口令,建议直接把root口令设置为password;

MySQL的Python异步驱动程序aiomysql:

$ pip3 install aiomysql

项目结构:

选择一个工作目录,然后,我们建立如下的目录结构。

  1. awesome-python3-webapp/ <-- 根目录
  2. |
  3. +- backup/ <-- 备份目录
  4. |
  5. +- conf/ <-- 配置文件
  6. |
  7. +- dist/ <-- 打包目录
  8. |
  9. +- www/ <-- Web目录,存放.py文件
  10. | |
  11. | +- static/ <-- 存放静态文件
  12. | |
  13. | +- templates/ <-- 存放模板文件
  14. |
  15. +- ios/ <-- 存放iOS App工程
  16. |
  17. +- LICENSE <-- 代码LICENSE

创建好项目的目录结构后,建议同时建立git仓库并同步至GitHub,保证代码修改的安全。

要了解git和GitHub的用法。

开发工具

自备,推荐用Sublime Text。

二、编写Web App骨架

由于我们的Web App建立在asyncio的基础上,因此用aiohttp写一个基本的app.py:

  1. import logging; logging.basicConfig(level=logging.INFO)
  2. import asyncio, os, json, time
  3. from datetime import datetime
  4. from aiohttp import web
  5. def index(request):
  6. return web.Response(body=b'<h1>Awesome</h1>')
  7. @asyncio.coroutine
  8. def init(loop):
  9. app = web.Application(loop=loop)
  10. app.router.add_route('GET', '/', index)
  11. srv = yield from loop.create_server(app.make_handler(), '127.0.0.1', 9000)
  12. logging.info('server started at http://127.0.0.1:9000...')
  13. return srv
  14. loop = asyncio.get_event_loop()
  15. loop.run_until_complete(init(loop))
  16. loop.run_forever()

运行python app.py,Web App将在9000端口监听HTTP请求,并且对首页/进行响应:

  1. $ python3 app.py
  2. INFO:root:server started at http://127.0.0.1:9000...

这里我们简单地返回一个Awesome字符串,在浏览器中可以看到效果:

这说明我们的Web App骨架已经搭好了,可以进一步往里面添加更多的东西。 

三、编写ORM

在一个Web App中,所有数据,包括用户信息、发布的日志、评论等,都存储在数据库中。在awesome-python3-webapp中,我们选择MySQL作为数据库。

Web App里面有很多地方都要访问数据库。访问数据库需要创建数据库连接、游标对象,然后执行SQL语句,最后处理异常,清理资源。这些访问数据库的代码如果分散到各个函数中,势必无法维护,也不利于代码复用。

所以,我们要首先把常用的SELECT、INSERT、UPDATE和DELETE操作用函数封装起来。

由于Web框架使用了基于asyncio的aiohttp,这是基于协程的异步模型。在协程中,不能调用普通的同步IO操作,因为所有用户都是由一个线程服务的,协程的执行速度必须非常快,才能处理大量用户的请求。而耗时的IO操作不能在协程中以同步的方式调用,否则,等待一个IO操作时,系统无法响应任何其他用户。

这就是异步编程的一个原则:一旦决定使用异步,则系统每一层都必须是异步,“开弓没有回头箭”。

幸运的是aiomysql为MySQL数据库提供了异步IO的驱动。

1、创建连接池

我们需要创建一个全局的连接池,每个HTTP请求都可以从连接池中直接获取数据库连接。使用连接池的好处是不必频繁地打开和关闭数据库连接,而是能复用就尽量复用。

连接池由全局变量__pool存储,缺省情况下将编码设置为utf8,自动提交事务:

  1. @asyncio.coroutine
  2. def create_pool(loop, **kw):
  3. logging.info('create database connection pool...')
  4. global __pool
  5. __pool = yield from aiomysql.create_pool(
  6. host=kw.get('host', 'localhost'),
  7. port=kw.get('port', 3306),
  8. user=kw['user'],
  9. password=kw['password'],
  10. db=kw['db'],
  11. charset=kw.get('charset', 'utf8'),
  12. autocommit=kw.get('autocommit', True),
  13. maxsize=kw.get('maxsize', 10),
  14. minsize=kw.get('minsize', 1),
  15. loop=loop
  16. )

2、Select

要执行SELECT语句,我们用select函数执行,需要传入SQL语句和SQL参数:

  1. @asyncio.coroutine
  2. def select(sql, args, size=None):
  3. log(sql, args)
  4. global __pool
  5. with (yield from __pool) as conn:
  6. cur = yield from conn.cursor(aiomysql.DictCursor)
  7. yield from cur.execute(sql.replace('?', '%s'), args or ())
  8. if size:
  9. rs = yield from cur.fetchmany(size)
  10. else:
  11. rs = yield from cur.fetchall()
  12. yield from cur.close()
  13. logging.info('rows returned: %s' % len(rs))
  14. return rs

SQL语句的占位符是?,而MySQL的占位符是%s,select()函数在内部自动替换。注意要始终坚持使用带参数的SQL,而不是自己拼接SQL字符串,这样可以防止SQL注入攻击。

注意到yield from将调用一个子协程(也就是在一个协程中调用另一个协程)并直接获得子协程的返回结果。

如果传入size参数,就通过fetchmany()获取最多指定数量的记录,否则,通过fetchall()获取所有记录。

3、Insert, Update, Delete

要执行INSERT、UPDATE、DELETE语句,可以定义一个通用的execute()函数,因为这3种SQL的执行都需要相同的参数,以及返回一个整数表示影响的行数:

  1. @asyncio.coroutine
  2. def execute(sql, args):
  3. log(sql)
  4. with (yield from __pool) as conn:
  5. try:
  6. cur = yield from conn.cursor()
  7. yield from cur.execute(sql.replace('?', '%s'), args)
  8. affected = cur.rowcount
  9. yield from cur.close()
  10. except BaseException as e:
  11. raise
  12. return affected

execute()函数和select()函数所不同的是,cursor对象不返回结果集,而是通过rowcount返回结果数。

4、ORM

有了基本的select()和execute()函数,我们就可以开始编写一个简单的ORM了。

设计ORM需要从上层调用者角度来设计。

我们先考虑如何定义一个User对象,然后把数据库表users和它关联起来。

  1. from orm import Model, StringField, IntegerField
  2. class User(Model):
  3. __table__ = 'users'
  4. id = IntegerField(primary_key=True)
  5. name = StringField()

注意到定义在User类中的__table__、id和name是类的属性,不是实例的属性。所以,在类级别上定义的属性用来描述User对象和表的映射关系,而实例属性必须通过__init__()方法去初始化,所以两者互不干扰:

  1. # 创建实例:
  2. user = User(id=123, name='Michael')
  3. # 存入数据库:
  4. user.insert()
  5. # 查询所有User对象:
  6. users = User.findAll()

5、定义Model

首先要定义的是所有ORM映射的基类Model:

  1. class Model(dict, metaclass=ModelMetaclass):
  2. def __init__(self, **kw):
  3. super(Model, self).__init__(**kw)
  4. def __getattr__(self, key):
  5. try:
  6. return self[key]
  7. except KeyError:
  8. raise AttributeError(r"'Model' object has no attribute '%s'" % key)
  9. def __setattr__(self, key, value):
  10. self[key] = value
  11. def getValue(self, key):
  12. return getattr(self, key, None)
  13. def getValueOrDefault(self, key):
  14. value = getattr(self, key, None)
  15. if value is None:
  16. field = self.__mappings__[key]
  17. if field.default is not None:
  18. value = field.default() if callable(field.default) else field.default
  19. logging.debug('using default value for %s: %s' % (key, str(value)))
  20. setattr(self, key, value)
  21. return value

Model从dict继承,所以具备所有dict的功能,同时又实现了特殊方法__getattr__()和__setattr__(),因此又可以像引用普通字段那样写:

  1. >>> user['id']
  2. 123
  3. >>> user.id
  4. 123

以及Field和各种Field子类:

  1. class Field(object):
  2. def __init__(self, name, column_type, primary_key, default):
  3. self.name = name
  4. self.column_type = column_type
  5. self.primary_key = primary_key
  6. self.default = default
  7. def __str__(self):
  8. return '<%s, %s:%s>' % (self.__class__.__name__, self.column_type, self.name)

映射varchar的StringField:

  1. class StringField(Field):
  2. def __init__(self, name=None, primary_key=False, default=None, ddl='varchar(100)'):
  3. super().__init__(name, ddl, primary_key, default)

注意到Model只是一个基类,如何将具体的子类如User的映射信息读取出来呢?答案就是通过metaclass:ModelMetaclass:

  1. class ModelMetaclass(type):
  2. def __new__(cls, name, bases, attrs):
  3. # 排除Model类本身:
  4. if name=='Model':
  5. return type.__new__(cls, name, bases, attrs)
  6. # 获取table名称:
  7. tableName = attrs.get('__table__', None) or name
  8. logging.info('found model: %s (table: %s)' % (name, tableName))
  9. # 获取所有的Field和主键名:
  10. mappings = dict()
  11. fields = []
  12. primaryKey = None
  13. for k, v in attrs.items():
  14. if isinstance(v, Field):
  15. logging.info(' found mapping: %s ==> %s' % (k, v))
  16. mappings[k] = v
  17. if v.primary_key:
  18. # 找到主键:
  19. if primaryKey:
  20. raise RuntimeError('Duplicate primary key for field: %s' % k)
  21. primaryKey = k
  22. else:
  23. fields.append(k)
  24. if not primaryKey:
  25. raise RuntimeError('Primary key not found.')
  26. for k in mappings.keys():
  27. attrs.pop(k)
  28. escaped_fields = list(map(lambda f: '`%s`' % f, fields))
  29. attrs['__mappings__'] = mappings # 保存属性和列的映射关系
  30. attrs['__table__'] = tableName
  31. attrs['__primary_key__'] = primaryKey # 主键属性名
  32. attrs['__fields__'] = fields # 除主键外的属性名
  33. # 构造默认的SELECT, INSERT, UPDATE和DELETE语句:
  34. attrs['__select__'] = 'select `%s`, %s from `%s`' % (primaryKey, ', '.join(escaped_fields), tableName)
  35. attrs['__insert__'] = 'insert into `%s` (%s, `%s`) values (%s)' % (tableName, ', '.join(escaped_fields), primaryKey, create_args_string(len(escaped_fields) + 1))
  36. attrs['__update__'] = 'update `%s` set %s where `%s`=?' % (tableName, ', '.join(map(lambda f: '`%s`=?' % (mappings.get(f).name or f), fields)), primaryKey)
  37. attrs['__delete__'] = 'delete from `%s` where `%s`=?' % (tableName, primaryKey)
  38. return type.__new__(cls, name, bases, attrs)

这样,任何继承自Model的类(比如User),会自动通过ModelMetaclass扫描映射关系,并存储到自身的类属性如__table__、__mappings__中。

然后,我们往Model类添加class方法,就可以让所有子类调用class方法:

  1. class Model(dict):
  2. ...
  3. @classmethod
  4. @asyncio.coroutine
  5. def find(cls, pk):
  6. ' find object by primary key. '
  7. rs = yield from select('%s where `%s`=?' % (cls.__select__, cls.__primary_key__), [pk], 1)
  8. if len(rs) == 0:
  9. return None
  10. return cls(**rs[0])

User类现在就可以通过类方法实现主键查找:

user = yield from User.find('123')

往Model类添加实例方法,就可以让所有子类调用实例方法:

  1. class Model(dict):
  2. ...
  3. @asyncio.coroutine
  4. def save(self):
  5. args = list(map(self.getValueOrDefault, self.__fields__))
  6. args.append(self.getValueOrDefault(self.__primary_key__))
  7. rows = yield from execute(self.__insert__, args)
  8. if rows != 1:
  9. logging.warn('failed to insert record: affected rows: %s' % rows)

这样,就可以把一个User实例存入数据库:

  1. user = User(id=123, name='Michael')
  2. yield from user.save()

最后一步是完善ORM,对于查找,我们可以实现以下方法:

  • findAll() – 根据WHERE条件查找;

  • findNumber() – 根据WHERE条件查找,但返回的是整数,适用于select count(*)类型的SQL。

以及update()和remove()方法。

所有这些方法都必须用@asyncio.coroutine装饰,变成一个协程。

调用时需要特别注意:

user.save()

没有任何效果,因为调用save()仅仅是创建了一个协程,并没有执行它。一定要用:

yield from user.save()

才真正执行了INSERT操作。

最后看看我们实现的ORM模块一共多少行代码?累计不到300多行。用Python写一个ORM是不是很容易呢?

四、编写Model

有了ORM,我们就可以把Web App需要的3个表用Model表示出来:

  1. import time, uuid
  2. from orm import Model, StringField, BooleanField, FloatField, TextField
  3. def next_id():
  4. return '%015d%s000' % (int(time.time() * 1000), uuid.uuid4().hex)
  5. class User(Model):
  6. __table__ = 'users'
  7. id = StringField(primary_key=True, default=next_id, ddl='varchar(50)')
  8. email = StringField(ddl='varchar(50)')
  9. passwd = StringField(ddl='varchar(50)')
  10. admin = BooleanField()
  11. name = StringField(ddl='varchar(50)')
  12. image = StringField(ddl='varchar(500)')
  13. created_at = FloatField(default=time.time)
  14. class Blog(Model):
  15. __table__ = 'blogs'
  16. id = StringField(primary_key=True, default=next_id, ddl='varchar(50)')
  17. user_id = StringField(ddl='varchar(50)')
  18. user_name = StringField(ddl='varchar(50)')
  19. user_image = StringField(ddl='varchar(500)')
  20. name = StringField(ddl='varchar(50)')
  21. summary = StringField(ddl='varchar(200)')
  22. content = TextField()
  23. created_at = FloatField(default=time.time)
  24. class Comment(Model):
  25. __table__ = 'comments'
  26. id = StringField(primary_key=True, default=next_id, ddl='varchar(50)')
  27. blog_id = StringField(ddl='varchar(50)')
  28. user_id = StringField(ddl='varchar(50)')
  29. user_name = StringField(ddl='varchar(50)')
  30. user_image = StringField(ddl='varchar(500)')
  31. content = TextField()
  32. created_at = FloatField(default=time.time)

在编写ORM时,给一个Field增加一个default参数可以让ORM自己填入缺省值,非常方便。并且,缺省值可以作为函数对象传入,在调用save()时自动计算。

例如,主键id的缺省值是函数next_id,创建时间created_at的缺省值是函数time.time,可以自动设置当前日期和时间。

日期和时间用float类型存储在数据库中,而不是datetime类型,这么做的好处是不必关心数据库的时区以及时区转换问题,排序非常简单,显示的时候,只需要做一个float到str的转换,也非常容易。

1、初始化数据库表

如果表的数量很少,可以手写创建表的SQL脚本:

  1. -- schema.sql
  2. drop database if exists awesome;
  3. create database awesome;
  4. use awesome;
  5. grant select, insert, update, delete on awesome.* to 'www-data'@'localhost' identified by 'www-data';
  6. create table users (
  7. `id` varchar(50) not null,
  8. `email` varchar(50) not null,
  9. `passwd` varchar(50) not null,
  10. `admin` bool not null,
  11. `name` varchar(50) not null,
  12. `image` varchar(500) not null,
  13. `created_at` real not null,
  14. unique key `idx_email` (`email`),
  15. key `idx_created_at` (`created_at`),
  16. primary key (`id`)
  17. ) engine=innodb default charset=utf8;
  18. create table blogs (
  19. `id` varchar(50) not null,
  20. `user_id` varchar(50) not null,
  21. `user_name` varchar(50) not null,
  22. `user_image` varchar(500) not null,
  23. `name` varchar(50) not null,
  24. `summary` varchar(200) not null,
  25. `content` mediumtext not null,
  26. `created_at` real not null,
  27. key `idx_created_at` (`created_at`),
  28. primary key (`id`)
  29. ) engine=innodb default charset=utf8;
  30. create table comments (
  31. `id` varchar(50) not null,
  32. `blog_id` varchar(50) not null,
  33. `user_id` varchar(50) not null,
  34. `user_name` varchar(50) not null,
  35. `user_image` varchar(500) not null,
  36. `content` mediumtext not null,
  37. `created_at` real not null,
  38. key `idx_created_at` (`created_at`),
  39. primary key (`id`)
  40. ) engine=innodb default charset=utf8;

如果表的数量很多,可以从Model对象直接通过脚本自动生成SQL脚本,使用更简单。

把SQL脚本放到MySQL命令行里执行:

$ mysql -u root -p < schema.sql

我们就完成了数据库表的初始化。

2、编写数据访问代码

接下来,就可以真正开始编写代码操作对象了。比如,对于User对象,我们就可以做如下操作:

  1. import orm
  2. from models import User, Blog, Comment
  3. def test():
  4. yield from orm.create_pool(user='www-data', password='www-data', database='awesome')
  5. u = User(name='Test', email='test@example.com', passwd='1234567890', image='about:blank')
  6. yield from u.save()
  7. for x in test():
  8. pass

可以在MySQL客户端命令行查询,看看数据是不是正常存储到MySQL里面了。

五、编写Web框架

在正式开始Web开发前,我们需要编写一个Web框架。

aiohttp已经是一个Web框架了,为什么我们还需要自己封装一个?

原因是从使用者的角度来说,aiohttp相对比较底层,编写一个URL的处理函数需要这么几步:

第一步,编写一个用@asyncio.coroutine装饰的函数:

  1. @asyncio.coroutine
  2. def handle_url_xxx(request):
  3. pass

第二步,传入的参数需要自己从request中获取:

  1. url_param = request.match_info['key']
  2. query_params = parse_qs(request.query_string)

最后,需要自己构造Response对象:

  1. text = render('template', data)
  2. return web.Response(text.encode('utf-8'))

这些重复的工作可以由框架完成。例如,处理带参数的URL/blog/{id}可以这么写:

  1. @get('/blog/{id}')
  2. def get_blog(id):
  3. pass

处理query_string参数可以通过关键字参数**kw或者命名关键字参数接收:

  1. @get('/api/comments')
  2. def api_comments(*, page='1'):
  3. pass

对于函数的返回值,不一定是web.Response对象,可以是str、bytes或dict。

如果希望渲染模板,我们可以这么返回一个dict:

  1. return {
  2. '__template__': 'index.html',
  3. 'data': '...'
  4. }

因此,Web框架的设计是完全从使用者出发,目的是让使用者编写尽可能少的代码。

编写简单的函数而非引入request和web.Response还有一个额外的好处,就是可以单独测试,否则,需要模拟一个request才能测试。

1、@get和@post

要把一个函数映射为一个URL处理函数,我们先定义@get():

  1. def get(path):
  2. '''
  3. Define decorator @get('/path')
  4. '''
  5. def decorator(func):
  6. @functools.wraps(func)
  7. def wrapper(*args, **kw):
  8. return func(*args, **kw)
  9. wrapper.__method__ = 'GET'
  10. wrapper.__route__ = path
  11. return wrapper
  12. return decorator

这样,一个函数通过@get()的装饰就附带了URL信息。

@post与@get定义类似。

2、定义RequestHandler

URL处理函数不一定是一个coroutine,因此我们用RequestHandler()来封装一个URL处理函数。

RequestHandler是一个类,由于定义了__call__()方法,因此可以将其实例视为函数。

RequestHandler目的就是从URL函数中分析其需要接收的参数,从request中获取必要的参数,调用URL函数,然后把结果转换为web.Response对象,这样,就完全符合aiohttp框架的要求:

  1. class RequestHandler(object):
  2. def __init__(self, app, fn):
  3. self._app = app
  4. self._func = fn
  5. ...
  6. @asyncio.coroutine
  7. def __call__(self, request):
  8. kw = ... 获取参数
  9. r = yield from self._func(**kw)
  10. return r

再编写一个add_route函数,用来注册一个URL处理函数:

  1. def add_route(app, fn):
  2. method = getattr(fn, '__method__', None)
  3. path = getattr(fn, '__route__', None)
  4. if path is None or method is None:
  5. raise ValueError('@get or @post not defined in %s.' % str(fn))
  6. if not asyncio.iscoroutinefunction(fn) and not inspect.isgeneratorfunction(fn):
  7. fn = asyncio.coroutine(fn)
  8. logging.info('add route %s %s => %s(%s)' % (method, path, fn.__name__, ', '.join(inspect.signature(fn).parameters.keys())))
  9. app.router.add_route(method, path, RequestHandler(app, fn))

最后一步,把很多次add_route()注册的调用:

  1. add_route(app, handles.index)
  2. add_route(app, handles.blog)
  3. add_route(app, handles.create_comment)
  4. ...

变成自动扫描:

  1. # 自动把handler模块的所有符合条件的函数注册了:
  2. add_routes(app, 'handlers')

add_routes()定义如下:

  1. def add_routes(app, module_name):
  2. n = module_name.rfind('.')
  3. if n == (-1):
  4. mod = __import__(module_name, globals(), locals())
  5. else:
  6. name = module_name[n+1:]
  7. mod = getattr(__import__(module_name[:n], globals(), locals(), [name]), name)
  8. for attr in dir(mod):
  9. if attr.startswith('_'):
  10. continue
  11. fn = getattr(mod, attr)
  12. if callable(fn):
  13. method = getattr(fn, '__method__', None)
  14. path = getattr(fn, '__route__', None)
  15. if method and path:
  16. add_route(app, fn)

最后,在app.py中加入middleware、jinja2模板和自注册的支持:

  1. app = web.Application(loop=loop, middlewares=[
  2. logger_factory, response_factory
  3. ])
  4. init_jinja2(app, filters=dict(datetime=datetime_filter))
  5. add_routes(app, 'handlers')
  6. add_static(app)

3、middleware

middleware是一种拦截器,一个URL在被某个函数处理前,可以经过一系列的middleware的处理。

一个middleware可以改变URL的输入、输出,甚至可以决定不继续处理而直接返回。middleware的用处就在于把通用的功能从每个URL处理函数中拿出来,集中放到一个地方。例如,一个记录URL日志的logger可以简单定义如下:

  1. @asyncio.coroutine
  2. def logger_factory(app, handler):
  3. @asyncio.coroutine
  4. def logger(request):
  5. # 记录日志:
  6. logging.info('Request: %s %s' % (request.method, request.path))
  7. # 继续处理请求:
  8. return (yield from handler(request))
  9. return logger

而response这个middleware把返回值转换为web.Response对象再返回,以保证满足aiohttp的要求:

  1. @asyncio.coroutine
  2. def response_factory(app, handler):
  3. @asyncio.coroutine
  4. def response(request):
  5. # 结果:
  6. r = yield from handler(request)
  7. if isinstance(r, web.StreamResponse):
  8. return r
  9. if isinstance(r, bytes):
  10. resp = web.Response(body=r)
  11. resp.content_type = 'application/octet-stream'
  12. return resp
  13. if isinstance(r, str):
  14. resp = web.Response(body=r.encode('utf-8'))
  15. resp.content_type = 'text/html;charset=utf-8'
  16. return resp
  17. if isinstance(r, dict):
  18. ...

有了这些基础设施,我们就可以专注地往handlers模块不断添加URL处理函数了,可以极大地提高开发效率。

六、编写配置文件

有了Web框架和ORM框架,我们就可以开始装配App了。

通常,一个Web App在运行时都需要读取配置文件,比如数据库的用户名、口令等,在不同的环境中运行时,Web App可以通过读取不同的配置文件来获得正确的配置。

由于Python本身语法简单,完全可以直接用Python源代码来实现配置,而不需要再解析一个单独的.properties或者.yaml等配置文件。

默认的配置文件应该完全符合本地开发环境,这样,无需任何设置,就可以立刻启动服务器。

我们把默认的配置文件命名为config_default.py:

  1. # config_default.py
  2. configs = {
  3. 'db': {
  4. 'host': '127.0.0.1',
  5. 'port': 3306,
  6. 'user': 'www-data',
  7. 'password': 'www-data',
  8. 'database': 'awesome'
  9. },
  10. 'session': {
  11. 'secret': 'AwEsOmE'
  12. }
  13. }

上述配置文件简单明了。但是,如果要部署到服务器时,通常需要修改数据库的host等信息,直接修改config_default.py不是一个好办法,更好的方法是编写一个config_override.py,用来覆盖某些默认设置:

  1. # config_override.py
  2. configs = {
  3. 'db': {
  4. 'host': '192.168.0.100'
  5. }
  6. }

把config_default.py作为开发环境的标准配置,把config_override.py作为生产环境的标准配置,我们就可以既方便地在本地开发,又可以随时把应用部署到服务器上。

应用程序读取配置文件需要优先从config_override.py读取。为了简化读取配置文件,可以把所有配置读取到统一的config.py中:

  1. # config.py
  2. configs = config_default.configs
  3. try:
  4. import config_override
  5. configs = merge(configs, config_override.configs)
  6. except ImportError:
  7. pass

这样,我们就完成了App的配置。

七、编写MVC

现在,ORM框架、Web框架和配置都已就绪,我们可以开始编写一个最简单的MVC,把它们全部启动起来。

通过Web框架的@get和ORM框架的Model支持,可以很容易地编写一个处理首页URL的函数:

  1. @get('/')
  2. def index(request):
  3. users = yield from User.findAll()
  4. return {
  5. '__template__': 'test.html',
  6. 'users': users
  7. }

‘__template__’指定的模板文件是test.html,其他参数是传递给模板的数据,所以我们在模板的根目录templates下创建test.html:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8" />
  5. <title>Test users - Awesome Python Webapp</title>
  6. </head>
  7. <body>
  8. <h1>All users</h1>
  9. {% for u in users %}
  10. <p>{{ u.name }} / {{ u.email }}</p>
  11. {% endfor %}
  12. </body>
  13. </html>

接下来,如果一切顺利,可以用命令行启动Web服务器:

$ python3 app.py

然后,在浏览器中访问http://localhost:9000/。

如果数据库的users表什么内容也没有,你就无法在浏览器中看到循环输出的内容。可以自己在MySQL的命令行里给users表添加几条记录,然后再访问:

八、构建前端

虽然我们跑通了一个最简单的MVC,但是页面效果肯定不会让人满意。

对于复杂的HTML前端页面来说,我们需要一套基础的CSS框架来完成页面布局和基本样式。另外,jQuery作为操作DOM的JavaScript库也必不可少。

从零开始写CSS不如直接从一个已有的功能完善的CSS框架开始。有很多CSS框架可供选择。我们这次选择uikit这个强大的CSS框架。它具备完善的响应式布局,漂亮的UI,以及丰富的HTML组件,让我们能轻松设计出美观而简洁的页面。

可以从uikit首页下载打包的资源文件。

所有的静态资源文件我们统一放到www/static目录下,并按照类别归类:

  1. static/
  2. +- css/
  3. | +- addons/
  4. | | +- uikit.addons.min.css
  5. | | +- uikit.almost-flat.addons.min.css
  6. | | +- uikit.gradient.addons.min.css
  7. | +- awesome.css
  8. | +- uikit.almost-flat.addons.min.css
  9. | +- uikit.gradient.addons.min.css
  10. | +- uikit.min.css
  11. +- fonts/
  12. | +- fontawesome-webfont.eot
  13. | +- fontawesome-webfont.ttf
  14. | +- fontawesome-webfont.woff
  15. | +- FontAwesome.otf
  16. +- js/
  17. +- awesome.js
  18. +- html5.js
  19. +- jquery.min.js
  20. +- uikit.min.js

由于前端页面肯定不止首页一个页面,每个页面都有相同的页眉和页脚。如果每个页面都是独立的HTML模板,那么我们在修改页眉和页脚的时候,就需要把每个模板都改一遍,这显然是没有效率的。

常见的模板引擎已经考虑到了页面上重复的HTML部分的复用问题。有的模板通过include把页面拆成三部分:

  1. <html>
  2. <% include file="inc_header.html" %>
  3. <% include file="index_body.html" %>
  4. <% include file="inc_footer.html" %>
  5. </html>

这样,相同的部分inc_header.html和inc_footer.html就可以共享。

但是include方法不利于页面整体结构的维护。jinjia2的模板还有另一种“继承”方式,实现模板的复用更简单。

“继承”模板的方式是通过编写一个“父模板”,在父模板中定义一些可替换的block(块)。然后,编写多个“子模板”,每个子模板都可以只替换父模板定义的block。比如,定义一个最简单的父模板:

  1. <!-- base.html -->
  2. <html>
  3. <head>
  4. <title>{% block title%} 这里定义了一个名为title的block {% endblock %}</title>
  5. </head>
  6. <body>
  7. {% block content %} 这里定义了一个名为content的block {% endblock %}
  8. </body>
  9. </html>

对于子模板a.html,只需要把父模板的title和content替换掉:

  1. {% extends 'base.html' %}
  2. {% block title %} A {% endblock %}
  3. {% block content %}
  4. <h1>Chapter A</h1>
  5. <p>blablabla...</p>
  6. {% endblock %}

对于子模板b.html,如法炮制:

  1. {% extends 'base.html' %}
  2. {% block title %} B {% endblock %}
  3. {% block content %}
  4. <h1>Chapter B</h1>
  5. <ul>
  6. <li>list 1</li>
  7. <li>list 2</li>
  8. </ul>
  9. {% endblock %}

这样,一旦定义好父模板的整体布局和CSS样式,编写子模板就会非常容易。

让我们通过uikit这个CSS框架来完成父模板__base__.html的编写:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8" />
  5. {% block meta %}<!-- block meta -->{% endblock %}
  6. <title>{% block title %} ? {% endblock %} - Awesome Python Webapp</title>
  7. <link rel="stylesheet" href="/static/css/uikit.min.css">
  8. <link rel="stylesheet" href="/static/css/uikit.gradient.min.css">
  9. <link rel="stylesheet" href="/static/css/awesome.css" />
  10. <script src="/static/js/jquery.min.js"></script>
  11. <script src="/static/js/md5.js"></script>
  12. <script src="/static/js/uikit.min.js"></script>
  13. <script src="/static/js/awesome.js"></script>
  14. {% block beforehead %}<!-- before head -->{% endblock %}
  15. </head>
  16. <body>
  17. <nav class="uk-navbar uk-navbar-attached uk-margin-bottom">
  18. <div class="uk-container uk-container-center">
  19. <a href="/" class="uk-navbar-brand">Awesome</a>
  20. <ul class="uk-navbar-nav">
  21. <li data-url="blogs"><a href="/"><i class="uk-icon-home"></i> 日志</a></li>
  22. <li><a target="_blank" href="#"><i class="uk-icon-book"></i> 教程</a></li>
  23. <li><a target="_blank" href="#"><i class="uk-icon-code"></i> 源码</a></li>
  24. </ul>
  25. <div class="uk-navbar-flip">
  26. <ul class="uk-navbar-nav">
  27. {% if user %}
  28. <li class="uk-parent" data-uk-dropdown>
  29. <a href="#0"><i class="uk-icon-user"></i> {{ user.name }}</a>
  30. <div class="uk-dropdown uk-dropdown-navbar">
  31. <ul class="uk-nav uk-nav-navbar">
  32. <li><a href="/signout"><i class="uk-icon-sign-out"></i> 登出</a></li>
  33. </ul>
  34. </div>
  35. </li>
  36. {% else %}
  37. <li><a href="/signin"><i class="uk-icon-sign-in"></i> 登陆</a></li>
  38. <li><a href="/register"><i class="uk-icon-edit"></i> 注册</a></li>
  39. {% endif %}
  40. </ul>
  41. </div>
  42. </div>
  43. </nav>
  44. <div class="uk-container uk-container-center">
  45. <div class="uk-grid">
  46. <!-- content -->
  47. {% block content %}
  48. {% endblock %}
  49. <!-- // content -->
  50. </div>
  51. </div>
  52. <div class="uk-margin-large-top" style="background-color:#eee; border-top:1px solid #ccc;">
  53. <div class="uk-container uk-container-center uk-text-center">
  54. <div class="uk-panel uk-margin-top uk-margin-bottom">
  55. <p>
  56. <a target="_blank" href="#" class="uk-icon-button uk-icon-weibo"></a>
  57. <a target="_blank" href="#" class="uk-icon-button uk-icon-github"></a>
  58. <a target="_blank" href="#" class="uk-icon-button uk-icon-linkedin-square"></a>
  59. <a target="_blank" href="#" class="uk-icon-button uk-icon-twitter"></a>
  60. </p>
  61. <p>Powered by <a href="#">Awesome Python Webapp</a>. Copyright &copy; 2014. [<a href="/manage/" target="_blank">Manage</a>]</p>
  62. <a target="_blank" href="#"><i class="uk-icon-html5" style="font-size:64px; color: #444;"></i></a>
  63. </div>
  64. </div>
  65. </div>
  66. </body>
  67. </html>

__base__.html定义的几个block作用如下:

用于子页面定义一些meta,例如rss feed:

{% block meta %} ... {% endblock %}

覆盖页面的标题:

{% block title %} ... {% endblock %}

子页面可以在<head>标签关闭前插入JavaScript代码:

{% block beforehead %} ... {% endblock %}

子页面的content布局和内容:

  1. {% block content %}
  2. ...
  3. {% endblock %}

我们把首页改造一下,从__base__.html继承一个blogs.html:

  1. {% extends '__base__.html' %}
  2. {% block title %}日志{% endblock %}
  3. {% block content %}
  4. <div class="uk-width-medium-3-4">
  5. {% for blog in blogs %}
  6. <article class="uk-article">
  7. <h2><a href="/blog/{{ blog.id }}">{{ blog.name }}</a></h2>
  8. <p class="uk-article-meta">发表于{{ blog.created_at}}</p>
  9. <p>{{ blog.summary }}</p>
  10. <p><a href="/blog/{{ blog.id }}">继续阅读 <i class="uk-icon-angle-double-right"></i></a></p>
  11. </article>
  12. <hr class="uk-article-divider">
  13. {% endfor %}
  14. </div>
  15. <div class="uk-width-medium-1-4">
  16. <div class="uk-panel uk-panel-header">
  17. <h3 class="uk-panel-title">友情链接</h3>
  18. <ul class="uk-list uk-list-line">
  19. <li><i class="uk-icon-thumbs-o-up"></i> <a target="_blank" href="#">编程</a></li>
  20. <li><i class="uk-icon-thumbs-o-up"></i> <a target="_blank" href="#">读书</a></li>
  21. <li><i class="uk-icon-thumbs-o-up"></i> <a target="_blank" href="#">Python教程</a></li>
  22. <li><i class="uk-icon-thumbs-o-up"></i> <a target="_blank" href="#">Git教程</a></li>
  23. </ul>
  24. </div>
  25. </div>
  26. {% endblock %}

相应地,首页URL的处理函数更新如下:

  1. @get('/')
  2. def index(request):
  3. summary = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
  4. blogs = [
  5. Blog(id='1', name='Test Blog', summary=summary, created_at=time.time()-120),
  6. Blog(id='2', name='Something New', summary=summary, created_at=time.time()-3600),
  7. Blog(id='3', name='Learn Swift', summary=summary, created_at=time.time()-7200)
  8. ]
  9. return {
  10. '__template__': 'blogs.html',
  11. 'blogs': blogs
  12. }

Blog的创建日期显示的是一个浮点数,因为它是由这段模板渲染出来的:

  1. <p class="uk-article-meta">发表于{{ blog.created_at }}</p>

解决方法是通过jinja2的filter(过滤器),把一个浮点数转换成日期字符串。我们来编写一个datetime的filter,在模板里用法如下:

  1. <p class="uk-article-meta">发表于{{ blog.created_at|datetime }}</p>

filter需要在初始化jinja2时设置。相关代码如下:

  1. def datetime_filter(t):
  2. delta = int(time.time() - t)
  3. if delta < 60:
  4. return '1分钟前'
  5. if delta < 3600:
  6. return '%s分钟前' % (delta // 60)
  7. if delta < 86400:
  8. return '%s小时前' % (delta // 3600)
  9. if delta < 604800:
  10. return '%s天前' % (delta // 86400)
  11. dt = datetime.fromtimestamp(t)
  12. return '%s年%s月%s日' % (dt.year, dt.month, dt.day)
  13. ...
  14. init_jinja2(app, filters=dict(datetime=datetime_filter))
  15. ...

现在,完善的首页显示如下:

九、编写API

​自从Roy Fielding博士在2000年他的博士论文中提出REST(Representational State Transfer)风格的软件架构模式后,REST就基本上迅速取代了复杂而笨重的SOAP,成为Web API的标准了。

什么是Web API呢?

如果我们想要获取一篇Blog,输入http://localhost:9000/blog/123,就可以看到id为123的Blog页面,但这个结果是HTML页面,它同时混合包含了Blog的数据和Blog的展示两个部分。对于用户来说,阅读起来没有问题,但是,如果机器读取,就很难从HTML中解析出Blog的数据。

如果一个URL返回的不是HTML,而是机器能直接解析的数据,这个URL就可以看成是一个Web API。比如,读取http://localhost:9000/api/blogs/123,如果能直接返回Blog的数据,那么机器就可以直接读取。

REST就是一种设计API的模式。最常用的数据格式是JSON。由于JSON能直接被JavaScript读取,所以,以JSON格式编写的REST风格的API具有简单、易读、易用的特点。

编写API有什么好处呢?由于API就是把Web App的功能全部封装了,所以,通过API操作数据,可以极大地把前端和后端的代码隔离,使得后端代码易于测试,前端代码编写更简单。

一个API也是一个URL的处理函数,我们希望能直接通过一个@api来把函数变成JSON格式的REST API,这样,获取注册用户可以用一个API实现如下:

  1. @get('/api/users')
  2. def api_get_users(*, page='1'):
  3. page_index = get_page_index(page)
  4. num = yield from User.findNumber('count(id)')
  5. p = Page(num, page_index)
  6. if num == 0:
  7. return dict(page=p, users=())
  8. users = yield from User.findAll(orderBy='created_at desc', limit=(p.offset, p.limit))
  9. for u in users:
  10. u.passwd = '******'
  11. return dict(page=p, users=users)

只要返回一个dict,后续的response这个middleware就可以把结果序列化为JSON并返回。

我们需要对Error进行处理,因此定义一个APIError,这种Error是指API调用时发生了逻辑错误(比如用户不存在),其他的Error视为Bug,返回的错误代码为internalerror。

客户端调用API时,必须通过错误代码来区分API调用是否成功。错误代码是用来告诉调用者出错的原因。很多API用一个整数表示错误码,这种方式很难维护错误码,客户端拿到错误码还需要查表得知错误信息。更好的方式是用字符串表示错误代码,不需要看文档也能猜到错误原因。

可以在浏览器直接测试API,例如,输入http://localhost:9000/api/users,就可以看到返回的JSON:

十、用户注册和登录

用户管理是绝大部分Web网站都需要解决的问题。用户管理涉及到用户注册和登录。

用户注册相对简单,我们可以先通过API把用户注册这个功能实现了:

  1. _RE_EMAIL = re.compile(r'^[a-z0-9\.\-\_]+\@[a-z0-9\-\_]+(\.[a-z0-9\-\_]+){1,4}$')
  2. _RE_SHA1 = re.compile(r'^[0-9a-f]{40}$')
  3. @post('/api/users')
  4. def api_register_user(*, email, name, passwd):
  5. if not name or not name.strip():
  6. raise APIValueError('name')
  7. if not email or not _RE_EMAIL.match(email):
  8. raise APIValueError('email')
  9. if not passwd or not _RE_SHA1.match(passwd):
  10. raise APIValueError('passwd')
  11. users = yield from User.findAll('email=?', [email])
  12. if len(users) > 0:
  13. raise APIError('register:failed', 'email', 'Email is already in use.')
  14. uid = next_id()
  15. sha1_passwd = '%s:%s' % (uid, passwd)
  16. user = User(id=uid, name=name.strip(), email=email, passwd=hashlib.sha1(sha1_passwd.encode('utf-8')).hexdigest(), image='http://www.gravatar.com/avatar/%s?d=mm&s=120' % hashlib.md5(email.encode('utf-8')).hexdigest())
  17. yield from user.save()
  18. # make session cookie:
  19. r = web.Response()
  20. r.set_cookie(COOKIE_NAME, user2cookie(user, 86400), max_age=86400, httponly=True)
  21. user.passwd = '******'
  22. r.content_type = 'application/json'
  23. r.body = json.dumps(user, ensure_ascii=False).encode('utf-8')
  24. return r

注意用户口令是客户端传递的经过SHA1计算后的40位Hash字符串,所以服务器端并不知道用户的原始口令。

接下来可以创建一个注册页面,让用户填写注册表单,然后,提交数据到注册用户的API:

  1. {% extends '__base__.html' %}
  2. {% block title %}注册{% endblock %}
  3. {% block beforehead %}
  4. <script>
  5. function validateEmail(email) {
  6. var re = /^[a-z0-9\.\-\_]+\@[a-z0-9\-\_]+(\.[a-z0-9\-\_]+){1,4}$/;
  7. return re.test(email.toLowerCase());
  8. }
  9. $(function () {
  10. var vm = new Vue({
  11. el: '#vm',
  12. data: {
  13. name: '',
  14. email: '',
  15. password1: '',
  16. password2: ''
  17. },
  18. methods: {
  19. submit: function (event) {
  20. event.preventDefault();
  21. var $form = $('#vm');
  22. if (! this.name.trim()) {
  23. return $form.showFormError('请输入名字');
  24. }
  25. if (! validateEmail(this.email.trim().toLowerCase())) {
  26. return $form.showFormError('请输入正确的Email地址');
  27. }
  28. if (this.password1.length < 6) {
  29. return $form.showFormError('口令长度至少为6个字符');
  30. }
  31. if (this.password1 !== this.password2) {
  32. return $form.showFormError('两次输入的口令不一致');
  33. }
  34. var email = this.email.trim().toLowerCase();
  35. $form.postJSON('/api/users', {
  36. name: this.name.trim(),
  37. email: email,
  38. passwd: CryptoJS.SHA1(email + ':' + this.password1).toString()
  39. }, function (err, r) {
  40. if (err) {
  41. return $form.showFormError(err);
  42. }
  43. return location.assign('/');
  44. });
  45. }
  46. }
  47. });
  48. $('#vm').show();
  49. });
  50. </script>
  51. {% endblock %}
  52. {% block content %}
  53. <div class="uk-width-2-3">
  54. <h1>欢迎注册!</h1>
  55. <form id="vm" v-on="submit: submit" class="uk-form uk-form-stacked">
  56. <div class="uk-alert uk-alert-danger uk-hidden"></div>
  57. <div class="uk-form-row">
  58. <label class="uk-form-label">名字:</label>
  59. <div class="uk-form-controls">
  60. <input v-model="name" type="text" maxlength="50" placeholder="名字" class="uk-width-1-1">
  61. </div>
  62. </div>
  63. <div class="uk-form-row">
  64. <label class="uk-form-label">电子邮件:</label>
  65. <div class="uk-form-controls">
  66. <input v-model="email" type="text" maxlength="50" placeholder="your-name@example.com" class="uk-width-1-1">
  67. </div>
  68. </div>
  69. <div class="uk-form-row">
  70. <label class="uk-form-label">输入口令:</label>
  71. <div class="uk-form-controls">
  72. <input v-model="password1" type="password" maxlength="50" placeholder="输入口令" class="uk-width-1-1">
  73. </div>
  74. </div>
  75. <div class="uk-form-row">
  76. <label class="uk-form-label">重复口令:</label>
  77. <div class="uk-form-controls">
  78. <input v-model="password2" type="password" maxlength="50" placeholder="重复口令" class="uk-width-1-1">
  79. </div>
  80. </div>
  81. <div class="uk-form-row">
  82. <button type="submit" class="uk-button uk-button-primary"><i class="uk-icon-user"></i> 注册</button>
  83. </div>
  84. </form>
  85. </div>
  86. {% endblock %}

这样我们就把用户注册的功能完成了:

用户登录比用户注册复杂。由于HTTP协议是一种无状态协议,而服务器要跟踪用户状态,就只能通过cookie实现。大多数Web框架提供了Session功能来封装保存用户状态的cookie。

Session的优点是简单易用,可以直接从Session中取出用户登录信息。

Session的缺点是服务器需要在内存中维护一个映射表来存储用户登录信息,如果有两台以上服务器,就需要对Session做集群,因此,使用Session的Web App很难扩展。

我们采用直接读取cookie的方式来验证用户登录,每次用户访问任意URL,都会对cookie进行验证,这种方式的好处是保证服务器处理任意的URL都是无状态的,可以扩展到多台服务器。

由于登录成功后是由服务器生成一个cookie发送给浏览器,所以,要保证这个cookie不会被客户端伪造出来。

实现防伪造cookie的关键是通过一个单向算法(例如SHA1),举例如下:

当用户输入了正确的口令登录成功后,服务器可以从数据库取到用户的id,并按照如下方式计算出一个字符串:

"用户id" + "过期时间" + SHA1("用户id" + "用户口令" + "过期时间" + "SecretKey")

当浏览器发送cookie到服务器端后,服务器可以拿到的信息包括:

  • 用户id

  • 过期时间

  • SHA1值

如果未到过期时间,服务器就根据用户id查找用户口令,并计算:

SHA1("用户id" + "用户口令" + "过期时间" + "SecretKey")

并与浏览器cookie中的哈希进行比较,如果相等,则说明用户已登录,否则,cookie就是伪造的。

这个算法的关键在于SHA1是一种单向算法,即可以通过原始字符串计算出SHA1结果,但无法通过SHA1结果反推出原始字符串。

所以登录API可以实现如下:

  1. @post('/api/authenticate')
  2. def authenticate(*, email, passwd):
  3. if not email:
  4. raise APIValueError('email', 'Invalid email.')
  5. if not passwd:
  6. raise APIValueError('passwd', 'Invalid password.')
  7. users = yield from User.findAll('email=?', [email])
  8. if len(users) == 0:
  9. raise APIValueError('email', 'Email not exist.')
  10. user = users[0]
  11. # check passwd:
  12. sha1 = hashlib.sha1()
  13. sha1.update(user.id.encode('utf-8'))
  14. sha1.update(b':')
  15. sha1.update(passwd.encode('utf-8'))
  16. if user.passwd != sha1.hexdigest():
  17. raise APIValueError('passwd', 'Invalid password.')
  18. # authenticate ok, set cookie:
  19. r = web.Response()
  20. r.set_cookie(COOKIE_NAME, user2cookie(user, 86400), max_age=86400, httponly=True)
  21. user.passwd = '******'
  22. r.content_type = 'application/json'
  23. r.body = json.dumps(user, ensure_ascii=False).encode('utf-8')
  24. return r
  25. # 计算加密cookie:
  26. def user2cookie(user, max_age):
  27. # build cookie string by: id-expires-sha1
  28. expires = str(int(time.time() + max_age))
  29. s = '%s-%s-%s-%s' % (user.id, user.passwd, expires, _COOKIE_KEY)
  30. L = [user.id, expires, hashlib.sha1(s.encode('utf-8')).hexdigest()]
  31. return '-'.join(L)

对于每个URL处理函数,如果我们都去写解析cookie的代码,那会导致代码重复很多次。

利用middle在处理URL之前,把cookie解析出来,并将登录用户绑定到request对象上,这样,后续的URL处理函数就可以直接拿到登录用户:

  1. @asyncio.coroutine
  2. def auth_factory(app, handler):
  3. @asyncio.coroutine
  4. def auth(request):
  5. logging.info('check user: %s %s' % (request.method, request.path))
  6. request.__user__ = None
  7. cookie_str = request.cookies.get(COOKIE_NAME)
  8. if cookie_str:
  9. user = yield from cookie2user(cookie_str)
  10. if user:
  11. logging.info('set current user: %s' % user.email)
  12. request.__user__ = user
  13. return (yield from handler(request))
  14. return auth
  15. # 解密cookie:
  16. @asyncio.coroutine
  17. def cookie2user(cookie_str):
  18. '''
  19. Parse cookie and load user if cookie is valid.
  20. '''
  21. if not cookie_str:
  22. return None
  23. try:
  24. L = cookie_str.split('-')
  25. if len(L) != 3:
  26. return None
  27. uid, expires, sha1 = L
  28. if int(expires) < time.time():
  29. return None
  30. user = yield from User.find(uid)
  31. if user is None:
  32. return None
  33. s = '%s-%s-%s-%s' % (uid, user.passwd, expires, _COOKIE_KEY)
  34. if sha1 != hashlib.sha1(s.encode('utf-8')).hexdigest():
  35. logging.info('invalid sha1')
  36. return None
  37. user.passwd = '******'
  38. return user
  39. except Exception as e:
  40. logging.exception(e)
  41. return None

这样,我们就完成了用户注册和登录的功能。

十一、编写日志创建页

在Web开发中,后端代码写起来其实是相当容易的。

例如,我们编写一个REST API,用于创建一个Blog:

  1. @post('/api/blogs')
  2. def api_create_blog(request, *, name, summary, content):
  3. check_admin(request)
  4. if not name or not name.strip():
  5. raise APIValueError('name', 'name cannot be empty.')
  6. if not summary or not summary.strip():
  7. raise APIValueError('summary', 'summary cannot be empty.')
  8. if not content or not content.strip():
  9. raise APIValueError('content', 'content cannot be empty.')
  10. blog = Blog(user_id=request.__user__.id, user_name=request.__user__.name, user_image=request.__user__.image, name=name.strip(), summary=summary.strip(), content=content.strip())
  11. yield from blog.save()
  12. return blog

编写后端Python代码不但很简单,而且非常容易测试,上面的API:api_create_blog()本身只是一个普通函数。

Web开发真正困难的地方在于编写前端页面。前端页面需要混合HTML、CSS和JavaScript,如果对这三者没有深入地掌握,编写的前端页面将很快难以维护。

更大的问题在于,前端页面通常是动态页面,也就是说,前端页面往往是由后端代码生成的。

生成前端页面最早的方式是拼接字符串:

  1. s = '<html><head><title>'
  2. + title
  3. + '</title></head><body>'
  4. + body
  5. + '</body></html>'

显然这种方式完全不具备可维护性。所以有第二种模板方式:

  1. <html>
  2. <head>
  3. <title>{{ title }}</title>
  4. </head>
  5. <body>
  6. {{ body }}
  7. </body>
  8. </html>

ASP、JSP、PHP等都是用这种模板方式生成前端页面。

如果在页面上大量使用JavaScript(事实上大部分页面都会),模板方式仍然会导致JavaScript代码与后端代码绑得非常紧密,以至于难以维护。其根本原因在于负责显示的HTML DOM模型与负责数据和交互的JavaScript代码没有分割清楚。

要编写可维护的前端代码绝非易事。和后端结合的MVC模式已经无法满足复杂页面逻辑的需要了,所以,新的MVVM:Model View ViewModel模式应运而生。

MVVM最早由微软提出来,它借鉴了桌面应用程序的MVC思想,在前端页面中,把Model用纯JavaScript对象表示:

  1. <script>
  2. var blog = {
  3. name: 'hello',
  4. summary: 'this is summary',
  5. content: 'this is content...'
  6. };
  7. </script>

View是纯HTML:

  1. <form action="/api/blogs" method="post">
  2. <input name="name">
  3. <input name="summary">
  4. <textarea name="content"></textarea>
  5. <button type="submit">OK</button>
  6. </form>

由于Model表示数据,View负责显示,两者做到了最大限度的分离。

把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。

ViewModel如何编写?需要用JavaScript编写一个通用的ViewModel,这样,就可以复用整个MVVM模型了。

好消息是已有许多成熟的MVVM框架,例如AngularJS,KnockoutJS等。我们选择Vue这个简单易用的MVVM框架来实现创建Blog的页面templates/manage_blog_edit.html:

  1. {% extends '__base__.html' %}
  2. {% block title %}编辑日志{% endblock %}
  3. {% block beforehead %}
  4. <script>
  5. var
  6. ID = '{{ id }}',
  7. action = '{{ action }}';
  8. function initVM(blog) {
  9. var vm = new Vue({
  10. el: '#vm',
  11. data: blog,
  12. methods: {
  13. submit: function (event) {
  14. event.preventDefault();
  15. var $form = $('#vm').find('form');
  16. $form.postJSON(action, this.$data, function (err, r) {
  17. if (err) {
  18. $form.showFormError(err);
  19. }
  20. else {
  21. return location.assign('/api/blogs/' + r.id);
  22. }
  23. });
  24. }
  25. }
  26. });
  27. $('#vm').show();
  28. }
  29. $(function () {
  30. if (ID) {
  31. getJSON('/api/blogs/' + ID, function (err, blog) {
  32. if (err) {
  33. return fatal(err);
  34. }
  35. $('#loading').hide();
  36. initVM(blog);
  37. });
  38. }
  39. else {
  40. $('#loading').hide();
  41. initVM({
  42. name: '',
  43. summary: '',
  44. content: ''
  45. });
  46. }
  47. });
  48. </script>
  49. {% endblock %}
  50. {% block content %}
  51. <div class="uk-width-1-1 uk-margin-bottom">
  52. <div class="uk-panel uk-panel-box">
  53. <ul class="uk-breadcrumb">
  54. <li><a href="/manage/comments">评论</a></li>
  55. <li><a href="/manage/blogs">日志</a></li>
  56. <li><a href="/manage/users">用户</a></li>
  57. </ul>
  58. </div>
  59. </div>
  60. <div id="error" class="uk-width-1-1">
  61. </div>
  62. <div id="loading" class="uk-width-1-1 uk-text-center">
  63. <span><i class="uk-icon-spinner uk-icon-medium uk-icon-spin"></i> 正在加载...</span>
  64. </div>
  65. <div id="vm" class="uk-width-2-3">
  66. <form v-on="submit: submit" class="uk-form uk-form-stacked">
  67. <div class="uk-alert uk-alert-danger uk-hidden"></div>
  68. <div class="uk-form-row">
  69. <label class="uk-form-label">标题:</label>
  70. <div class="uk-form-controls">
  71. <input v-model="name" name="name" type="text" placeholder="标题" class="uk-width-1-1">
  72. </div>
  73. </div>
  74. <div class="uk-form-row">
  75. <label class="uk-form-label">摘要:</label>
  76. <div class="uk-form-controls">
  77. <textarea v-model="summary" rows="4" name="summary" placeholder="摘要" class="uk-width-1-1" style="resize:none;"></textarea>
  78. </div>
  79. </div>
  80. <div class="uk-form-row">
  81. <label class="uk-form-label">内容:</label>
  82. <div class="uk-form-controls">
  83. <textarea v-model="content" rows="16" name="content" placeholder="内容" class="uk-width-1-1" style="resize:none;"></textarea>
  84. </div>
  85. </div>
  86. <div class="uk-form-row">
  87. <button type="submit" class="uk-button uk-button-primary"><i class="uk-icon-save"></i> 保存</button>
  88. <a href="/manage/blogs" class="uk-button"><i class="uk-icon-times"></i> 取消</a>
  89. </div>
  90. </form>
  91. </div>
  92. {% endblock %}

初始化Vue时,我们指定3个参数:

el:根据选择器查找绑定的View,这里是#vm,就是id为vm的DOM,对应的是一个<div>标签;

data:JavaScript对象表示的Model,我们初始化为{ name: ”, summary: ”, content: ”};

methods:View可以触发的JavaScript函数,submit就是提交表单时触发的函数。

接下来,我们在<form>标签中,用几个简单的v-model,就可以让Vue把Model和View关联起来:

  1. <!-- input的value和Model的name关联起来了 -->
  2. <input v-model="name" class="uk-width-1-1">

Form表单通过<form v-on=”submit: submit”>把提交表单的事件关联到submit方法。

需要特别注意的是,在MVVM中,Model和View是双向绑定的。如果我们在Form中修改了文本框的值,可以在Model中立刻拿到新的值。试试在表单中输入文本,然后在Chrome浏览器中打开JavaScript控制台,可以通过vm.name访问单个属性,或者通过vm.$data访问整个Model:

如果我们在JavaScript逻辑中修改了Model,这个修改会立刻反映到View上。试试在JavaScript控制台输入vm.name = ‘MVVM简介’,可以看到文本框的内容自动被同步了:

双向绑定是MVVM框架最大的作用。借助于MVVM,我们把复杂的显示逻辑交给框架完成。由于后端编写了独立的REST API,所以,前端用AJAX提交表单非常容易,前后端分离得非常彻底。 

十二、编写日志列表页

MVVM模式不但可用于Form表单,在复杂的管理页面中也能大显身手。例如,分页显示Blog的功能,我们先把后端代码写出来:

在apis.py中定义一个Page类用于存储分页信息:

  1. class Page(object):
  2. def __init__(self, item_count, page_index=1, page_size=10):
  3. self.item_count = item_count
  4. self.page_size = page_size
  5. self.page_count = item_count // page_size + (1 if item_count % page_size > 0 else 0)
  6. if (item_count == 0) or (page_index > self.page_count):
  7. self.offset = 0
  8. self.limit = 0
  9. self.page_index = 1
  10. else:
  11. self.page_index = page_index
  12. self.offset = self.page_size * (page_index - 1)
  13. self.limit = self.page_size
  14. self.has_next = self.page_index < self.page_count
  15. self.has_previous = self.page_index > 1
  16. def __str__(self):
  17. return 'item_count: %s, page_count: %s, page_index: %s, page_size: %s, offset: %s, limit: %s' % (self.item_count, self.page_count, self.page_index, self.page_size, self.offset, self.limit)
  18. __repr__ = __str__

在handlers.py中实现API:

  1. @get('/api/blogs')
  2. def api_blogs(*, page='1'):
  3. page_index = get_page_index(page)
  4. num = yield from Blog.findNumber('count(id)')
  5. p = Page(num, page_index)
  6. if num == 0:
  7. return dict(page=p, blogs=())
  8. blogs = yield from Blog.findAll(orderBy='created_at desc', limit=(p.offset, p.limit))
  9. return dict(page=p, blogs=blogs)

管理页面:

  1. @get('/manage/blogs')
  2. def manage_blogs(*, page='1'):
  3. return {
  4. '__template__': 'manage_blogs.html',
  5. 'page_index': get_page_index(page)
  6. }

模板页面首先通过API:GET /api/blogs?page=?拿到Model:

  1. {
  2. "page": {
  3. "has_next": true,
  4. "page_index": 1,
  5. "page_count": 2,
  6. "has_previous": false,
  7. "item_count": 12
  8. },
  9. "blogs": [...]
  10. }

然后,通过Vue初始化MVVM:

  1. <script>
  2. function initVM(data) {
  3. var vm = new Vue({
  4. el: '#vm',
  5. data: {
  6. blogs: data.blogs,
  7. page: data.page
  8. },
  9. methods: {
  10. edit_blog: function (blog) {
  11. location.assign('/manage/blogs/edit?id=' + blog.id);
  12. },
  13. delete_blog: function (blog) {
  14. if (confirm('确认要删除“' + blog.name + '”?删除后不可恢复!')) {
  15. postJSON('/api/blogs/' + blog.id + '/delete', function (err, r) {
  16. if (err) {
  17. return alert(err.message || err.error || err);
  18. }
  19. refresh();
  20. });
  21. }
  22. }
  23. }
  24. });
  25. $('#vm').show();
  26. }
  27. $(function() {
  28. getJSON('/api/blogs', {
  29. page: {{ page_index }}
  30. }, function (err, results) {
  31. if (err) {
  32. return fatal(err);
  33. }
  34. $('#loading').hide();
  35. initVM(results);
  36. });
  37. });
  38. </script>

View的容器是#vm,包含一个table,我们用v-repeat可以把Model的数组blogs直接变成多行的<tr>:

  1. <div id="vm" class="uk-width-1-1">
  2. <a href="/manage/blogs/create" class="uk-button uk-button-primary"><i class="uk-icon-plus"></i> 新日志</a>
  3. <table class="uk-table uk-table-hover">
  4. <thead>
  5. <tr>
  6. <th class="uk-width-5-10">标题 / 摘要</th>
  7. <th class="uk-width-2-10">作者</th>
  8. <th class="uk-width-2-10">创建时间</th>
  9. <th class="uk-width-1-10">操作</th>
  10. </tr>
  11. </thead>
  12. <tbody>
  13. <tr v-repeat="blog: blogs" >
  14. <td>
  15. <a target="_blank" v-attr="href: '/blog/'+blog.id" v-text="blog.name"></a>
  16. </td>
  17. <td>
  18. <a target="_blank" v-attr="href: '/user/'+blog.user_id" v-text="blog.user_name"></a>
  19. </td>
  20. <td>
  21. <span v-text="blog.created_at.toDateTime()"></span>
  22. </td>
  23. <td>
  24. <a href="#0" v-on="click: edit_blog(blog)"><i class="uk-icon-edit"></i>
  25. <a href="#0" v-on="click: delete_blog(blog)"><i class="uk-icon-trash-o"></i>
  26. </td>
  27. </tr>
  28. </tbody>
  29. </table>
  30. <div v-component="pagination" v-with="page"></div>
  31. </div>

往Model的blogs数组中增加一个Blog元素,table就神奇地增加了一行;把blogs数组的某个元素删除,table就神奇地减少了一行。所有复杂的Model-View的映射逻辑全部由MVVM框架完成,我们只需要在HTML中写上v-repeat指令,就什么都不用管了。

可以把v-repeat=”blog: blogs”看成循环代码,所以,可以在一个<tr>内部引用循环变量blog。v-text和v-attr指令分别用于生成文本和DOM节点属性。

完整的Blog列表页如下:

十三、提升开发效率

现在,我们已经把一个Web App的框架完全搭建好了,从后端的API到前端的MVVM,流程已经跑通了。

在继续工作前,注意到每次修改Python代码,都必须在命令行先Ctrl-C停止服务器,再重启,改动才能生效。

在开发阶段,每天都要修改、保存几十次代码,每次保存都手动来这么一下非常麻烦,严重地降低了我们的开发效率。有没有办法让服务器检测到代码修改后自动重新加载呢?

Django的开发环境在Debug模式下就可以做到自动重新加载,如果我们编写的服务器也能实现这个功能,就能大大提升开发效率。

可惜的是,Django没把这个功能独立出来,不用Django就享受不到,怎么办?

其实Python本身提供了重新载入模块的功能,但不是所有模块都能被重新载入。另一种思路是检测www目录下的代码改动,一旦有改动,就自动重启服务器。

按照这个思路,我们可以编写一个辅助程序pymonitor.py,让它启动wsgiapp.py,并时刻监控www目录下的代码改动,有改动时,先把当前wsgiapp.py进程杀掉,再重启,就完成了服务器进程的自动重启。

要监控目录文件的变化,我们也无需自己手动定时扫描,Python的第三方库watchdog可以利用操作系统的API来监控目录文件的变化,并发送通知。我们先用pip安装:

$ pip3 install watchdog

利用watchdog接收文件变化的通知,如果是.py文件,就自动重启wsgiapp.py进程。

利用Python自带的subprocess实现进程的启动和终止,并把输入输出重定向到当前进程的输入输出中:

  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. __author__ = 'yyds'
  4. import os, sys, time, subprocess
  5. from watchdog.observers import Observer
  6. from watchdog.events import FileSystemEventHandler
  7. def log(s):
  8. print('[Monitor] %s' % s)
  9. class MyFileSystemEventHander(FileSystemEventHandler):
  10. def __init__(self, fn):
  11. super(MyFileSystemEventHander, self).__init__()
  12. self.restart = fn
  13. def on_any_event(self, event):
  14. if event.src_path.endswith('.py'):
  15. log('Python source file changed: %s' % event.src_path)
  16. self.restart()
  17. command = ['echo', 'ok']
  18. process = None
  19. def kill_process():
  20. global process
  21. if process:
  22. log('Kill process [%s]...' % process.pid)
  23. process.kill()
  24. process.wait()
  25. log('Process ended with code %s.' % process.returncode)
  26. process = None
  27. def start_process():
  28. global process, command
  29. log('Start process %s...' % ' '.join(command))
  30. process = subprocess.Popen(command, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr)
  31. def restart_process():
  32. kill_process()
  33. start_process()
  34. def start_watch(path, callback):
  35. observer = Observer()
  36. observer.schedule(MyFileSystemEventHander(restart_process), path, recursive=True)
  37. observer.start()
  38. log('Watching directory %s...' % path)
  39. start_process()
  40. try:
  41. while True:
  42. time.sleep(0.5)
  43. except KeyboardInterrupt:
  44. observer.stop()
  45. observer.join()
  46. if __name__ == '__main__':
  47. argv = sys.argv[1:]
  48. if not argv:
  49. print('Usage: ./pymonitor your-script.py')
  50. exit(0)
  51. if argv[0] != 'python3':
  52. argv.insert(0, 'python3')
  53. command = argv
  54. path = os.path.abspath('.')
  55. start_watch(path, None)

一共70行左右的代码,就实现了Debug模式的自动重新加载。用下面的命令启动服务器:

$ python3 pymonitor.py wsgiapp.py

或者给pymonitor.py加上可执行权限,启动服务器:

$ ./pymonitor.py app.py

在编辑器中打开一个.py文件,修改后保存,看看命令行输出,是不是自动重启了服务器:

  1. $ ./pymonitor.py app.py
  2. [Monitor] Watching directory /Users/michael/Github/awesome-python3-webapp/www...
  3. [Monitor] Start process python app.py...
  4. ...
  5. INFO:root:application (/Users/michael/Github/awesome-python3-webapp/www) will start at 0.0.0.0:9000...
  6. [Monitor] Python source file changed: /Users/michael/Github/awesome-python-webapp/www/handlers.py
  7. [Monitor] Kill process [2747]...
  8. [Monitor] Process ended with code -9.
  9. [Monitor] Start process python app.py...
  10. ...
  11. INFO:root:application (/Users/michael/Github/awesome-python3-webapp/www) will start at 0.0.0.0:9000...

现在,只要一保存代码,就可以刷新浏览器看到效果,大大提升了开发效率。

十四、完成Web App

在Web App框架和基本流程跑通后,剩下的工作全部是体力活了:在Debug开发模式下完成后端所有API、前端所有页面。我们需要做的事情包括:

把当前用户绑定到request上,并对URL/manage/进行拦截,检查当前用户是否是管理员身份:

  1. @asyncio.coroutine
  2. def auth_factory(app, handler):
  3. @asyncio.coroutine
  4. def auth(request):
  5. logging.info('check user: %s %s' % (request.method, request.path))
  6. request.__user__ = None
  7. cookie_str = request.cookies.get(COOKIE_NAME)
  8. if cookie_str:
  9. user = yield from cookie2user(cookie_str)
  10. if user:
  11. logging.info('set current user: %s' % user.email)
  12. request.__user__ = user
  13. if request.path.startswith('/manage/') and (request.__user__ is None or not request.__user__.admin):
  14. return web.HTTPFound('/signin')
  15. return (yield from handler(request))
  16. return auth

后端API包括:

  • 获取日志:GET /api/blogs

  • 创建日志:POST /api/blogs

  • 修改日志:POST /api/blogs/:blog_id

  • 删除日志:POST /api/blogs/:blog_id/delete

  • 获取评论:GET /api/comments

  • 创建评论:POST /api/blogs/:blog_id/comments

  • 删除评论:POST /api/comments/:comment_id/delete

  • 创建新用户:POST /api/users

  • 获取用户:GET /api/users

管理页面包括:

  • 评论列表页:GET /manage/comments

  • 日志列表页:GET /manage/blogs

  • 创建日志页:GET /manage/blogs/create

  • 修改日志页:GET /manage/blogs/

  • 用户列表页:GET /manage/users

用户浏览页面包括:

  • 注册页:GET /register

  • 登录页:GET /signin

  • 注销页:GET /signout

  • 首页:GET /

  • 日志详情页:GET /blog/:blog_id

把所有的功能实现,我们第一个Web App就宣告完成!

十五、部署Web App

作为一个合格的开发者,在本地环境下完成开发还远远不够,我们需要把Web App部署到远程服务器上,这样,广大用户才能访问到网站。

很多做开发的同学把部署这件事情看成是运维同学的工作,这种看法是完全错误的。首先,最近流行DevOps理念,就是说,开发和运维要变成一个整体。其次,运维的难度,其实跟开发质量有很大的关系。代码写得垃圾,运维再好也架不住天天挂掉。最后,DevOps理念需要把运维、监控等功能融入到开发中。你想服务器升级时不中断用户服务?那就得在开发时考虑到这一点。

下面,我们就来把awesome-python3-webapp部署到Linux服务器。

1、搭建Linux服务器

要部署到Linux,首先得有一台Linux服务器。要在公网上体验的同学,可以在Amazon的AWS申请一台EC2虚拟机(免费使用1年),或者使用国内的一些云服务器,一般都提供Ubuntu Server的镜像。想在本地部署的同学,请安装虚拟机,推荐使用VirtualBox。

我们选择的Linux服务器版本是Ubuntu Server 14.04 LTS,原因是apt太简单了。如果你准备使用其他Linux版本,也没有问题。

Linux安装完成后,请确保ssh服务正在运行,否则,需要通过apt安装:

$ sudo apt-get install openssh-server

有了ssh服务,就可以从本地连接到服务器上。建议把公钥复制到服务器端用户的.ssh/authorized_keys中,这样,就可以通过证书实现无密码连接。

2、部署方式

利用Python自带的asyncio,我们已经编写了一个异步高性能服务器。但是,我们还需要一个高性能的Web服务器,这里选择Nginx,它可以处理静态资源,同时作为反向代理把动态请求交给Python代码处理。这个模型如下:

Nginx负责分发请求: 

在服务器端,我们需要定义好部署的目录结构:

  1. /
  2. +- srv/
  3. +- awesome/ <-- Web App根目录
  4. +- www/ <-- 存放Python源码
  5. | +- static/ <-- 存放静态资源文件
  6. +- log/ <-- 存放log

在服务器上部署,要考虑到新版本如果运行不正常,需要回退到旧版本时怎么办。每次用新的代码覆盖掉旧的文件是不行的,需要一个类似版本控制的机制。由于Linux系统提供了软链接功能,所以,我们把www作为一个软链接,它指向哪个目录,哪个目录就是当前运行的版本:

而Nginx和python代码的配置文件只需要指向www目录即可。

Nginx可以作为服务进程直接启动,但app.py还不行,所以,Supervisor登场!Supervisor是一个管理进程的工具,可以随系统启动而启动服务,它还时刻监控服务进程,如果服务进程意外退出,Supervisor可以自动重启服务。

总结一下我们需要用到的服务有:

  • Nginx:高性能Web服务器+负责反向代理;

  • Supervisor:监控服务进程的工具;

  • MySQL:数据库服务。

在Linux服务器上用apt可以直接安装上述服务:

$ sudo apt-get install nginx supervisor python3 mysql-server

然后,再把我们自己的Web App用到的Python库安装了:

$ sudo pip3 install jinja2 aiomysql aiohttp

在服务器上创建目录/srv/awesome/以及相应的子目录。

在服务器上初始化MySQL数据库,把数据库初始化脚本schema.sql复制到服务器上执行:

$ mysql -u root -p < schema.sql

服务器端准备就绪。

3、部署

用FTP还是SCP还是rsync复制文件?如果你需要手动复制,用一次两次还行,一天如果部署50次不但慢、效率低,而且容易出错。

正确的部署方式是使用工具配合脚本完成自动化部署。Fabric就是一个自动化部署工具。由于Fabric是用Python 2.x开发的,所以,部署脚本要用Python 2.7来编写,本机还必须安装Python 2.7版本。

要用Fabric部署,需要在本机(是开发机器,不是Linux服务器)安装Fabric:

$ easy_install fabric

Linux服务器上不需要安装Fabric,Fabric使用SSH直接登录服务器并执行部署命令。

下一步是编写部署脚本。Fabric的部署脚本叫fabfile.py,我们把它放到awesome-python-webapp的目录下,与www目录平级:

  1. awesome-python-webapp/
  2. +- fabfile.py
  3. +- www/
  4. +- ...

Fabric的脚本编写很简单,首先导入Fabric的API,设置部署时的变量:

  1. # fabfile.py
  2. import os, re
  3. from datetime import datetime
  4. # 导入Fabric API:
  5. from fabric.api import *
  6. # 服务器登录用户名:
  7. env.user = 'michael'
  8. # sudo用户为root:
  9. env.sudo_user = 'root'
  10. # 服务器地址,可以有多个,依次部署:
  11. env.hosts = ['192.168.0.3']
  12. # 服务器MySQL用户名和口令:
  13. db_user = 'www-data'
  14. db_password = 'www-data'

然后,每个Python函数都是一个任务。我们先编写一个打包的任务:

  1. _TAR_FILE = 'dist-awesome.tar.gz'
  2. def build():
  3. includes = ['static', 'templates', 'transwarp', 'favicon.ico', '*.py']
  4. excludes = ['test', '.*', '*.pyc', '*.pyo']
  5. local('rm -f dist/%s' % _TAR_FILE)
  6. with lcd(os.path.join(os.path.abspath('.'), 'www')):
  7. cmd = ['tar', '--dereference', '-czvf', '../dist/%s' % _TAR_FILE]
  8. cmd.extend(['--exclude=\'%s\'' % ex for ex in excludes])
  9. cmd.extend(includes)
  10. local(' '.join(cmd))

Fabric提供local(‘…’)来运行本地命令,with lcd(path)可以把当前命令的目录设定为lcd()指定的目录,注意Fabric只能运行命令行命令,Windows下可能需要Cgywin环境。

在awesome-python-webapp目录下运行:

$ fab build

看看是否在dist目录下创建了dist-awesome.tar.gz的文件。

打包后,我们就可以继续编写deploy任务,把打包文件上传至服务器,解压,重置www软链接,重启相关服务:

  1. _REMOTE_TMP_TAR = '/tmp/%s' % _TAR_FILE
  2. _REMOTE_BASE_DIR = '/srv/awesome'
  3. def deploy():
  4. newdir = 'www-%s' % datetime.now().strftime('%y-%m-%d_%H.%M.%S')
  5. # 删除已有的tar文件:
  6. run('rm -f %s' % _REMOTE_TMP_TAR)
  7. # 上传新的tar文件:
  8. put('dist/%s' % _TAR_FILE, _REMOTE_TMP_TAR)
  9. # 创建新目录:
  10. with cd(_REMOTE_BASE_DIR):
  11. sudo('mkdir %s' % newdir)
  12. # 解压到新目录:
  13. with cd('%s/%s' % (_REMOTE_BASE_DIR, newdir)):
  14. sudo('tar -xzvf %s' % _REMOTE_TMP_TAR)
  15. # 重置软链接:
  16. with cd(_REMOTE_BASE_DIR):
  17. sudo('rm -f www')
  18. sudo('ln -s %s www' % newdir)
  19. sudo('chown www-data:www-data www')
  20. sudo('chown -R www-data:www-data %s' % newdir)
  21. # 重启Python服务和nginx服务器:
  22. with settings(warn_only=True):
  23. sudo('supervisorctl stop awesome')
  24. sudo('supervisorctl start awesome')
  25. sudo('/etc/init.d/nginx reload')

注意run()函数执行的命令是在服务器上运行,with cd(path)和with lcd(path)类似,把当前目录在服务器端设置为cd()指定的目录。如果一个命令需要sudo权限,就不能用run(),而是用sudo()来执行。

4、配置Supervisor

上面让Supervisor重启awesome的命令会失败,因为我们还没有配置Supervisor呢。

编写一个Supervisor的配置文件awesome.conf,存放到/etc/supervisor/conf.d/目录下:

  1. [program:awesome]
  2. command = /srv/awesome/www/app.py
  3. directory = /srv/awesome/www
  4. user = www-data
  5. startsecs = 3
  6. redirect_stderr = true
  7. stdout_logfile_maxbytes = 50MB
  8. stdout_logfile_backups = 10
  9. stdout_logfile = /srv/awesome/log/app.log

配置文件通过[program:awesome]指定服务名为awesome,command指定启动app.py。

然后重启Supervisor后,就可以随时启动和停止Supervisor管理的服务了:

  1. $ sudo supervisorctl reload
  2. $ sudo supervisorctl start awesome
  3. $ sudo supervisorctl status
  4. awesome RUNNING pid 1401, uptime 5:01:34

5、配置Nginx

Supervisor只负责运行app.py,我们还需要配置Nginx。把配置文件awesome放到/etc/nginx/sites-available/目录下:

  1. server {
  2. listen 80; # 监听80端口
  3. root /srv/awesome/www;
  4. access_log /srv/awesome/log/access_log;
  5. error_log /srv/awesome/log/error_log;
  6. # server_name awesome.yyds.com; # 配置域名
  7. # 处理静态文件/favicon.ico:
  8. location /favicon.ico {
  9. root /srv/awesome/www;
  10. }
  11. # 处理静态资源:
  12. location ~ ^\/static\/.*$ {
  13. root /srv/awesome/www;
  14. }
  15. # 动态请求转发到9000端口:
  16. location / {
  17. proxy_pass http://127.0.0.1:9000;
  18. proxy_set_header X-Real-IP $remote_addr;
  19. proxy_set_header Host $host;
  20. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  21. }
  22. }

然后在/etc/nginx/sites-enabled/目录下创建软链接:

  1. $ pwd
  2. /etc/nginx/sites-enabled
  3. $ sudo ln -s /etc/nginx/sites-available/awesome .

让Nginx重新加载配置文件,不出意外,我们的awesome-python3-webapp应该正常运行:

$ sudo /etc/init.d/nginx reload

如果有任何错误,都可以在/srv/awesome/log下查找Nginx和App本身的log。如果Supervisor启动时报错,可以在/var/log/supervisor下查看Supervisor的log。

如果一切顺利,你可以在浏览器中访问Linux服务器上的awesome-python3-webapp了:

如果在开发环境更新了代码,只需要在命令行执行:

  1. $ fab build
  2. $ fab deploy

自动部署完成!刷新浏览器就可以看到服务器代码更新后的效果。

提升网速可以用网易和搜狐的镜像站点:

欢迎访问网易开源镜像站

Index of /

十六、编写移动App

网站部署上线后,还缺点啥呢?

在移动互联网浪潮席卷而来的今天,一个网站没有上线移动App,出门根本不好意思跟人打招呼。

所以,awesome-python3-webapp必须得有一个移动App版本!

开发iPhone版本

我们首先来看看如何开发iPhone App。前置条件:一台Mac电脑,安装XCode和最新的iOS SDK。

在使用MVVM编写前端页面时,我们就能感受到,用REST API封装网站后台的功能,不但能清晰地分离前端页面和后台逻辑,现在这个好处更加明显,移动App也可以通过REST API从后端拿到数据。

我们来设计一个简化版的iPhone App,包含两个屏幕:列出最新日志和阅读日志的详细内容:

只需要调用API:/api/blogs。

在XCode中完成App编写:

由于我们的教程是Python,关于如何开发iOS,请移步Develop Apps for iOS。 

十七、一些常见问题

1、如何获取当前路径

当前路径可以用’.’表示,再用os.path.abspath()将其转换为绝对路径:

  1. # -*- coding:utf-8 -*-
  2. # test.py
  3. import os
  4. print(os.path.abspath('.'))

运行结果:

  1. $ python3 test.py
  2. /Users/michael/workspace/testing

2、如何获取当前模块的文件名

可以通过特殊变量__file__获取:

  1. # -*- coding:utf-8 -*-
  2. # test.py
  3. print(__file__)

输出:

  1. $ python3 test.py
  2. test.py

3、如何获取命令行参数

可以通过sys模块的argv获取:

  1. # -*- coding:utf-8 -*-
  2. # test.py
  3. import sys
  4. print(sys.argv)

输出:

  1. $ python3 test.py -a -s "Hello world"
  2. ['test.py', '-a', '-s', 'Hello world']

argv的第一个元素永远是命令行执行的.py文件名。

4、如何获取当前Python命令的可执行文件路径

sys模块的executable变量就是Python命令可执行文件的路径:

  1. # -*- coding:utf-8 -*-
  2. # test.py
  3. import sys
  4. print(sys.executable)

在Mac下的结果:

  1. $ python3 test.py
  2. /usr/local/opt/python3/bin/python3.4