
个人维信:zixuekaoshidian 个人QQ:798244092 学习交流QQ群:477287381
【百度云搜索:http://www.lqkweb.com】 【搜网盘:http://www.swpan.cn】 1、基本概念 2、反爬虫的目的 3、爬虫和反爬的对抗过程以及策略 scrapy架构源码分析图
转: http://www.bdyss.cn http://www.swpan.cn 用命令创建自动爬虫文件 创建爬虫文件是根据scrapy的母版来创建爬虫文件的 scrapy genspider -l 查看scrapy创建爬虫文件可用的母版 Available templates:母版说明 basic 创建基础爬虫文件 crawl 创建自动爬虫文件 csvfeed 创建爬取csv数据爬虫文件 xmlfeed 创建爬取xml数据爬虫文件 创建一个基础母版爬虫,其他同理 scrapy genspider -t 母版名称 爬虫文件名称 要爬取的域名 创建一个基础母版爬虫,其他同理如:scrapy genspider -t crawl lagou www.lagou.com 第一步,配置items.py接收数据字段 default_output_processor = TakeFirst()默认利用ItemLoader类,加载items容器类填充数据,是列表类型,可以通过TakeFirst()方法,获取到列表里的内容 input_processor = MapCompose(预处理函数)设置数据字段的预处理函数,可以是多个函数 # -*- coding: utf-8 -*- # Define here the models for your scraped items # # See documentation in: # http://doc.scrapy.org/en/latest/topics/items.html #items.py,文件是专门用于,接收爬虫获取到的数据信息的,就相当于是容器文件 import scrapy from scrapy.loader.processors import MapCompose,TakeFirst from scrapy.loader import ItemLoader #导入ItemLoader类也就加载items容器类填充数据 class LagouItemLoader(ItemLoader): #自定义Loader继承ItemLoader类,在爬虫页面调用这个类填充数据到Item类 default_output_processor = TakeFirst() #默认利用ItemLoader类,加载items容器类填充数据,是列表类型,可以通过TakeFirst()方法,获取到列表里的内容 def tianjia(value): #自定义数据预处理函数 return '叫卖录音网'+value #将处理后的数据返给Item class LagouItem(scrapy.Item): #设置爬虫获取到的信息容器类 title = scrapy.Field( #接收爬虫获取到的title信息 input_processor = MapCompose(tianjia), #将数据预处理函数名称传入MapCompose方法里处理,数据预处理函数的形式参数value会自动接收字段title ) 第二步,编写自动爬虫与利用ItemLoader类加载items容器类填充数据 自动爬虫Rule()设置爬虫规则 参数: LinkExtractor()设置url规则 callback='回调函数名称' follow=True 表示在抓取页面继续深入 LinkExtractor()对爬虫获取到的url做规则判断处理 参数: allow= r'jobs/' 是一个正则表达式,表示符合这个url格式的,才提取 deny= r'jobs/' 是一个正则表达式,表示符合这个url格式的,不提取抛弃掉,与allow相反 allow_domains= www.lagou.com/ 表示这个域名下的连接才提取 deny_domains= www.lagou.com/ 表示这个域名下的连接不提取抛弃 restrict_xpaths= xpath表达式 表示可以用xpath表达式限定爬虫只提取一个页面指定区域的URL restrict_css= css选择器,表示可以用css选择器限定爬虫只提取一个页面指定区域的URL tags= 'a' 表示爬虫通过a标签去寻找url,默认已经设置,默认即可 attrs= 'href' 表示获取到a标签的href属性,默认已经设置,默认即可 * 利用自定义Loader类继承ItemLoader类,加载items容器类填充数据 *ItemLoader()实例化一个ItemLoader对象来加载items容器类,填充数据,如果是自定义Loader继承的ItemLoader同样的用法 参数: 第一个参数:要填充数据的items容器类注意加上括号, 第二个参数:response* ItemLoader对象下的方法: add_xpath('字段名称','xpath表达式')方法,用xpath表达式获取数据填充到指定字段 add_css('字段名称','css选择器')方法,用css选择器获取数据填充到指定字段 add_value('字段名称',字符串内容)方法,将指定字符串数据填充到指定字段 load_item()方法无参,将所有数据生成,load_item()方法被yield后数据被填充items容器指定类的各个字段 爬虫文件 # -*- coding: utf-8 -*- import scrapy from scrapy.linkextractors import LinkExtractor from scrapy.spiders import CrawlSpider, Rule from adc.items import LagouItem,LagouItemLoader #导入items容器类,和ItemLoader类 class LagouSpider(CrawlSpider): #创建爬虫类 name = 'lagou' #爬虫名称 allowed_domains = ['www.luyin.org'] #起始域名 start_urls = ['http://www.luyin.org/'] #起始url rules = ( #配置抓取列表页规则 Rule(LinkExtractor(allow=('ggwa/.*')), follow=True), #配置抓取内容页规则 Rule(LinkExtractor(allow=('post/\d+.html.*')), callback='parse_job', follow=True), ) def parse_job(self, response): #回调函数,注意:因为CrawlS模板的源码创建了parse回调函数,所以切记我们不能创建parse名称的函数 #利用ItemLoader类,加载items容器类填充数据 item_loader = LagouItemLoader(LagouItem(), response=response) item_loader.add_xpath('title','/html/head/title/text()') article_item = item_loader.load_item() yield article_item items.py文件与爬虫文件的原理图 【转载自:http://www.lqkweb.com】
转自: http://www.bdyss.cn http://www.swpan.cn 第一步。首先下载,大神者也的倒立文字验证码识别程序 下载地址:https://github.com/muchrooms/zheye 注意:此程序依赖以下模块包 Keras==2.0.1 Pillow==3.4.2 jupyter==1.0.0 matplotlib==1.5.3 numpy==1.12.1 scikit-learn==0.18.1 tensorflow==1.0.1 h5py==2.6.0 numpy-1.13.1+mkl 我们用豆瓣园来加速安以上依赖装如: pip install -i https://pypi.douban.com/simple h5py==2.6.0 如果是win系统,可能存在安装失败的可能,如果那个包安装失败,就到 http://www.lfd.uci.edu/~gohlke/pythonlibs/ 找到win对应的版本下载到本地安装,如: pip install h5py-2.7.0-cp35-cp35m-win_amd64.whl 第二步,将者也的,验证码识别程序的zheye文件夹放到工程目录里 第三步,爬虫实现 start_requests()方法,起始url函数,会替换start_urls Request()方法,get方式请求网页 url=字符串类型url headers=字典类型浏览器代理 meta=字典类型的数据,会传递给回调函数 callback=回调函数名称 scrapy.FormRequest()post方式提交数据 url=字符串类型url headers=字典类型浏览器代理 meta=字典类型的数据,会传递给回调函数 callback=回调函数名称 formdata=字典类型,要提交的数据字段 response.headers.getlist('Set-Cookie') 获取响应Cookiesresponse.request.headers.getlist('Cookie') 获取请求Cookies # -*- coding: utf-8 -*- import scrapy from scrapy.http import Request,FormRequest import re class PachSpider(scrapy.Spider): #定义爬虫类,必须继承scrapy.Spider name = 'pach' #设置爬虫名称 allowed_domains = ['zhihu.com'] #爬取域名 # start_urls = [''] #爬取网址,只适于不需要登录的请求,因为没法设置cookie等信息 header = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0'} #设置浏览器用户代理 def start_requests(self): #起始url函数,会替换start_urls """第一次请求一下登录页面,设置开启cookie使其得到cookie,设置回调函数""" return [Request( url='https://www.zhihu.com/#signin', headers=self.header, meta={'cookiejar':1}, #开启Cookies记录,将Cookies传给回调函数 callback=self.parse )] def parse(self, response): # 响应Cookies Cookie1 = response.headers.getlist('Set-Cookie') #查看一下响应Cookie,也就是第一次访问注册页面时后台写入浏览器的Cookie print('后台首次写入的响应Cookies:',Cookie1) #获取xsrf密串 xsrf = response.xpath('//input[@name="_xsrf"]/@value').extract()[0] print('获取xsrf密串:' + xsrf) #获取验证码 import time t = str(int(time.time()*1000)) captcha_url = 'https://www.zhihu.com/captcha.gif?r={0}&type=login&lang=cn'.format(t) #构造验证码请求地址 yield Request(url=captcha_url, #请求验证码图片 headers=self.header, meta={'cookiejar':response.meta['cookiejar'],'xsrf':xsrf}, #将Cookies和xsrf密串传给回调函数 callback=self.post_tj ) def post_tj(self, response): with open('yzhm.jpg','wb') as f: #打开图片句柄 f.write(response.body) #将验证码图片写入本地 f.close() #关闭句柄 #---------------------------者也验证码识别----------------------- from zheye import zheye #导入者也倒立文字验证码识别模块对象 z = zheye() #实例化对象 positions = z.Recognize('yzhm.jpg') #将验证码本地路径传入Recognize方法识别,返回倒立图片的坐标 # print(positions) #默认倒立文字的y坐标在前,x坐标在后 #知乎网要求的倒立文字坐标是x轴在前,y轴在后,所以我们需要定义一个列表来改变默认的,倒立文字坐标位置 pos_arr = [] if len(positions) == 2: if positions[0][1] > positions[1][1]: #判断列表里第一个元祖里的第二个元素如果大于,第二个元祖里的第二个元素 pos_arr.append([positions[1][1],positions[1][0]]) pos_arr.append([positions[0][1], positions[0][0]]) else: pos_arr.append([positions[0][1], positions[0][0]]) pos_arr.append([positions[1][1], positions[1][0]]) else: pos_arr.append([positions[0][1], positions[0][0]]) print('处理后的验证码坐标',pos_arr) # -------------者也验证码识别结束-------- if len(pos_arr) == 2: data = { # 设置用户登录信息,对应抓包得到字段 '_xsrf': response.meta['xsrf'], 'password': '279819', 'captcha': '{"img_size":[200,44],"input_points":[[%.2f,%f],[%.2f,%f]]}' %( pos_arr[0][0] / 2, pos_arr[0][1] / 2, pos_arr[1][0] / 2, pos_arr[1][1] / 2), #因为验证码识别默认是400X88的尺寸所以要除以2 'captcha_type': 'cn', 'phone_num': '15284816568' } else: data = { # 设置用户登录信息,对应抓包得到字段 '_xsrf': response.meta['xsrf'], 'password': '279819', 'captcha': '{"img_size":[200,44],"input_points":[[%.2f,%f]]}' %( pos_arr[0][0] / 2, pos_arr[0][1] / 2), 'captcha_type': 'cn', 'phone_num': '15284816568' } print('登录提交数据',data) print('登录中....!') """第二次用表单post请求,携带Cookie、浏览器代理、用户登录信息,进行登录给Cookie授权""" return [scrapy.FormRequest( url='https://www.zhihu.com/login/phone_num', #真实post地址 meta={'cookiejar':response.meta['cookiejar']}, #接收第传过来的Cookies headers=self.header, formdata=data, callback=self.next )] def next(self,response): # 请求Cookie Cookie2 = response.request.headers.getlist('Cookie') print('登录时携带请求的Cookies:',Cookie2) jieg = response.body.decode("utf-8") #登录后可以查看一下登录响应信息 print('登录响应结果:',jieg) print('正在请需要登录才可以访问的页面....!') """登录后请求需要登录才能查看的页面,如个人中心,携带授权后的Cookie请求""" yield Request( url='https://www.zhihu.com/people/lin-gui-xiu-41/activities', headers=self.header, meta={'cookiejar':True}, callback=self.next2 ) def next2(self,response): # 请求Cookie Cookie3 = response.request.headers.getlist('Cookie') print('查看需要登录才可以访问的页面携带Cookies:',Cookie3) leir = response.xpath('/html/head/title/text()').extract() #得到个人中心页面 print('最终内容',leir) # print(response.body.decode("utf-8")) 【转载自:http://www.lqkweb.com】
注意:数据保存的操作都是在pipelines.py文件里操作的 将数据保存为json文件 spider是一个信号检测 # -*- coding: utf-8 -*- # Define your item pipelines here # # Don't forget to add your pipeline to the ITEM_PIPELINES setting # See: http://doc.scrapy.org/en/latest/topics/item-pipeline.html from scrapy.pipelines.images import ImagesPipeline #导入图片下载器模块 import codecs import json class AdcPipeline(object): #定义数据处理类,必须继承object def __init__(self): self.file = codecs.open('shuju.json', 'w', encoding='utf-8') #初始化时打开json文件 def process_item(self, item, spider): #process_item(item)为数据处理函数,接收一个item,item里就是爬虫最后yield item 来的数据对象 # print('文章标题是:' + item['title'][0]) # print('文章缩略图url是:' + item['img'][0]) # print('文章缩略图保存路径是:' + item['img_tplj']) #接收图片下载器填充的,图片下载后的路径 #将数据保存为json文件 lines = json.dumps(dict(item), ensure_ascii=False) + '\n' #将数据对象转换成json格式 self.file.write(lines) #将json格式数据写入文件 return item def spider_closed(self,spider): #创建一个方法继承spider,spider是一个信号,当前数据操作完成后触发这个方法 self.file.close() #关闭打开文件 class imgPipeline(ImagesPipeline): #自定义一个图片下载内,继承crapy内置的ImagesPipeline图片下载器类 def item_completed(self, results, item, info): #使用ImagesPipeline类里的item_completed()方法获取到图片下载后的保存路径 for ok, value in results: img_lj = value['path'] #接收图片保存路径 # print(ok) item['img_tplj'] = img_lj #将图片保存路径填充到items.py里的字段里 return item #将item给items.py 文件的容器函数 #注意:自定义图片下载器设置好后,需要在 将数据保存到数据库 我们使用一个ORM框架sqlalchemy模块,保存数据 数据库操作文件 #!/usr/bin/env python # -*- coding:utf-8 -*- from sqlalchemy.ext.declarative import declarative_base from sqlalchemy import Column from sqlalchemy import Integer, String, TIMESTAMP from sqlalchemy import ForeignKey, UniqueConstraint, Index from sqlalchemy.orm import sessionmaker, relationship from sqlalchemy import create_engine #配置数据库引擎信息 ENGINE = create_engine("mysql+pymysql://root:279819@127.0.0.1:3306/cshi?charset=utf8", max_overflow=10, echo=True) Base = declarative_base() #创建一个SQLORM基类 class SendMsg(Base): #设计表 __tablename__ = 'sendmsg' id = Column(Integer, primary_key=True, autoincrement=True) title = Column(String(300)) img_tplj = Column(String(300)) def init_db(): Base.metadata.create_all(ENGINE) #向数据库创建指定表 def drop_db(): Base.metadata.drop_all(ENGINE) #向数据库删除指定表 def session(): cls = sessionmaker(bind=ENGINE) #创建sessionmaker类,操作表 return cls() # drop_db() #删除表 # init_db() #创建表 pipelines.py文件 # -*- coding: utf-8 -*- # Define your item pipelines here # # Don't forget to add your pipeline to the ITEM_PIPELINES setting # See: http://doc.scrapy.org/en/latest/topics/item-pipeline.html from scrapy.pipelines.images import ImagesPipeline #导入图片下载器模块 from adc import shujuku as ORM #导入数据库文件 class AdcPipeline(object): #定义数据处理类,必须继承object def __init__(self): ORM.init_db() #创建数据库表 def process_item(self, item, spider): #process_item(item)为数据处理函数,接收一个item,item里就是爬虫最后yield item 来的数据对象 print('文章标题是:' + item['title'][0]) print('文章缩略图url是:' + item['img'][0]) print('文章缩略图保存路径是:' + item['img_tplj']) #接收图片下载器填充的,图片下载后的路径 mysq = ORM.session() shuju = ORM.SendMsg(title=item['title'][0], img_tplj=item['img_tplj']) mysq.add(shuju) mysq.commit() return item class imgPipeline(ImagesPipeline): #自定义一个图片下载内,继承crapy内置的ImagesPipeline图片下载器类 def item_completed(self, results, item, info): #使用ImagesPipeline类里的item_completed()方法获取到图片下载后的保存路径 for ok, value in results: img_lj = value['path'] #接收图片保存路径 # print(ok) item['img_tplj'] = img_lj #将图片保存路径填充到items.py里的字段里 return item #将item给items.py 文件的容器函数 #注意:自定义图片下载器设置好后,需要在 【转载自:http://www.lqkweb.com】
编写spiders爬虫文件循环抓取内容 Request()方法,将指定的url地址添加到下载器下载页面,两个必须参数, 参数: url='url' callback=页面处理函数 使用时需要yield Request() parse.urljoin()方法,是urllib库下的方法,是自动url拼接,如果第二个参数的url地址是相对路径会自动与第一个参数拼接 # -*- coding: utf-8 -*- import scrapy from scrapy.http import Request #导入url返回给下载器的方法 from urllib import parse #导入urllib库里的parse模块 class PachSpider(scrapy.Spider): name = 'pach' allowed_domains = ['blog.jobbole.com'] #起始域名 start_urls = ['http://blog.jobbole.com/all-posts/'] #起始url def parse(self, response): """ 获取列表页的文章url地址,交给下载器 """ #获取当前页文章url lb_url = response.xpath('//a[@class="archive-title"]/@href').extract() #获取文章列表url for i in lb_url: # print(parse.urljoin(response.url,i)) #urllib库里的parse模块的urljoin()方法,是自动url拼接,如果第二个参数的url地址是相对路径会自动与第一个参数拼接 yield Request(url=parse.urljoin(response.url, i), callback=self.parse_wzhang) #将循环到的文章url添加给下载器,下载后交给parse_wzhang回调函数 #获取下一页列表url,交给下载器,返回给parse函数循环 x_lb_url = response.xpath('//a[@class="next page-numbers"]/@href').extract() #获取下一页文章列表url if x_lb_url: yield Request(url=parse.urljoin(response.url, x_lb_url[0]), callback=self.parse) #获取到下一页url返回给下载器,回调给parse函数循环进行 def parse_wzhang(self,response): title = response.xpath('//div[@class="entry-header"]/h1/text()').extract() #获取文章标题 print(title) Request()函数在返回url时,同时可以通过meta属性返回一个自定义字典给回调函数 # -*- coding: utf-8 -*- import scrapy from scrapy.http import Request #导入url返回给下载器的方法 from urllib import parse #导入urllib库里的parse模块 from adc.items import AdcItem #导入items数据接收模块的接收类 class PachSpider(scrapy.Spider): name = 'pach' allowed_domains = ['blog.jobbole.com'] #起始域名 start_urls = ['http://blog.jobbole.com/all-posts/'] #起始url def parse(self, response): """ 获取列表页的文章url地址,交给下载器 """ #获取当前页文章url lb = response.css('div .post.floated-thumb') #获取文章列表区块,css选择器 # print(lb) for i in lb: lb_url = i.css('.archive-title ::attr(href)').extract_first('') #获取区块里文章url # print(lb_url) lb_img = i.css('.post-thumb img ::attr(src)').extract_first('') #获取区块里文章缩略图 # print(lb_img) yield Request(url=parse.urljoin(response.url, lb_url), meta={'lb_img':parse.urljoin(response.url, lb_img)}, callback=self.parse_wzhang) #将循环到的文章url添加给下载器,下载后交给parse_wzhang回调函数 #获取下一页列表url,交给下载器,返回给parse函数循环 x_lb_url = response.css('.next.page-numbers ::attr(href)').extract_first('') #获取下一页文章列表url if x_lb_url: yield Request(url=parse.urljoin(response.url, x_lb_url), callback=self.parse) #获取到下一页url返回给下载器,回调给parse函数循环进行 def parse_wzhang(self,response): title = response.css('.entry-header h1 ::text').extract() #获取文章标题 # print(title) tp_img = response.meta.get('lb_img', '') #接收meta传过来的值,用get获取防止出错 # print(tp_img) shjjsh = AdcItem() #实例化数据接收类 shjjsh['title'] = title #将数据传输给items接收模块的指定类 shjjsh['img'] = tp_img yield shjjsh #将接收对象返回给pipelines.py处理模块 * Scrapy内置图片下载器使用 Scrapy给我们内置了一个图片下载器在crapy.pipelines.images.ImagesPipeline,专门用于将爬虫抓取到图片url后将图片下载到本地 第一步、爬虫抓取图片URL地址后,填充到 items.py文件的容器函数 爬虫文件 # -*- coding: utf-8 -*- import scrapy from scrapy.http import Request #导入url返回给下载器的方法 from urllib import parse #导入urllib库里的parse模块 from adc.items import AdcItem #导入items数据接收模块的接收类 class PachSpider(scrapy.Spider): name = 'pach' allowed_domains = ['blog.jobbole.com'] #起始域名 start_urls = ['http://blog.jobbole.com/all-posts/'] #起始url def parse(self, response): """ 获取列表页的文章url地址,交给下载器 """ #获取当前页文章url lb = response.css('div .post.floated-thumb') #获取文章列表区块,css选择器 # print(lb) for i in lb: lb_url = i.css('.archive-title ::attr(href)').extract_first('') #获取区块里文章url # print(lb_url) lb_img = i.css('.post-thumb img ::attr(src)').extract_first('') #获取区块里文章缩略图 # print(lb_img) yield Request(url=parse.urljoin(response.url, lb_url), meta={'lb_img':parse.urljoin(response.url, lb_img)}, callback=self.parse_wzhang) #将循环到的文章url添加给下载器,下载后交给parse_wzhang回调函数 #获取下一页列表url,交给下载器,返回给parse函数循环 x_lb_url = response.css('.next.page-numbers ::attr(href)').extract_first('') #获取下一页文章列表url if x_lb_url: yield Request(url=parse.urljoin(response.url, x_lb_url), callback=self.parse) #获取到下一页url返回给下载器,回调给parse函数循环进行 def parse_wzhang(self,response): title = response.css('.entry-header h1 ::text').extract() #获取文章标题 # print(title) tp_img = response.meta.get('lb_img', '') #接收meta传过来的值,用get获取防止出错 # print(tp_img) shjjsh = AdcItem() #实例化数据接收类 shjjsh['title'] = title #将数据传输给items接收模块的指定类 shjjsh['img'] = [tp_img] yield shjjsh #将接收对象返回给pipelines.py处理模块 第二步、设置 items.py 文件的容器函数,接收爬虫获取到的数据填充 # -*- coding: utf-8 -*- # Define here the models for your scraped items # # See documentation in: # http://doc.scrapy.org/en/latest/topics/items.html import scrapy #items.py,文件是专门用于,接收爬虫获取到的数据信息的,就相当于是容器文件 class AdcItem(scrapy.Item): #设置爬虫获取到的信息容器类 title = scrapy.Field() #接收爬虫获取到的title信息 img = scrapy.Field() #接收缩略图 img_tplj = scrapy.Field() #图片保存路径 第三步、在pipelines.py使用crapy内置的图片下载器 1、首先引入内置图片下载器 2、自定义一个图片下载内,继承crapy内置的ImagesPipeline图片下载器类 3、使用ImagesPipeline类里的item_completed()方法获取到图片下载后的保存路径 4、在settings.py设置文件里,注册自定义图片下载器类,和设置图片保存路径 # -*- coding: utf-8 -*- # Define your item pipelines here # # Don't forget to add your pipeline to the ITEM_PIPELINES setting # See: http://doc.scrapy.org/en/latest/topics/item-pipeline.html from scrapy.pipelines.images import ImagesPipeline #导入图片下载器模块 class AdcPipeline(object): #定义数据处理类,必须继承object def process_item(self, item, spider): #process_item(item)为数据处理函数,接收一个item,item里就是爬虫最后yield item 来的数据对象 print('文章标题是:' + item['title'][0]) print('文章缩略图url是:' + item['img'][0]) print('文章缩略图保存路径是:' + item['img_tplj']) #接收图片下载器填充的,图片下载后的路径 return item class imgPipeline(ImagesPipeline): #自定义一个图片下载内,继承crapy内置的ImagesPipeline图片下载器类 def item_completed(self, results, item, info): #使用ImagesPipeline类里的item_completed()方法获取到图片下载后的保存路径 for ok, value in results: img_lj = value['path'] #接收图片保存路径 # print(ok) item['img_tplj'] = img_lj #将图片保存路径填充到items.py里的字段里 return item #将item给items.py 文件的容器函数 #注意:自定义图片下载器设置好后,需要在 在settings.py设置文件里,注册自定义图片下载器类,和设置图片保存路径 IMAGES_URLS_FIELD 设置要下载图片的url地址,一般设置的items.py里接收的字段IMAGES_STORE 设置图片保存路径 # Configure item pipelines # See http://scrapy.readthedocs.org/en/latest/topics/item-pipeline.html ITEM_PIPELINES = { 'adc.pipelines.AdcPipeline': 300, #注册adc.pipelines.AdcPipeline类,后面一个数字参数表示执行等级, 'adc.pipelines.imgPipeline': 1, #注册自定义图片下载器,数值越小,越优先执行 } IMAGES_URLS_FIELD = 'img' #设置要下载图片的url字段,就是图片在items.py里的字段里 lujin = os.path.abspath(os.path.dirname(__file__)) IMAGES_STORE = os.path.join(lujin, 'img') #设置图片保存路径 【转载自:http://www.lqkweb.com】
css选择器 1、 2、 3、 ::attr()获取元素属性,css选择器 ::text获取标签文本 举例: extract_first('')获取过滤后的数据,返回字符串,有一个默认参数,也就是如果没有数据默认是什么,一般我们设置为空字符串 extract()获取过滤后的数据,返回字符串列表 # -*- coding: utf-8 -*- import scrapy class PachSpider(scrapy.Spider): name = 'pach' allowed_domains = ['blog.jobbole.com'] start_urls = ['http://blog.jobbole.com/all-posts/'] def parse(self, response): asd = response.css('.archive-title::text').extract() #这里也可以用extract_first('')获取返回字符串 # print(asd) for i in asd: print(i) 【转载自:http://www.lqkweb.com】
【http://www.bdyss.cn】 【http://www.swpan.cn】 我们自定义一个main.py来作为启动文件 main.py #!/usr/bin/env python # -*- coding:utf8 -*- from scrapy.cmdline import execute #导入执行scrapy命令方法 import sys import os sys.path.append(os.path.join(os.getcwd())) #给Python解释器,添加模块新路径 ,将main.py文件所在目录添加到Python解释器 execute(['scrapy', 'crawl', 'pach', '--nolog']) #执行scrapy命令 爬虫文件 # -*- coding: utf-8 -*- import scrapy from scrapy.http import Request import urllib.response from lxml import etree import re class PachSpider(scrapy.Spider): name = 'pach' allowed_domains = ['blog.jobbole.com'] start_urls = ['http://blog.jobbole.com/all-posts/'] def parse(self, response): pass xpath表达式 1、 2、 3、 基本使用 allowed_domains设置爬虫起始域名start_urls设置爬虫起始url地址parse(response)默认爬虫回调函数,response返回的是爬虫获取到的html信息对象,里面封装了一些关于htnl信息的方法和属性 responsehtml信息对象下的方法和属性response.url获取抓取的rulresponse.body获取网页内容response.body_as_unicode()获取网站内容unicode编码xpath()方法,用xpath表达式过滤节点extract()方法,获取过滤后的数据,返回列表 # -*- coding: utf-8 -*- import scrapy class PachSpider(scrapy.Spider): name = 'pach' allowed_domains = ['blog.jobbole.com'] start_urls = ['http://blog.jobbole.com/all-posts/'] def parse(self, response): leir = response.xpath('//a[@class="archive-title"]/text()').extract() #获取指定标题 leir2 = response.xpath('//a[@class="archive-title"]/@href ').extract() #获取指定url print(response.url) #获取抓取的rul print(response.body) #获取网页内容 print(response.body_as_unicode()) #获取网站内容unicode编码 for i in leir: print(i) for i in leir2: print(i) 【转载自:http://www.lqkweb.com】
【http://www.lqkweb.com】 【http://www.swpan.cn】 网站树形结构 深度优先 是从左到右深度进行爬取的,以深度为准则从左到右的执行(递归方式实现)Scrapy默认是深度优先的 广度优先 是以层级来执行的,(列队方式实现) 【转载自:http://www.lqkweb.com】
【http://bdy.lqkweb.com】 【http://www.swpan.cn】 【转载自:http://www.lqkweb.com】 PhantomJS虚拟浏览器 phantomjs 是一个基于js的webkit内核无头浏览器 也就是没有显示界面的浏览器,利用这个软件,可以获取到网址js加载的任何信息,也就是可以获取浏览器异步加载的信息 下载网址:http://phantomjs.org/download.html 下载对应系统版本 下载后解压PhantomJS文件,将解压文件夹,剪切到python安装文件夹 然后将PhantomJS文件夹里的bin文件夹添加系统环境变量 cdm 输入命令:PhantomJS 出现以下信息说明安装成功 selenium模块是一个python操作PhantomJS软件的一个模块 selenium模块PhantomJS软件 webdriver.PhantomJS()实例化PhantomJS浏览器对象get('url')访问网站find_element_by_xpath('xpath表达式')通过xpath表达式找对应元素clear()清空输入框里的内容send_keys('内容')将内容写入输入框click()点击事件get_screenshot_as_file('截图保存路径名称')将网页截图,保存到此目录page_source获取网页htnl源码quit()关闭PhantomJS浏览器 #!/usr/bin/env python # -*- coding:utf8 -*- from selenium import webdriver #导入selenium模块来操作PhantomJS import os import time import re llqdx = webdriver.PhantomJS() #实例化PhantomJS浏览器对象 llqdx.get("https://www.baidu.com/") #访问网址 # time.sleep(3) #等待3秒 # llqdx.get_screenshot_as_file('H:/py/17/img/123.jpg') #将网页截图保存到此目录 #模拟用户操作 llqdx.find_element_by_xpath('//*[@id="kw"]').clear() #通过xpath表达式找到输入框,clear()清空输入框里的内容 llqdx.find_element_by_xpath('//*[@id="kw"]').send_keys('叫卖录音网') #通过xpath表达式找到输入框,send_keys()将内容写入输入框 llqdx.find_element_by_xpath('//*[@id="su"]').click() #通过xpath表达式找到搜索按钮,click()点击事件 time.sleep(3) #等待3秒 llqdx.get_screenshot_as_file('H:/py/17/img/123.jpg') #将网页截图,保存到此目录 neir = llqdx.page_source #获取网页内容 print(neir) llqdx.quit() #关闭浏览器 pat = "<title>(.*?)</title>" title = re.compile(pat).findall(neir) #正则匹配网页标题 print(title) PhantomJS浏览器伪装,和滚动滚动条加载数据 有些网站是动态加载数据的,需要滚动条滚动加载数据 实现代码 DesiredCapabilities 伪装浏览器对象execute_script()执行js代码 current_url获取当前的url #!/usr/bin/env python # -*- coding:utf8 -*- from selenium import webdriver #导入selenium模块来操作PhantomJS from selenium.webdriver.common.desired_capabilities import DesiredCapabilities #导入浏览器伪装模块 import os import time import re dcap = dict(DesiredCapabilities.PHANTOMJS) dcap['phantomjs.page.settings.userAgent'] = ('Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.221 Safari/537.36 SE 2.X MetaSr 1.0') print(dcap) llqdx = webdriver.PhantomJS(desired_capabilities=dcap) #实例化PhantomJS浏览器对象 llqdx.get("https://www.jd.com/") #访问网址 #模拟用户操作 for j in range(20): js3 = 'window.scrollTo('+str(j*1280)+','+str((j+1)*1280)+')' llqdx.execute_script(js3) #执行js语言滚动滚动条 time.sleep(1) llqdx.get_screenshot_as_file('H:/py/17/img/123.jpg') #将网页截图,保存到此目录 url = llqdx.current_url print(url) neir = llqdx.page_source #获取网页内容 print(neir) llqdx.quit() #关闭浏览器 pat = "<title>(.*?)</title>" title = re.compile(pat).findall(neir) #正则匹配网页标题 print(title) 【转载自:http://www.lqkweb.com】
在urllib中,我们一样可以使用xpath表达式进行信息提取,此时,你需要首先安装lxml模块,然后将网页数据通过lxml下的etree转化为treedata的形式 urllib库中使用xpath表达式 etree.HTML()将获取到的html字符串,转换成树形结构,也就是xpath表达式可以获取的格式 #!/usr/bin/env python # -*- coding:utf8 -*- import urllib.request from lxml import etree #导入html树形结构转换模块 wye = urllib.request.urlopen('http://sh.qihoo.com/pc/home').read().decode("utf-8",'ignore') zhuanh = etree.HTML(wye) #将获取到的html字符串,转换成树形结构,也就是xpath表达式可以获取的格式 print(zhuanh) hqq = zhuanh.xpath('/html/head/title/text()') #通过xpath表达式获取标题 #注意,xpath表达式获取到数据,有时候是列表,有时候不是列表所以要做如下处理 if str(type(hqq)) == "<class 'list'>": #判断获取到的是否是列表 print(hqq) else: xh_hqq = [i for i in hqq] #如果不是列表,循环数据组合成列表 print(xh_hqq) #返回 :['【今日爆点】你的专属资讯平台'] BeautifulSoup基础 BeautifulSoup是获取thml元素的模块 BeautifulSoup-3.2.1版本 【转载自:http://www.lqkweb.com】
打码接口文件 # -*- coding: cp936 -*- import sys import os from ctypes import * # 下载接口放目录 http://www.yundama.com/apidoc/YDM_SDK.html # 错误代码请查询 http://www.yundama.com/apidoc/YDM_ErrorCode.html # 所有函数请查询 http://www.yundama.com/apidoc print('>>>正在初始化...') YDMApi = windll.LoadLibrary('H:/py/16/adc/adc/yamzhm/yundamaAPI-x64') # 1. http://www.yundama.com/index/reg/developer 注册开发者账号 # 2. http://www.yundama.com/developer/myapp 添加新软件 # 3. 使用添加的软件ID和密钥进行开发,享受丰厚分成 appId = 3818 # 软件ID,开发者分成必要参数。登录开发者后台【我的软件】获得! appKey = b'6ff56e09e89fffe45c14abe624af9456' # 软件密钥,开发者分成必要参数。登录开发者后台【我的软件】获得! # print('软件ID:%d\r\n软件密钥:%s' % (appId, appKey)) # 注意这里是普通会员账号,不是开发者账号,注册地址 http://www.yundama.com/index/reg/user # 开发者可以联系客服领取免费调试题分 username = b'adc8868' password = b'adc279819' if username == b'test': exit('\r\n>>>请先设置用户名密码') ####################### 一键识别函数 YDM_EasyDecodeByPath ####################### # print('\r\n>>>正在一键识别...') # # # 例:1004表示4位字母数字,不同类型收费不同。请准确填写,否则影响识别率。在此查询所有类型 http://www.yundama.com/price.html # codetype = 1004 # # # 分配30个字节存放识别结果 # result = c_char_p(b" ") # # # 识别超时时间 单位:秒 # timeout = 60 # # # 验证码文件路径 # filename = b'H:/py/16/adc/adc/yamzhm/yan_zhe_nma.jpg' # # # 一键识别函数,无需调用 YDM_SetAppInfo 和 YDM_Login,适合脚本调用 # captchaId = YDMApi.YDM_EasyDecodeByPath(username, password, appId, appKey, filename, codetype, timeout, result) # # print("一键识别:验证码ID:%d,识别结果:%s" % (captchaId, result.value)) ################################################################################ ########################## 普通识别函数 YDM_DecodeByPath ######################### # print('\r\n>>>正在登陆...') # 第一步:初始化云打码,只需调用一次即可 YDMApi.YDM_SetAppInfo(appId, appKey) # 第二步:登陆云打码账号,只需调用一次即可 uid = YDMApi.YDM_Login(username, password) if uid > 0: # print('>>>正在获取余额...') # 查询账号余额,按需要调用 balance = YDMApi.YDM_GetBalance(username, password) print('登陆成功,用户名:%s,剩余题分:%d' % (username, balance)) print('\r\n>>>正在普通识别...') # 第三步:开始识别 # 例:1004表示4位字母数字,不同类型收费不同。请准确填写,否则影响识别率。在此查询所有类型 http://www.yundama.com/price.html codetype = 3000 # 分配30个字节存放识别结果 result = c_char_p(b" ") # 验证码文件路径 filename = b'H:/py/16/adc/adc/yamzhm/yan_zhe_nma.jpg' # 普通识别函数,需先调用 YDM_SetAppInfo 和 YDM_Login 初始化 captchaId = YDMApi.YDM_DecodeByPath(filename, codetype, result) print("普通识别:验证码ID:%d,识别结果:%s" % (captchaId, result.value)) else: print('登陆失败,错误代码:%d' % uid) ################################################################################ # print('\r\n>>>错误代码请查询 http://www.yundama.com/apidoc/YDM_ErrorCode.html') # input('\r\n测试完成,按回车键结束...') 实现文件 # -*- coding: utf-8 -*- import os from urllib import request #导入request模块 import scrapy from scrapy.http import Request,FormRequest class PachSpider(scrapy.Spider): #定义爬虫类,必须继承scrapy.Spider name = 'pach' #设置爬虫名称 allowed_domains = ['douban.com'] #爬取域名 # start_urls = [''] #爬取网址,只适于不需要登录的请求,因为没法设置cookie等信息 header = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0'} #设置浏览器用户代理 def start_requests(self): """第一次请求一下登录页面,设置开启cookie使其得到cookie,设置回调函数""" print("第一次请求页面获取Cookies.........!") return [Request('https://accounts.douban.com/login',meta={'cookiejar':1},callback=self.parse,headers=self.header)] def parse(self, response): # 响应Cookies Cookie1 = response.headers.getlist('Set-Cookie') #查看一下响应Cookie,也就是第一次访问注册页面时后台写入浏览器的Cookie print('后台首次写入的响应Cookies:',Cookie1) #判断是否出现验证码 yzhm = response.xpath('//img[@id="captcha_image"]/@src').extract() if len(yzhm) > 0: print("出现验证码,请输入验证码") print('验证码图片地址:',yzhm) #将验证码图片保存到本地 file_path = os.path.join(os.getcwd() + '/adc/yamzhm/yan_zhe_nma.jpg') # 拼接图片保存路径 print(file_path) request.urlretrieve(yzhm[0], file_path) # 将图片保存到本地,参数1获取到的src,参数2保存路径 #使用在线打码,自动识别验证码 from adc.yamzhm import YDMPython3 #导入打码模块 yan_zhen_ma = str(YDMPython3.result.value,encoding='utf-8') #接收打码结果 print('写入验证码',yan_zhen_ma) data = { # 设置用户登录信息,对应抓包得到字段 'source': 'None', 'redir': 'https://www.douban.com/people/81309370/', 'form_email': '729088188@qq.com', 'form_password': 'adc279819', 'login': '登录', 'captcha-solution': yan_zhen_ma } print('第二次post请求携带Cookies授权,登录中........!') """第二次用表单post请求,携带Cookie、浏览器代理、用户登录信息,进行登录给Cookie授权""" return [FormRequest.from_response(response, url='https://accounts.douban.com/login', #真实post地址 meta={'cookiejar':response.meta['cookiejar']}, headers=self.header, formdata=data, callback=self.next, )] else: data = { # 设置用户登录信息,对应抓包得到字段 'source': 'None', 'redir': 'https://www.douban.com/people/81309370/', 'form_email': '729088188@qq.com', 'form_password': 'adc279819', 'login': '登录', } print('第二次post请求携带Cookies授权,登录中........!') """第二次用表单post请求,携带Cookie、浏览器代理、用户登录信息,进行登录给Cookie授权""" return [FormRequest.from_response(response, url='https://accounts.douban.com/login', # 真实post地址 meta={'cookiejar': response.meta['cookiejar']}, headers=self.header, formdata=data, callback=self.next, )] def next(self,response): # 请求Cookie Cookie2 = response.request.headers.getlist('Cookie') print('登录时携带请求的Cookies:',Cookie2) dlujieg = response.xpath('/html/head/title/text()').extract() if dlujieg: print('登录响应结果:',dlujieg) else: jieg = response.body.decode("utf-8") #登录后可以查看一下登录响应信息 print('登录响应结果:',jieg) print('第三次请求携带授权Cookie,请求需要登录才能查看的页面.........!') yield Request('https://www.douban.com/people/81309370/',meta={'cookiejar':True},headers=self.header,callback=self.next2) def next2(self,response): # 请求Cookie Cookie3 = response.request.headers.getlist('Cookie') print('查看需要登录才可以访问的页面携带Cookies:',Cookie3) leir = response.xpath('/html/head/title/text()').extract() #得到个人中心页面 print('最终内容',leir) # leir2 = response.xpath('//div[@class="set-tags"]/a/text()').extract() # 得到个人中心页面 # print(leir2) 【转载自:http://www.lqkweb.com】
crapy爬取百度新闻,爬取Ajax动态生成的信息,抓取百度新闻首页的新闻rul地址 有多网站,当你浏览器访问时看到的信息,在html源文件里却找不到,由得信息还是滚动条滚动到对应的位置后才显示信息,那么这种一般都是 js 的 Ajax 动态请求生成的信息 我们以百度新闻为列: 1、分析网站 首先我们浏览器打开百度新闻,在网页中间部分找一条新闻信息 然后查看源码,看看在源码里是否有这条新闻,可以看到源文件里没有这条信息,这种情况爬虫是无法爬取到信息的 那么我们就需要抓包分析了,启动抓包软件和抓包浏览器,前后有说过软件了,就不在说了,此时我们经过抓包看到这条信息是通过Ajax动态生成的JSON数据,也就是说,当html页面加载完成后才生成的,所有我们在源文件里无法找到,当然爬虫也找不到 我们首先将这个JSON数据网址拿出来,到浏览器看看,我们需要的数据是不是全部在里面,此时我们看到这次请求里只有 17条信息,显然我们需要的信息不是完全在里面,还得继续看看其他js包 我们将抓包浏览器滚动条拉到底,以便触发所有js请求,然后在继续找js包,我们将所有js包都找完了再也没看到新闻信息的包了 那信息就不在js包里了,我们回头在看看其他类型的请求,此时我们看到很多get请求响应的是我们需要的新闻信息,说明只有第一次那个Ajax请求返回的JSON数据,后面的Ajax请求返回的都是html类型的字符串数据, 我们将Ajax请求返回的JSON数据的网址和Ajax请求返回html类型的字符串数据网址,拿来做一下比较看看是否能找到一定规律, 此时我们可以看到,JSON数据的网址和html类型的字符串数据网址是一个请求地址, 只是请求时传递的参数不一样而已,那么说明无论返回的什么类型的数据,都是在一个请求地址处理的,只是根据不同的传参返回不同类型的数据而已 http://news.baidu.com/widget?id=LocalNews&ajax=json&t=1501348444467 JSON数据的网址 http://news.baidu.com/widget?id=civilnews&t=1501348728134 html类型的字符串数据网址 http://news.baidu.com/widget?id=InternationalNews&t=1501348728196 html类型的字符串数据网址 我们可以将html类型的字符串数据网址加上JSON数据的网址参数,那是否会返回JSON数据类型?试一试,果然成功了 http://news.baidu.com/widget?id=civilnews&ajax=json 将html类型的字符串数据网址加上JSON数据的网址参数 http://news.baidu.com/widget?id=InternationalNews&ajax=json 将html类型的字符串数据网址加上JSON数据的网址参数 这下就好办了,找到所有的html类型的字符串数据网址,按照上面的方法将其转换成JSON数据的网址,然后循环的去访问转换后的JSON数据的网址,就可以拿到所有新闻的url地址了 crapy实现 # -*- coding: utf-8 -*- import scrapy from scrapy.http import Request,FormRequest import re import json from adc.items import AdcItem from scrapy.selector import Selector class PachSpider(scrapy.Spider): #定义爬虫类,必须继承scrapy.Spider name = 'pach' #设置爬虫名称 allowed_domains = ['news.baidu.com'] #爬取域名 start_urls = ['http://news.baidu.com/widget?id=civilnews&ajax=json'] qishiurl = [ #的到所有页面id 'InternationalNews', 'FinanceNews', 'EnterNews', 'SportNews', 'AutoNews', 'HouseNews', 'InternetNews', 'InternetPlusNews', 'TechNews', 'EduNews', 'GameNews', 'DiscoveryNews', 'HealthNews', 'LadyNews', 'SocialNews', 'MilitaryNews', 'PicWall' ] urllieb = [] for i in range(0,len(qishiurl)): #构造出所有idURL kaishi_url = 'http://news.baidu.com/widget?id=' + qishiurl[i] + '&ajax=json' urllieb.append(kaishi_url) # print(urllieb) def parse(self, response): #选项所有连接 for j in range(0, len(self.urllieb)): a = '正在处理第%s个栏目:url地址是:%s' % (j, self.urllieb[j]) yield scrapy.Request(url=self.urllieb[j], callback=self.enxt) #每次循环到的url 添加爬虫 def enxt(self, response): neir = response.body.decode("utf-8") pat2 = '"m_url":"(.*?)"' url = re.compile(pat2, re.S).findall(neir) #通过正则获取爬取页面 的URL for k in range(0,len(url)): zf_url = url[k] url_zf = re.sub("\\\/", "/", zf_url) pduan = url_zf.find('http://') if pduan == 0: print(url_zf) #输出获取到的所有url 【转载自:http://www.lqkweb.com】
模拟浏览器登录 start_requests()方法,可以返回一个请求给爬虫的起始网站,这个返回的请求相当于start_urls,start_requests()返回的请求会替代start_urls里的请求 Request()get请求,可以设置,url、cookie、回调函数 FormRequest.from_response()表单post提交,第一个必须参数,上一次响应cookie的response对象,其他参数,cookie、url、表单内容等 yield Request()可以将一个新的请求返回给爬虫执行 在发送请求时cookie的操作,meta={'cookiejar':1}表示开启cookie记录,首次请求时写在Request()里meta={'cookiejar':response.meta['cookiejar']}表示使用上一次response的cookie,写在FormRequest.from_response()里post授权meta={'cookiejar':True}表示使用授权后的cookie访问需要登录查看的页面 获取Scrapy框架Cookies 请求CookieCookie = response.request.headers.getlist('Cookie')print(Cookie) 响应CookieCookie2 = response.headers.getlist('Set-Cookie')print(Cookie2) # -*- coding: utf-8 -*- import scrapy from scrapy.http import Request,FormRequest class PachSpider(scrapy.Spider): #定义爬虫类,必须继承scrapy.Spider name = 'pach' #设置爬虫名称 allowed_domains = ['edu.iqianyue.com'] #爬取域名 # start_urls = ['http://edu.iqianyue.com/index_user_login.html'] #爬取网址,只适于不需要登录的请求,因为没法设置cookie等信息 header = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0'} #设置浏览器用户代理 def start_requests(self): #用start_requests()方法,代替start_urls """第一次请求一下登录页面,设置开启cookie使其得到cookie,设置回调函数""" return [Request('http://edu.iqianyue.com/index_user_login.html',meta={'cookiejar':1},callback=self.parse)] def parse(self, response): #parse回调函数 data = { #设置用户登录信息,对应抓包得到字段 'number':'adc8868', 'passwd':'279819', 'submit':'' } # 响应Cookie Cookie1 = response.headers.getlist('Set-Cookie') #查看一下响应Cookie,也就是第一次访问注册页面时后台写入浏览器的Cookie print(Cookie1) print('登录中') """第二次用表单post请求,携带Cookie、浏览器代理、用户登录信息,进行登录给Cookie授权""" return [FormRequest.from_response(response, url='http://edu.iqianyue.com/index_user_login', #真实post地址 meta={'cookiejar':response.meta['cookiejar']}, headers=self.header, formdata=data, callback=self.next, )] def next(self,response): a = response.body.decode("utf-8") #登录后可以查看一下登录响应信息 # print(a) """登录后请求需要登录才能查看的页面,如个人中心,携带授权后的Cookie请求""" yield Request('http://edu.iqianyue.com/index_user_index.html',meta={'cookiejar':True},callback=self.next2) def next2(self,response): # 请求Cookie Cookie2 = response.request.headers.getlist('Cookie') print(Cookie2) body = response.body # 获取网页内容字节类型 unicode_body = response.body_as_unicode() # 获取网站内容字符串类型 a = response.xpath('/html/head/title/text()').extract() #得到个人中心页面 print(a) 模拟浏览器登录2 第一步、 爬虫的第一次访问,一般用户登录时,第一次访问登录页面时,后台会自动写入一个Cookies到浏览器,所以我们的第一次主要是获取到响应Cookies 首先访问网站的登录页面,如果登录页面是一个独立的页面,我们的爬虫第一次应该从登录页面开始,如果登录页面不是独立的页面如 js 弹窗,那么我们的爬虫可以从首页开始 # -*- coding: utf-8 -*- import scrapy from scrapy.http import Request,FormRequest import re class PachSpider(scrapy.Spider): #定义爬虫类,必须继承scrapy.Spider name = 'pach' #设置爬虫名称 allowed_domains = ['dig.chouti.com'] #爬取域名 # start_urls = [''] #爬取网址,只适于不需要登录的请求,因为没法设置cookie等信息 header = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0'} #设置浏览器用户代理 def start_requests(self): """第一次请求一下登录页面,设置开启cookie使其得到cookie,设置回调函数""" return [Request('http://dig.chouti.com/',meta={'cookiejar':1},callback=self.parse)] def parse(self, response): # 响应Cookies Cookie1 = response.headers.getlist('Set-Cookie') #查看一下响应Cookie,也就是第一次访问注册页面时后台写入浏览器的Cookie print('后台首次写入的响应Cookies:',Cookie1) data = { # 设置用户登录信息,对应抓包得到字段 'phone': '8615284816568', 'password': '279819', 'oneMonth': '1' } print('登录中....!') """第二次用表单post请求,携带Cookie、浏览器代理、用户登录信息,进行登录给Cookie授权""" return [FormRequest.from_response(response, url='http://dig.chouti.com/login', #真实post地址 meta={'cookiejar':response.meta['cookiejar']}, headers=self.header, formdata=data, callback=self.next, )] def next(self,response): # 请求Cookie Cookie2 = response.request.headers.getlist('Cookie') print('登录时携带请求的Cookies:',Cookie2) jieg = response.body.decode("utf-8") #登录后可以查看一下登录响应信息 print('登录响应结果:',jieg) print('正在请需要登录才可以访问的页面....!') """登录后请求需要登录才能查看的页面,如个人中心,携带授权后的Cookie请求""" yield Request('http://dig.chouti.com/user/link/saved/1',meta={'cookiejar':True},callback=self.next2) def next2(self,response): # 请求Cookie Cookie3 = response.request.headers.getlist('Cookie') print('查看需要登录才可以访问的页面携带Cookies:',Cookie3) leir = response.xpath('//div[@class="tu"]/a/text()').extract() #得到个人中心页面 print('最终内容',leir) leir2 = response.xpath('//div[@class="set-tags"]/a/text()').extract() # 得到个人中心页面 print(leir2) 【转载自:http://www.lqkweb.com】
xpath表达式 //x 表示向下查找n层指定标签,如://div 表示查找所有div标签 /x 表示向下查找一层指定的标签 /@x 表示查找指定属性的值,可以连缀如:@id @src [@属性名称="属性值"]表示查找指定属性等于指定值的标签,可以连缀 ,如查找class名称等于指定名称的标签 /text() 获取标签文本类容 [x] 通过索引获取集合里的指定一个元素 1、将xpath表达式过滤出来的结果进行正则匹配,用正则取最终内容最后.re('正则') xpath('//div[@class="showlist"]/li//img')[0].re('alt="(\w+)') 2、在选择器规则里应用正则进行过滤[re:正则规则] xpath('//div[re:test(@class, "showlist")]').extract() 实战使用Scrapy获取一个电商网站的、商品标题、商品链接、和评论数 分析源码 第一步、编写items.py容器文件 我们已经知道了我们要获取的是、商品标题、商品链接、和评论数 在items.py创建容器接收爬虫获取到的数据 设置爬虫获取到的信息容器类,必须继承scrapy.Item类 scrapy.Field()方法,定义变量用scrapy.Field()方法接收爬虫指定字段的信息 # -*- coding: utf-8 -*- # Define here the models for your scraped items # # See documentation in: # http://doc.scrapy.org/en/latest/topics/items.html import scrapy #items.py,文件是专门用于,接收爬虫获取到的数据信息的,就相当于是容器文件 class AdcItem(scrapy.Item): #设置爬虫获取到的信息容器类 # define the fields for your item here like: # name = scrapy.Field() title = scrapy.Field() #接收爬虫获取到的title信息 link = scrapy.Field() #接收爬虫获取到的连接信息 comment = scrapy.Field() #接收爬虫获取到的商品评论数 第二步、编写pach.py爬虫文件 定义爬虫类,必须继承scrapy.Spider name设置爬虫名称allowed_domains设置爬取域名start_urls设置爬取网址parse(response)爬虫回调函数,接收response,response里是获取到的html数据对象xpath()过滤器,参数是xpath表达式extract()获取html数据对象里的数据yield item 接收了数据的容器对象,返回给pipelies.py # -*- coding: utf-8 -*- import scrapy from adc.items import AdcItem #导入items.py里的AdcItem类,容器类 class PachSpider(scrapy.Spider): #定义爬虫类,必须继承scrapy.Spider name = 'pach' #设置爬虫名称 allowed_domains = ['search.dangdang.com'] #爬取域名 start_urls = ['http://category.dangdang.com/pg1-cid4008149.html'] #爬取网址 def parse(self, response): #parse回调函数 item = AdcItem() #实例化容器对象 item['title'] = response.xpath('//p[@class="name"]/a/text()').extract() #表达式过滤获取到数据赋值给,容器类里的title变量 # print(rqi['title']) item['link'] = response.xpath('//p[@class="name"]/a/@href').extract() #表达式过滤获取到数据赋值给,容器类里的link变量 # print(rqi['link']) item['comment'] = response.xpath('//p[@class="star"]//a/text()').extract() #表达式过滤获取到数据赋值给,容器类里的comment变量 # print(rqi['comment']) yield item #接收了数据的容器对象,返回给pipelies.py robots协议 注意:如果获取的网站在robots.txt文件里设置了,禁止爬虫爬取协议,那么将无法爬取,因为scrapy默认是遵守这个robots这个国际协议的,如果想不遵守这个协议,需要在settings.py设置 到settings.py文件里找到ROBOTSTXT_OBEY变量,这个变量等于False不遵守robots协议,等于True遵守robots协议 # Obey robots.txt rules ROBOTSTXT_OBEY = False #不遵循robots协议 第三步、编写pipelines.py数据处理文件 如果需要pipelines.py里的数据处理类能工作,需在settings.py设置文件里的ITEM_PIPELINES变量里注册数据处理类 # Configure item pipelines # See http://scrapy.readthedocs.org/en/latest/topics/item-pipeline.html ITEM_PIPELINES = { 'adc.pipelines.AdcPipeline': 300, #注册adc.pipelines.AdcPipeline类,后面一个数字参数表示执行等级,数值越大越先执行 } 注册后pipelines.py里的数据处理类就能工作 定义数据处理类,必须继承objectprocess_item(item)为数据处理函数,接收一个item,item里就是爬虫最后yield item 来的数据对象 # -*- coding: utf-8 -*- # Define your item pipelines here # # Don't forget to add your pipeline to the ITEM_PIPELINES setting # See: http://doc.scrapy.org/en/latest/topics/item-pipeline.html class AdcPipeline(object): #定义数据处理类,必须继承object def process_item(self, item, spider): #process_item(item)为数据处理函数,接收一个item,item里就是爬虫最后yield item 来的数据对象 for i in range(0,len(item['title'])): #可以通过item['容器名称']来获取对应的数据列表 title = item['title'][i] print(title) link = item['link'][i] print(link) comment = item['comment'][i] print(comment) return item 最后执行 执行爬虫文件,scrapy crawl pach --nolog 可以看到我们需要的数据已经拿到了【转载自:http://www.lqkweb.com】
Scrapy框架安装 1、首先,终端执行命令升级pip: python -m pip install --upgrade pip2、安装,wheel(建议网络安装) pip install wheel3、安装,lxml(建议下载安装)4、安装,Twisted(建议下载安装)5、安装,Scrapy(建议网络安装) pip install Scrapy 测试Scrapy是否安装成功 Scrapy框架指令 scrapy -h 查看帮助信息 Available commands: bench Run quick benchmark test (scrapy bench 硬件测试指令,可以测试当前服务器每分钟最多能爬多少个页面) fetch Fetch a URL using the Scrapy downloader (scrapy fetch http://www.iqiyi.com/ 获取一个网页html源码) genspider Generate new spider using pre-defined templates () runspider Run a self-contained spider (without creating a project) () settings Get settings values () shell Interactive scraping console () startproject Create new project (cd 进入要创建项目的目录,scrapy startproject 项目名称 ,创建scrapy项目) version Print Scrapy version () view Open URL in browser, as seen by Scrapy () 创建项目以及项目说明 scrapy startproject adc 创建项目 项目说明 目录结构如下: ├── firstCrawler │ ├── __init__.py │ ├── items.py │ ├── middlewares.py │ ├── pipelines.py │ ├── settings.py │ └── spiders │ └── __init__.py └── scrapy.cfg scrapy.cfg: 项目的配置文件 tems.py: 项目中的item文件,用来定义解析对象对应的属性或字段。 pipelines.py: 负责处理被spider提取出来的item。典型的处理有清理、 验证及持久化(例如存取到数据库) [](http://lib.csdn.net/base/mysql "MySQL知识库") settings.py: 项目的设置文件. spiders:实现自定义爬虫的目录 middlewares.py:Spider中间件是在引擎及Spider之间的特定钩子(specific hook),处理spider的输入(response)和输出(items及requests)。 其提供了一个简便的机制,通过插入自定义代码来扩展Scrapy功能。 项目指令 项目指令是需要cd进入项目目录执行的指令 scrapy -h 项目指令帮助 Available commands: bench Run quick benchmark test check Check spider contracts crawl Run a spider edit Edit spider fetch Fetch a URL using the Scrapy downloader genspider Generate new spider using pre-defined templates list List available spiders parse Parse URL (using its spider) and print the results runspider Run a self-contained spider (without creating a project) settings Get settings values shell Interactive scraping console startproject Create new project version Print Scrapy version (scrapy version 查看scrapy版本信息) view Open URL in browser, as seen by Scrapy (scrapy view http://www.zhimaruanjian.com/ 下载一个网页并打开) 创建爬虫文件 创建爬虫文件是根据scrapy的母版来创建爬虫文件的 scrapy genspider -l 查看scrapy创建爬虫文件可用的母版 Available templates:母版说明 basic 创建基础爬虫文件 crawl 创建自动爬虫文件 csvfeed 创建爬取csv数据爬虫文件 xmlfeed 创建爬取xml数据爬虫文件 创建一个基础母版爬虫,其他同理 scrapy genspider -t 母版名称 爬虫文件名称 要爬取的域名 创建一个基础母版爬虫,其他同理如:scrapy genspider -t basic pach baidu.com scrapy check 爬虫文件名称 测试一个爬虫文件是否合规如:scrapy check pach scrapy crawl 爬虫名称 执行爬虫文件,显示日志 【重点】 scrapy crawl 爬虫名称 --nolog 执行爬虫文件,不显示日志【重点】【转载自:http://www.lqkweb.com】
封装模块 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib from urllib import request import json import random import re import urllib.error def hq_html(hq_url): """ hq_html()封装的爬虫函数,自动启用了用户代理和ip代理 接收一个参数url,要爬取页面的url,返回html源码 """ def yh_dl(): #创建用户代理池 yhdl = [ 'Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50', 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0', 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/2.8.131 Version/11.11', 'Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; TencentTraveler 4.0)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; The World)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'User-Agent:Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'Mozilla/5.0 (iPad; U; CPU OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'Mozilla/5.0 (Linux; U; Android 2.3.7; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1', 'Opera/9.80 (Android 2.3.4; Linux; Opera Mobi/build-1107180945; U; en-GB) Presto/2.8.149 Version/11.10', 'Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/534.13 (KHTML, like Gecko) Version/4.0 Safari/534.13', 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en) AppleWebKit/534.1+ (KHTML, like Gecko) Version/6.0.0.337 Mobile Safari/534.1+', 'Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; HTC; Titan)', 'UCWEB7.0.2.37/28/999', 'NOKIA5700/ UCWEB7.0.2.37/28/999', 'Openwave/ UCWEB7.0.2.37/28/999', 'Mozilla/4.0 (compatible; MSIE 6.0; ) Opera/UCWEB7.0.2.37/28/999' ] thisua = random.choice(yhdl) #随机获取代理信息 headers = ("User-Agent",thisua) #拼接报头信息 opener = urllib.request.build_opener() #创建请求对象 opener.addheaders=[headers] #添加报头到请求对象 urllib.request.install_opener(opener) #将报头信息设置为全局,urlopen()方法请求时也会自动添加报头 def dai_li_ip(hq_url): #创建ip代理池 url = "http://http-webapi.zhimaruanjian.com/getip?num=1&type=2&pro=&city=0&yys=0&port=11&time=1&ts=0&ys=0&cs=0&lb=1&sb=0&pb=4&mr=1" if url: data = urllib.request.urlopen(url).read().decode("utf-8") data2 = json.loads(data) # 将字符串还原它本来的数据类型 # print(data2['data'][0]) ip = str(data2['data'][0]['ip']) dkou = str(data2['data'][0]['port']) zh_ip = ip + ':' + dkou pat = "(\w*):\w*" rst = re.compile(pat).findall(hq_url) #正则匹配获取是http协议还是https协议 rst2 = rst[0] proxy = urllib.request.ProxyHandler({rst2: zh_ip}) # 格式化IP,注意,第一个参数,请求目标可能是http或者https,对应设置 opener = urllib.request.build_opener(proxy, urllib.request.HTTPHandler) # 初始化IP urllib.request.install_opener(opener) # 将代理IP设置成全局,当使用urlopen()请求时自动使用代理IP else: pass #请求 try: dai_li_ip(hq_url) #执行代理IP函数 yh_dl() #执行用户代理池函数 data = urllib.request.urlopen(hq_url).read().decode("utf-8") return data except urllib.error.URLError as e: # 如果出现错误 if hasattr(e, "code"): # 如果有错误代码 # print(e.code) # 打印错误代码 pass if hasattr(e, "reason"): # 如果有错误信息 # print(e.reason) # 打印错误信息 pass # a = hq_html('http://www.baid.com/') # print(a) 实战爬取搜狗微信公众号 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib.request import fzhpach import re #抓取搜狗微信公众号 #http://weixin.sogou.com/weixin?type=1&query=php&page=1 #type=1表示显示公众号 #type=2表示抓取文章 #query=关键词 #page=页码 gjc = '火锅' gjc = urllib.request.quote(gjc) #将关键词转码成浏览器认识的字符,默认网站不能是中文 url = 'http://weixin.sogou.com/weixin?type=1&query=%s&page=1' %(gjc) html = fzhpach.hq_html(url) #使用我们封装的爬虫模块 pat = '微信号:<label name="em_weixinhao">(\w*)</label>' rst = re.compile(pat).findall(html) #正则获取公众号 print(rst) #返回 #['cqhuoguo1', 'qkmjscj888', 'cdsbcdhs', 'zk4538111', 'lamanannv', 'ctm2813333', 'cslhg2016', 'gh_978a858b478f', 'CCLWL0431', 'yuhehaixian'] 抓包教程 首先安装Fiddler4 软件界面说明 清除请求 设置抓包浏览器 这样设置好后,这个浏览器访问的网址就会在抓包软件里看到信息了 设置抓取https协议的网站 导出证书到桌面 将证书安装到浏览器 可以看到软件已经获取到https网站了 疑难问题解决: 有些可能已经按照流程在feiddler中设置好了https抓包,但死活抓不了 (1)首先,看看火狐浏览器的配置,是不是下方“为所有协议使用相同代理”的地方没有勾选上,如果是,请勾选上。 有一部分做到这一步应该能解决无法抓https的问题。如果还不行,请继续往下看。 一般这个时候,还不行,应该就是你的证书问题了,有些可能会问,我是按照正常流程导出并安装的证书,也会有问题? 对的,就是这么奇怪。 (2)接下来,请在下面这个地方输入certmgr.msc并回车,打开证书管理。 打开后如下所示: 请点击操作–查找证书,如下所示: 然后输入fiddler查找所有相关证书,如下所示: 可以看到,我们找到一个,您可能会找到多个,不要紧,有多少个删多少个,分别右键–删除这些证书,如下所示: 全删之后,这一步完成。 (3)再接下来,打开火狐浏览器,进入选项-高级-证书-查看证书,然后找以DO_NOT开头的关于Fiddler的证书,以字母排序的,所以你可以很快找到。如下所示,我们找到两个,不用多说,右键,然后全部依次删除。你可能找到一个或多个,反正找到多少个删多少个就行,另外,特别注意,请如图中【个人、服务器、证书机构、其他】等标签依次查找,以免遗漏,切记切记! 这些全删之后,这一步完成,现在证书已经全部清理了,进入下一步。 (4)下载 FiddlerCertMaker.exe,可以去官网找,如不想麻烦,直接下载我提供的,链接如下: 链接: https://pan.baidu.com/s/1bQBhxG 密码: cu85 下载了这个之后,直接打开,不管出现什么错误,直接忽略,直到出现如下界面为止: 然后点击确定,关掉它。 (5)有了证书之后,请重启Fiddler(关掉再开),重启之后,访问https的网站,比如淘宝首页,有可能成功了,但你也有可能会发现如下错误: “你的连接并不安全” 等类似提示 见到这里,你应该开心,离成功近了。 (6)果断的,打开fiddler,“Tools–Fiddler Options–HTTPS”,然后把下图中同样的地方勾上(注意一致),然后点击actions,然后先点击Trust Root…,然后,再点击Export Root…,此时,导出成功的话,在桌面就有你的证书了。务必注意:这一步成功的话,把第(7)步跳过,不要做了,直接进入第(8)步,如失败,请继续第(7步)。 (7)务必注意:上一步成功的话,把这一步跳过,不要做了。如果,你在导出的时候出现:creation of the root certificate was not located等错误,不要慌。接下来在cmd命令行中进入Fiddler安装目录,比如我的是C盘,所以进入如下图所示Fiddler2目录,然后直接复制下方make那一行代码,然后直接cmd中运行,出现如下所示succeeded提示,出现这一步提示之后,再按步骤(6)的方法导出证书,就能成功了: makecert.exe -r -ss my -n “CN=DO_NOT_TRUST_FiddlerRoot, O=DO_NOT_TRUST, OU=Created by http://www.fiddler2.com” -sky signature -eku 1.3.6.1.5.5.7.3.1 -h 1 -cy authority -a sha1 -m 120 -b 09/05/2012 (8)好,证书导入到桌面后,请打开火狐浏览器,然后进入选项-高级-证书-查看证书-导入-选择刚导出的桌面的证书-确定。 (9)随后,为了保险,Fiddler重启,火狐浏览器也重启一下,然后开始抓HTTPS的包,此时你会发现“你的连接并不安全” 等类似提示已经消失,并且已经能够抓包了。
使用IP代理 ProxyHandler()格式化IP,第一个参数,请求目标可能是http或者https,对应设置build_opener()初始化IPinstall_opener()将代理IP设置成全局,当使用urlopen()请求时自动使用代理IP #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib import urllib.request import random #引入随机模块文件 ip = "180.115.8.212:39109" proxy = urllib.request.ProxyHandler({"https":ip}) #格式化IP,注意:第一个参数可能是http或者https,对应设置 opener = urllib.request.build_opener(proxy,urllib.request.HTTPHandler) #初始化IP urllib.request.install_opener(opener) #将代理IP设置成全局,当使用urlopen()请求时自动使用代理IP #请求 url = "https://www.baidu.com/" data = urllib.request.urlopen(url).read().decode("utf-8") print(data) ip代理池构建一 适合IP存活时间长,稳定性好的代理ip,随机调用列表里的ip #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib from urllib import request import random #引入随机模块文件 def dai_li_ip(): ip = [ '110.73.8.103:8123', '115.46.151.100:8123', '42.233.187.147:19' ] shui = random.choice(ip) print(shui) proxy = urllib.request.ProxyHandler({"https": shui}) # 格式化IP,注意,第一个参数,请求目标可能是http或者https,对应设置 opener = urllib.request.build_opener(proxy, urllib.request.HTTPHandler) # 初始化IP urllib.request.install_opener(opener) # 将代理IP设置成全局,当使用urlopen()请求时自动使用代理IP #请求 dai_li_ip() #执行代理IP函数 url = "https://www.baidu.com/" data = urllib.request.urlopen(url).read().decode("utf-8") print(data) ip代理池构建二,接口方式 每次调用第三方接口动态获取ip,适用于IP存活时间短的情况 我们用http://http.zhimaruanjian.com/第三方接口测试 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib from urllib import request import json def dai_li_ip(): url = "http://http-webapi.zhimaruanjian.com/getip?num=1&type=2&pro=&city=0&yys=0&port=11&time=1&ts=0&ys=0&cs=0&lb=1&sb=0&pb=4&mr=1" data = urllib.request.urlopen(url).read().decode("utf-8") data2 = json.loads(data) # 将字符串还原它本来的数据类型 print(data2['data'][0]) ip = str(data2['data'][0]['ip']) dkou = str(data2['data'][0]['port']) zh_ip = ip + ':' + dkou print(zh_ip) proxy = urllib.request.ProxyHandler({"https": zh_ip}) # 格式化IP,注意,第一个参数,请求目标可能是http或者https,对应设置 opener = urllib.request.build_opener(proxy, urllib.request.HTTPHandler) # 初始化IP urllib.request.install_opener(opener) # 将代理IP设置成全局,当使用urlopen()请求时自动使用代理IP #请求 dai_li_ip() #执行代理IP函数 url = "https://www.baidu.com/" data = urllib.request.urlopen(url).read().decode("utf-8") print(data) 用户代理和ip代理结合应用 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib from urllib import request import json import random def yh_dl(): #创建用户代理池 yhdl = [ 'Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50', 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0', 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/2.8.131 Version/11.11', 'Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; TencentTraveler 4.0)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; The World)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'User-Agent:Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'Mozilla/5.0 (iPad; U; CPU OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'Mozilla/5.0 (Linux; U; Android 2.3.7; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1', 'Opera/9.80 (Android 2.3.4; Linux; Opera Mobi/build-1107180945; U; en-GB) Presto/2.8.149 Version/11.10', 'Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/534.13 (KHTML, like Gecko) Version/4.0 Safari/534.13', 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en) AppleWebKit/534.1+ (KHTML, like Gecko) Version/6.0.0.337 Mobile Safari/534.1+', 'Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; HTC; Titan)', 'UCWEB7.0.2.37/28/999', 'NOKIA5700/ UCWEB7.0.2.37/28/999', 'Openwave/ UCWEB7.0.2.37/28/999', 'Mozilla/4.0 (compatible; MSIE 6.0; ) Opera/UCWEB7.0.2.37/28/999' ] thisua = random.choice(yhdl) #随机获取代理信息 headers = ("User-Agent",thisua) #拼接报头信息 opener = urllib.request.build_opener() #创建请求对象 opener.addheaders=[headers] #添加报头到请求对象 urllib.request.install_opener(opener) #将报头信息设置为全局,urlopen()方法请求时也会自动添加报头 def dai_li_ip(): #创建ip代理池 url = "http://http-webapi.zhimaruanjian.com/getip?num=1&type=2&pro=&city=0&yys=0&port=11&time=1&ts=0&ys=0&cs=0&lb=1&sb=0&pb=4&mr=1" data = urllib.request.urlopen(url).read().decode("utf-8") data2 = json.loads(data) # 将字符串还原它本来的数据类型 print(data2['data'][0]) ip = str(data2['data'][0]['ip']) dkou = str(data2['data'][0]['port']) zh_ip = ip + ':' + dkou print(zh_ip) proxy = urllib.request.ProxyHandler({"https": zh_ip}) # 格式化IP,注意,第一个参数,请求目标可能是http或者https,对应设置 opener = urllib.request.build_opener(proxy, urllib.request.HTTPHandler) # 初始化IP urllib.request.install_opener(opener) # 将代理IP设置成全局,当使用urlopen()请求时自动使用代理IP #请求 dai_li_ip() #执行代理IP函数 yh_dl() #执行用户代理池函数 gjci = '连衣裙' zh_gjci = gjc = urllib.request.quote(gjci) #将关键词转码成浏览器认识的字符,默认网站不能是中文 url = "https://s.taobao.com/search?q=%s&s=0" %(zh_gjci) # print(url) data = urllib.request.urlopen(url).read().decode("utf-8") print(data) 用户代理和ip代理结合应用封装模块 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib from urllib import request import json import random import re import urllib.error def hq_html(hq_url): """ hq_html()封装的爬虫函数,自动启用了用户代理和ip代理 接收一个参数url,要爬取页面的url,返回html源码 """ def yh_dl(): #创建用户代理池 yhdl = [ 'Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50', 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0', 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/2.8.131 Version/11.11', 'Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; TencentTraveler 4.0)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; The World)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'User-Agent:Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'Mozilla/5.0 (iPad; U; CPU OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'Mozilla/5.0 (Linux; U; Android 2.3.7; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1', 'Opera/9.80 (Android 2.3.4; Linux; Opera Mobi/build-1107180945; U; en-GB) Presto/2.8.149 Version/11.10', 'Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/534.13 (KHTML, like Gecko) Version/4.0 Safari/534.13', 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en) AppleWebKit/534.1+ (KHTML, like Gecko) Version/6.0.0.337 Mobile Safari/534.1+', 'Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; HTC; Titan)', 'UCWEB7.0.2.37/28/999', 'NOKIA5700/ UCWEB7.0.2.37/28/999', 'Openwave/ UCWEB7.0.2.37/28/999', 'Mozilla/4.0 (compatible; MSIE 6.0; ) Opera/UCWEB7.0.2.37/28/999' ] thisua = random.choice(yhdl) #随机获取代理信息 headers = ("User-Agent",thisua) #拼接报头信息 opener = urllib.request.build_opener() #创建请求对象 opener.addheaders=[headers] #添加报头到请求对象 urllib.request.install_opener(opener) #将报头信息设置为全局,urlopen()方法请求时也会自动添加报头 def dai_li_ip(hq_url): #创建ip代理池 url = "http://http-webapi.zhimaruanjian.com/getip?num=1&type=2&pro=&city=0&yys=0&port=11&time=1&ts=0&ys=0&cs=0&lb=1&sb=0&pb=4&mr=1" if url: data = urllib.request.urlopen(url).read().decode("utf-8") data2 = json.loads(data) # 将字符串还原它本来的数据类型 # print(data2['data'][0]) ip = str(data2['data'][0]['ip']) dkou = str(data2['data'][0]['port']) zh_ip = ip + ':' + dkou pat = "(\w*):\w*" rst = re.compile(pat).findall(hq_url) #正则匹配获取是http协议还是https协议 rst2 = rst[0] proxy = urllib.request.ProxyHandler({rst2: zh_ip}) # 格式化IP,注意,第一个参数,请求目标可能是http或者https,对应设置 opener = urllib.request.build_opener(proxy, urllib.request.HTTPHandler) # 初始化IP urllib.request.install_opener(opener) # 将代理IP设置成全局,当使用urlopen()请求时自动使用代理IP else: pass #请求 try: dai_li_ip(hq_url) #执行代理IP函数 yh_dl() #执行用户代理池函数 data = urllib.request.urlopen(hq_url).read().decode("utf-8") return data except urllib.error.URLError as e: # 如果出现错误 if hasattr(e, "code"): # 如果有错误代码 # print(e.code) # 打印错误代码 pass if hasattr(e, "reason"): # 如果有错误信息 # print(e.reason) # 打印错误信息 pass # a = hq_html('http://www.baid.com/') # print(a) 模块使用 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib.request import fzhpach gjc = '广告录音' gjc = urllib.request.quote(gjc) #将关键词转码成浏览器认识的字符,默认网站不能是中文 url = 'https://www.baidu.com/s?wd=%s&pn=0' %(gjc) a = fzhpach.hq_html(url) print(a) 【转载自:http://www.lqkweb.com】
如果爬虫没有异常处理,那么爬行中一旦出现错误,程序将崩溃停止工作,有异常处理即使出现错误也能继续执行下去 1.常见状态码 301:重定向到新的URL,永久性302:重定向到临时URL,非永久性304:请求的资源未更新400:非法请求401:请求未经授权403:禁止访问404:没找到对应页面500:服务器内部出现错误501:服务器不支持实现请求所需要的功能 2.异常处理 URLError捕获异常信息 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib.request import urllib.error try: #尝试执行里面的内容 html = urllib.request.urlopen('http://www.xiaohuar.com/').read().decode("utf-8") print(html) except urllib.error.URLError as e: #如果出现错误 if hasattr(e,"code"): #如果有错误代码 print(e.code) #打印错误代码 if hasattr(e,"reason"): #如果有错误信息 print(e.reason) #打印错误信息 #返回 说明网站禁止了爬虫访问 # 403 # Forbidden 浏览器伪装技术 很多网站,做了反爬技术,一般在后台检测请求头信息里是否有User-Agent浏览器信息,如果没有说明不是浏览器访问,就屏蔽了这次请求 所以,我们需要伪装浏览器报头来请求 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib.request url = 'https://www.qiushibaike.com/' #抓取页面URL tou = ('User-Agent','Mozilla/5.0 (Windows NT 10.0; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0') #设置模拟浏览器报头 b_tou = urllib.request.build_opener() #创建请求对象 b_tou.addheaders=[tou] #添加报头 html = b_tou.open(url).read().decode("utf-8") #开始抓取页面 print(html) 注意:我们可以看到这次请求并不是用urlopen()方法请求的,此时用urlopen()无法请求,但是我们就会感觉到这样很费劲,难道每次请求都要创建build_opener(),所以我们需要设置使用urlopen()方法请求自动报头 设置使用urlopen()方法请求自动报头,也就是设置用户代理 install_opener()将报头信息设置为全局,urlopen()方法请求时也会自动添加报头 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib.request #设置报头信息 tou = ('User-Agent','Mozilla/5.0 (Windows NT 10.0; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0') #设置模拟浏览器报头 b_tou = urllib.request.build_opener() #创建请求对象 b_tou.addheaders=[tou] #添加报头到请求对象 #将报头信息设置为全局,urlopen()方法请求时也会自动添加报头 urllib.request.install_opener(b_tou) #请求 url = 'https://www.qiushibaike.com/' html = urllib.request.urlopen(url).read().decode("utf-8") print(html) 创建用户代理池 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib.request import random #引入随机模块文件 def yh_dl(): #创建用户代理池 yhdl = [ 'Mozilla/5.0 (Windows; U; Windows NT 6.1; en-us) AppleWebKit/534.50 (KHTML, like Gecko) Version/5.1 Safari/534.50', 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0', 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Mozilla/5.0 (Windows NT 6.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1', 'Opera/9.80 (Macintosh; Intel Mac OS X 10.6.8; U; en) Presto/2.8.131 Version/11.11', 'Opera/9.80 (Windows NT 6.1; U; en) Presto/2.8.131 Version/11.11', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon 2.0)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; TencentTraveler 4.0)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; The World)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; 360SE)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser)', 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)', 'Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'User-Agent:Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'Mozilla/5.0 (iPad; U; CPU OS 4_3_3 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5', 'Mozilla/5.0 (Linux; U; Android 2.3.7; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1', 'Opera/9.80 (Android 2.3.4; Linux; Opera Mobi/build-1107180945; U; en-GB) Presto/2.8.149 Version/11.10', 'Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/534.13 (KHTML, like Gecko) Version/4.0 Safari/534.13', 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en) AppleWebKit/534.1+ (KHTML, like Gecko) Version/6.0.0.337 Mobile Safari/534.1+', 'Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; HTC; Titan)', 'UCWEB7.0.2.37/28/999', 'NOKIA5700/ UCWEB7.0.2.37/28/999', 'Openwave/ UCWEB7.0.2.37/28/999', 'Mozilla/4.0 (compatible; MSIE 6.0; ) Opera/UCWEB7.0.2.37/28/999' ] thisua = random.choice(yhdl) #随机获取代理信息 headers = ("User-Agent",thisua) #拼接报头信息 opener = urllib.request.build_opener() #创建请求对象 opener.addheaders=[headers] #添加报头到请求对象 urllib.request.install_opener(opener) #将报头信息设置为全局,urlopen()方法请求时也会自动添加报头 #请求 yh_dl() #执行用户代理池函数 url = 'https://www.qiushibaike.com/' html = urllib.request.urlopen(url).read().decode("utf-8") print(html) 这样爬虫会随机调用,用户代理,也就是随机报头,保证每次报头信息不一样【转载自:http://www.lqkweb.com】
利用python系统自带的urllib库写简单爬虫 urlopen()获取一个URL的html源码read()读出html源码内容decode("utf-8")将字节转化成字符串 #!/usr/bin/env python # -*- coding:utf-8 -*- import urllib.request html = urllib.request.urlopen('http://edu.51cto.com/course/8360.html').read().decode("utf-8") print(html) <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="csrf-param" content="_csrf"> <meta name="csrf-token" content="X1pZZnpKWnQAIGkLFisPFT4jLlJNIWMHHWM6HBBnbiwPbz4/LH1pWQ=="> 正则获取页面指定内容 #!/usr/bin/env python # -*- coding:utf-8 -*- import urllib.request import re html = urllib.request.urlopen('http://edu.51cto.com/course/8360.html').read().decode("utf-8") #获取html源码 pat = "51CTO学院Python实战群\((\d*?)\)" #正则规则,获取到QQ号 rst = re.compile(pat).findall(html) print(rst) #['325935753'] urlretrieve()将网络文件下载保存到本地,参数1网络文件URL,参数2保存路径 #!/usr/bin/env python # -*- coding:utf-8 -*- from urllib import request import re import os file_path = os.path.join(os.getcwd() + '/222.html') #拼接文件保存路径 # print(file_path) request.urlretrieve('http://edu.51cto.com/course/8360.html', file_path) #下载这个文件保存到指定路径 urlcleanup()清除爬虫产生的内存 #!/usr/bin/env python # -*- coding:utf-8 -*- from urllib import request import re import os file_path = os.path.join(os.getcwd() + '/222.html') #拼接文件保存路径 # print(file_path) request.urlretrieve('http://edu.51cto.com/course/8360.html', file_path) #下载这个文件保存到指定路径 request.urlcleanup() info()查看抓取页面的简介 #!/usr/bin/env python # -*- coding:utf-8 -*- import urllib.request import re html = urllib.request.urlopen('http://edu.51cto.com/course/8360.html') #获取html源码 a = html.info() print(a) # C:\Users\admin\AppData\Local\Programs\Python\Python35\python.exe H:/py/15/chshi.py # Date: Tue, 25 Jul 2017 16:08:17 GMT # Content-Type: text/html; charset=UTF-8 # Transfer-Encoding: chunked # Connection: close # Set-Cookie: aliyungf_tc=AQAAALB8CzAikwwA9aReq63oa31pNIez; Path=/; HttpOnly # Server: Tengine # Vary: Accept-Encoding # Vary: Accept-Encoding # Vary: Accept-Encoding getcode()获取状态码 #!/usr/bin/env python # -*- coding:utf-8 -*- import urllib.request import re html = urllib.request.urlopen('http://edu.51cto.com/course/8360.html') #获取html源码 a = html.getcode() #获取状态码 print(a) #200 geturl()获取当前抓取页面的URL #!/usr/bin/env python # -*- coding:utf-8 -*- import urllib.request import re html = urllib.request.urlopen('http://edu.51cto.com/course/8360.html') #获取html源码 a = html.geturl() #获取当前抓取页面的URL print(a) #http://edu.51cto.com/course/8360.html timeout抓取超时设置,单位为秒 是指抓取一个页面时对方服务器响应太慢,或者很久没响应,设置一个超时时间,超过超时时间就不抓取了 #!/usr/bin/env python # -*- coding:utf-8 -*- import urllib.request import re html = urllib.request.urlopen('http://edu.51cto.com/course/8360.html',timeout=30) #获取html源码 a = html.geturl() #获取当前抓取页面的URL print(a) #http://edu.51cto.com/course/8360.html 自动模拟http请求 http请求一般常用的就是get请求和post请求 get请求 比如360搜索,就是通过get请求并且将用户的搜索关键词传入到服务器获取数据的 所以我们可以模拟百度http请求,构造关键词自动请求 quote()将关键词转码成浏览器认识的字符,默认网站不能是中文 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib.request import re gjc = "手机" #设置关键词 gjc = urllib.request.quote(gjc) #将关键词转码成浏览器认识的字符,默认网站不能是中文 url = "https://www.so.com/s?q="+gjc #构造url地址 # print(url) html = urllib.request.urlopen(url).read().decode("utf-8") #获取html源码 pat = "(\w*<em>\w*</em>\w*)" #正则获取相关标题 rst = re.compile(pat).findall(html) # print(rst) for i in rst: print(i) #循环出获取的标题 # 官网 < em > 手机 < / em > # 官网 < em > 手机 < / em > # 官网 < em > 手机 < / em > 这么低的价格 # 大牌 < em > 手机 < / em > 低价抢 # < em > 手机 < / em > # 淘宝网推荐 < em > 手机 < / em > # < em > 手机 < / em > # < em > 手机 < / em > # < em > 手机 < / em > # < em > 手机 < / em > # 苏宁易购买 < em > 手机 < / em > # 买 < em > 手机 < / em > # 买 < em > 手机 < / em > post请求 urlencode()封装post请求提交的表单数据,参数是字典形式的键值对表单数据Request()提交post请求,参数1是url地址,参数2是封装的表单数据 #!/usr/bin/env python # -*- coding: utf-8 -*- import urllib.request import urllib.parse posturl = "http://www.iqianyue.com/mypost/" shuju = urllib.parse.urlencode({ #urlencode()封装post请求提交的表单数据,参数是字典形式的键值对表单数据 'name': '123', 'pass': '456' }).encode('utf-8') req = urllib.request.Request(posturl,shuju) #Request()提交post请求,参数1是url地址,参数2是封装的表单数据 html = urllib.request.urlopen(req).read().decode("utf-8") #获取post请求返回的页面 print(html) 【转载自:http://www.lqkweb.com】
【百度云搜索:http://www.lqkweb.com】【搜网盘:http://www.swpan.cn】 一般抓取过的url不重复抓取,那么就需要记录url,判断当前URL如果在记录里说明已经抓取过了,如果不存在说明没抓取过 记录url可以是缓存,或者数据库,如果保存数据库按照以下方式: id URL加密(建索引以便查询) 原始URL 保存URL表里应该至少有以上3个字段1、URL加密(建索引以便查询)字段:用来查询这样速度快,2、原始URL,用来给加密url做对比,防止加密不同的URL出现同样的加密值 自动递归url # -*- coding: utf-8 -*- import scrapy #导入爬虫模块 from scrapy.selector import HtmlXPathSelector #导入HtmlXPathSelector模块 from scrapy.selector import Selector class AdcSpider(scrapy.Spider): name = 'adc' #设置爬虫名称 allowed_domains = ['hao.360.cn'] start_urls = ['https://hao.360.cn/'] def parse(self, response): #这里做页面的各种获取以及处理 #递归查找url循环执行 hq_url = Selector(response=response).xpath('//a/@href') #查找到当前页面的所有a标签的href,也就是url for url in hq_url: #循环url yield scrapy.Request(url=url, callback=self.parse) #每次循环将url传入Request方法进行继续抓取,callback执行parse回调函数,递归循环 #这样就会递归抓取url并且自动执行了,但是需要在settings.py 配置文件中设置递归深度,DEPTH_LIMIT=3表示递归3层 这样就会递归抓取url并且自动执行了,但是需要在settings.py 配置文件中设置递归深度,DEPTH_LIMIT=3表示递归3层 【转载自:http://www.leiqiankun.com/?id=48】
转载自:https://www.jianshu.com/p/8f22cace85c7 标签选择器对象 HtmlXPathSelector()创建标签选择器对象,参数接收response回调的html对象需要导入模块:from scrapy.selector import HtmlXPathSelector select()标签选择器方法,是HtmlXPathSelector里的一个方法,参数接收选择器规则,返回列表元素是一个标签对象 extract()获取到选择器过滤后的内容,返回列表元素是内容 选择器规则 //x 表示向下查找n层指定标签,如://div 表示查找所有div标签 /x 表示向下查找一层指定的标签 /@x 表示查找指定属性,可以连缀如:@id @src [@class="class名称"] 表示查找指定属性等于指定值的标签,可以连缀 ,查找class名称等于指定名称的标签 /text() 获取标签文本类容 [x] 通过索引获取集合里的指定一个元素 获取指定的标签对象 # -*- coding: utf-8 -*- import scrapy #导入爬虫模块 from scrapy.selector import HtmlXPathSelector #导入HtmlXPathSelector模块 from urllib import request #导入request模块 import os class AdcSpider(scrapy.Spider): name = 'adc' #设置爬虫名称 allowed_domains = ['www.shaimn.com'] start_urls = ['http://www.shaimn.com/xinggan/'] def parse(self, response): hxs = HtmlXPathSelector(response) #创建HtmlXPathSelector对象,将页面返回对象传进去 items = hxs.select('//div[@class="showlist"]/li') #标签选择器,表示获取所有class等于showlist的div,下面的li标签 print(items) #返回标签对象 循环获取到每个li标签里的子标签,以及各种属性或者文本 # -*- coding: utf-8 -*- import scrapy #导入爬虫模块 from scrapy.selector import HtmlXPathSelector #导入HtmlXPathSelector模块 from urllib import request #导入request模块 import os class AdcSpider(scrapy.Spider): name = 'adc' #设置爬虫名称 allowed_domains = ['www.shaimn.com'] start_urls = ['http://www.shaimn.com/xinggan/'] def parse(self, response): hxs = HtmlXPathSelector(response) #创建HtmlXPathSelector对象,将页面返回对象传进去 items = hxs.select('//div[@class="showlist"]/li') #标签选择器,表示获取所有class等于showlist的div,下面的li标签 # print(items) #返回标签对象 for i in range(len(items)): #根据li标签的长度循环次数 title = hxs.select('//div[@class="showlist"]/li[%d]//img/@alt' % i).extract() #根据循环的次数作为下标获取到当前li标签,下的img标签的alt属性内容 src = hxs.select('//div[@class="showlist"]/li[%d]//img/@src' % i).extract() #根据循环的次数作为下标获取到当前li标签,下的img标签的src属性内容 if title and src: print(title,src) #返回类容列表 将获取到的图片下载到本地 urlretrieve()将文件保存到本地,参数1要保存文件的src,参数2保存路径urlretrieve是urllib下request模块的一个方法,需要导入from urllib import request # -*- coding: utf-8 -*- import scrapy #导入爬虫模块 from scrapy.selector import HtmlXPathSelector #导入HtmlXPathSelector模块 from urllib import request #导入request模块 import os class AdcSpider(scrapy.Spider): name = 'adc' #设置爬虫名称 allowed_domains = ['www.shaimn.com'] start_urls = ['http://www.shaimn.com/xinggan/'] def parse(self, response): hxs = HtmlXPathSelector(response) #创建HtmlXPathSelector对象,将页面返回对象传进去 items = hxs.select('//div[@class="showlist"]/li') #标签选择器,表示获取所有class等于showlist的div,下面的li标签 # print(items) #返回标签对象 for i in range(len(items)): #根据li标签的长度循环次数 title = hxs.select('//div[@class="showlist"]/li[%d]//img/@alt' % i).extract() #根据循环的次数作为下标获取到当前li标签,下的img标签的alt属性内容 src = hxs.select('//div[@class="showlist"]/li[%d]//img/@src' % i).extract() #根据循环的次数作为下标获取到当前li标签,下的img标签的src属性内容 if title and src: # print(title[0],src[0]) #通过下标获取到字符串内容 file_path = os.path.join(os.getcwd() + '/img/', title[0] + '.jpg') #拼接图片保存路径 request.urlretrieve(src[0], file_path) #将图片保存到本地,参数1获取到的src,参数2保存路径 xpath()标签选择器,是Selector类里的一个方法,参数是选择规则【推荐】 选择器规则同上 selector()创建选择器类,需要接受html对象需要导入:from scrapy.selector import Selector # -*- coding: utf-8 -*- import scrapy #导入爬虫模块 from scrapy.selector import HtmlXPathSelector #导入HtmlXPathSelector模块 from scrapy.selector import Selector class AdcSpider(scrapy.Spider): name = 'adc' #设置爬虫名称 allowed_domains = ['www.shaimn.com'] start_urls = ['http://www.shaimn.com/xinggan/'] def parse(self, response): items = Selector(response=response).xpath('//div[@class="showlist"]/li').extract() # print(items) #返回标签对象 for i in range(len(items)): title = Selector(response=response).xpath('//div[@class="showlist"]/li[%d]//img/@alt' % i).extract() src = Selector(response=response).xpath('//div[@class="showlist"]/li[%d]//img/@src' % i).extract() print(title,src) 正则表达式的应用 正则表达式是弥补,选择器规则无法满足过滤情况时使用的, 分为两种正则使用方式 1、将选择器规则过滤出来的结果进行正则匹配 2、在选择器规则里应用正则进行过滤 1、将选择器规则过滤出来的结果进行正则匹配,用正则取最终内容 最后.re('正则') # -*- coding: utf-8 -*- import scrapy #导入爬虫模块 from scrapy.selector import HtmlXPathSelector #导入HtmlXPathSelector模块 from scrapy.selector import Selector class AdcSpider(scrapy.Spider): name = 'adc' #设置爬虫名称 allowed_domains = ['www.shaimn.com'] start_urls = ['http://www.shaimn.com/xinggan/'] def parse(self, response): items = Selector(response=response).xpath('//div[@class="showlist"]/li//img')[0].extract() print(items) #返回标签对象 items2 = Selector(response=response).xpath('//div[@class="showlist"]/li//img')[0].re('alt="(\w+)') print(items2) # <img src="http://www.shaimn.com/uploads/170724/1-1FH4221056141.jpg" alt="人体艺术mmSunny前凸后翘性感诱惑写真"> # ['人体艺术mmSunny前凸后翘性感诱惑写真'] 2、在选择器规则里应用正则进行过滤 [re:正则规则] # -*- coding: utf-8 -*- import scrapy #导入爬虫模块 from scrapy.selector import HtmlXPathSelector #导入HtmlXPathSelector模块 from scrapy.selector import Selector class AdcSpider(scrapy.Spider): name = 'adc' #设置爬虫名称 allowed_domains = ['www.shaimn.com'] start_urls = ['http://www.shaimn.com/xinggan/'] def parse(self, response): items = Selector(response=response).xpath('//div').extract() # print(items) #返回标签对象 items2 = Selector(response=response).xpath('//div[re:test(@class, "showlist")]').extract() #正则找到div的class等于showlist的元素 print(items2)
Scrapy是一个为了爬取网站数据,提取结构性数据而编写的应用框架。 其可以应用在数据挖掘,信息处理或存储历史数据等一系列的程序中。其最初是为了页面抓取 (更确切来说, 网络抓取 )所设计的, 也可以应用在获取API所返回的数据(例如 Amazon Associates Web Services ) 或者通用的网络爬虫。Scrapy用途广泛,可以用于数据挖掘、监测和自动化测试。 Scrapy 使用了 Twisted异步网络库来处理网络通讯。整体架构大致如下 Scrapy主要包括了以下组件: 引擎(Scrapy) 用来处理整个系统的数据流处理, 触发事务(框架核心) 调度器(Scheduler) 用来接受引擎发过来的请求, 压入队列中, 并在引擎再次请求的时候返回. 可以想像成一个URL(抓取网页的网址或者说是链接)的优先队列, 由它来决定下一个要抓取的网址是什么, 同时去除重复的网址 下载器(Downloader) 用于下载网页内容, 并将网页内容返回给蜘蛛(Scrapy下载器是建立在twisted这个高效的异步模型上的) 爬虫(Spiders) 爬虫是主要干活的, 用于从特定的网页中提取自己需要的信息, 即所谓的实体(Item)。用户也可以从中提取出链接,让Scrapy继续抓取下一个页面 项目管道(Pipeline) 负责处理爬虫从网页中抽取的实体,主要的功能是持久化实体、验证实体的有效性、清除不需要的信息。当页面被爬虫解析后,将被发送到项目管道,并经过几个特定的次序处理数据。 下载器中间件(Downloader Middlewares) 位于Scrapy引擎和下载器之间的框架,主要是处理Scrapy引擎与下载器之间的请求及响应。 爬虫中间件(Spider Middlewares) 介于Scrapy引擎和爬虫之间的框架,主要工作是处理蜘蛛的响应输入和请求输出。 调度中间件(Scheduler Middewares) 介于Scrapy引擎和调度之间的中间件,从Scrapy引擎发送到调度的请求和响应。 Scrapy运行流程大概如下: 引擎从调度器中取出一个链接(URL)用于接下来的抓取 引擎把URL封装成一个请求(Request)传给下载器 下载器把资源下载下来,并封装成应答包(Response) 爬虫解析Response 解析出实体(Item),则交给实体管道进行进一步的处理 解析出的是链接(URL),则把URL交给调度器等待抓取 创建Scrapy框架项目 Scrapy框架项目是有python安装目录里的Scripts文件夹里scrapy.exe文件创建的,所以python安装目录下的Scripts文件夹要配置到系统环境变量里,才能运行命令生成项目 创建项目 首先运行cmd终端,然后cd 进入要创建项目的目录,如:cd H:py14 进入要创建项目的目录后执行命令 scrapy startproject 项目名称 scrapy startproject pach1 项目创建成功 项目说明 目录结构如下: ├── firstCrawler │ ├── __init__.py │ ├── items.py │ ├── middlewares.py │ ├── pipelines.py │ ├── settings.py │ └── spiders │ └── __init__.py └── scrapy.cfg scrapy.cfg: 项目的配置文件 tems.py: 项目中的item文件,用来定义解析对象对应的属性或字段。 pipelines.py: 负责处理被spider提取出来的item。典型的处理有清理、 验证及持久化(例如存取到数据库) [](http://lib.csdn.net/base/mysql "MySQL知识库") settings.py: 项目的设置文件. spiders:实现自定义爬虫的目录 middlewares.py:Spider中间件是在引擎及Spider之间的特定钩子(specific hook),处理spider的输入(response)和输出(items及requests)。 其提供了一个简便的机制,通过插入自定义代码来扩展Scrapy功能。 创建第一个爬虫 创建爬虫文件在spiders文件夹里创建 1、创建一个类必须继承scrapy.Spider类,类名称自定义 类里的属性和方法: name属性,设置爬虫名称allowed_domains属性,设置爬取的域名,不带httpstart_urls属性,设置爬取的URL,带httpparse()方法,爬取页面后的回调方法,response参数是一个对象,封装了所有的爬取信息 response对象的方法和属性 response.url获取抓取的rulresponse.body获取网页内容字节类型response.body_as_unicode()获取网站内容字符串类型 # -*- coding: utf-8 -*- import scrapy class AdcSpider(scrapy.Spider): name = 'adc' #设置爬虫名称 allowed_domains = ['www.shaimn.com'] start_urls = ['http://www.shaimn.com/xinggan/'] def parse(self, response): current_url = response.url #获取抓取的rul body = response.body #获取网页内容字节类型 unicode_body = response.body_as_unicode() #获取网站内容字符串类型 print(unicode_body) 爬虫写好后执行爬虫,cd到爬虫目录里执行scrapy crawl adc --nolog命令,说明:scrapy crawl adc(adc表示爬虫名称) --nolog(--nolog表示不显示日志)** 也可以在PyCharm执行命令 【转载自:https://www.jianshu.com/u/3fe4aab60ac4】
当前环境python3.5 ,windows10系统 Linux系统安装 在线安装,会自动安装scrapy模块以及相关依赖模块 pip install Scrapy 手动源码安装,比较麻烦要自己手动安装scrapy模块以及依赖模块 安装以下模块 1、lxml-3.8.0.tar.gz (XML处理库) 2、Twisted-17.5.0.tar.bz2 (用Python编写的异步网络框架) 3、Scrapy-1.4.0.tar.gz (高级web爬行和web抓取框架) 4、pyOpenSSL-17.2.0.tar.gz (OpenSSL库) 5、queuelib-1.4.2.tar.gz (Queuelib是用于Python的持久(基于磁盘的)队列的集合) 6、w3lib-1.17.0.tar.gz (与web相关的函数的Python库) 7、cryptography-2.0.tar.gz (密码学是一种包) 8、pyasn1-0.2.3.tar.gz (ASN类型和编解码器) 9、pyasn1-modules-0.0.9.tar.gz (ASN的集合。基于协议模块) 10、cffi-1.10.0.tar.gz (用于Python调用C代码的外部函数接口) 11、asn1crypto-0.22.0.tar.gz (快速的ASN一个解析器和序列化器) 12、idna-2.5.tar.gz (应用程序中的国际化域名(IDNA)) 13、pycparser-2.18.tar.gz (C解析器在Python中) windows系统安装 windows安装,首先要安装pywin32,根据自己的系统来安装32位还是64位 pywin32-221.win32-py3.5.exe pywin32-221.win-amd64-py3.5.exe 在线安装 pip install scrapy 手动源码安装,比较麻烦要自己手动安装scrapy模块以及依赖模块 安装以下模块 1、lxml-3.8.0.tar.gz (XML处理库) 2、Twisted-17.5.0.tar.bz2 (用Python编写的异步网络框架) 3、Scrapy-1.4.0.tar.gz (高级web爬行和web抓取框架) 4、pyOpenSSL-17.2.0.tar.gz (OpenSSL库) 5、queuelib-1.4.2.tar.gz (Queuelib是用于Python的持久(基于磁盘的)队列的集合) 6、w3lib-1.17.0.tar.gz (与web相关的函数的Python库) 7、cryptography-2.0.tar.gz (密码学是一种包) 8、pyasn1-0.2.3.tar.gz (ASN类型和编解码器) 9、pyasn1-modules-0.0.9.tar.gz (ASN的集合。基于协议模块) 10、cffi-1.10.0.tar.gz (用于Python调用C代码的外部函数接口) 11、asn1crypto-0.22.0.tar.gz (快速的ASN一个解析器和序列化器) 12、idna-2.5.tar.gz (应用程序中的国际化域名(IDNA)) 13、pycparser-2.18.tar.gz (C解析器在Python中) 测试是否安装成功 在cmd终端,运行python 然后运行:import scrapy ,没有提示错误说明安装成功 【转载自:https://www.jianshu.com/u/3fe4aab60ac4】
【转载自:https://www.jianshu.com/u/3fe4aab60ac4】requests请求,就是用python的requests模块模拟浏览器请求,返回html源码 模拟浏览器请求有两种,一种是不需要用户登录或者验证的请求,一种是需要用户登录或者验证的请求 一、不需要用户登录或者验证的请求 这种比较简单,直接利用requests模块发一个请求即可拿到html源码 #!/usr/bin/env python # -*- coding:utf8 -*- import requests #导入模拟浏览器请求模块 http =requests.get(url="http://www.iqiyi.com/") #发送http请求 http.encoding = "utf-8" #http请求编码 neir = http.text #获取http字符串代码 print(neir) 得到html源码 <!DOCTYPE html> <html> <head> <title>抽屉新热榜-聚合每日热门、搞笑、有趣资讯</title> <meta charset="utf-8" /> <meta name="keywords" content="抽屉新热榜,资讯,段子,图片,公众场合不宜,科技,新闻,节操,搞笑" /> <meta name="description" content=" 抽屉新热榜,汇聚每日搞笑段子、热门图片、有趣新闻。它将微博、门户、社区、bbs、社交网站等海量内容聚合在一起,通过用户推荐生成最热榜单。看抽屉新热榜,每日热门、有趣资讯尽收眼底。 " /> <meta name="robots" content="index,follow" /> <meta name="GOOGLEBOT" content="index,follow" /> <meta name="Author" content="搞笑" /> <meta http-equiv="X-UA-Compatible" content="IE=EmulateIE8"> <link type="image/x-icon" href="/images/chouti.ico" rel="icon"/> <link type="image/x-icon" href="/images/chouti.ico" rel="Shortcut Icon"/> <link type="image/x-icon" href="/images/chouti.ico" rel="bookmark"/> <link type="application/opensearchdescription+xml" href="opensearch.xml" title="抽屉新热榜" rel="search" /> 二、需要用户登录或者验证的请求 获取这种页面时,我们首先要了解整个登录过程,一般登录过程是,当用户第一次访问时,会自动在浏览器生成cookie文件,当用户输入登录信息后会携带着生成的cookie文件,如果登录信息正确会给这个cookie 授权,授权后以后访问需要登录的页面时携带授权后cookie即可 1、首先访问一下首页,然后查看是否有自动生成cookie #!/usr/bin/env python # -*- coding:utf8 -*- import requests #导入模拟浏览器请求模块 ### 1、在没登录之前访问一下首页,获取cookie i1 = requests.get( url="http://dig.chouti.com/", headers={'Referer': 'http://dig.chouti.com/'} ) i1.encoding = "utf-8" #http请求编码 i1_cookie = i1.cookies.get_dict() print(i1_cookie) #返回获取到的cookie #返回:{'JSESSIONID': 'aaaTztKP-KaGLbX-T6R0v', 'gpsd': 'c227f059746c839a28ab136060fe6ebe', 'route': 'f8b4f4a95eeeb2efcff5fd5e417b8319'} 可以看到生成了cookie,说明如果登陆信息正确,后台会给这里的cookie授权,以后访问需要登录的页面携带授权后的cookie即可 2、让程序自动去登录授权cookie 首先我们用浏览器访问登录页面,随便乱输入一下登录密码和账号,获取登录页面url,和登录所需要的字段 携带cookie登录授权 #!/usr/bin/env python # -*- coding:utf8 -*- import requests #导入模拟浏览器请求模块 ### 1、在没登录之前访问一下首页,获取cookie i1 = requests.get( url="http://dig.chouti.com/", headers={'Referer':'http://dig.chouti.com/'} ) i1.encoding = "utf-8" #http请求编码 i1_cookie = i1.cookies.get_dict() print(i1_cookie) #返回获取到的cookie #返回:{'JSESSIONID': 'aaaTztKP-KaGLbX-T6R0v', 'gpsd': 'c227f059746c839a28ab136060fe6ebe', 'route': 'f8b4f4a95eeeb2efcff5fd5e417b8319'} ### 2、用户登陆,携带上一次的cookie,后台对cookie中的随机字符进行授权 i2 = requests.post( url="http://dig.chouti.com/login", #登录url data={ #登录字段 'phone': "8615284816568", 'password': "279819", 'oneMonth': "" }, headers={'Referer':'http://dig.chouti.com/'}, cookies=i1_cookie #携带cookie ) i2.encoding = "utf-8" dluxxi = i2.text print(dluxxi) #查看登录后服务器的响应 #返回:{"result":{"code":"9999", "message":"", "data":{"complateReg":"0","destJid":"cdu_50072007463"}}} 登录成功 3、登录成功后,说明后台已经给cookie授权,这样我们访问需要登录的页面时,携带这个cookie即可,比如获取个人中心 #!/usr/bin/env python # -*- coding:utf8 -*- import requests #导入模拟浏览器请求模块 ### 1、在没登录之前访问一下首页,获取cookie i1 = requests.get( url="http://dig.chouti.com/", headers={'Referer':'http://dig.chouti.com/'} ) i1.encoding = "utf-8" #http请求编码 i1_cookie = i1.cookies.get_dict() print(i1_cookie) #返回获取到的cookie #返回:{'JSESSIONID': 'aaaTztKP-KaGLbX-T6R0v', 'gpsd': 'c227f059746c839a28ab136060fe6ebe', 'route': 'f8b4f4a95eeeb2efcff5fd5e417b8319'} ### 2、用户登陆,携带上一次的cookie,后台对cookie中的随机字符进行授权 i2 = requests.post( url="http://dig.chouti.com/login", #登录url data={ #登录字段 'phone': "8615284816568", 'password': "279819", 'oneMonth': "" }, headers={'Referer':'http://dig.chouti.com/'}, cookies=i1_cookie #携带cookie ) i2.encoding = "utf-8" dluxxi = i2.text print(dluxxi) #查看登录后服务器的响应 #返回:{"result":{"code":"9999", "message":"", "data":{"complateReg":"0","destJid":"cdu_50072007463"}}} 登录成功 ### 3、访问需要登录才能查看的页面,携带着授权后的cookie访问 shouquan_cookie = i1_cookie i3 = requests.get( url="http://dig.chouti.com/user/link/saved/1", headers={'Referer':'http://dig.chouti.com/'}, cookies=shouquan_cookie #携带着授权后的cookie访问 ) i3.encoding = "utf-8" print(i3.text) #查看需要登录才能查看的页面 获取需要登录页面的html源码成功 全部代码 get()方法,发送get请求encoding属性,设置请求编码cookies.get_dict()获取cookiespost()发送post请求text获取服务器响应信息 #!/usr/bin/env python # -*- coding:utf8 -*- import requests #导入模拟浏览器请求模块 ### 1、在没登录之前访问一下首页,获取cookie i1 = requests.get( url="http://dig.chouti.com/", headers={'Referer':'http://dig.chouti.com/'} ) i1.encoding = "utf-8" #http请求编码 i1_cookie = i1.cookies.get_dict() print(i1_cookie) #返回获取到的cookie #返回:{'JSESSIONID': 'aaaTztKP-KaGLbX-T6R0v', 'gpsd': 'c227f059746c839a28ab136060fe6ebe', 'route': 'f8b4f4a95eeeb2efcff5fd5e417b8319'} ### 2、用户登陆,携带上一次的cookie,后台对cookie中的随机字符进行授权 i2 = requests.post( url="http://dig.chouti.com/login", #登录url data={ #登录字段 'phone': "8615284816568", 'password': "279819", 'oneMonth': "" }, headers={'Referer':'http://dig.chouti.com/'}, cookies=i1_cookie #携带cookie ) i2.encoding = "utf-8" dluxxi = i2.text print(dluxxi) #查看登录后服务器的响应 #返回:{"result":{"code":"9999", "message":"", "data":{"complateReg":"0","destJid":"cdu_50072007463"}}} 登录成功 ### 3、访问需要登录才能查看的页面,携带着授权后的cookie访问 shouquan_cookie = i1_cookie i3 = requests.get( url="http://dig.chouti.com/user/link/saved/1", headers={'Referer':'http://dig.chouti.com/'}, cookies=shouquan_cookie #携带着授权后的cookie访问 ) i3.encoding = "utf-8" print(i3.text) #查看需要登录才能查看的页面 注意:如果登录需要验证码,那就需要做图像处理,根据验证码图片,识别出验证码,将验证码写入登录字段
本文转载自:https://www.jianshu.com/p/6088c36f2c88 我为此应用程序构建的所有功能都只适用于特定类型的客户端:Web浏览器。 但其他类型的客户端呢? 例如,如果我想构建Android或iOS APP,有两种主流方法可以解决这个问题。 最简单的解决方案是构建一个简单的APP,仅使用一个Web视图组件并用Microblog网站填充整个屏幕,但相比在设备的Web浏览器中打开网站,这种方案几乎没有什么卖点。 一个更好的解决方案(尽管更费力)将是构建一个本地APP,但这个APP如何与仅返回HTML页面的服务器交互呢? 这就是应用程序编程接口(API)的能力范畴了。 API是一组HTTP路由,被设计为应用程序中的低级入口点。与定义返回HTML以供Web浏览器使用的路由和视图函数不同,API允许客户端直接使用应用程序的资源,从而决定如何通过客户端完全地向用户呈现信息。 例如,Microblog中的API可以向用户提供用户信息和用户动态,并且它还可以允许用户编辑现有动态,但仅限于数据级别,不会将此逻辑与HTML混合。 如果你研究了应用程序中当前定义的所有路由,会注意到其中的几个符合我上面使用的API的定义。 找到它们了吗? 我说的是返回JSON的几条路由,比如第十四章中定义的/translate路由。 这种路由的内容都以JSON格式编码,并在请求时使用POST方法。 此请求的响应也是JSON格式,服务器仅返回所请求的信息,客户端负责将此信息呈现给用户。 虽然应用程序中的JSON路由具有API的“感觉”,但它们的设计初衷是为支持在浏览器中运行的Web应用程序。 设想一下,如果智能手机APP想要使用这些路由,它将无法使用,因为这需要用户登录,而登录只能通过HTML表单进行。 在本章中,我将展示如何构建不依赖于Web浏览器的API,并且不会假设连接到它们的客户端的类型。 本章的GitHub链接为:Browse, Zip, Diff. REST API设计风格 REST as a Foundation of API Design 有些人可能会强烈反对上面提到的/translate和其他JSON路由是API路由。 其他人可能会同意,但也会认为它们是一个设计糟糕的API。 那么一个精心设计的API有什么特点,为什么上面的JSON路由不是一个好的API路由呢? 你可能听说过REST API。 REST(Representational State Transfer)是Roy Fielding在博士论文中提出的一种架构。 该架构中,Dr. Fielding以相当抽象和通用的方式展示了REST的六个定义特征。 除了Dr.Fielding的论文外,没有关于REST的权威性规范,从而留下了许多细节供读者解读。 一个给定的API是否符合REST规范的话题往往是REST“纯粹主义者”之间激烈争论的源头,REST“纯粹主义者”认为REST API必须以非常明确的方式遵循全部六个特征,而不像REST“实用主义者”那样,仅仅将Dr. Fielding在论文中提出的想法作为指导原则或建议。Dr.Fielding站在纯粹主义阵营的一边,并在博客文章和在线评论中的撰写了一些额外的见解来表达他的愿景。 目前实施的绝大多数API都遵循“实用主义”的REST实现。 包括来自Facebook,GitHub,Twitter等“大玩家”的大部分API都是如此。很少有公共API被一致认为是纯REST,因为大多数API都没有包含纯粹主义者认为必须实现的某些细节。 尽管Dr. Fielding和其他REST纯粹主义者对评判一个API是否是REST API有严格的规定,但软件行业在实际运用中引用REST是很常见的。 为了让你了解REST论文中的内容,以下各节将介绍Dr. Fielding列举的六项原则。 客户端-服务器 客户端-服务器原则相当简单,正如其字面含义,在REST API中,客户端和服务器的角色应该明确区分。 在实践中,这意味着客户端和服务器都是单独的进程,并在大多数情况下,使用基于TCP网络上的HTTP协议进行通信。 分层系统 分层系统原则是说当客户端需要与服务器通信时,它可能最终连接到代理服务器而不是实际的服务器。 因此,对于客户端来说,如果不直接连接到服务器,它发送请求的方式应该没有什么区别,事实上,它甚至可能不知道它是否连接到目标服务器。 同样,这个原则规定服务器兼容直接接收来自代理服务器的请求,所以它绝不能假设连接的另一端一定是客户端。 这是REST的一个重要特性,因为能够添加中间节点的这个特性,允许应用程序架构师使用负载均衡器,缓存,代理服务器等来设计满足大量请求的大型复杂网络。 缓存 该原则扩展了分层系统,通过明确指出允许服务器或代理服务器缓存频繁且相同请求的响应内容以提高系统性能。 有一个你可能熟悉的缓存实现:所有Web浏览器中的缓存。 Web浏览器缓存层通常用于避免一遍又一遍地请求相同的文件,例如图像。 为了达到API的目的,目标服务器需要通过使用缓存控制来指示响应是否可以在代理服务器传回客户端时进行缓存。 请注意,由于安全原因,部署到生产环境的API必须使用加密,因此,除非此代理服务器terminates SSL连接,或者执行解密和重新加密,否则缓存通常不会在代理服务器中完成。 按需获取客户端代码(Code On Demand) 这是一项可选要求,规定服务器可以提供可执行代码以响应客户端,这样一来,就可以从服务器上获取客户端的新功能。 因为这个原则需要服务器和客户端之间就客户端能够运行的可执行代码类型达成一致,所以这在API中很少使用。 你可能会认为服务器可能会返回JavaScript代码以供Web浏览器客户端执行,但REST并非专门针对Web浏览器客户端而设计。 例如,如果客户端是iOS或Android设备,执行JavaScript可能会带来一些复杂情况。 无状态 无状态原则是REST纯粹主义者和实用主义者之间争论最多的两个中心之一。 它指出,REST API不应保存客户端发送请求时的任何状态。 这意味着,在Web开发中常见的机制都不能在用户浏览应用程序页面时“记住”用户。 在无状态API中,每个请求都需要包含服务器需要识别和验证客户端并执行请求的信息。这也意味着服务器无法在数据库或其他存储形式中存储与客户端连接有关的任何数据。 如果你想知道为什么REST需要无状态服务器,主要原因是无状态服务器非常容易扩展,你只需在负载均衡器后面运行多个服务器实例即可。 如果服务器存储客户端状态,则事情会变得更复杂,因为你必须弄清楚多个服务器如何访问和更新该状态,或者确保给定客户端始终由同一服务器处理,这样的机制通常称为粘性会话。 再思考一下本章介绍中讨论的/translate路由,就会发现它不能被视为RESTful,因为与该路由相关的视图函数依赖于Flask-Login的@login_required装饰器, 这会将用户的登录状态存储在Flask用户会话中。 统一接口 最后,最重要的,最有争议的,最含糊不清的REST原则是统一接口。 Dr. Fielding列举了REST统一接口的四个特性:唯一资源标识符,资源表示,自描述性消息和超媒体。 唯一资源标识符是通过为每个资源分配唯一的URL来实现的。 例如,与给定用户关联的URL可以是/api/users/,其中是在数据库表主键中分配给用户的标识符。 大多数API都能很好地实现这一点。 资源表示的使用意味着当服务器和客户端交换关于资源的信息时,他们必须使用商定的格式。 对于大多数现代API,JSON格式用于构建资源表示。 API可以选择支持多种资源表示格式,并且在这种情况下,HTTP协议中的内容协商选项是客户端和服务器确认格式的机制。 自描述性消息意味着在客户端和服务器之间交换的请求和响应必须包含对方需要的所有信息。 作为一个典型的例子,HTTP请求方法用于指示客户端希望服务器执行的操作。 GET请求表示客户想要检索资源信息,POST请求表示客户想要创建新资源,PUT或PATCH请求定义对现有资源的修改,DELETE表示删除资源的请求。 目标资源被指定为请求的URL,并在HTTP头,URL的查询字符串部分或请求主体中提供附加信息。 超媒体需求是最具争议性的,而且很少有API实现,而那些实现它的API很少以满足REST纯粹主义者的方式进行。由于应用程序中的资源都是相互关联的,因此此要求会要求将这些关系包含在资源表示中,以便客户端可以通过遍历关系来发现新资源,这几乎与你在Web应用程序中通过点击从一个页面到另一个页面的链接来发现新页面的方式相同。理想情况下,客户端可以输入一个API,而不需要任何有关其中的资源的信息,就可以简单地通过超媒体链接来了解它们。但是,与HTML和XML不同,通常用于API中资源表示的JSON格式没有定义包含链接的标准方式,因此你不得不使用自定义结构,或者类似JSON-API,HAL, JSON-LD这样的试图解决这种差距的JSON扩展之一。 实现API Blueprint 为了让你体验开发API所涉及的内容,我将在Microblog添加API。 我不会实现所有的API,只会实现与用户相关的所有功能,并将其他资源(如用户动态)的实现留给读者作为练习。 为了保持组织有序,并遵循我在第十五章中描述的结构, 我将创建一个包含所有API路由的新blueprint。 所以,让我们从创建blueprint所在的目录开始: (venv) $ mkdir app/api 在blueprint的__init__.py文件中创建blueprint对象,这与应用程序中的其他blueprint类似: app/api/__init__.py: API blueprint 构造器。 from flask import Blueprint bp = Blueprint('api', __name__) from app.api import users, errors, tokens 你可能会记得有时需要将导入移动到底部以避免循环依赖错误。 这就是为什么app/api/users.py,app/api/errors.py和app/api/tokens.py模块(我还没有写)在blueprint创建之后导入的原因。 API的主要内容将存储在app/api/users.py模块中。 下表总结了我要实现的路由: HTTP 方法 资源 URL 注释 GET /api/users/ 返回一个用户 GET /api/users 返回所有用户的集合 GET /api/users//followers 返回某个用户的粉丝集合 GET /api/users//followed 返回某个用户关注的用户集合 POST /api/users 注册一个新用户 PUT /api/users/ 修改某个用户 现在我要创建一个模块的框架,其中使用占位符来暂时填充所有的路由: app/api/users.py:用户API资源占位符。 from app.api import bp @bp.route('/users/<int:id>', methods=['GET']) def get_user(id): pass @bp.route('/users', methods=['GET']) def get_users(): pass @bp.route('/users/<int:id>/followers', methods=['GET']) def get_followers(id): pass @bp.route('/users/<int:id>/followed', methods=['GET']) def get_followed(id): pass @bp.route('/users', methods=['POST']) def create_user(): pass @bp.route('/users/<int:id>', methods=['PUT']) def update_user(id): pass app/api/errors.py模块将定义一些处理错误响应的辅助函数。 但现在,我使用占位符,并将在之后填充内容: app/api/errors.py:错误处理占位符。 def bad_request(): pass app/api/tokens.py是将要定义认证子系统的模块。 它将为非Web浏览器登录的客户端提供另一种方式。现在,我也使用占位符来处理该模块: app/api/tokens.py: Token处理占位符。 def get_token(): pass def revoke_token(): pass 新的API blueprint需要在应用工厂函数中注册: app/__init__.py:应用中注册API blueprint。 # ... def create_app(config_class=Config): app = Flask(__name__) # ... from app.api import bp as api_bp app.register_blueprint(api_bp, url_prefix='/api') # ... 将用户表示为JSON对象 实施API时要考虑的第一个方面是决定其资源表示形式。 我要实现一个用户类型的API,因此我需要决定的是用户资源的表示形式。 经过一番头脑风暴,得出了以下JSON表示形式: { "id": 123, "username": "susan", "password": "my-password", "email": "susan@example.com", "last_seen": "2017-10-20T15:04:27Z", "about_me": "Hello, my name is Susan!", "post_count": 7, "follower_count": 35, "followed_count": 21, "_links": { "self": "/api/users/123", "followers": "/api/users/123/followers", "followed": "/api/users/123/followed", "avatar": "https://www.gravatar.com/avatar/..." } } 许多字段直接来自用户数据库模型。 password字段的特殊之处在于,它仅在注册新用户时才会使用。 回顾第五章,用户密码不存储在数据库中,只存储一个散列字符串,所以密码永远不会被返回。email字段也被专门处理,因为我不想公开用户的电子邮件地址。 只有当用户请求自己的条目时,才会返回email字段,但是当他们检索其他用户的条目时不会返回。post_count,follower_count和followed_count字段是“虚拟”字段,它们在数据库字段中不存在,提供给客户端是为了方便。 这是一个很好的例子,它演示了资源表示不需要和服务器中资源的实际定义一致。 请注意_links部分,它实现了超媒体要求。 定义的链接包括指向当前资源的链接,用户的粉丝列表链接,用户关注的用户列表链接,最后是指向用户头像图像的链接。 将来,如果我决定向这个API添加用户动态,那么用户的动态列表链接也应包含在这里。 JSON格式的一个好处是,它总是转换为Python字典或列表的表示形式。 Python标准库中的json包负责Python数据结构和JSON之间的转换。因此,为了生成这些表示,我将在User模型中添加一个名为to_dict()的方法,该方法返回一个Python字典: app/models.py:User模型转换成表示。 from flask import url_for # ... class User(UserMixin, db.Model): # ... def to_dict(self, include_email=False): data = { 'id': self.id, 'username': self.username, 'last_seen': self.last_seen.isoformat() + 'Z', 'about_me': self.about_me, 'post_count': self.posts.count(), 'follower_count': self.followers.count(), 'followed_count': self.followed.count(), '_links': { 'self': url_for('api.get_user', id=self.id), 'followers': url_for('api.get_followers', id=self.id), 'followed': url_for('api.get_followed', id=self.id), 'avatar': self.avatar(128) } } if include_email: data['email'] = self.email return data 该方法一目了然,只是简单地生成并返回用户表示的字典。正如我上面提到的那样,email字段需要特殊处理,因为我只想在用户请求自己的数据时才包含电子邮件。 所以我使用include_email标志来确定该字段是否包含在表示中。 注意一下last_seen字段的生成。 对于日期和时间字段,我将使用ISO 8601格式,Python的datetime对象可以通过isoformat()方法生成这样格式的字符串。 但是因为我使用的datetime对象的时区是UTC,且但没有在其状态中记录时区,所以我需要在末尾添加Z,即ISO 8601的UTC时区代码。 最后,看看我如何实现超媒体链接。 对于指向应用其他路由的三个链接,我使用url_for()生成URL(目前指向我在app/api/users.py中定义的占位符视图函数)。 头像链接是特殊的,因为它是应用外部的Gravatar URL。 对于这个链接,我使用了与渲染网页中的头像的相同avatar()方法。 to_dict()方法将用户对象转换为Python表示,以后会被转换为JSON。 我还需要其反向处理的方法,即客户端在请求中传递用户表示,服务器需要解析并将其转换为User对象。 以下是实现从Python字典到User对象转换的from_dict()方法: app/models.py:表示转换成User模型。 class User(UserMixin, db.Model): # ... def from_dict(self, data, new_user=False): for field in ['username', 'email', 'about_me']: if field in data: setattr(self, field, data[field]) if new_user and 'password' in data: self.set_password(data['password']) 本处我决定使用循环来导入客户端可以设置的任何字段,即username,email和about_me。 对于每个字段,我检查它是否存在于data参数中,如果存在,我使用Python的setattr()在对象的相应属性中设置新值。 password字段被视为特例,因为它不是对象中的字段。 new_user参数确定了这是否是新的用户注册,这意味着data中包含password。 要在用户模型中设置密码,需要调用set_password()方法来创建密码哈希。 表示用户集合 除了使用单个资源表示形式外,此API还需要一组用户的表示。 例如客户请求用户或粉丝列表时使用的格式。 以下是一组用户的表示: { "items": [ { ... user resource ... }, { ... user resource ... }, ... ], "_meta": { "page": 1, "per_page": 10, "total_pages": 20, "total_items": 195 }, "_links": { "self": "http://localhost:5000/api/users?page=1", "next": "http://localhost:5000/api/users?page=2", "prev": null } } 在这个表示中,items是用户资源的列表,每个用户资源的定义如前一节所述。 _meta部分包含集合的元数据,客户端在向用户渲染分页控件时就会用得上。 _links部分定义了相关链接,包括集合本身的链接以及上一页和下一页链接,也能帮助客户端对列表进行分页。 由于分页逻辑,生成用户集合的表示很棘手,但是该逻辑对于我将来可能要添加到此API的其他资源来说是一致的,所以我将以通用的方式实现它,以便适用于其他模型。 可以回顾第十六章,就会发现我目前的情况与全文索引类似,都是实现一个功能,还要让它可以应用于任何模型。 对于全文索引,我使用的解决方案是实现一个SearchableMixin类,任何需要全文索引的模型都可以从中继承。 我会故技重施,实现一个新的mixin类,我命名为PaginatedAPIMixin: app/models.py:分页表示mixin类。 class PaginatedAPIMixin(object): @staticmethod def to_collection_dict(query, page, per_page, endpoint, **kwargs): resources = query.paginate(page, per_page, False) data = { 'items': [item.to_dict() for item in resources.items], '_meta': { 'page': page, 'per_page': per_page, 'total_pages': resources.pages, 'total_items': resources.total }, '_links': { 'self': url_for(endpoint, page=page, per_page=per_page, **kwargs), 'next': url_for(endpoint, page=page + 1, per_page=per_page, **kwargs) if resources.has_next else None, 'prev': url_for(endpoint, page=page - 1, per_page=per_page, **kwargs) if resources.has_prev else None } } return data to_collection_dict()方法产生一个带有用户集合表示的字典,包括items,_meta和_links部分。 你可能需要仔细检查该方法以了解其工作原理。 前三个参数是Flask-SQLAlchemy查询对象,页码和每页数据数量。 这些是决定要返回的条目是什么的参数。 该实现使用查询对象的paginate()方法来获取该页的条目,就像我对主页,发现页和个人主页中的用户动态所做的一样。 复杂的部分是生成链接,其中包括自引用以及指向下一页和上一页的链接。 我想让这个函数具有通用性,所以我不能使用类似url_for('api.get_users', id=id, page=page)这样的代码来生成自链接(译者注:因为这样就固定成用户资源专用了)。 url_for()的参数将取决于特定的资源集合,所以我将依赖于调用者在endpoint参数中传递的值,来确定需要发送到url_for()的视图函数。 由于许多路由都需要参数,我还需要在kwargs中捕获更多关键字参数,并将它们传递给url_for()。 page和per_page查询字符串参数是明确给出的,因为它们控制所有API路由的分页。 这个mixin类需要作为父类添加到User模型中: app/models.py:添加PaginatedAPIMixin到User模型中。 class User(PaginatedAPIMixin, UserMixin, db.Model): # ... 将集合转换成json表示,不需要反向操作,因为我不需要客户端发送用户列表到服务器。 错误处理 我在第七章中定义的错误页面仅适用于使用Web浏览器的用户。当一个API需要返回一个错误时,它需要是一个“机器友好”的错误类型,以便客户端可以轻松解释这些错误。 因此,我同样设计错误的表示为一个JSON。 以下是我要使用的基本结构: { "error": "short error description", "message": "error message (optional)" } 除了错误的有效载荷之外,我还会使用HTTP协议的状态代码来指示常见错误的类型。 为了帮助我生成这些错误响应,我将在app/api/errors.py中写入error_response()函数: app/api/errors.py:错误响应。 from flask import jsonify from werkzeug.http import HTTP_STATUS_CODES def error_response(status_code, message=None): payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')} if message: payload['message'] = message response = jsonify(payload) response.status_code = status_code return response 该函数使用来自Werkzeug(Flask的核心依赖项)的HTTP_STATUS_CODES字典,它为每个HTTP状态代码提供一个简短的描述性名称。 我在错误表示中使用这些名称作为error字段的值,所以我只需要操心数字状态码和可选的长描述。 jsonify()函数返回一个默认状态码为200的FlaskResponse对象,因此在创建响应之后,我将状态码设置为对应的错误代码。 API将返回的最常见错误将是代码400,代表了“错误的请求”。 这是客户端发送请求中包含无效数据的错误。 为了更容易产生这个错误,我将为它添加一个专用函数,只需传入长的描述性消息作为参数就可以调用。 下面是我之前添加的bad_request()占位符: app/api/errors.py:错误请求的响应。 # ... def bad_request(message): return error_response(400, message) 用户资源Endpoint 必需的用户JSON表示的支持已完成,因此我已准备好开始对API endpoint进行编码了。 检索单个用户 让我们就从使用给定的id来检索指定用户开始吧: app/api/users.py:返回一个用户。 from flask import jsonify from app.models import User @bp.route('/users/<int:id>', methods=['GET']) def get_user(id): return jsonify(User.query.get_or_404(id).to_dict()) 视图函数接收被请求用户的id作为URL中的动态参数。 查询对象的get_or_404()方法是以前见过的get()方法的一个非常有用的变体,如果用户存在,它返回给定id的对象,当id不存在时,它会中止请求并向客户端返回一个404错误,而不是返回None。 get_or_404()比get()更有优势,它不需要检查查询结果,简化了视图函数中的逻辑。 我添加到User的to_dict()方法用于生成用户资源表示的字典,然后Flask的jsonify()函数将该字典转换为JSON格式的响应以返回给客户端。 如果你想查看第一条API路由的工作原理,请启动服务器,然后在浏览器的地址栏中输入以下URL: http://localhost:5000/api/users/1 浏览器会以JSON格式显示第一个用户。 也尝试使用大一些的id值来查看SQLAlchemy查询对象的get_or_404()方法如何触发404错误(我将在稍后向你演示如何扩展错误处理,以便返回这些错误 JSON格式)。 为了测试这条新路由,我将安装HTTPie,这是一个用Python编写的命令行HTTP客户端,可以轻松发送API请求: (venv) $ pip install httpie 我现在可以请求id为1的用户(可能是你自己),命令如下: (venv) $ http GET http://localhost:5000/api/users/1 HTTP/1.0 200 OK Content-Length: 457 Content-Type: application/json Date: Mon, 27 Nov 2017 20:19:01 GMT Server: Werkzeug/0.12.2 Python/3.6.3 { "_links": { "avatar": "https://www.gravatar.com/avatar/993c...2724?d=identicon&s=128", "followed": "/api/users/1/followed", "followers": "/api/users/1/followers", "self": "/api/users/1" }, "about_me": "Hello! I'm the author of the Flask Mega-Tutorial.", "followed_count": 0, "follower_count": 1, "id": 1, "last_seen": "2017-11-26T07:40:52.942865Z", "post_count": 10, "username": "miguel" } 检索用户集合 要返回所有用户的集合,我现在可以依靠PaginatedAPIMixin的to_collection_dict()方法: app/api/users.py:返回所有用户的集合。 from flask import request @bp.route('/users', methods=['GET']) def get_users(): page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 10, type=int), 100) data = User.to_collection_dict(User.query, page, per_page, 'api.get_users') return jsonify(data) 对于这个实现,我首先从请求的查询字符串中提取page和per_page,如果它们没有被定义,则分别使用默认值1和10。 per_page具有额外的逻辑,以100为上限。 给客户端控件请求太大的页面并不是一个好主意,因为这可能会导致服务器的性能问题。 然后page和per_page以及query对象(在本例中,该查询只是User.query,是返回所有用户的最通用的查询)参数被传递给to_collection_query()方法。 最后一个参数是api.get_users,这是我在表示中使用的三个链接所需的endpoint名称。 要使用HTTPie测试此endpoint,请使用以下命令: (venv) $ http GET http://localhost:5000/api/users 接下来的两个endpoint是返回粉丝集合和关注用户集合。 与上面的非常相似: app/api/users.py:返回粉丝列表和关注用户列表。 @bp.route('/users/<int:id>/followers', methods=['GET']) def get_followers(id): user = User.query.get_or_404(id) page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 10, type=int), 100) data = User.to_collection_dict(user.followers, page, per_page, 'api.get_followers', id=id) return jsonify(data) @bp.route('/users/<int:id>/followed', methods=['GET']) def get_followed(id): user = User.query.get_or_404(id) page = request.args.get('page', 1, type=int) per_page = min(request.args.get('per_page', 10, type=int), 100) data = User.to_collection_dict(user.followed, page, per_page, 'api.get_followed', id=id) return jsonify(data) 由于这两条路由是特定于用户的,因此它们具有id动态参数。 id用于从数据库中获取用户,然后将user.followers和user.followed关系查询提供给to_collection_dict(),所以希望现在你可以看到,花费一点点额外的时间,并以通用的方式设计该方法,对于获得的回报而言是值得的。 to_collection_dict()的最后两个参数是endpoint名称和id,id将在kwargs中作为一个额外关键字参数,然后在生成链接时将它传递给url_for() 。 和前面的示例类似,你可以使用HTTPie来测试这两个路由,如下所示: (venv) $ http GET http://localhost:5000/api/users/1/followers (venv) $ http GET http://localhost:5000/api/users/1/followed 由于超媒体,你不需要记住这些URL,因为它们包含在用户表示的_links部分。 注册新用户 /users路由的POST请求将用于注册新的用户帐户。 你可以在下面看到这条路由的实现: app/api/users.py:注册新用户。 from flask import url_for from app import db from app.api.errors import bad_request @bp.route('/users', methods=['POST']) def create_user(): data = request.get_json() or {} if 'username' not in data or 'email' not in data or 'password' not in data: return bad_request('must include username, email and password fields') if User.query.filter_by(username=data['username']).first(): return bad_request('please use a different username') if User.query.filter_by(email=data['email']).first(): return bad_request('please use a different email address') user = User() user.from_dict(data, new_user=True) db.session.add(user) db.session.commit() response = jsonify(user.to_dict()) response.status_code = 201 response.headers['Location'] = url_for('api.get_user', id=user.id) return response 该请求将接受请求主体中提供的来自客户端的JSON格式的用户表示。 Flask提供request.get_json()方法从请求中提取JSON并将其作为Python结构返回。 如果在请求中没有找到JSON数据,该方法返回None,所以我可以使用表达式request.get_json() or {}确保我总是可以获得一个字典。 在我可以使用这些数据之前,我需要确保我已经掌握了所有信息,因此我首先检查是否包含三个必填字段,username, email和password。 如果其中任何一个缺失,那么我使用app/api/errors.py模块中的bad_request()辅助函数向客户端返回一个错误。 除此之外,我还需要确保username和email字段尚未被其他用户使用,因此我尝试使用获得的用户名和电子邮件从数据库中加载用户,如果返回了有效的用户,那么我也将返回错误给客户端。 一旦通过了数据验证,我可以轻松创建一个用户对象并将其添加到数据库中。 为了创建用户,我依赖User模型中的from_dict()方法,new_user参数被设置为True,所以它也接受通常不存在于用户表示中的password字段。 我为这个请求返回的响应将是新用户的表示,所以使用to_dict()产生它的有效载荷。 创建资源的POST请求的响应状态代码应该是201,即创建新实体时使用的代码。 此外,HTTP协议要求201响应包含一个值为新资源URL的Location头部。 下面你可以看到如何通过HTTPie从命令行注册一个新用户: (venv) $ http POST http://localhost:5000/api/users username=alice password=dog \ email=alice@example.com "about_me=Hello, my name is Alice!" 编辑用户 示例API中使用的最后一个endpoint用于修改已存在的用户: app/api/users.py:修改用户。 @bp.route('/users/<int:id>', methods=['PUT']) def update_user(id): user = User.query.get_or_404(id) data = request.get_json() or {} if 'username' in data and data['username'] != user.username and \ User.query.filter_by(username=data['username']).first(): return bad_request('please use a different username') if 'email' in data and data['email'] != user.email and \ User.query.filter_by(email=data['email']).first(): return bad_request('please use a different email address') user.from_dict(data, new_user=False) db.session.commit() return jsonify(user.to_dict()) 一个请求到来,我通过URL收到一个动态的用户id,所以我可以加载指定的用户或返回404错误(如果找不到)。 就像注册新用户一样,我需要验证客户端提供的username和email字段是否与其他用户发生了冲突,但在这种情况下,验证有点棘手。 首先,这些字段在此请求中是可选的,所以我需要检查字段是否存在。 第二个复杂因素是客户端可能提供与目前字段相同的值,所以在检查用户名或电子邮件是否被采用之前,我需要确保它们与当前的不同。 如果任何验证检查失败,那么我会像之前一样返回400错误给客户端。 一旦数据验证通过,我可以使用User模型的from_dict()方法导入客户端提供的所有数据,然后将更改提交到数据库。 该请求的响应会将更新后的用户表示返回给用户,并使用默认的200状态代码。 以下是一个示例请求,它用HTTPie编辑about_me字段: (venv) $ http PUT http://localhost:5000/api/users/2 "about_me=Hi, I am Miguel" API 认证 我在前一节中添加的API endpoint当前对任何客户端都是开放的。 显然,执行这些操作需要认证用户才安全,为此我需要添加认证和授权,简称“AuthN”和“AuthZ”。 思路是,客户端发送的请求提供了某种标识,以便服务器知道客户端代表的是哪位用户,并且可以验证是否允许该用户执行请求的操作。 保护这些API endpoint的最明显的方法是使用Flask-Login中的@login_required装饰器,但是这种方法存在一些问题。 装饰器检测到未通过身份验证的用户时,会将用户重定向到HTML登录页面。 在API中没有HTML或登录页面的概念,如果客户端发送带有无效或缺少凭证的请求,服务器必须拒绝请求并返回401状态码。 服务器不能假定API客户端是Web浏览器,或者它可以处理重定向,或者它可以渲染和处理HTML登录表单。 当API客户端收到401状态码时,它知道它需要向用户询问凭证,但是它是如何实现的,服务器不需要关心。 User模型中实现Token 对于API身份验证需求,我将使用token身份验证方案。 当客户端想要开始与API交互时,它需要使用用户名和密码进行验证,然后获得一个临时token。 只要token有效,客户端就可以发送附带token的API请求以通过认证。 一旦token到期,需要请求新的token。 为了支持用户token,我将扩展User模型: app/models.py:支持用户token。 import base64 from datetime import datetime, timedelta import os class User(UserMixin, PaginatedAPIMixin, db.Model): # ... token = db.Column(db.String(32), index=True, unique=True) token_expiration = db.Column(db.DateTime) # ... def get_token(self, expires_in=3600): now = datetime.utcnow() if self.token and self.token_expiration > now + timedelta(seconds=60): return self.token self.token = base64.b64encode(os.urandom(24)).decode('utf-8') self.token_expiration = now + timedelta(seconds=expires_in) db.session.add(self) return self.token def revoke_token(self): self.token_expiration = datetime.utcnow() - timedelta(seconds=1) @staticmethod def check_token(token): user = User.query.filter_by(token=token).first() if user is None or user.token_expiration < datetime.utcnow(): return None return user 我为用户模型添加了一个token属性,并且因为我需要通过它搜索数据库,所以我为它设置了唯一性和索引。 我还添加了token_expiration字段,它保存token过期的日期和时间。 这使得token不会长时间有效,以免成为安全风险。 我创建了三种方法来处理这些token。 get_token()方法为用户返回一个token。 以base64编码的24位随机字符串来生成这个token,以便所有字符都处于可读字符串范围内。 在创建新token之前,此方法会检查当前分配的token在到期之前是否至少还剩一分钟,并且在这种情况下会返回现有的token。 使用token时,有一个策略可以立即使token失效总是一件好事,而不是仅依赖到期日期。 这是一个经常被忽视的安全最佳实践。 revoke_token()方法使得当前分配给用户的token失效,只需设置到期时间为当前时间的前一秒。 check_token()方法是一个静态方法,它将一个token作为参数传入并返回此token所属的用户。 如果token无效或过期,则该方法返回None。 由于我对数据库进行了更改,因此需要生成新的数据库迁移,然后使用它升级数据库: (venv) $ flask db migrate -m "user tokens" (venv) $ flask db upgrade 带Token的请求 当你编写一个API时,你必须考虑到你的客户端并不总是要连接到Web应用程序的Web浏览器。 当独立客户端(如智能手机APP)甚至是基于浏览器的单页应用程序访问后端服务时,API展示力量的机会就来了。 当这些专用客户端需要访问API服务时,他们首先需要请求token,对应传统Web应用程序中登录表单的部分。 为了简化使用token认证时客户端和服务器之间的交互,我将使用名为Flask-HTTPAuth的Flask插件。 Flask-HTTPAuth可以使用pip安装: (venv) $ pip install flask-httpauth Flask-HTTPAuth支持几种不同的认证机制,都对API友好。 首先,我将使用HTTPBasic Authentication,该机制要求客户端在标准的Authorization头部中附带用户凭证。 要与Flask-HTTPAuth集成,应用需要提供两个函数:一个用于检查用户提供的用户名和密码,另一个用于在认证失败的情况下返回错误响应。这些函数通过装饰器在Flask-HTTPAuth中注册,然后在认证流程中根据需要由插件自动调用。 实现如下: app/api/auth.py:基本认证支持。 from flask import g from flask_httpauth import HTTPBasicAuth from app.models import User from app.api.errors import error_response basic_auth = HTTPBasicAuth() @basic_auth.verify_password def verify_password(username, password): user = User.query.filter_by(username=username).first() if user is None: return False g.current_user = user return user.check_password(password) @basic_auth.error_handler def basic_auth_error(): return error_response(401) Flask-HTTPAuth的HTTPBasicAuth类实现了基本的认证流程。 这两个必需的函数分别通过verify_password和error_handler装饰器进行注册。 验证函数接收客户端提供的用户名和密码,如果凭证有效则返回True,否则返回False。 我依赖User类的check_password()方法来检查密码,它在Web应用的认证过程中,也会被Flask-Login使用。 我将认证用户保存在g.current_user中,以便我可以从API视图函数中访问它。 错误处理函数只返回由app/api/errors.py模块中的error_response()函数生成的401错误。 401错误在HTTP标准中定义为“未授权”错误。 HTTP客户端知道当它们收到这个错误时,需要重新发送有效的凭证。 现在我已经实现了基本认证的支持,因此我可以添加一条token检索路由,以便客户端在需要token时调用: app/api/tokens.py:生成用户token。 from flask import jsonify, g from app import db from app.api import bp from app.api.auth import basic_auth @bp.route('/tokens', methods=['POST']) @basic_auth.login_required def get_token(): token = g.current_user.get_token() db.session.commit() return jsonify({'token': token}) 这个视图函数使用了HTTPBasicAuth实例中的@basic_auth.login_required装饰器,它将指示Flask-HTTPAuth验证身份(通过我上面定义的验证函数),并且仅当提供的凭证是有效的才运行下面的视图函数。 该视图函数的实现依赖于用户模型的get_token()方法来生成token。 数据库提交在生成token后发出,以确保token及其到期时间被写回到数据库。 如果你尝试直接向token API路由发送POST请求,则会发生以下情况: (venv) $ http POST http://localhost:5000/api/tokens HTTP/1.0 401 UNAUTHORIZED Content-Length: 30 Content-Type: application/json Date: Mon, 27 Nov 2017 20:01:00 GMT Server: Werkzeug/0.12.2 Python/3.6.3 WWW-Authenticate: Basic realm="Authentication Required" { "error": "Unauthorized" } HTTP响应包括401状态码和我在basic_auth_error()函数中定义的错误负载。 下面请求带上了基本认证需要的凭证: (venv) $ http --auth <username>:<password> POST http://localhost:5000/api/tokens HTTP/1.0 200 OK Content-Length: 50 Content-Type: application/json Date: Mon, 27 Nov 2017 20:01:22 GMT Server: Werkzeug/0.12.2 Python/3.6.3 { "token": "pC1Nu9wwyNt8VCj1trWilFdFI276AcbS" } 现在状态码是200,这是成功请求的代码,并且有效载荷包括用户的token。 请注意,当你发送这个请求时,你需要用你自己的凭证来替换<username>:<password>。 用户名和密码需要以冒号作为分隔符。 使用Token机制保护API路由 客户端现在可以请求一个token来和API endpoint一起使用,所以剩下的就是向这些endpoint添加token验证。 Flask-HTTPAuth也可以为我处理的这些事情。 我需要创建基于HTTPTokenAuth类的第二个身份验证实例,并提供token验证回调: app/api/auth.py: Token认证支持。 # ... from flask_httpauth import HTTPTokenAuth # ... token_auth = HTTPTokenAuth() # ... @token_auth.verify_token def verify_token(token): g.current_user = User.check_token(token) if token else None return g.current_user is not None @token_auth.error_handler def token_auth_error(): return error_response(401) 使用token认证时,Flask-HTTPAuth使用的是verify_token装饰器注册验证函数,除此之外,token认证的工作方式与基本认证相同。 我的token验证函数使用User.check_token()来定位token所属的用户。 该函数还通过将当前用户设置为None来处理缺失token的情况。返回值是True还是False,决定了Flask-HTTPAuth是否允许视图函数的运行。 为了使用token保护API路由,需要添加@token_auth.login_required装饰器: app/api/users.py:使用token认证保护用户路由。 from app.api.auth import token_auth @bp.route('/users/<int:id>', methods=['GET']) @token_auth.login_required def get_user(id): # ... @bp.route('/users', methods=['GET']) @token_auth.login_required def get_users(): # ... @bp.route('/users/<int:id>/followers', methods=['GET']) @token_auth.login_required def get_followers(id): # ... @bp.route('/users/<int:id>/followed', methods=['GET']) @token_auth.login_required def get_followed(id): # ... @bp.route('/users', methods=['POST']) def create_user(): # ... @bp.route('/users/<int:id>', methods=['PUT']) @token_auth.login_required def update_user(id): # ... 请注意,装饰器被添加到除create_user()之外的所有API视图函数中,显而易见,这个函数不能使用token认证,因为用户都不存在时,更不会有token了。 如果你直接对上面列出的受token保护的endpoint发起请求,则会得到一个401错误。为了成功访问,你需要添加Authorization头部,其值是请求/api/tokens获得的token的值。Flask-HTTPAuth期望的是”不记名”token,但是它没有被HTTPie直接支持。就像针对基本认证,HTTPie提供了--auth选项来接受用户名和密码,但是token的头部则需要显式地提供了。下面是发送不记名token的格式: (venv) $ http GET http://localhost:5000/api/users/1 \ "Authorization:Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS" 撤销Token 我将要实现的最后一个token相关功能是token撤销,如下所示: app/api/tokens.py:撤销token。 from app.api.auth import token_auth @bp.route('/tokens', methods=['DELETE']) @token_auth.login_required def revoke_token(): g.current_user.revoke_token() db.session.commit() return '', 204 客户端可以向/tokens URL发送DELETE请求,以使token失效。此路由的身份验证是基于token的,事实上,在Authorization头部中发送的token就是需要被撤销的。撤销使用了User类中的辅助方法,该方法重新设置token过期日期来实现撤销操作。之后提交数据库会话,以确保将更改写入数据库。这个请求的响应没有正文,所以我可以返回一个空字符串。Return语句中的第二个值设置状态代码为204,该代码用于成功请求却没有响应主体的响应。 下面是撤销token的一个HTTPie请求示例: (venv) $ http DELETE http://localhost:5000/api/tokens \ Authorization:"Bearer pC1Nu9wwyNt8VCj1trWilFdFI276AcbS" API友好的错误消息 你是否还记得,在本章的前部分,当我要求你用一个无效的用户URL从浏览器发送一个API请求时发生了什么?服务器返回了404错误,但是这个错误被格式化为标准的404 HTML错误页面。在API blueprint中的API可能返回的许多错误可以被重写为JSON版本,但是仍然有一些错误是由Flask处理的,处理这些错误的处理函数是被全局注册到应用中的,返回的是HTML。 HTTP协议支持一种机制,通过该机制,客户机和服务器可以就响应的最佳格式达成一致,称为内容协商。客户端需要发送一个Accept头部,指示格式首选项。然后,服务器查看自身格式列表并使用匹配客户端格式列表中的最佳格式进行响应。 我想做的是修改全局应用的错误处理器,使它们能够根据客户端的格式首选项对返回内容是使用HTML还是JSON进行内容协商。这可以通过使用Flask的request.accept_mimetypes来完成: app/errors/handlers.py:为错误响应进行内容协商。 from flask import render_template, request from app import db from app.errors import bp from app.api.errors import error_response as api_error_response def wants_json_response(): return request.accept_mimetypes['application/json'] >= \ request.accept_mimetypes['text/html'] @bp.app_errorhandler(404) def not_found_error(error): if wants_json_response(): return api_error_response(404) return render_template('errors/404.html'), 404 @bp.app_errorhandler(500) def internal_error(error): db.session.rollback() if wants_json_response(): return api_error_response(500) return render_template('errors/500.html'), 500 wants_json_response()辅助函数比较客户端对JSON和HTML格式的偏好程度。 如果JSON比HTML高,那么我会返回一个JSON响应。 否则,我会返回原始的基于模板的HTML响应。 对于JSON响应,我将使用从API blueprint中导入error_response辅助函数,但在这里我要将其重命名为api_error_response(),以便清楚它的作用和来历。
本文转载自:https://www.jianshu.com/p/026394cacc06 这是Flask Mega-Tutorial系列的第二十二部分,我将告诉你如何创建独立于Web服务器之外运行的后台作业。 本章致力于为应用程序中运行时间较长或复杂的异步任务进程进行优化。这些进程不能在请求的上下文中同步执行,因为这会在任务持续期间阻塞对客户端的响应。在第十章中,我将邮件的发送转移到后台线程中执行,以免阻塞响应。 虽然使用线程处理电子邮件是可以接受的,但当问题处理时间更长时,此解决方案就不足以支撑了。 公认的做法是将耗时长的任务移交到worker进程(或进程池)。 为了证明长时间运行任务存在的必要性,我将介绍Microblog的一个导出功能,用户通过它可以请求一个包含他们所有用户动态的数据文件。 当用户使用该选项时,应用程序将启动一个导出任务,该导出任务将生成包含所有用户动态的JSON文件,然后通过电子邮件发送给用户。 所有这些活动都将在worker进程中发生,并且在执行时,用户可以看到显示完成百分比的进度。 本章的GitHub链接为:Browse, Zip, Diff. 任务队列简介 任务队列为后台作业提供了一个便捷的解决方案。 Worker进程独立于应用程序运行,甚至可以位于不同的系统上。 应用程序和worker之间的通信是通过消息队列完成的。 应用程序提交作业,然后通过与队列交互来监视其进度。 下图展示了一个典型的实现: Python中最流行的任务队列是Celery。 这是一个相当复杂的软件包,它有很多选项并支持多个消息队列。 另一个流行的Python任务队列是Redis Queue(RQ),它牺牲了一些灵活性,比如只支持Redis消息队列,但作为交换,它的建立要比Celery简单得多。 Celery和RQ都非常适合在Flask应用程序中支持后台任务,所以我倾向于选择更简单的RQ。 不过,用Celery实现相同的功能其实也不难。 如果你对Celery更感兴趣,可以阅读我的博客中的Using Celery with Flask文章。 使用RQ RQ是一个标准的Python三方软件包,用pip安装: (venv) $ pip install rq (venv) $ pip freeze > requirements.txt 正如我前面提到的,应用和RQ worker之间的通信将在Redis消息队列中执行,因此你需要运行Redis服务器。 有许多途径来安装和运行Redis服务器,比如下载其源码并执行编译和安装。 如果你使用的是Windows,Microsoft在此处维护了Redis的安装程序。 在Linux上,你可以通过操作系统的软件包管理器安装Redis。 Mac OS X用户可以运行brew install redis,然后使用redis-server命令手动启动服务。 除了确保服务正在运行并可供RQ访问之外,你不需要与Redis进行其他交互。 创建任务 通过RQ执行一项简单的任务后,你就会很快熟悉它。 一个任务,不过是一个Python函数而已。 以下是一个示例任务,我将其放入一个新的app/tasks.py模块: app/tasks.py:示例后台任务。 import time def example(seconds): print('Starting task') for i in range(seconds): print(i) time.sleep(1) print('Task completed') 该任务将秒数作为参数,然后在该时间量内等待,并每秒打印一次计数器。 运行RQ Worker 任务准备就绪,可以通过rq worker来启动一个worker进程了: (venv) $ rq worker microblog-tasks 18:55:06 RQ worker 'rq:worker:miguelsmac.90369' started, version 0.9.1 18:55:06 Cleaning registries for queue: microblog-tasks 18:55:06 18:55:06 *** Listening on microblog-tasks... Worker进程现在连接到了Redis,并在名为microblog-tasks的队列上查看可能分配给它的任何作业。 如果你想启动多个worker来扩展吞吐量,你只需要运行rq worker来生成更多连接到同一个队列的进程。 然后,当作业出现在队列中时,任何可用的worker进程都可以获取它。 在生产环境中,你可能希望至少运行可用CPU数量的worker。 执行任务 现在打开第二个终端窗口并激活虚拟环境。 我将使用shell会话来启动worker中的example()任务: >>> from redis import Redis >>> import rq >>> queue = rq.Queue('microblog-tasks', connection=Redis.from_url('redis://')) >>> job = queue.enqueue('app.tasks.example', 23) >>> job.get_id() 'c651de7f-21a8-4068-afd5-8b982a6f6d32' 来自RQ的Queue类表示从应用程序端看到的任务队列。 它采用的参数是队列名称和一个Redis连接对象,本处使用默认URL进行初始化。 如果你的Redis服务器运行在不同的主机或端口号上,则需要使用其他URL。 Queue的enqueue()方法用于将作业添加到队列中。 第一个参数是要执行的任务的名称,可直接传入函数对象或导入字符串。 我发现传入字符串更加方便,因为不需要在应用程序的一端导入函数。 对enqueue()传入的任何剩余参数将被传递给worker中运行的函数。 只要进行了enqueue()调用,运行着RQ worker的终端窗口上就会出现一些活动。 你会看到example()函数正在运行,并且每秒打印一次计数器。 同时,你的其他终端不会被阻塞,你可以继续在shell中执行表达式。在上面的例子中,我调用job.get_id()方法来获取分配给任务的唯一标识符。 你可以尝试使用另一个有趣表达式来检查worker上的函数是否已完成: >>> job.is_finished False 如果你像我在上面的例子中那样传递了23,那么函数将运行约23秒。 在那之后,job.is_finished表达式将变为True。 就是这么简单,炫酷否? 一旦函数完成,worker又回到等待作业的状态,所以如果你想进行更多的实验,你可以用不同的参数重复执行enqueue()调用。 队列中存储的有关任务的数据将保留一段时间(默认为500秒),但最终会被删除。 这很重要,任务队列不保留已执行作业的历史记录。 报告任务进度 上面使用的示例任务简单得不现实。 通常,对于长时间运行的任务,你需要将一些进度信息提供给应用程序,从而可以将其显示给用户。 RQ通过使用作业对象的meta属性来支持这一点。 让我重写example()任务来编写进度报告: app/tasks.py::带进度的示例后台任务。 import time from rq import get_current_job def example(seconds): job = get_current_job() print('Starting task') for i in range(seconds): job.meta['progress'] = 100.0 * i / seconds job.save_meta() print(i) time.sleep(1) job.meta['progress'] = 100 job.save_meta() print('Task completed') 这个新版本的example()使用RQ的get_current_job()函数来获取一个作业实例,该实例与提交任务时返回给应用程序的实例类似。 作业对象的meta属性是一个字典,任务可以编写任何想要与应用程序通信的自定义数据。 在这个例子中,我写入了progress,表示完成任务的百分比。 每次进程更新时,我都调用job.save_meta()指示RQ将数据写入Redis,应用程序可以在其中找到它。 在应用程序方面(目前只是一个Python shell),我可以运行此任务,然后监视进度,如下所示: >>> job = queue.enqueue('app.tasks.example', 23) >>> job.meta {} >>> job.refresh() >>> job.meta {'progress': 13.043478260869565} >>> job.refresh() >>> job.meta {'progress': 69.56521739130434} >>> job.refresh() >>> job.meta {'progress': 100} >>> job.is_finished True 如你所见,在另一侧,meta属性可以被读取。 需要调用refresh()方法来从Redis更新内容。 任务的数据库表示 对于上面的例子来说,启动一个任务并观察它运行就足够了。 对于Web应用程序,情况会变得更复杂一些,因为一旦任务随着请求的处理而启动,该请求随即结束,而该任务的所有上下文都将丢失。 因为我希望应用程序跟踪每个用户正在运行的任务,所以我需要使用数据库表来维护状态。 你可以在下面看到新的Task模型实现: app/models.py:Task模型。 # ... import redis import rq class User(UserMixin, db.Model): # ... tasks = db.relationship('Task', backref='user', lazy='dynamic') # ... class Task(db.Model): id = db.Column(db.String(36), primary_key=True) name = db.Column(db.String(128), index=True) description = db.Column(db.String(128)) user_id = db.Column(db.Integer, db.ForeignKey('user.id')) complete = db.Column(db.Boolean, default=False) def get_rq_job(self): try: rq_job = rq.job.Job.fetch(self.id, connection=current_app.redis) except (redis.exceptions.RedisError, rq.exceptions.NoSuchJobError): return None return rq_job def get_progress(self): job = self.get_rq_job() return job.meta.get('progress', 0) if job is not None else 100 这个模型和以前的模型有一个有趣的区别是id主键字段是字符串类型,而不是整数类型。 这是因为对于这个模型,我不会依赖数据库自己的主键生成,而是使用由RQ生成的作业标识符。 该模型将存储符合任务命名规范的名称(会传递给RQ),适用于向用户显示的任务描述,该任务的所属用户的关系以及任务是否已完成的布尔值。complete字段的目的是将正在运行的任务与已完成的任务分开,因为运行中的任务需要特殊处理才能显示最新进度。 get_rq_job()辅助方法可以用给定的任务ID加载RQJob实例。 这是通过Job.fetch()完成的,它会从Redis中存在的数据中加载Job实例。 get_progress()方法建立在get_rq_job()的基础之上,并返回任务的进度百分比。 该方法做一些有趣的假设,如果模型中的作业ID不存在于RQ队列中,则表示作业已完成并且数据已过期并已从队列中删除,因此在这种情况下返回的百分比为100。 另一方面,如果job存在,但’meta’属性中找不到进度相关的信息,那么可以安全地假定该job计划运行,但还没有启动,所以在这种情况下进度是0。 要将更改应用于数据库,需要生成新的迁移,然后升级数据库: (venv) $ flask db migrate -m "tasks" (venv) $ flask db upgrade 新模型也可以添加到shell上下文中,以便在shell会话中访问它时无需导入: microblog.py:添加Task模型到shell上下文中。 from app import create_app, db, cli from app.models import User, Post, Message, Notification, Task app = create_app() cli.register(app) @app.shell_context_processor def make_shell_context(): return {'db': db, 'User': User, 'Post': Post, 'Message': Message, 'Notification': Notification, 'Task': Task} 将RQ与Flask应用集成 Redis服务的连接URL需要添加到配置中: class Config(object): # ... REDIS_URL = os.environ.get('REDIS_URL') or 'redis://' 与往常一样,Redis连接URL将来自环境变量,如果该变量未定义,则会假定该服务在当前主机的默认端口上运行并使用默认URL。 应用工厂函数将负责初始化Redis和RQ: app/init.py:整合RQ。 # ... from redis import Redis import rq # ... def create_app(config_class=Config): # ... app.redis = Redis.from_url(app.config['REDIS_URL']) app.task_queue = rq.Queue('microblog-tasks', connection=app.redis) # ... app.task_queue将成为提交任务的队列。 将队列附加到应用上会提供很大的便利,因为我可以在应用的任何地方使用current_app.task_queue来访问它。 为了方便应用的任何部分提交或检查任务,我可以在User模型中创建一些辅助方法: app/models.py:用户模型中的任务辅助方法。 # ... class User(UserMixin, db.Model): # ... def launch_task(self, name, description, *args, **kwargs): rq_job = current_app.task_queue.enqueue('app.tasks.' + name, self.id, *args, **kwargs) task = Task(id=rq_job.get_id(), name=name, description=description, user=self) db.session.add(task) return task def get_tasks_in_progress(self): return Task.query.filter_by(user=self, complete=False).all() def get_task_in_progress(self, name): return Task.query.filter_by(name=name, user=self, complete=False).first() launch_task()方法负责将任务提交到RQ队列,并将其添加到数据库中。 name参数是函数名称,如app/tasks.py中所定义的那样。 提交给RQ时,该函数会将app.tasks.预先添加到该名称中以构建符合规范的函数名称。description参数是对呈现给用户的任务的友好描述。 对于导出用户动态的函数,我将名称设置为export_posts,将描述设置为Exporting posts...。 其余参数将传递给任务函数。 launch_task()函数首先调用队列的enqueue()方法来提交作业。 返回的作业对象包含由RQ分配的任务ID,因此我可以使用它在我的数据库中创建相应的Task对象。 请注意,launch_task()将新的任务对象添加到会话中,但不会发出提交。 一般来说,最好在更高层次函数中的数据库会话上进行操作,因为它允许你在单个事务中组合由较低级别函数所做的多个更新。 这不是一个严格的规则,并且,在本章后面的子函数中也会存在一个例外的提交。 get_tasks_in_progress()方法返回该用户未完成任务的列表。 稍后你会看到,我使用此方法在将有关正在运行的任务的信息渲染到用户的页面中。 最后,get_task_in_progress()是上一个方法的简化版本并返回指定的任务。 我阻止用户同时启动两个或多个相同类型的任务,因此在启动任务之前,可以使用此方法来确定前一个任务是否还在运行。 利用RQ任务发送电子邮件 不要认为本节偏离主题,我在上面说过,当后台导出任务完成时,将使用包含所有用户动态的JSON文件向用户发送电子邮件。 我在第十章中构建的电子邮件功能需要通过两种方式进行扩展。 首先,我需要添加对文件附件的支持,以便我可以附加JSON文件。 其次,send_email()函数总是使用后台线程异步发送电子邮件。 当我要从后台任务发送一封电子邮件时(已经是异步的了),基于线程的二级后台任务没有什么意义,所以我需要同时支持同步和异步电子邮件的发送。 幸运的是,Flask-Mail支持附件,所以我需要做的就是扩展send_email()函数的默认关键字参数,然后在Message对象中配置它们。 选择在前台发送电子邮件时,我只需要添加一个sync=True的关键字参数即可: app/email.py:发送带附件的邮件。 # ... def send_email(subject, sender, recipients, text_body, html_body, attachments=None, sync=False): msg = Message(subject, sender=sender, recipients=recipients) msg.body = text_body msg.html = html_body if attachments: for attachment in attachments: msg.attach(*attachment) if sync: mail.send(msg) else: Thread(target=send_async_email, args=(current_app._get_current_object(), msg)).start() Message类的attach()方法接受三个定义附件的参数:文件名,媒体类型和实际文件数据。 文件名就是收件人看到的与附件关联的名称。 媒体类型定义了这种附件的类型,这有助于电子邮件读者适当地渲染它。 例如,如果你发送image/png作为媒体类型,则电子邮件阅读器会知道该附件是一个图像,在这种情况下,它可以显示它。 对于用户动态数据文件,我将使用JSON格式,该格式使用application/json媒体类型。 最后一个参数包含附件内容的字符串或字节序列。 简单来说,send_email()的attachments参数将成为一个元组列表,每个元组将有三个元素对应于attach()的三个参数。 因此,我需要将此列表中的每个元素作为参数发送给attach()。 在Python中,如果你想将列表或元组中的每个元素作为参数传递给函数,你可以使用func(*args)将这个列表或元祖解包成函数中的多个参数,而不必枯燥地一个个地传递,如func(args[0], args[1], args[2])。 例如,如果你有一个列表args = [1, 'foo'],func(*args)将会传递两个参数,就和你调用func(1, 'foo')一样。 如果没有*,调用将会传入一个参数,即args列表。 至于电子邮件的同步发送,我需要做的就是,当sync是True的时候恢复成调用mail.send(msg)。 任务助手 尽管我上面使用的example()任务是一个简单的独立函数,但导出用户动态的函数却需要应用中具有的一些功能,例如访问数据库和发送电子邮件。 因为这将在单独的进程中运行,所以我需要初始化Flask-SQLAlchemy和Flask-Mail,而Flask-Mail又需要Flask应用实例以从中获取它们的配置。 因此,我将在app/tasks.py模块的顶部添加Flask应用实例和应用上下文: app/tasks.py:创建应用及其上下文。 from app import create_app app = create_app() app.app_context().push() 应用在此模块中创建,因为这是RQ worker要导入的唯一模块。 当使用flask命令时,根目录中的microblog.py模块创建应用实例,但RQ worker对此却一无所知,所以当任务函数需要它时,它需要创建自己的应用实例。 你已经在好几个地方看到了app.app_context()方法,推送一个上下文使应用成为“当前”的应用实例,这样一来Flask-SQLAlchemy等插件才可以使用current_app.config 获取它们的配置。 没有上下文,current_app表达式会返回一个错误。 然后我开始考虑如何在这个函数运行时报告进度。除了通过job.meta字典传递进度信息之外,我还想将通知推送给客户端,以便自动动态更新完成百分比。为此,我将使用我在第二十一章中构建的通知机制。更新将以与未读消息徽章非常类似的方式工作。当服务器渲染模板时,它将包含从job.meta获得的“静态”进度信息,但是一旦页面位于客户端的浏览器中,通知将使用通知来动态更新百分比。由于通知的原因,更新正在运行的任务的进度将比上一个示例中的操作稍微多一些,所以我将创建一个专用于更新进度的包装函数: app/tasks.py:设置任务进度。 from rq import get_current_job from app import db from app.models import Task # ... def _set_task_progress(progress): job = get_current_job() if job: job.meta['progress'] = progress job.save_meta() task = Task.query.get(job.get_id()) task.user.add_notification('task_progress', {'task_id': job.get_id(), 'progress': progress}) if progress >= 100: task.complete = True db.session.commit() 导出任务可以调用_set_task_progress()来记录进度百分比。 该函数首先将百分比写入job.meta字典并将其保存到Redis,然后从数据库加载相应的任务对象,并使用task.user已有的add_notification()方法将通知推送给请求该任务的用户。 通知将被命名为task_progress,并且与其关联的数据将成为具有两个条目的字典:任务标识符和进度数值。 稍后我将添加JavaScript代码来处理这种新的通知类型。 该函数查看进度来确认任务函数是否已完成,并在这种情况下更新数据库中任务对象的complete属性。 数据库提交调用确保通过add_notification()添加的任务和通知对象都立即保存到数据库。 我需要非常精确地设计父任务,确保不执行任何数据库更改,因为执行本调用会将父任务的更改也写入数据库。 实现导出任务 现在所有的准备工作已经完成,可以开始编写导出函数了。 这个函数的高层结构如下: app/tasks.py:导出用户动态通用结构。 def export_posts(user_id): try: # read user posts from database # send email with data to user except: # handle unexpected errors 为什么将整个任务包装在try/except块中呢? 请求处理器中的应用代码可以防止意外错误,因为Flask本身捕获异常,然后将它们以我设置的日志配置的方式来进行处理。 然而,这个函数将运行在由RQ控制的单独进程中,而非Flask,因此如果发生任何意外错误,任务将中止,RQ将向控制台显示错误,然后返回等待新的job。 所以基本上,除非你正在观看RQ worker的输出或将其记录到文件中,否则将永远不会发现有错误。 让我们从上面带有注释的三部分中最简单的错误处理部分开始梳理: app/tasks.py:导出用户动态错误处理。 import sys # ... def export_posts(user_id): try: # ... except: _set_task_progress(100) app.logger.error('Unhandled exception', exc_info=sys.exc_info()) 每当发生意外错误时,我将通过将进度设置为100%来将任务标记为完成,然后使用Flask应用程序中的日志记录器对象记录错误以及堆栈跟踪信息(调用sys.exc_info()来获得)。 使用Flask应用日志记录器来记录错误的好处在于,你可以观察到你为Flask应用实现的任何日志记录机制。 例如,在第七章中,我配置了要发送到管理员电子邮件地址的错误。 只要使用app.logger,我也可以得到这些错误信息。 接下来,我将编写实际的导出代码,它只需发出一个数据库查询并在循环中遍历结果,并将它们累积在字典中: app/tasks.py:从数据库读取用户动态。 import time from app.models import User, Post # ... def export_posts(user_id): try: user = User.query.get(user_id) _set_task_progress(0) data = [] i = 0 total_posts = user.posts.count() for post in user.posts.order_by(Post.timestamp.asc()): data.append({'body': post.body, 'timestamp': post.timestamp.isoformat() + 'Z'}) time.sleep(5) i += 1 _set_task_progress(100 * i // total_posts) # send email with data to user except: # ... 每条动态都是一个包含两个条目的字典,即动态正文和动态发表的时间。 时间格式将采用ISO 8601标准。 我使用的Python的datetime对象不存储时区,因此在以ISO格式导出时间后,我添加了’Z’,它表示UTC。 由于需要跟踪进度,代码变得稍微复杂了些。 我维护了一个计数器i,并且在进入循环之前还需要发出一个额外的数据库查询,查询total_posts以获得用户动态的总数。 使用了i和total_posts,在每个循环迭代我都可以使用从0到100的数字来更新任务进度。 你可能会好奇我为什么会在每个循环迭代中加入time.sleep(5)调用。主要原因是我想要延长导出所需的时间,以便在用户动态不多的情况下也可以方便地查看到导出进度的增长。 下面是函数的最后部分,将会带上data附件发送邮件给用户: app/tasks.py:发送带用户动态的邮件给用户。 import json from flask import render_template from app.email import send_email # ... def export_posts(user_id): try: # ... send_email('[Microblog] Your blog posts', sender=app.config['ADMINS'][0], recipients=[user.email], text_body=render_template('email/export_posts.txt', user=user), html_body=render_template('email/export_posts.html', user=user), attachments=[('posts.json', 'application/json', json.dumps({'posts': data}, indent=4))], sync=True) except: # ... 其实只是对send_email()函数的调用。 附件被定义为一个元组,其中有三个元素被传递给Flask-Mail的Message对象的attach()方法。 元组中的第三个元素是附件内容,它是用Python的json.dumps()函数生成的。 这里引用了一对新模板,它们以纯文本和HTML格式提供电子邮件正文的内容。 这是文本模板的内容: app/templates/email/export_posts.txt:导出用户动态文本邮件模板。 Dear {{ user.username }}, Please find attached the archive of your posts that you requested. Sincerely, The Microblog Team 这是HTML版本的邮件模板: app/templates/email/export_posts.html:导出用户动态HTML邮件模板。 <p>Dear {{ user.username }},</p> <p>Please find attached the archive of your posts that you requested.</p> <p>Sincerely,</p> <p>The Microblog Team</p> 应用中的导出功能 所有支持后台导出任务的核心组件现已到位。 剩下的就是将这个功能连接到应用,以便用户发起请求并通过电子邮件发送用户动态给他们。 下面是新的export_posts视图函数: app/main/routes.py:导出用户动态路由和视图函数。 @bp.route('/export_posts') @login_required def export_posts(): if current_user.get_task_in_progress('export_posts'): flash(_('An export task is currently in progress')) else: current_user.launch_task('export_posts', _('Exporting posts...')) db.session.commit() return redirect(url_for('main.user', username=current_user.username)) 该函数首先检查用户是否有未完成的导出任务,并在这种情况下只是闪现消息。 对同一用户同时执行两个导出任务是没有意义的,可以避免。 我可以使用前面实现的get_task_in_progress()方法来检查这种情况。 如果用户没有正在运行的导出任务,则调用launch_task()来启动它。 第一个参数是将传递给RQ worker的函数的名称,前缀为app.tasks.。 第二个参数只是一个友好的文本描述,将会显示给用户。 这两个值都会被写入数据库中的Task对象。 该函数以重定向到用户个人主页结束。 现在我需要暴露该路由的链接,以便用户可以请求导出。 我认为最合适的地方是在用户个人主页,只有在用户查看他们自己的主页时,链接在“编辑个人资料”链接下面显示: app/templates/user.html:用户个人主页的导出链接。 ... <p> <a href="{{ url_for('main.edit_profile') }}"> {{ _('Edit your profile') }} </a> </p> {% if not current_user.get_task_in_progress('export_posts') %} <p> <a href="{{ url_for('main.export_posts') }}"> {{ _('Export your posts') }} </a> </p> ... {% endif %} 此链接的渲染是有条件的,因为我不希望它在用户已经有导出任务执行时出现。 此时的后台作业是可以运作的,但是不会向用户提供任何反馈。 如果你想尝试一下,你可以按如下方式启动应用和RQ worker: 确保Redis正在运行 打开一个终端窗口,启动至少一个RQ worker实例。本处你可以运行命令rq worker microblog-tasks 再打开另一个终端窗口,使用flask run (记得先设置 FLASK_APP变量)命令启动Flask应用 进度通知 为了完善这个功能,我想在后台任务运行时提醒用户任务完成的百分比进度。 在浏览Bootstrap组件选项时,我决定在导航栏的下方使用一个Alert组件。 Alert组件是向用户显示信息的带颜色的横条。 我用蓝色的Alert框来渲染闪现的消息。 现在我要添加一个绿色的Alert框来显示任务进度。 样式如下: app/templates/base.html:基础模板中的导出进度Alert组件。 ... {% block content %} <div class="container"> {% if current_user.is_authenticated %} {% with tasks = current_user.get_tasks_in_progress() %} {% if tasks %} {% for task in tasks %} <div class="alert alert-success" role="alert"> {{ task.description }} <span id="{{ task.id }}-progress">{{ task.get_progress() }}</span>% </div> {% endfor %} {% endif %} {% endwith %} {% endif %} ... {% endblock %} ... 渲染任务Alert组件的方法几乎与闪现消息相同。 外部条件在用户未登录时跳过所有与Alert相关的标记。而对于已登录用户,我通过调用前面创建的get_tasks_in_progress()方法来获取当前正在进行的任务列表。 在当前版本的应用中,我最多只能得到一个结果,因为我不允许多个导出任务同时执行,但将来我可能要支持可以共存的其他类型的任务,所以以通用的方式渲染Alert可以节省我以后的时间。 对于每项任务,我都会在页面上渲染一个Alert元素。 Alert的颜色由第二个CSS样式控制,本处是alert-success,而在闪现消息是alert-info。 Bootstrap文档包含有关Alert的HTML结构的详细信息。 Alert文本包括存储在Task模型中的description字段,后面跟着完成百分比。 百分比被封装在具有id属性的<span>元素中。 原因是我要在收到通知时用JavaScript刷新百分比。 我给任务ID末尾附加-progress来构造id属性。 当有通知到达时,通过其中的任务ID,我可以很容易地使用#<task.id>-progress选择器找到正确的<span>元素来更新。 如果你此时进行尝试,则每次导航到新页面时都会看到“静态”的进度更新。 你可以注意到,在启动导出任务后,你可以自由导航到应用程序的不同页面,正在运行的任务的状态始终都会展示出来。 为了对span>元素的百分比的动态更新做准备,我将在JavaScript端编写一个辅助函数: app/templates/base.html:动态更新任务进度的辅助函数。 ... {% block scripts %} ... <script> ... function set_task_progress(task_id, progress) { $('#' + task_id + '-progress').text(progress); } </script> ... {% endblock %} 这个函数接受一个任务id和一个进度值,并使用jQuery为这个任务定位<span>元素,并将新进度作为其内容写入。 实际上不需要验证页面上是否存在该元素,因为如果没有找到该元素,jQuery将不会执行任何操作。 app/tasks.py中的_set_task_progress()函数每次更新进度时调用add_notification(),就会产生新的通知。 而我在第二十一章明智地以完全通用的方式实现了通知功能。 所以当浏览器定期向服务器发送通知更新请求时,浏览器会获得通过add_notification()方法添加的任何通知。 但是,这些JavaScript代码只能识别具有unread_message_count名称的那些通知,并忽略其余部分。 我现在需要做的是扩展该函数,通过调用我上面定义的set_task_progress()函数来处理task_progress通知。 以下是处理通知更新版本JavaScript代码: app/templates/base.html:通知处理器。 for (var i = 0; i < notifications.length; i++) { switch (notifications[i].name) { case 'unread_message_count': set_message_count(notifications[i].data); break; case 'task_progress': set_task_progress( notifications[i].data.task_id, notifications[i].data.progress); break; } since = notifications[i].timestamp; } 现在我需要处理两个不同的通知,我决定用一个switch语句替换检查unread_message_count通知名称的if语句,该语句包含我现在需要支持的每个通知。 如果你对“C”系列语言不熟悉,就可能从未见过switch语句,它提供了一种方便的语法,可以替代一长串的if/elseif语句。这是一个很棒的特性,因为当我需要支持更多通知时,只需简单地添加case块即可。 回顾一下,RQ任务附加到task_progress通知的数据是一个包含两个元素task_id和progress的字典,这两个元素是我用来调用set_task_progress()的两个参数。 如果你现在运行该应用,则绿色Alert框中的进度指示器将每10秒刷新一次(因为刷新通知的时间间隔是10秒)。 由于本章介绍了新的可翻译字符串,因此需要更新翻译文件。 如果你要维护非英语语言文件,则需要使用Flask-Babel刷新翻译文件,然后添加新的翻译: (venv) $ flask translate update 如果你使用的是西班牙语翻译,那么我已经为你完成了翻译工作,因此可以从下载包中提取app/translations/es/LC_MESSAGES/messages.po文件,并将其添加到你的项目中。 翻译文件到位后,还要编译翻译文件: (venv) $ flask translate compile 部署注意事项 为了完成本章,我还要讨论应用程序部署的变化。 为了支持后台任务,我在部署栈中增加了两个新组件,一个Redis服务器和一/多个RQ worker。 很明显,它们需要包含在部署策略中,因此我将简要介绍前几章中不同部署方式的一些调整。 部署到Linux服务器 如果你正在Linux服务器上运行应用,则添加Redis十分简单。 对于Ubuntu Linux,你可以运行sudo apt-get install redis-server来安装Redis服务器。 要运行RQ worker进程,可以按照第十七章中“设置Gunicorn和Supervisor”一节那样创建第二个Supervisor配置,在其中运行的命令改成rq worker microblog-tasks。 如果你想要运行多个worker(假设是生产环境),则可以使用Supervisor的numprocs指令来指示要同时运行多少个实例。 部署到Heroku 要在Heroku上部署应用,你需要将Redis服务添加到你的帐户。 这与我添加Postgres数据库的过程类似。 Redis也有一个免费档次,可以使用以下命令添加: $ heroku addons:create heroku-redis:hobby-dev 新的redis服务的访问URL将作为REDIS_URL变量添加到你的Heroku环境中,这正是应用所需的。 Heroku的免费方案允许同时启动一个web进程和一个worker进程,因此你可以在免费的情况下启动一个rq worker进程。 为此,你将需要在procfile的一个单独的行中声明worker: web: flask db upgrade; flask translate compile; gunicorn microblog:app worker: rq worker microblog-tasks 将这些变更重新部署之后,可以使用以下命令启动worker: $ heroku ps:scale worker=1 部署到Docker 如果你将应用程序部署到Docker容器,那么首先需要创建一个Redis容器。 为此,你可以使用Docker镜像仓库中的其中一个官方Redis镜像: $ docker run --name redis -d -p 6379:6379 redis:3-alpine 当运行你的应用时,你需要以类似于MySQL容器的链接方式,链接redis容器并设置REDIS_URL环境变量。 下面是一个完整的命令来启动应用,包含了一个redis链接: $ docker run --name microblog -d -p 8000:5000 --rm -e SECRET_KEY=my-secret-key \ -e MAIL_SERVER=smtp.googlemail.com -e MAIL_PORT=587 -e MAIL_USE_TLS=true \ -e MAIL_USERNAME=<your-gmail-username> -e MAIL_PASSWORD=<your-gmail-password> \ --link mysql:dbserver --link redis:redis-server \ -e DATABASE_URL=mysql+pymysql://microblog:<database-password>@dbserver/microblog \ -e REDIS_URL=redis://redis-server:6379/0 \ microblog:latest 最后,你需要为RQ worker运行一/多个容器。 由于worker与主应用具有相同的代码,因此可以使用与应用相同的容器镜像,并覆盖启动命令,以便启动worker而不是Web应用。 以下是启动worker的docker run命令: $ docker run --name rq-worker -d --rm -e SECRET_KEY=my-secret-key \ -e MAIL_SERVER=smtp.googlemail.com -e MAIL_PORT=587 -e MAIL_USE_TLS=true \ -e MAIL_USERNAME=<your-gmail-username> -e MAIL_PASSWORD=<your-gmail-password> \ --link mysql:dbserver --link redis:redis-server \ -e DATABASE_URL=mysql+pymysql://microblog:<database-password>@dbserver/microblog \ -e REDIS_URL=redis://redis-server:6379/0 \ --entrypoint venv/bin/rq \ microblog:latest worker -u redis://redis-server:6379/0 microblog-tasks 覆盖Docker镜像的默认启动命令有点棘手,因为命令需要分两部分给出。 --entrypoint参数只取得可执行文件的名称,但是参数(如果有的话)需要在镜像和标签之后,也就是在命令行的结尾处给出。 请注意rq命令需要使用venv/bin/rq,以便在没有手动激活虚拟环境的情况下,也能识别虚拟环境并正常工作。
本文转载自:https://www.jianshu.com/p/14bc349c0716 这是Flask Mega-Tutorial系列的第二十部分,我将添加一个功能,当你将鼠标悬停在用户的昵称上时,会弹出一个漂亮的窗口。 现在,构建一个Web应用而不使用JavaScript是不可能的。 你一定知道,JavaScript是Web浏览器中可本地运行的唯一语言。 在第十四章中,你看到我在Flask模板中添加了一个简单的JavaScript的启用链接,以提供实时的用户动态的语言翻译。 而在本章中,我将深入探讨该主题,并向你展示另一个有用的JavaScript技巧,给应用程序增添趣味来吸引用户。 社交网站的常见用户交互模式是,当你将鼠标悬停在用户的名称上时,可以在弹出窗口中显示用户的主要信息。 如果你从未注意到这一点,请访问Twitter,Facebook,LinkedIn或任何其他主要社交网站,当你看到用户名时,只需将鼠标指针放在上面几秒钟即可看到弹出窗口。 本章将致力于为Microblog实现该功能,你可以在下面看到预览效果: 本章的GitHub链接为:Browse, Zip, Diff. 服务端的支持 在深入研究客户端之前,让我们先了解一下支持这些用户弹窗所需的服务器端的工作。 用户弹窗的内容将由新路由返回,它是现有个人主页路由的简化版本。 视图函数如下: app/main/routes.py:用户弹窗视图函数。 @bp.route('/user/<username>/popup') @login_required def user_popup(username): user = User.query.filter_by(username=username).first_or_404() return render_template('user_popup.html', user=user) 该路由将被附加到/user//popup URL,并且将简单地加载所请求的用户,然后渲染到模板中。 该模板是个人主页的简化版本: app/templates/user_popup.html:用户弹窗模板。 <table class="table"> <tr> <td width="64" style="border: 0px;"><img src="{{ user.avatar(64) }}"></td> <td style="border: 0px;"> <p> <a href="{{ url_for('main.user', username=user.username) }}"> {{ user.username }} </a> </p> <small> {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %} {% if user.last_seen %} <p>{{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}</p> {% endif %} <p>{{ _('%(count)d followers', count=user.followers.count()) }}, {{ _('%(count)d following', count=user.followed.count()) }}</p> {% if user != current_user %} {% if not current_user.is_following(user) %} <a href="{{ url_for('main.follow', username=user.username) }}"> {{ _('Follow') }} </a> {% else %} <a href="{{ url_for('main.unfollow', username=user.username) }}"> {{ _('Unfollow') }} </a> {% endif %} {% endif %} </small> </td> </tr> </table> 当用户将鼠标指针悬停在用户名上时,随后小节中编写的JavaScript代码将会调用此路由。客户端将服务器端返回的响应中的html内容显示在弹出窗口中。 当用户移开鼠标时,弹出窗口将被删除。 听起来很简单,对吧? 如果你想了解弹窗像什么样,现在可以运行应用,跳转到任何用户的个人主页,然后在地址栏的URL中追加/popup以查看全屏版本的弹出窗口内容。 Bootstrap Popover组件简介 在第十一章中,我向你介绍了可便捷地创建精美网页的Bootstrap框架。 到目前为止,我只使用了这个框架的一小部分。 Bootstrap捆绑了许多常见的UI元素,所有这些元素都在地址为https://getbootstrap.com的Bootstrap文档中有demo和示例。 其中一个组件是Popover(弹窗),在文档中将其描述为“用于容纳辅助信息的小的覆盖窗口”。 这正是我需要的! 大多数bootstrap组件都是通过HTML标记定义的,该标记引用Bootstrap CSS的定义内容来添加漂亮的样式。 一些高级的组件还需要JavaScript。 应用程序在网页中包含这些组件的标准方式是在适当的位置添加HTML,然后为需要脚本支持的组件调用JavaScript函数,以便初始化或激活它。 popover组件确实需要JavaScript的支持。 要做弹窗的HTML部分非常简单,你只需要定义将触发弹窗的元素。 就我而言,就是处理每条用户动态中出现的可点击的用户名。 app/templates/_post.html子模板具有已定义的用户名: <a href="{{ url_for('main.user', username=post.author.username) }}"> {{ post.author.username }} </a> 现在根据popover文档,我需要调用每个链接上的popover() JavaScript函数,就像上面出现在页面上的链接一样,这才能初始化弹出窗口。 初始化调用接受许多配置弹出窗口的选项,包括传递想要在弹出窗口中显示的内容,以及使用什么方法触发弹出窗口出现或消失(单击,悬停在元素上等),如果内容是纯文本或HTML,那么在文档中可以找到更多的选项。不幸的是,在阅读完这些信息之后,我的疑惑更多了,因为这个组件看起来并没有按照我需要的方式工作。 以下是我实现此功能需要解决的问题列表: 在页面中会有很多用户名链接,每条用户动态都会显示一个。我需要有一种方法可以在页面渲染后用JavaScript中找到所有这些链接,以便我可以将它们初始化为弹出窗口。 Bootstrap文档中的popover示例都将目标HTML元素的data-content属性设置为popover的内容,因此当触发悬停事件时,Bootstrap需要做的只是显示弹出窗口。这对我来说要做的就不止这些了,因为我想对服务器进行Ajax调用以获取内容,并且只有当收到服务器的响应时,我才希望弹出窗口出现。 使用“悬停”模式时,只要你将鼠标指针放在目标元素中,弹出窗口就会保持可见状态。当你移开鼠标时,弹出窗口将消失。这具有糟糕的副作用,即如果用户想要将鼠标指针移动到弹出窗口中,弹出窗口将消失。我需要找出一种方法来将悬停行为扩展为包含弹出窗口,以便用户可以移动到弹出窗口中,例如,单击那里的链接。 在开发基于浏览器的应用程序时,事情变得越来越复杂的情况,实际上并不罕见。 你必须非常仔细地考虑DOM元素如何相互作用,并使其行为方式提供良好的用户体验。 在页面加载完成后执行函数 很明显,我将需要在每个页面加载后立即运行一些JavaScript代码。 我要运行的函数将搜索页面中用户名的所有链接,并使用Bootstrap中的弹出窗口组件配置它们。 jQuery JavaScript库作为Bootstrap的依赖项加载,因此我将利用它。 当使用jQuery时,你可以用$(...)封装来注册一个函数,函数将会在页面加载完毕后运行。 我可以将它添加到app/templates/base.html模板中,以便它可以在应用程序的每个页面上运行: app/templates/base.html:页面加载完毕后运行函数。 ... <script> // ... $(function() { // write start up code here }); </script> 正如你所看到的,我已经在<script>元素中添加了我的启动函数,而在第十四章中,我已在该元素中定义了中的translate()函数。 使用选择器查找DOM元素 第一个要解决的问题是创建一个JavaScript函数来查找页面中的所有用户链接。 这个函数将在页面加载完成时运行,并且当完成时,将为所有页面配置悬停和弹出行为。 现在我要集中精力来寻找链接。 回顾第十四章,在实时翻译中被调用的HTML元素具有唯一的ID。 例如,ID = 123的用户动态中具有id="post123"属性。 然后使用jQuery,在JavaScript中使用表达式$('#post123')在DOM中定位此元素。 $()函数功能非常强大,并且具有相当复杂的查询语言来搜索DOM元素,可以参考CSS Selectors。 我用于翻译功能的选择器旨在使用id属性查找一个具有唯一标识符的特定元素。 识别元素的另一种方法是使用class属性,它可以分配给页面中的多个元素。 例如,我可以用class="user_popup"标记所有的用户链接,然后我可以通过$('.user_popup')获取这些元素的列表(CSS选择器中,#前缀代表查询id属性,’.’前缀代表查询class属性)。 在本处,返回值将是具有该类的所有元素的集合。 弹窗和DOM元素 通过使用Bootstrap文档中的弹出窗口示例并在浏览器的调试器中检查DOM,我确定Bootstrap将弹出窗口组件创建为DOM中目标元素的同级元素。 正如我上面提到的,这会影响悬停事件的行为,只要用户将鼠标从<a>链接移动到弹出窗口本身,就会触发“鼠标移出”事件。 我可以扩展悬停事件以包含弹出窗口,就是将弹出窗口作为目标元素的子元素,这样悬停事件就会继承。 通过查看文档中的弹出选项,可以通过在container选项中传递父元素来完成此操作。 将popover作为悬停元素的子元素可以很好地用于按钮或一般的<div>或<span>元素,但在我的情况下,popover的target将是显示用户名的可点击链接的<a>元素。 使popover成为<a>元素的子元素的问题是,弹出窗口将获得<a>父元素的链接行为。 最终的结果是这样的: <a href="..." class="user_popup"> username <div> ... popover elements here ... </div> </a> 为了避免弹出窗口出现在<a>元素中,我要使用的是另一个技巧。 我要将<a>元素封装在<span>元素中,然后将悬停事件和弹出窗口与<span>相关联。 由此产生的结构将是: <span class="user_popup"> <a href="..."> username </a> <div> ... popover elements here ... </div> </span> <div>和<span>元素是不可见的,因此它们是用于帮助组织和构建DOM的重要元素。 div元素是块元素,有点像HTML文档中的段落,而<span>元素是行内元素,它可以用于字词级别。 本处,我决定使用<span>元素,因为我要包装的<a>元素也是行内元素。 因此,我将继续并重构我的app/templates/_post.html子模板以包含<span>元素: ... {% set user_link %} <span class="user_popup"> <a href="url_for('main.user', username=post.author.username)"> {{ post.author.username }} </a> </span> {% endset %} ... 如果你想知道弹出式HTML元素在哪里,好消息是我不必操心这一点。 当我在刚刚创建的<span>元素上调用popover()初始化函数时,Bootstrap框架会为我动态地插入弹出组件。 悬停事件 正如我上面提到的,Bootstrap中的popover组件使用的悬停行为不够灵活,无法满足我的需求,但如果你查看trigger选项的文档,则hover只是其中一个可能的值。 一个引起我注意的是manual模式,在这种模式下,可以通过JavaScript调用手动显示或删除弹出窗口,这种模式可以让我自由地实现悬停逻辑,所以我将使用该选项并实现我自己的悬停事件处理程序,并以我需要的方式工作。 所以我的下一步是将一个“hover”事件附加到页面中的所有链接。 使用jQuery,可以通过调用element.hover(handlerIn, handlerOut)将悬停事件附加到任何HTML元素。 如果在元素集合上调用这个函数,jQuery方便地将事件附加到所有元素上。 这两个参数是两个函数,分别在用户将鼠标指针移入和移出目标元素时调用对应的函数。 app/templates/base.html:悬停事件。 $(function() { $('.user_popup').hover( function(event) { // mouse in event handler var elem = event.currentTarget; }, function(event) { // mouse out event handler var elem = event.currentTarget; } ) }); 事件参数是一个事件对象,它包含了一些有用的信息。 在本处,我使用event.currentTarget来提取事件的目标元素。 浏览器在鼠标进入受影响的元素后立即调度悬停事件。 针对弹出行为,你只想鼠标停留在元素上一段时间才能激活,以防当鼠标指针短暂通过元素但不停留在元素上时出现弹出闪烁。 由于该事件不支持延迟,因此这是我需要自己实现的另一件事情。 所以我打算在“鼠标进入”事件处理程序中添加一秒计时器: app/templates/base.html:悬停延迟。 $(function() { var timer = null; $('.user_popup').hover( function(event) { // mouse in event handler var elem = event.currentTarget; timer = setTimeout(function() { timer = null; // popup logic goes here }, 1000); }, function(event) { // mouse out event handler var elem = event.currentTarget; if (timer) { clearTimeout(timer); timer = null; } } ) }); setTimeout()函数在浏览器环境中才可用。 它需要两个参数,函数和毫秒单位的时间。 setTimeout()的效果是函数在给定的延迟后被调用。 所以我添加了一个函数(现在是空的),将在悬停事件的一秒钟后被调用。 由于JavaScript语言中的闭包机制,此函数可以访问在外部作用域中定义的变量,例如elem。 我将timer对象存储在hover()调用之外定义的timer变量中,以使timer对象也可以被“mouse out”处理程序访问。 我需要这么做的原因是为了获得良好的用户体验。 如果用户将鼠标指针移动到其中一个用户链接中,并在移动它之前停留了半秒钟,我不希望该timer继续运行并调用显示弹出窗口的函数。 所以我的鼠标移出事件处理程序检查是否有一个活动的timer对象,如果有,就取消它。 Ajax请求 Ajax请求不是一个新话题了,因为我已经在第十四章中已介绍过这个主题,来作为实时语言翻译功能。 当使用jQuery时,$.ajax()函数向服务器发送一个异步请求。 我要发送到服务器的请求将具有类似/user//popup模式的URL,在本章开始时我已经将该URL添加到应用程序中。 这个请求的响应将包含我需要在弹出窗口中插入的HTML。 关于这个请求的直接问题是我需要知道包含在URL中的“username”的值是什么。 鼠标进入的事件处理函数是通用的,它将在页面中找到的所有用户链接,所以该函数需要从其上下文中确定用户名。 elem变量包含悬停事件中的目标元素,它是包裹<a>元素的<span>元素。 为了提取用户名,我可以从<span>开始浏览DOM,移至第一个子元素,即<a>元素,然后从中提取文本,这就是在网址中要使用的用户名 。 使用jQuery的DOM遍历函数,可以很简单地做到: elem.first().text().trim() 应用于DOM节点的first()函数返回其第一个子节点。 text()函数返回节点的文本内容。 该函数不会对文本进行任何修剪,例如,如果在一行中有<a>,在下一行中有文本,在另一行中有</a>,text()将返回文本周围的所有空白。 为了消除所有空白并只留下文本,我使用了名为trim()的JavaScript函数。 这就是我需要向服务器发出请求的所有信息: app/templates/base.html:XHR请求。 $(function() { var timer = null; var xhr = null; $('.user_popup').hover( function(event) { // mouse in event handler var elem = $(event.currentTarget); timer = setTimeout(function() { timer = null; xhr = $.ajax( '/user/' + elem.first().text().trim() + '/popup').done( function(data) { xhr = null // create and display popup here } ); }, 1000); }, function(event) { // mouse out event handler var elem = $(event.currentTarget); if (timer) { clearTimeout(timer); timer = null; } else if (xhr) { xhr.abort(); xhr = null; } else { // destroy popup here } } ) }); 代码中,我在外部范围中定义了一个新变量xhr。 这个变量将保存我通过调用$ .ajax()来初始化的异步请求对象。 不幸的是,当直接在JavaScript端构建URL时,我无法使用Flask中的url_for(),所以在这种情况下,我必须显式连接URL的各个部分。 $ .ajax()调用返回一个promise,这是一个代表异步操作的特殊JavaScript对象。 我可以通过添加.done(function)来附加一个完成回调函数,所以一旦请求完成,我的回调函数就会被调用。 回调函数将接收到的响应作为参数,你可以在上面的代码中看到,我将其命名为data。 这将是我要放入popover的HTML内容。 但在我们获得弹窗之前,还有一个细节需要处理,以便给予用户一个良好的体验。 回想一下之前添加的逻辑,如果用户在触发鼠标进入事件之后的一秒内将鼠标指针移出<span>,将触发取消弹窗的逻辑。 同样的逻辑也需要应用于异步请求,所以我添加了第二个子句来放弃我的xhr请求对象(如果存在)。 弹窗的创建和销毁 最后我使用在Ajax回调函数中传递给我的data参数来创建我的弹窗组件: app/templates/base.html:显示弹窗。 function(data) { xhr = null; elem.popover({ trigger: 'manual', html: true, animation: false, container: elem, content: data }).popover('show'); flask_moment_render_all(); } 弹出窗口的实际创建非常简单,Bootstrap的popover()函数完成设置所需的所有工作。 弹出窗口的选项作为参数给出。 我已经用manual触发模式,HTML内容,没有淡入淡出的动画(这样它就会更快地出现和消失)配置了这个弹出窗口,并且我已经将父元素设置为<span>元素本身,所以悬停行为通过继承扩展到弹出窗口。 最后,我将Ajax回调函数的data参数作为content参数的值。 popover()调用创建了一个弹窗组件,该组件也具有一个名为popover()的方法来显示弹窗。因此我不得不添加第二个popover('show')调用来将弹窗显示到页面中。 弹出窗口的内容包括第十二章中通过Flask-Moment插件生成的“最后访问”日期。 文档中提到,当通过Ajax添加新的Flask-Moment元素时,需要调用flask_moment_render_all()函数来适当地渲染这些元素。 现在剩下的就是完善鼠标移出事件处理程序上的删除弹出窗口逻辑。 如果用户将鼠标移出目标元素,该处理程序已经具有中止弹出操作的逻辑。 如果这些条件都不适用,那么这意味着弹出窗口当前显示并且用户正在离开target区域,所以在这种情况下,对目标元素的popover('destroy')调用将正确地执行移除和清理。 app/templates/base.html:销毁弹窗。 function(event) { // mouse out event handler var elem = $(event.currentTarget); if (timer) { clearTimeout(timer); timer = null; } else if (xhr) { xhr.abort(); xhr = null; } else { elem.popover('destroy'); } }
本文转载自:https://www.jianshu.com/p/c29bc412f21a 这是Flask Mega-Tutorial系列的第十九部分,我将在其中部署Microblog到Docker容器平台。 在第十七章中,你了解了传统部署,使用这种部署方式,你必须关注服务器配置的每个细节。 然后在第十八章我带你到另一个极端——Heroku ,这是一项完全掌控配置和部署任务的服务,使你能够全神贯注于应用程序。 在本章中,你将学习基于容器(尤其是在Docker容器平台)的第三种应用程序部署策略。 这种部署的工作量,介于另外两个选项之间。 容器建立在轻量级虚拟化技术的基础上,允许应用程序及其依赖和配置完全隔离宿主机地运行,而不需要使用虚拟机等完整的虚拟化解决方案。使用虚拟机需要更多的资源,并且有时可能与宿主机相比,性能显著下降。 配置为容器宿主机的系统可以运行大量容器,所有这些容器共享主机的内核并直接访问主机的硬件。 这与虚拟机不同,虚拟机必须模拟完整的系统,包括CPU,磁盘,其他硬件,内核等。 尽管必须共享内核,但容器中的隔离级别非常高。 容器具有自己的文件系统,并且可以基于容器宿主机使用不同的操作系统。 例如,你可以在Fedora宿主机上运行基于Ubuntu Linux的容器,反之亦然。 尽管容器是Linux操作系统上诞生的技术,但由于虚拟化的原因,也可以在Windows和Mac OS X宿主机上运行Linux容器。 这允许你在开发系统上测试部署操作,并且如果你愿意的话,还可以将容器合并到开发工作流程中去。 本章的GitHub链接为:Browse, Zip, Diff. 安装Docker社区版 尽管Docker不是唯一的容器平台,但它是迄今为止最受欢迎的,所以我选择了它。 有两个版本的Docker,免费的社区版(CE)和付费的企业版(EE)。 对于本教程来说,Docker CE就够了。 要使用Docker CE,首先必须将其安装在系统上。 在Docker网站上有适用于Windows,Mac OS X和多个Linux发行版的安装程序。 如果你正在使用Microsoft Windows系统,请务必注意Docker CE依赖Hyper-V。 如有必要,安装程序将为你启用此功能,但请记住,启用Hyper-V会限制诸如VirtualBox等其他虚拟化技术产品的运行。 一旦Docker CE安装在你的系统上,你可以通过在终端窗口或命令提示符处输入以下命令来验证安装是否成功: $ docker version Client: Version: 17.09.0-ce API version: 1.32 Go version: go1.8.3 Git commit: afdb6d4 Built: Tue Sep 26 22:40:09 2017 OS/Arch: darwin/amd64 Server: Version: 17.09.0-ce API version: 1.32 (minimum version 1.12) Go version: go1.8.3 Git commit: afdb6d4 Built: Tue Sep 26 22:45:38 2017 OS/Arch: linux/amd64 Experimental: true 构建容器镜像 为Microblog创建容器的第一步是为它构建一个镜像。 容器镜像是用于创建容器的模板。 它包含容器文件系统的完整表示,以及与网络,启动选项等相关的各种设置。 为应用程序创建容器镜像的最基本方法是启动一个要使用的基本操作系统(Ubuntu,Fedora等)容器,连接到运行在其中的bash shell进程,然后手动安装应用程序,可以参照我在第十七章中介绍的流程进行传统部署。 安装完所有内容后,你可以保存容器的快照,并生成容器镜像。 docker命令支持这种类型的工作流,但我不打算讨论这种方法,因为它非常不便,每次需要生成新镜像时都必须手动安装应用程序。 更好的方法是通过脚本生成容器镜像。 创建脚本化容器镜像的命令是docker build。 该命令从一个名为Dockerfile的文件读取并执行构建指令(我需要创建这些指令)。 Dockerfile基本上可以认为是一个安装程序脚本,它执行安装步骤来部署应用程序,以及一些容器特定的设置。 这是Microblog的一份基础的Dockerfile: Dockerfile: Microblog的Dockerfile。 FROM python:3.6-alpine RUN adduser -D microblog WORKDIR /home/microblog COPY requirements.txt requirements.txt RUN python -m venv venv RUN venv/bin/pip install -r requirements.txt RUN venv/bin/pip install gunicorn COPY app app COPY migrations migrations COPY microblog.py config.py boot.sh ./ RUN chmod +x boot.sh ENV FLASK_APP microblog.py RUN chown -R microblog:microblog ./ USER microblog EXPOSE 5000 ENTRYPOINT ["./boot.sh"] Dockerfile中的每一行都是一条命令。 FROM命令指定将在其上构建新镜像的基础容器镜像。 这样一来,你从一个现有的镜像开始,添加或改变一些东西,并最终得到一个派生的镜像。 镜像由名称和标签来标记,它们之间用冒号分隔。 该标签用作版本控制机制,允许容器镜像提供多个版本。 我选择的镜像的名称是python,它是Python的官方Docker镜像。 该镜像的标签允许你指定解释器版本和基础操作系统。 3.6-alpine标签选择安装在Alpine Linux上的Python 3.6解释器。 由于其体积小,Alpine Linux发行版比起更常见的发行版(例如Ubuntu)会更多地被使用。 你可以在Python镜像库中查看Python镜像可用的标签。 RUN命令在容器的上下文中执行任意命令。 这与你在shell提示符下输入命令相似。 adduser -D microblog命令创建一个名为microblog的新用户。 大多数容器镜像都使用root作为默认用户,但以root身份运行应用程序并不是一个好习惯,所以我创建了自己的用户。 WORKDIR命令设置将要安装应用程序的默认目录。 当我在上面创建microblog用户时,会自动创建了一个主目录,所以现在我将该目录设置为默认目录。 在Dockerfile中的任何剩余命令执行以及运行容器时,其当前目录为这个默认目录。 COPY命令将文件从你的机器复制到容器文件系统。 该命令需要两个或更多参数,源文件/目录和目标文件/目录。 源文件必须与Dockerfile所在的目录相关。 目的地可以是绝对路径,也可以是相对于在之前的WORKDIR命令中设置的目录的路径。 在这第一个COPY命令中,我将requirements.txt文件复制到容器文件系统的microblog用户的主目录中。 容器中有了requirements.txt文件,我就可以使用RUN命令创建一个虚拟环境。 首先我创建它,然后在其中安装所有依赖。 由于依赖文件仅包含通用依赖项,因此我明确安装gunicorn,以将其用作Web服务器。 当然,我也可以在我的requirements.txt文件中添加gunicorn。 接下来的三个COPY命令从顶级目录中复制app包,含有数据库迁移的migrations目录以及中的microblog.py和config.py脚本。 我还复制了一个新文件,boot.sh,我将在下面讨论它。 RUN chmod命令确保将这个新的boot.sh文件正确设置为可执行文件。 如果你使用的是基于Unix的文件系统,并且你的源文件已被标记为可执行文件,则复制的文件将会已是可执行的。 我显式地对其进行授权,是因为在Windows上很难设置可执行位。 如果你正在使用Mac OS X或Linux,你可能不需要这个步骤,但有了它也不会有什么问题。 ENV命令在容器中设置环境变量。我需要设置FLASK_APP,它是flask命令所依赖的。 下面的RUN chown命令将存储在/home/microblog中的所有目录和文件的所有者设置为新的microblog用户。 尽管我在Dockerfile的顶部附近创建了该用户,但所有命令的默认用户仍为root,因此所有这些文件的属主都需要切换到microblog用户,以便在容器启动时该用户可以正确运行这些文件。 下一行中的USER命令使得这个新的microblog用户成为任何后续指令的默认用户,并且也是容器启动时的默认用户。 EXPOSE命令配置该容器将用于服务的端口。 这是必要的,以便Docker可以适当地在容器中配置网络。 我选择了标准的Flask端口5000,但这其实可以是任意端口。 最后,ENTRYPOINT命令定义了容器启动时应该执行的默认命令。 这是启动应用程序Web服务器的命令。 为了保持良好的代码组织逻辑,我决定为此创建一个单独的脚本,正是我之前复制到容器的boot.sh文件。 这里是这个脚本的内容: boot.sh:Docker容器启动脚本。 #!/bin/sh source venv/bin/activate flask db upgrade flask translate compile exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app 这是一个相当标准的启动脚本,与第十七章和第十八章的部署启动十分类似。 激活虚拟环境,执行迁移框架升级数据库,编译语言翻译,最后用gunicorn运行服务器。 请注意gunicorn命令之前的exec。 在shell脚本中,exec触发正在运行脚本的进程被给定的命令来替换掉,而不是将这个命令作为新进程启动。 这很重要,因为Docker会将容器的生命与其上运行的第一个进程关联起来。 在像这样的情况下,启动进程不是容器的主进程,你需要确保主进程取代启动进程,以确保容器不会提前停止。 Docker的一个有趣的方面是容器写入stdout或stderr的任何内容都将被捕获并存储为容器的日志。 出于这个原因,-access-logfile和--error-logfile都配置为-,它将日志发送到标准输出,以便它们作为日志由Docker存储。 Dockerfile写好后,我现在可以构建容器镜像了: $ docker build -t microblog:latest . 我给docker build命令的-t参数设置了新容器镜像的名称和标签。 .表示容器构建的基础目录,这就是Dockerfile所在的目录。 构建过程将执行Dockerfile中的所有命令并创建镜像,该镜像将存储在你自己的机器上。 你可以使用docker images命令获取本地镜像的列表: $ docker images REPOSITORY TAG IMAGE ID CREATED SIZE microblog latest 54a47d0c27cf About a minute ago 216MB python 3.6-alpine a6beab4fa70b 3 months ago 88.7MB 此列表将包含你的新镜像以及它的基础镜像。 每当你对应用程序进行更改后,都可以通过再次运行build命令来更新容器镜像。 启动容器 使用已创建的镜像,你现在可以运行应用程序的容器版本。 通过docker run命令,通常再搭配大量的参数,就可以完成容器的启动。 我将首先向你展示一个基本的例子: $ docker run --name microblog -d -p 8000:5000 --rm microblog:latest 021da2e1e0d390320248abf97dfbbe7b27c70fefed113d5a41bb67a68522e91c --name选项为新容器提供了一个名称。 -d选项告诉Docker在后台运行容器。 如果没有-d,容器将作为前台应用程序运行,从而阻塞你的命令提示符。 -p选项将容器端口映射到主机端口。 第一个端口是主机上的端口,右边的端口是容器内的端口。 上面的例子暴露了主机端口8000,其对应容器中的端口5000,因此即使内部容器使用5000,你也将在宿主机上访问端口8000来访问应用程序。 一旦容器停止,--rm选项将使其自动被删除。 虽然这不是必需的,但完成或中断的容器通常不再需要,因此可以自动删除。 最后一个参数是容器使用的容器镜像名称和标签。 运行上述命令后,可以在http://localhost:8000上访问该应用程序。 docker run的输出是分配给新容器的ID。 这是一个很长的十六进制字符串,在随后的命令中你可以使用它来引用容器。 实际上,只有前几个字符是必需的,足以保证ID的唯一性。 如果你想看看哪些容器正在运行,你可以使用docker ps命令: $ docker ps CONTAINER ID IMAGE COMMAND PORTS NAMES 021da2e1e0d3 microblog:latest "./boot.sh" 0.0.0.0:8000->5000/tcp microblog 你可以看到,其实docker ps命令显示的是缩短了的容器ID。 如果你现在想停止容器,你可以使用docker stop: $ docker stop 021da2e1e0d3 021da2e1e0d3 回顾一下,应用程序配置中有许多来自环境变量的选项。 例如,Flask密钥,数据库URL和电子邮件服务器选项都是从环境变量中导入的。 在上面的docker run例子中,我没有考虑这些,因此所有这些配置选项都将使用默认值。 在更实际的例子中,你将在容器内设置这些环境变量。 你在前面的章节看到,Dockerfile中的ENV命令设置了环境变量,对于将变为静态的变量来说,这是一个方便的选项。 但是,对于依赖于安装的变量,将它们作为构建过程的一部分并不方便,因为你希望容器镜像具有良好的可移植性。 如果你想将应用程序作为容器镜像提供给另一个人,你希望该人员能够按原样使用它,而不必使用不同的变量重新构建它。 所以构建时的环境变量可能很有用,但是也需要有可以通过docker run命令设置的运行时环境变量,对于这些变量,可以使用-e选项来设置。 以下示例设置了密钥和gmail帐户: $ docker run --name microblog -d -p 8000:5000 --rm -e SECRET_KEY=my-secret-key \ -e MAIL_SERVER=smtp.googlemail.com -e MAIL_PORT=587 -e MAIL_USE_TLS=true \ -e MAIL_USERNAME=<your-gmail-username> -e MAIL_PASSWORD=<your-gmail-password> \ microblog:latest 由于具有许多环境变量定义,docker run命令行非常长的情况并不罕见。 使用第三方“容器化”服务 Microblog的容器版本看起来不错,但我还没有真正考虑过很多关于存储的问题。 实际上,由于我没有设置DATABASE_URL环境变量,因此应用程序正在使用默认SQLite数据库并将数据存储在容器内部的文件系统上。 当你停止并删除容器时,你认为数据去哪里了? 数据也会被删除! 容器中的文件系统是临时的,这意味着它随着容器的删除而删除。 你可以将数据写入容器内的文件系统,并且容器可以正常读写数据,但如果出于任何原因需要回收容器并将其替换为新的容器,则应用程序保存到容器内的任何数据将永远丢失。 容器应用程序的一个好的设计策略是保持应用程序容器无状态。 如果你的应用程序代码和数据容器没有任何问题,可以将其丢弃并替换为新的容器,容器变为真正的一次性容器,这在简化升级部署方面非常有用。 但是,这意味着数据必须放在应用程序容器之外的某个位置。 这就是神奇的Docker生态系统发挥作用的地方了。 Docker容器镜像仓库包含大量的容器镜像。你已经了解了Python容器镜像,我正在使用它作为我的Microblog容器的基础镜像。 除此之外,Docker还为Docker容器镜像仓库中的许多其他语言,数据库和其他服务维护镜像,如果这还不够,Docker容器镜像仓库还允许公司为其产品发布容器镜像,并且像你我这样的常规用户也可以发布自己的镜像。 这意味着安装第三方服务需要做出的努力会减少成只需在Docker容器镜像仓库中找到合适的镜像,并通过带有适当参数的docker run命令启动它。 所以我现在要做的是创建两个额外的容器,一个用于MySQL数据库,另一个用于Elasticsearch服务,然后我将加长启动Microblog容器的命令, 以使其能够访问这两个新的容器。 添加MySQL容器 像许多其他产品和服务一样,MySQL在Docker镜像仓库中提供了公共容器镜像。 就像我自己的Microblog容器一样,MySQL依赖于需要传递给docker run的环境变量。 他们配置了密码,数据库名称等。在镜像仓库中有许多MySQL镜像时,我决定使用由MySQL官方团队维护的镜像。 你可以在其镜像仓库页面找到有关MySQL容器镜像的详细信息:https://hub.docker.com/r/mysql/mysql-server/。 回顾一下在第十七章中设置MySQL的繁琐过程,你就会赞叹在Docker中部署MySQL的轻松体验。 这里是启动MySQL服务器的docker run命令: $ docker run --name mysql -d -e MYSQL_RANDOM_ROOT_PASSWORD=yes \ -e MYSQL_DATABASE=microblog -e MYSQL_USER=microblog \ -e MYSQL_PASSWORD=<database-password> \ mysql/mysql-server:5.7 这就对了! 在安装了Docker的任何机器上,你可以运行上面的命令,就会得到一个完成安装的MySQL服务器,它具有一个随机生成的root密码,一个名为microblog的全新数据库和一个名字相同的用户,该用户具备访问这个数据库的所有权限。 请注意,你需要输入正确的密码,以便它可以从MYSQL_PASSWORD环境变量获得。 现在在应用程序方面,我需要添加一个MySQL客户端软件包,就像我在Ubuntu上进行传统部署一样。 我将再次使用pymysql,我可以将它添加到Dockerfile中: Dockerfile:添加pymysql到Dockerfile中。 # ... RUN venv/bin/pip install gunicorn pymysql # ... 任何时候对应用程序或Dockerfile进行更改后,都需要重建容器镜像: $ docker build -t microblog:latest . 现在我可以再次启动Microblog,但是这次连接到数据库容器,以便两者都可以通过网络进行通信: $ docker run --name microblog -d -p 8000:5000 --rm -e SECRET_KEY=my-secret-key \ -e MAIL_SERVER=smtp.googlemail.com -e MAIL_PORT=587 -e MAIL_USE_TLS=true \ -e MAIL_USERNAME=<your-gmail-username> -e MAIL_PASSWORD=<your-gmail-password> \ --link mysql:dbserver \ -e DATABASE_URL=mysql+pymysql://microblog:<database-password>@dbserver/microblog \ microblog:latest --link选项告诉Docker让正要运行的容器可以访问参数中指定的容器。 该参数包含由冒号分隔的两个名称。 第一部分是要链接的容器的名称或ID,在本例中是我在上面创建的一个名为mysql的容器。 第二部分定义了一个可以在这个容器中用来引用链接的主机名。 这里我使用dbserver作为代表数据库服务器的通用名称。 通过建立两个容器之间的链接,我可以设置DATABASE_URL环境变量,以便SQLAlchemy被引导使用其他容器中的MySQL数据库。 数据库URL将使用dbserver作为数据库主机名,microblog作为数据库名称和用户,以及你在启动MySQL时选择的密码。 我在试用MySQL容器时注意到的一件事是,这个容器需要几秒钟才能完全运行并准备好接受数据库连接。 如果启动MySQL容器,然后立刻启动应用容器,在boot.sh脚本尝试运行flask db migrate时,则可能会因数据库未准备好接受连接而失败。 为了使我的解决方案更加健壮,我决定在boot.sh中添加一个重试循环: boot.sh:重试数据库连接。 #!/bin/sh source venv/bin/activate while true; do flask db upgrade if [[ "$?" == "0" ]]; then break fi echo Upgrade command failed, retrying in 5 secs... sleep 5 done flask translate compile exec gunicorn -b :5000 --access-logfile - --error-logfile - microblog:app 此循环检查flask db upgrade命令的退出代码,如果它不为零,则认为出现了问题,因此它会等待5秒钟然后重试。 添加Elasticsearch容器 Elasticsearch Docker文档演示了如何将该服务作为单一节点以用于开发模式,以及部署两个节点的生产环境服务。 现在,我将使用单节点模式,并使用引擎开源的“oss”镜像。 容器使用以下命令启动: $ docker run --name elasticsearch -d -p 9200:9200 -p 9300:9300 --rm \ -e "discovery.type=single-node" \ docker.elastic.co/elasticsearch/elasticsearch-oss:6.1.1 这个docker run命令与我用于Microblog和MySQL的命令有很多相似之处,但是有一些有趣的区别。 首先,有两个-p选项,这意味着这个容器将在两个端口上而不是一个端口上进行监听。 端口9200和9300都映射到主机中的相同端口。 另一个区别在于用于引用容器镜像的语法。 对于我在本地构建的镜像,语法是<name>:<tag>。 MySQL容器使用格式为稍微更完整的<account>/<name>:<tag>语法,适用于在Docker镜像仓库中引用容器镜像。 我使用的Elasticsearch镜像遵循模式<registry>/<account><name>:<tag>,其中包括镜像仓库的地址作为第一个组件。 此语法用于未托管在Docker镜像仓库中的镜像。 在本处,Elasticsearch在docker.elastic.co上运行自己的容器镜像仓库服务,而不是使用由Docker维护的主镜像仓库。 所以,现在我已经启动并运行了Elasticsearch服务,我可以修改Microblog容器的启动命令以创建指向它的链接并设置Elasticsearch服务URL: $ docker run --name microblog -d -p 8000:5000 --rm -e SECRET_KEY=my-secret-key \ -e MAIL_SERVER=smtp.googlemail.com -e MAIL_PORT=587 -e MAIL_USE_TLS=true \ -e MAIL_USERNAME=<your-gmail-username> -e MAIL_PASSWORD=<your-gmail-password> \ --link mysql:dbserver \ -e DATABASE_URL=mysql+pymysql://microblog:<database-password>@dbserver/microblog \ --link elasticsearch:elasticsearch \ -e ELASTICSEARCH_URL=http://elasticsearch:9200 \ microblog:latest 在运行此命令之前,如果你仍然在运行Microblog容器,请先停止它。 还要仔细操作来为数据库设置正确的密码,并让Elasticsearch服务的参数处于命令中的恰当位置。 现在你应该可以访问http://localhost:8000并使用搜索功能。 如果你遇到任何错误,可以通过查看容器日志来对其进行排查。 你很可能希望查看Microblog容器的日志,其中将显示任何Python堆栈跟踪: $ docker logs microblog Docker容器镜像仓库 现在我已经在Docker上使用三个容器来运行了完整的应用程序,其中两个容器来自公开的第三方镜像。 如果你想提供自己的容器镜像给其他人,那么你必须将它们推送到任何人都可以获取到的Docker镜像仓库中。 要访问Docker镜像仓库,你需要转到https://hub.docker.com并为自己创建一个帐户。 确保你选择一个你喜欢的用户名,因为这将用于你发布的所有镜像。 为了能够从命令行访问你的账户,你需要使用docker login命令登录: $ docker login 如果你一直跟随我的引导,现在你的计算机上已经有一个名为microblog:latest的镜像存储在本地。 为了能够将这个镜像推送到Docker镜像仓库中,它需要重新命名以包含该帐户,正如来自MySQL的镜像。 这是通过docker tag命令完成的: $ docker tag microblog:latest <your-docker-registry-account>/microblog:latest 如果你再次用docker images列出你的镜像,你会看到两个Microblog条目,一个是microblog:latest,另一个还包括你的帐户名。 它们实际上是同一镜像的两个别名。 要将镜像发布到Docker镜像仓库,请使用docker push命令: $ docker push <your-docker-registry-account>/microblog:latest 现在你的镜像被公开了,你可以像MySQL和服务那样,说明如何安装它并从Docker镜像仓库运行。 容器化应用的部署 让你的应用程序在Docker容器中运行的最大的好处之一是,一旦该容器在你的本地测试通过了,就可以将它们运行到任何提供Docker支持的平台。 例如,你可以使用第十七章中推荐的Digital Ocean,Linode或Amazon Lightsail上的相同服务器。 即使这些提供商提供的最便宜的产品也足以让Docker运行一些容器。 Amazon Container Service(ECS)使你能够创建一个容器宿主机集群,以在其中运行容器。在集成完备的AWS环境中,提供了水平扩展和负载平衡,以及为容器镜像使用私有容器镜像仓库的功能。 最后,容器编排平台例如Kubernetes通过允许你以简单的YAML格式文本文件描述你的多容器部署逻辑,来提供了更高级别的自动化和便利性, 负载均衡,水平扩展,密钥的安全管理以及滚动升级和回滚。
本文转载自:https://www.jianshu.com/p/d13dc21c6e43这是Flask Mega-Tutorial系列的第十八部分,我将在其中部署Microblog到Heroku云平台。 在前面的文章中,我向你展示了托管Python应用程序的“传统”方式,并且我演示了两个部署到Linux的服务器的实际示例。 如果你不曾管理过Linux系统,那么你可能认为需要投入大量工作到这项任务中,而且肯定会有一个更简单的方法。 在本章中,我将向你展示一种完全不同的部署方法,该方法依赖第三方云托管提供程序来执行大部分管理任务,从而使你能够腾出更多时间处理应用程序。 许多云托管提供商提供了一个应用程序可以运行的托管平台。 你只需提供部署到这些平台上的实际应用程序,因为硬件,操作系统,脚本语言解释器,数据库等都由该服务管理。 这种服务称为平台即服务(PaaS)。 是不是感到难以置信? 我将把Microblog部署到Heroku,这是一种流行的云托管服务,对Python应用程序也非常友好。 我选择Heroku不仅仅是因为它非常受欢迎,还因为它有一个免费的服务级别,可以让你跟随我并在不花钱的情况下完成部署。 本章的GitHub链接为:Browse, Zip, Diff. 托管于Heroku Heroku是首批PaaS平台之一。 它以Ruby的应用程序的托管服务开始,随后逐渐发展到支持诸多其他语言,如Java,Node.js和Python。 在Heroku中部署Web应用程序主要是通过git版本控制工具完成的,因此你必须将应用程序放在git代码库中。 Heroku在应用程序的根目录中查找名为Procfile的文件,以获取有关如何启动应用程序的描述。 对于Python项目,Heroku还期望requirements.txt文件列出需要安装的所有模块依赖项。 在通过git将应用程序上传到Heroku的服务器之后,你的工作基本就完成了,只需等待几秒钟,应用程序就会上线。 整个操作流程就是这么简单。 Heroku提供不同的服务级别,允许你自主选择为应用程序提供多少计算能力和运行时间,随着用户群的增长,你需要购买更多的“dynos”计算单元。 准备好了吗?让我们开始吧! 创建Heroku账户 在部署应用到Heroku之前,你需要拥有一个帐户。 所以请访问heroku.com并创建一个免费账户。 一旦注册成功并登录到Heroku,你将可以访问一个dashboard,其中列出了你的所有应用程序。 安装Heroku命令行客户端 Heroku提供了一个名为Heroku CLI的命令行工具来与服务交互,可安装于Windows,Mac OS X和Linux。 该文档包括了支持的所有平台的安装说明。 如果你计划部署应用程序以测试该服务,请将其安装在你的系统上。 安装CLI后应该做的第一件事是登录到你的Heroku帐户: $ heroku login Heroku CLI会要求你输入电子邮件地址和帐户密码。 你的身份验证状态将在随后的命令中被记住。 设置Git git工具是Heroku应用程序部署的核心,因此如果你还没有安装它的话,则必须将它安装到你的系统上。 如果你没有可用于你的操作系统的安装包,可以访问git site下载安装程序。 使用git的原因很多并且都理由充分。 如果你打算部署应用到Heroku,那么这些原因就要又增加一个,因为要部署应用到Heroku,你的应用程序必须在git代码库中。 如果你要为Microblog执行测试部署,可以从GitHub克隆应用程序: $ git clone https://github.com/miguelgrinberg/microblog $ cd microblog $ git checkout v0.18 git checkout命令将代码库切换到指定的历史提交点,也就是本章所处的位置。 如果更喜欢使用你自己的代码,你可以通过在顶层目录中运行git init .来将你自己的项目转换成git代码库(注意init后面的句号,它告诉git你想要在当前目录中初始化代码库)。 创建Heroku应用 要用Heroku注册一个新应用,需要在应用程序根目录下使用apps:create子命令,并将应用程序名称作为唯一参数传递: $ heroku apps:create flask-microblog Creating flask-microblog... done http://flask-microblog.herokuapp.com/ | https://git.heroku.com/flask-microblog.git Heroku要求应用程序的名称具有唯一性。 我上面已使用了flask-microblog这个名称,所以你需要为你的部署选择一个不同的名称。 该命令的输出将包含Heroku分配给应用程序的URL以及git代码库。 你的本地git代码库将配置一个额外的remote,称为heroku。 你可以用git remote命令验证它是否存在: $ git remote -v heroku https://git.heroku.com/flask-microblog.git (fetch) heroku https://git.heroku.com/flask-microblog.git (push) 根据你创建git代码库的方式,上述命令的输出还可能包含另一个名为origin的远程仓库地址。 临时文件系统 Heroku平台与其他部署平台不同之处在于它在虚拟化平台上运行的文件系统是临时的。 那是什么意思? 这意味着Heroku可以随时将运行你的应用的虚拟服务器重置为干净状态。 你不该天真地认为你保存到文件系统的任何数据都会被持久存储,事实上,Heroku经常回收服务器。 在这种条件下工作会为我的应用程序带来一些问题,因为它使用了如下的几个文件: 默认的SQLite数据库引擎将数据写入磁盘文件中 应用程序的日志也写入磁盘文件中 编译的语言翻译存储库同样是本地文件 以下部分将针对这三个方面提出解决方案。 使用Heroku Postgres数据库 为了解决第一个问题,我将切换到不同的数据库引擎。 在第十七章中,你看到我使用MySQL数据库为Ubuntu部署添加健壮性。 Heroku基于Postgres数据库提供了自己的数据库产品,因此我将转而使用它来避免使用基于文件的SQLite。 Heroku应用的数据库使用相同的Heroku CLI进行设置。 在本章中,我将创建一个免费级别的数据库: $ heroku addons:add heroku-postgresql:hobby-dev Creating heroku-postgresql:hobby-dev on flask-microblog... free Database has been created and is available ! This database is empty. If upgrading, you can transfer ! data from another database with pg:copy Created postgresql-parallel-56076 as DATABASE_URL Use heroku addons:docs heroku-postgresql to view documentation 新创建的数据库的URL存储在DATABASE_URL环境变量中,该变量在应用程序运行时将可用。 这就非常方便了,因为应用程序已经设定为在该变量中查找数据库URL。 输出日志到标准输出 Heroku希望应用程序直接输出日志到stdout。 当你使用heroku logs命令时,应用程序打印到标准输出的任何内容都将被保存并返回。 所以我要添加一个配置变量,指示我是要输出日志到stdout,还是像我之前那样输出到文件。 这是配置的变化: config.py:输出日志到标准输出的选项。 class Config(object): # ... LOG_TO_STDOUT = os.environ.get('LOG_TO_STDOUT') 然后在应用工厂函数中,我会检查此配置以了解应该如何配置应用程序的日志记录器: app/__init__.py:输出日志到标准输出或文件。 def create_app(config_class=Config): # ... if not app.debug and not app.testing: # ... if app.config['LOG_TO_STDOUT']: stream_handler = logging.StreamHandler() stream_handler.setLevel(logging.INFO) app.logger.addHandler(stream_handler) else: if not os.path.exists('logs'): os.mkdir('logs') file_handler = RotatingFileHandler('logs/microblog.log', maxBytes=10240, backupCount=10) file_handler.setFormatter(logging.Formatter( '%(asctime)s %(levelname)s: %(message)s ' '[in %(pathname)s:%(lineno)d]')) file_handler.setLevel(logging.INFO) app.logger.addHandler(file_handler) app.logger.setLevel(logging.INFO) app.logger.info('Microblog startup') return app 所以现在我需要在Heroku中运行应用程序时设置LOG_TO_STDOUT环境变量,但在其他配置中则不需要。 Heroku CLI使得做到这一点变得简单,因为它提供了一个选项来设置运行时使用的环境变量: $ heroku config:set LOG_TO_STDOUT=1 Setting LOG_TO_STDOUT and restarting flask-microblog... done, v4 LOG_TO_STDOUT: 1 编译翻译 Microblog依赖本地文件的第三个方面是编译后的语言翻译文件。 确保这些文件永远不会从临时文件系统中消失的粗暴做法是将编译后的语言文件添加到git代码库,以便在部署到Heroku后它们成为应用程序初始状态的一部分。 在我看来,更优雅的选择是在Heroku的启动命令中包含flask translate compile命令,以便在服务器重新启动时再次编译这些文件。 我打算选择这个方案,因为我知道启动过程需要多个命令,至少我还需要运行数据库迁移。 所以现在,我将把这个问题放在一边,稍后当我写Procfile的时候会重新讨论它。 托管Elasticsearch Elasticsearch是可以添加到Heroku项目中的众多服务之一,但与Postgres不同的是,这不是由Heroku提供的服务,而是由与Heroku合作提供附加组件的第三方提供的。 在我写这篇文章的时候,有三个不同的集成Elasticsearch服务提供商。 在配置Elasticsearch之前,请注意,Heroku要求你的帐户在安装任何第三方附加组件之前添加信用卡信息,即使你仍处于在免费级别中。 如果你不想将信用卡信息提供给Heroku,请跳过此部分。 你仍然可以部署应用程序,但搜索功能不起作用。 在可作为附加组件提供的Elasticsearch选项中,我决定尝试SearchBox,它附带一个免费的初试计划。 要将SearchBox添加到你的帐户,你必须在登录到Heroku后运行以下命令: $ heroku addons:create searchbox:starter 该命令将部署一个Elasticsearch服务,并将该服务的连接URL保存在与你的应用程序关联的SEARCHBOX_URL环境变量中。 请记住,除非将你的信用卡信息添加到你的Heroku帐户中,否则此命令将失败。 回忆一下第十六章,我的应用程序在Elasticsearch连接URL中查找的是ELASTICSEARCH_URL变量,所以我需要添加这个变量并将其设置为由SearchBox分配的连接URL: $ heroku config:get SEARCHBOX_URL <your-elasticsearch-url> $ heroku config:set ELASTICSEARCH_URL=<your-elasticsearch-url> 在这里,我首先要求Heroku打印SEARCHBOX_URL的值,然后将其添加到一个名为ELASTICSEARCH_URL的新环境变量中。 更新依赖 Heroku期望依赖关系在requirements.txt文件中,就像我在第十五章中定义的那样。 但是为了在Heroku上运行应用程序,我需要为这个文件添加两个新的依赖关系。 Heroku不提供自己的Web服务器。 相反,它希望应用程序根据环境变量$PORT中给出的端口号启动自己的Web服务器。 由于Flask开发Web服务器不足以用于生产,因此我将再次使用gunicorn,这是Heroku为Python应用程序推荐的服务器。 该应用程序还将连接到Postgres数据库,为此SQLAlchemy依赖psycopg2软件包的安装。 gunicorn 和psycopg2 都需要添加到requirements.txt文件中。 Procfile Heroku需要知道如何执行应用程序,并且它会在应用程序的根目录中使用名为Procfile的文件。 这个文件的格式很简单,每行包含一个进程名称,一个冒号,然后是启动进程的命令。 在Heroku上运行的最常见的应用程序类型是一个Web应用程序,对于这种类型的应用程序,进程名称应该是web。 下面你可以看到Microblog的Procfile: Procfile:Heroku Procfile。 web: flask db upgrade; flask translate compile; gunicorn microblog:app 在这里,我定义的启动命令中将按顺序执行三个命令作以启动Web应用程序。 首先,我运行数据库迁移升级,然后编译语言翻译,最后启动服务器。 因为前两个子命令是基于flask命令的,所以我需要添加FLASK_APP环境变量: $ heroku config:set FLASK_APP=microblog.py Setting FLASK_APP and restarting flask-microblog... done, v4 FLASK_APP: microblog.py gunicorn命令比我用于Ubuntu部署的还要简单,因为这个服务与Heroku环境有很好的集成。 例如,$PORT环境变量默认会被设置,取代使用-w选项来设置worker的数量,heroku推荐添加一个名为WEB_CONCURRENCY的环境变量,在-w参数没有提供的时候,就会使用这个环境变量,因此你可以灵活地控制worker的数量而无需修改Procfile。 部署应用 所有准备步骤都已完成,所以现在是时候执行部署了。 要将应用程序上传到Heroku的服务器进行部署,需要使用git push命令。 这与你将本地git代码库中的更改推送到GitHub或其他远程git服务器的方式类似。 现在我已经达到了最有趣的部分,就是将应用程序推送到我们的Heroku托管帐户。 这其实很简单,我只需要使用git将应用程序推送到Heroku git代码库的主分支就行了。 关于如何做到这一点有几种方法,取决于你是如何创建你的git代码库的。 如果你使用我的v0.18代码,那么你需要基于此标记创建一个分支,并将其作为远程主分支推送,如下所示: $ git checkout -b deploy $ git push heroku deploy:master 相反,如果你正在使用自己的代码库,那么你的代码已经在master分支中,所以你首先需要确保你的更改已经提交: $ git commit -a -m "heroku deployment changes" 然后运行如下命令启动部署: $ git push heroku master 无论你如何推送分支,都应该看到Heroku的以下输出: $ git push heroku deploy:master Counting objects: 247, done. Delta compression using up to 8 threads. Compressing objects: 100% (238/238), done. Writing objects: 100% (247/247), 53.26 KiB | 3.80 MiB/s, done. Total 247 (delta 136), reused 3 (delta 0) remote: Compressing source files... done. remote: Building source: remote: remote: -----> Python app detected remote: -----> Installing python-3.6.2 remote: -----> Installing pip remote: -----> Installing requirements with pip ... remote: remote: -----> Discovering process types remote: Procfile declares types -> web remote: remote: -----> Compressing... remote: Done: 57M remote: -----> Launching... remote: Released v5 remote: https://flask-microblog.herokuapp.com/ deployed to Heroku remote: remote: Verifying deploy... done. To https://git.heroku.com/flask-microblog.git * [new branch] deploy -> master 我们在git push命令中使用的标签heroku是在创建应用程序时由Heroku CLI自动添加的远程代码库。 deploy:master参数意味着我将代码从本地代码库的deploy分支推送到Heroku代码库上的master分支。 当你使用自己的项目时,你可能会用git push heroku master命令推动你的本地master分支。 由于这个项目的代码库分支结构,我推送了一个非master的分支,但Heroku侧要求的目标分支是’master’,因为这是Heroku唯一接受部署的分支。 就这样,应用程序现在应该已经部署在创建应用程序的命令的输出中给出的URL上了。 在我的案例中,URL是https://flask-microblog.herokuapp.com,所以这就是我需要键入和访问该应用程序的URL。 如果你想查看正在运行的应用程序的日志,请使用heroku logs命令。 如果由于任何原因导致应用程序无法启动,该命令可能很有用。 如果有任何错误,将在日志中显示。 部署应用更新 要部署新版本的应用程序,只需要使用git push命令将新的代码库推送到Heroku即可。 这将重复部署过程,关停旧部署,然后用新代码替换它。 Procfile中的命令将作为新部署的一部分再次运行,因此在此过程中将更新任何新的数据库迁移或翻译内容。
本文转载自:https://www.jianshu.com/p/e9eff3dbc2a2 这是Flask Mega-Tutorial系列的第十七部分,我将把Microblog部署到Linux服务器。 在本章中,我将谈到Microblog应用生命周期中的一个里程碑,因为我将讨论如何将应用部署到生产服务器上,以便真实用户可以访问它。 部署的主题非常广泛,因此不可能在这里涵盖所有范畴。 本章致力于探讨传统托管方式,包括Ubuntu发行版的Linux服务器和流行的树莓派微机。 我将在后面的章节中介绍其他选项,例如云和容器部署。 本章的GitHub链接为:Browse, Zip, Diff. 传统托管 当提到“传统托管”时,意思是应用是手动或通过原始服务器机器上的脚本安装部署的。 该过程涉及安装应用程序、其依赖项和生产规模的Web服务器,并配置系统以确保其安全。 当你要部署自己的项目时,要问的第一个问题是在哪找服务器。 目前有很多经济的托管服务。 只需每月5美元,Digital Ocean,Linode或Amazon Lightsail就可以租借一台虚拟化Linux服务器(Linode和Digital Ocean为其入门级服务器提供1GB RAM,而亚马逊仅提供512MB)给你运行部署实验。 如果你一分钱都不愿意花,那么Vagrant和VirtualBox组合而成的工具,可以让你在自己的计算机上创建一个与付费服务器类似的虚拟服务器。 就技术角度而言,该应用可以部署在任何主流操作系统上,包括各种开放源代码的Linux和BSD发行版以及商用的OS X(OS X是一个开源和商业的混种,因为它基于开源BSD衍生产品Darwin)和Microsoft Windows。 由于OS X和Windows是的桌面操作系统,不是作为服务器的最佳选择,因此不是首选。 Linux或BSD操作系统之间的选择很大程度上取决于爱好,所以我将选择其中更受欢迎的Linux。 而Linux发行版中,我将再次选择受欢迎的Ubuntu。 创建Ubuntu服务器 如果你有兴趣与我一起部署,那么就需要一台服务器才能开始工作。 为你推荐两种选择,一种是付费的,另一种是免费的。 如果你愿意花一点钱,可以在Digital Ocean,Linode或Amazon Lightsail上注册一个账户,并创建一个Ubuntu 16.04镜像的虚拟服务器。 你应该使用最低配置的服务器,在我写这篇文章的时候,三家的最低配置都是每月5美元。 开销是按照服务器启动的小时数进行比例计算的,因此,如果你创建服务器后,使用几个小时然后删除它,那么有可能你只需支付美分级别的费用。 免费的方案基于你的计算机上可以运行虚拟机。 要使用此选项,请在你的机器上安装Vagrant和VirtualBox,然后创建一个名为Vagrantfile的文件并用以下内容来描述虚拟机的规格: Vagrantfile:Vagrant配置。 Vagrant.configure("2") do |config| config.vm.box = "ubuntu/xenial64" config.vm.network "private_network", ip: "192.168.33.10" config.vm.provider "virtualbox" do |vb| vb.memory = "1024" end end 该文件配置了一个带有1GB RAM的Ubuntu 16.04服务器,你可以用其IP地址192.168.33.10来访问该服务器。 要创建服务器,请运行以下命令: $ vagrant up 请参阅Vagrant 命令行文档了解其他管理虚拟服务器的选项。 使用SSH客户端 你的服务器处于后端,所以不需要像个人计算机上那样拥有桌面。 你可以通过SSH客户端连接到服务器,并运行命令行进行交互。 如果你使用的是Linux或Mac OS X,则可能已经安装了OpenSSH。 如果你使用Microsoft Windows,Cygwin,Git和Windows Subsystem for Linux提供OpenSSH,因此你可以安装这些选项中的任何一个。 如果你正在使用来自第三方提供商的虚拟服务器,则在创建服务器时,会为其分配IP地址。 你可以使用以下命令打开终端会话来连接到该服务器: $ ssh root@<server-ip-address> 系统会提示你输入密码。密码已在创建服务器后自动生成并显示给你,或者你自己指定了密码。 如果你使用的是Vagrant VM,则可以使用以下命令打开终端会话: $ vagrant ssh 如果你使用的是Windows并且拥有Vagrant虚拟机,请注意你需要从可以调用ssh命令的shell运行上述命令。 免密登录 如果你使用的是Vagrant虚拟机,那么可以跳过本节,因为你的虚拟机已正确配置为使用名为ubuntu的非root帐户,Vagrant不用输入密码就可以自动登录。 要是你使用的是虚拟服务器,则建议创建一个常规用户来完成你的部署工作,并配置此帐户以便在不使用密码的情况下登录,这么做最初看起来似乎是一个糟糕的主意, 之后你会发现它不仅更方便,而且更安全。 我将创建一个名为ubuntu的用户帐户(如果你愿意,可以使用其他名称)。 要创建这个用户,请使用前一节中的ssh指令登录到你的服务器的root帐户,然后键入以下命令来创建用户,给它sudo权限并最终切换到它: $ adduser --gecos "" ubuntu $ usermod -aG sudo ubuntu $ su ubuntu 现在我要配置这个新的ubuntu帐户来使用public key认证,以便你可以免密登录。 先不管服务器上打开的终端会话,然后在本地计算机上启动第二个终端。 如果你使用的是Windows,这需要是可以访问ssh命令的终端,所以它可能是一个bash或者类似的提示符的终端,而不是本地的Windows终端。 在该终端会话中,检查~/.ssh目录的内容: $ ls ~/.ssh id_rsa id_rsa.pub 如果目录列表显示如上所述的名为id_rsa和id_rsa.pub的文件,那么你已经有一个密钥。 如果没有这两个文件,或者根本没有~/.ssh目录,则你需要运行以下命令(也是OpenSSH工具集的一部分)来创建SSH密钥对: $ ssh-keygen 此应用程序将提示你输入一些内容,为此我建议你在所有提示中按Enter以接受默认设置。 你当然也可以做一些设置,如果你知道这么做意味着什么的话。 运行此命令后,应该有上面列出的两个文件了。 文件id_rsa.pub是你的公钥,这是一个你将提供给第三方的文件,用于识别你的身份。 id_rsa文件是你的私钥,不应与任何人共享。 你现在需要将公钥配置为服务器中的授权主机。 在你自己的计算机上打开的终端上,将公钥打印到屏幕上: $ cat ~/.ssh/id_rsa.pub ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCjw....F8Xv4f/0+7WT miguel@miguelspc 这将是一个非常长的字符序列,显示时可能跨越多行(但实际上只有一行)。 你需要将此数据复制到剪贴板,然后切换回远程服务器上的终端,你将在其中运行以下命令来存储公钥: $ echo <paste-your-key-here> >> ~/.ssh/authorized_keys $ chmod 600 ~/.ssh/authorized_keys 免密登录现在应该可以工作了。 背后逻辑是,你机器上的ssh会用私钥执行加密操作来向服务器标识自己。 然后服务器使用你的公钥验证操作是否有效。 你现在可以注销ubuntu会话,然后注销root会话,然后尝试直接登录到ubuntu帐户: $ ssh ubuntu@<server-ip-address> 这一次不用输入密码就登录了! 保护你的服务器 为了最大限度地降低服务器受到攻击的风险,你可以采取一些措施来关闭攻击者可能访问的大量潜在漏洞。 我要做的第一个更改是禁用root用户通过SSH登录。 你现在可以无密码地访问ubuntu帐户,并且可以通过sudo从该帐户运行管理员命令,因此实际上不需要暴露root帐户。 要禁用root登录,你需要编辑服务器上的/etc/ssh/sshd_config文件。 你可能在你的服务器上安装了vi和nano文本编辑器,你可以用它来编辑文件(如果你不熟悉这两种文件编辑器,可以首先尝试nano)。 由于SSH配置对普通用户是不可访问的,所以你需要在编辑器命令前添加sudo(即sudo vi /etc/ssh/sshd_config)。 你需要更改此文件中的单行: /etc/ssh/sshd_config:禁止root登录。 PermitRootLogin no 请注意,要进行此更改,你需要找到以PermitRootLogin开头的行(找不到就新建一行)并将该值更改为no。 下一个更改在同一个文件中。 现在我要为所有帐户禁用密码登录。 你有一个无密码的登录设置,所以没有必要允许密码。 如果你对完全禁用密码感到紧张,可以跳过此更改,但对于生产服务器来说,这是一个非常好的主意,因为攻击者经常在所有服务器上尝试随机帐户名和密码并希望能中奖。 要禁用密码登录,请在/etc/ssh/sshd_config中更改以下行: /etc/ssh/sshd_config:禁用密码登录。 PasswordAuthentication no 完成编辑SSH配置后,需要重新启动ssh服务以使更改生效: $ sudo service ssh restart 我要做的第三个改变是安装防火墙。 这是一个阻止在任何未明确启用的端口上访问服务器的软件: $ sudo apt-get install -y ufw $ sudo ufw allow ssh $ sudo ufw allow http $ sudo ufw allow 443/tcp $ sudo ufw --force enable $ sudo ufw status 这些命令会安装ufw(简单防火墙),并将其配置为仅允许端口22(ssh),80(http)和443(https)上的外部通信。 任何其他端口将不被允许。 安装基础依赖 如果你遵循了我的建议并配置了Ubuntu 16.04发行版的服务器,那么你的系统完全支持Python 3.5,因此这是我将用于部署的Python版本。 基础的Python解释器可能已经预先安装在你的服务器上,但有一些额外的软件包可能却没有,而且Python之外还有一些其他软件包可用于创建健壮的生产环境部署。 对于数据库服务器,我将从SQLite切换到MySQL。 Postfix包是一个邮件传输代理,我将用它来发送电子邮件。 Supervisor工具将监视Flask服务器进程,并在其崩溃时自动重启,并当Supervisor服务重启后自动启动其监视的服务。 Nginx服务器将接受来自外部世界的所有请求,并将它们转发给应用程序。 最后,我将使用git来从git仓库下载应用程序。 $ sudo apt-get -y update $ sudo apt-get -y install python3 python3-venv python3-dev $ sudo apt-get -y install mysql-server postfix supervisor nginx git 这些安装大部分是无人值守的,但是在运行第三条安装语句到一定进度时,系统会提示你为MySQL服务选择一个root密码,并且还会询问关于安装postfix软件包的一些问题,你可以接受他们的默认答案。 请注意,对于此部署,我选择不安装Elasticsearch。 这项服务需要大量的RAM,所以只有拥有超过2GB内存的大型服务器时才可以考虑。 为了避免服务器内存不足的问题,我将停用搜索功能。 如果你有高配的服务器,可以从Elasticsearch站点下载官方的.deb软件包,并按照其安装说明将其添加到你的服务器。 请注意,Ubuntu 16.04软件包存储库中提供的Elasticsearch软件包太旧,无法运行,你需要6.x或更高版本。 我还注意到,默认安装的postfix可能不足以在生产环境中发送电子邮件。 为了避免垃圾邮件和恶意邮件,很多服务器都要求发件人服务器通过安全扩展标识自己,这意味着至少你必须拥有与你的服务器相关联的域名。 如果你想了解如何完全配置电子邮件服务器以使其通过标准安全测试,请参阅以下Digital Ocean的指南: Postfix Configuration Adding an SPF Record DKIM Installation and Configuration 安装应用 现在我要使用git从我的GitHub代码库下载Microblog源代码。 如果你不熟悉git源码控制,我建议你阅读git for beginners。 要将应用下载到服务器,请确保你位于ubuntu用户的主目录中,然后运行: $ git clone https://github.com/miguelgrinberg/microblog $ cd microblog $ git checkout v0.17 这会将代码克隆到你的服务器上,并将其同步到本章的内容。 如果你在学习本教程的过程中维护了自己的git代码库,则可以将代码库URL更改为你的URL,在这种情况下,你可以跳过git checkout命令。 现在我需要创建一个虚拟环境并使用所有的包依赖项来填充它,在第十五章中,我已将依赖包的列表保存到requirements.txt文件中: $ python3 -m venv venv $ source venv/bin/activate (venv) $ pip install -r requirements.txt 除了requirements.txt中的包之外,我还将使用此生产部署指定的两个包,因此它们不包含在requirements.txt文件中。 gunicorn软件包是Python应用程序的生产Web服务器。 pymysql软件包包含MySQL驱动程序,它使SQLAlchemy能够与MySQL数据库一起工作: (venv) $ pip install gunicorn pymysql 我需要创建一个.env文件,其中包含所有需要的环境变量: /home/ubuntu/microblog/.env:环境配置。 SECRET_KEY=52cb883e323b48d78a0a36e8e951ba4a MAIL_SERVER=localhost MAIL_PORT=25 DATABASE_URL=mysql+pymysql://microblog:<db-password>@localhost:3306/microblog MS_TRANSLATOR_KEY=<your-translator-key-here> 这个.env文件与我在第十五章展示的非常类似,但是我为SECRET_KEY使用了一个随机字符串。 为了生成这个随机字符串,我使用了下面的命令: python3 -c "import uuid; print(uuid.uuid4().hex) 对于DATABASE_URL变量,我定义了一个MySQL URL。 我将在下一节中向你介绍如何配置数据库。 我需要将FLASK_APP环境变量设置为应用程序的入口点以启用flask命令,但在解析.env文件之前需要此变量,因此需要手动设置。 为避免每次都设置它,我把它添加到ubuntu帐户的~/.profile文件的底部,以便每次登录时自动设置它: $ echo "export FLASK_APP=microblog.py" >> ~/.profile 如果你注销并重新登录,现在FLASK_APP就已经设置好了。 你可以通过运行flask --help来确认它是否已经设置好了。 如果帮助信息显示应用程序已添加的translate命令,那么你就知道应用程序已被找到。 现在flask命令是有效的,我可以编译语言翻译: (venv) $ flask translate compile 设置MySQL 我在开发过程中使用过的sqlite数据库非常适合简单的应用程序,但是当部署可能需要一次处理多个请求的健壮Web服务器时,最好使用更强大的数据库。 出于这个原因,我要建立一个名为’microblog’的MySQL数据库。 要管理数据库服务器,我将使用mysql命令,该命令应该已经安装在你的服务器上: $ mysql -u root -p Enter password: Welcome to the MySQL monitor. Commands end with ; or \g. Your MySQL connection id is 6 Server version: 5.7.19-0ubuntu0.16.04.1 (Ubuntu) Copyright (c) 2000, 2017, Oracle and/or its affiliates. All rights reserved. Oracle is a registered trademark of Oracle Corporation and/or its affiliates. Other names may be trademarks of their respective owners. Type 'help;' or '\h' for help. Type '\c' to clear the current input statement. mysql> 请注意,你需要键入你在安装MySQL时选择的MySQL root密码才能访问MySQL命令提示符。 这些是创建名为microblog的新数据库的命令,以及具有完全访问权限的同名用户: mysql> create database microblog character set utf8 collate utf8_bin; mysql> create user 'microblog'@'localhost' identified by '<db-password>'; mysql> grant all privileges on microblog.* to 'microblog'@'localhost'; mysql> flush privileges; mysql> quit; 你将需要用你选择的密码来替换<db-password>。 这将是microblog数据库用户的密码,所以不要使用你已为root用户选择的密码。 microblog用户的密码需要与你包含在.env文件中的DATABASE_URL变量中的密码相匹配。 如果你的数据库配置是正确的,你现在应该能够运行数据库迁移以创建所有的表: (venv) $ flask db upgrade 继续下一步之前,确保上述命令成功完成且不会产生任何错误。 设置Gunicorn和Supervisor 当你使用flask run运行服务器时,正在使用的是Flask附带的Web服务器。 该服务器在开发过程中非常有用,但它不适合用于生产服务器,因为它不考虑性能和稳健性。 取而代之,我决定使用gunicorn,它是一个纯粹的Python Web服务器,但与Flask不同,它是一个支持高并发的强大生产服务器,同时它也非常容易使用。 要在gunicorn下启动Microblog,你可以使用以下命令: (venv) $ gunicorn -b localhost:8000 -w 4 microblog:app -b选项告诉gunicorn在哪里监听请求,我在8000端口上监听了内部网络接口。 在没有外部访问的情况下运行Python Web应用程序通常是一个好主意,然后还需要一个非常快速的Web服务器,它可以优化来自客户端的所有静态文件的请求。 这个快速的Web服务器将直接提供静态文件,并将用于应用程序的任何请求转发到内部服务器。 我将在下一节中向你展示如何将nginx设置为面向公众的服务器。 -w选项配置gunicorn将运行多少worker。 拥有四个进程可以让应用程序同时处理多达四个客户端,这对于Web应用程序通常足以处理大量客户端请求,因为并非所有客户端都在不断请求内容。 根据服务器的RAM大小,你可能需要调整worker数量,以免内存不足。 microblog:app参数告诉gunicorn如何加载应用程序实例。 冒号前的名称是包含应用程序的模块,冒号后面的名称是此应用程序的名称。 虽然gunicorn的设置非常简单,但从命令行运行服务器在生产服务器实际上不是一个恰当的方案。 我想要做的是让服务器在后台运行,并持续监视,因为如果由于某种原因导致服务器崩溃并退出,我想确保新的服务器自动启动以取代它。 而且我还想确保如果机器重新启动,服务器在启动时自动运行,而无需人工登录和启动。 我将使用上面安装的supervisor包来执行此操作。 Supervisor使用配置文件定义它要监视什么程序以及如何在必要时重新启动它们。 配置文件必须存储在/etc/supervisor/conf.d中。 这是Microblog的配置文件,我将其称为microblog.conf: /etc/supervisor/conf.d/microblog.conf:Supervisor配置。 [program:microblog] command=/home/ubuntu/microblog/venv/bin/gunicorn -b localhost:8000 -w 4 microblog:app directory=/home/ubuntu/microblog user=ubuntu autostart=true autorestart=true stopasgroup=true killasgroup=true command,directory和user设置告诉supervisor如何运行应用程序。 如果计算机启动或崩溃,autostart和autorestart设置会使microblog自动重新启动。 stopasgroup和killasgroup选项确保当supervisor需要停止应用程序来重新启动它时,它仍然会调度成顶级gunicorn进程的子进程。 编写此配置文件后,必须重载supervisor服务的配置才能导入它: $ sudo supervisorctl reload 像这样,这个gunicorn web服务器就已经启动和运行,并处于监控之中! 设置Nginx 由gunicorn启动的microblog应用服务器现在运行在本地端口8000。 我现在需要做的是将应用程序暴露给外部世界,为了使面向公众的web服务器能够被访问,我在防火墙上打开了两个端口(80和443)来处理应用程序的Web通信。 我希望这是一个安全的部署,所以我要配置端口80将所有流量转发到将要加密的端口443。 我将首先创建一个SSL证书。创建一个自签名SSL证书,这对于测试是可以的,但对于真正的部署不太好,因为Web浏览器会警告用户,证书不是由可信证书颁发机构颁发的。 创建microblog的SSL证书的命令是: $ mkdir certs $ openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \ -keyout certs/key.pem -out certs/cert.pem 该命令将要求你提供关于应用程序和你自己的一些信息。 这些信息将包含在SSL证书中,如果用户请求查看它,Web浏览器则会向用户显示它们。上述命令的结果将是名为key.pem和cert.pem的两个文件,我将其放置在Microblog根目录的certs子目录中。 要有一个由nginx服务的网站,你需要为它编写配置文件。 在大多数nginx安装中,这个文件需要位于/etc/nginx/sites-enabled目录中。Nginx在这个位置安装了一个我不需要的测试站点,所以我将首先删除它: $ sudo rm /etc/nginx/sites-enabled/default 下面你可以看到Microblog的nginx配置文件,它在/etc/nginx/sites-enabled/microblog中: /etc/nginx/sites-enabled/microblog:Nginx配置。 server { # listen on port 80 (http) listen 80; server_name _; location / { # redirect any requests to the same URL but on https return 301 https://$host$request_uri; } } server { # listen on port 443 (https) listen 443 ssl; server_name _; # location of the self-signed SSL certificate ssl_certificate /home/ubuntu/microblog/certs/cert.pem; ssl_certificate_key /home/ubuntu/microblog/certs/key.pem; # write access and error logs to /var/log access_log /var/log/microblog_access.log; error_log /var/log/microblog_error.log; location / { # forward application requests to the gunicorn server proxy_pass http://localhost:8000; proxy_redirect off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /static { # handle static files directly, without forwarding to the application alias /home/ubuntu/microblog/static; expires 30d; } } Nginx的配置不易理解,但我添加了一些注释,至少你可以知道每个部分的功能。 如果你想获得关于特定指令的信息,请参阅nginx官方文档。 添加此文件后,你需要告诉nginx重新加载配置以激活它: $ sudo service nginx reload 现在应用程序应该部署成功了。 在你的Web浏览器中,可以键入服务器的IP地址(如果使用的是Vagrant VM,则为192.168.33.10),然后该服务器将连接到应用程序。 由于你使用的是自签名证书,因此将收到来自Web浏览器的警告,你必须解除该警告。 使用上述说明为自己的项目完成部署之后,我强烈建议你将自签名证书替换为真实的证书,以便浏览器不会在用户访问你的网站时发出警告。 为此,你首先需要购买域名并将其配置为指向你的服务器的IP地址。 一旦你有一个域名,你可以申请一个免费的Let’s Encrypt SSL证书。 我在博客上写了一篇关于如何通过HTTPS运行你的Flask应用程序的详细文章。 部署应用更新 我想讨论的基于Linux的部署的最后一个主题是如何处理应用程序升级。 应用程序源代码通过git安装在服务器中,因此,无论何时想要将应用程序升级到最新版本,都可以运行git pull来下载自上次部署以来的新提交。 当然,下载新版本的代码不会导致升级。 当前正在运行的服务器进程将继续运行,旧代码已被读取并存储在内存中。 要触发升级,你必须停止当前的服务器并启动一个新的服务器,以强制重新读取所有代码。 进行升级通常比重新启动服务器更为复杂。 你可能需要应用数据库迁移或编译新的语言翻译,因此实际上,执行升级的过程涉及一系列命令: (venv) $ git pull # download the new version (venv) $ sudo supervisorctl stop microblog # stop the current server (venv) $ flask db upgrade # upgrade the database (venv) $ flask translate compile # upgrade the translations (venv) $ sudo supervisorctl start microblog # start a new server 树莓派托管 树莓派是一款革命性低成本的小型Linux计算机,功耗非常低,因此它是托管家庭在线服务器的理想设备,可以全天候在线而无需捆绑你的台式电脑或笔记本电脑。 有几个Linux发行版可以在树莓派上运行。 我的选择是Raspbian,这是树莓派基金会的官方发行版。 为了准备树莓派的环境,我要安装一个新的Raspbian版本。 我将使用2017年9月版的Raspbian Stretch Lite,但在阅读本文时,可能会有更新的版本,请查看官方下载页面获得最新版本。 Raspbian镜像需要安装在SD卡上,然后插入树莓派,以便它启动时可以识别到。 在树莓派站点上可以查看到从Windows,Mac OS X和Linux将Raspbian镜像复制到SD卡的方法。 当你第一次启动树莓派时,请在连接到键盘和显示器时进行操作,以便你可以进行设置。 至少应该启用SSH,以便你可以从计算机登录并方便地执行部署任务。 和Ubuntu一样,Raspbian也是Debian的衍生产品,所以上面针对的Ubuntu Linux的说明,大部分都可以在树莓派上生效。 但是,如果你计划在家庭网络上运行小型应用程序而无需外部访问时,则可以跳过某些步骤。 例如,你可能不需要防火墙或无密码登录。 你可能想在这样一台小型的计算机上使用SQLite而不是MySQL。 你可以选择不使用nginx,并且让gunicorn服务器直接监听来自客户端的请求。 你可能只想要一个gunicorn worker进程。 Supervisor服务对于确保应用程序始终处于运行状态非常有用,因此我建议你仍然在树莓派上使用它。
本文转载自:https://www.jianshu.com/p/56cfc972d372 这是Flask Mega-Tutorial系列的第十六部分,我将在其中为Microblog添加全文搜索功能。 本章的目标是为Microblog实现搜索功能,以便用户可以使用自然语言查找有趣的用户动态内容。许多不同类型的网站,都可以使用Google,Bing等搜索引擎来索引所有内容,并通过其搜索API提供搜索结果。 这这方法适用于静态页面较多的的大部分网站,比如论坛。 但在我的应用中,基本的内容单元是一条用户动态,它是整个网页的很小一部分。 我想要的搜索结果的类型是针对这些单独的用户动态而不是整个页面。 例如,如果我搜索单词“dog”,我想查看任何用户发表的包含该单词的动态。 很明显,显示所有包含“dog”(或任何其他可能的搜索字词)的用户动态的页面并不存在,大型搜索引擎也就无法索引到它。所以,我别无选择,只能自己实现搜索功能。 本章的GitHub链接为:Browse, Zip, Diff. 全文搜索引擎简介 对于全文搜索的支持不像关系数据库那样是标准化的。 有几种开源的全文搜索引擎:Elasticsearch,Apache Solr,Whoosh,Xapian,Sphinx等等,如果这还不够,常用的数据库也可以像我上面列举的那些专用搜索引擎一样提供搜索服务。 SQLite,MySQL和PostgreSQL都提供了对搜索文本的支持,以及MongoDB和CouchDB等NoSQL数据库当然也提供这样的功能。 如果你想知道哪些应用程序可以在Flask应用中运行,那么答案就是所有! 这是Flask的强项之一,它在完成工作的同时不会自作主张。 那么到底选择哪一个呢? 在专用搜索引擎列表中,Elasticsearch非常流行,部分原因是它在ELK栈中是用于索引日志的“E”,另两个是Logstash和Kibana。 使用某个关系数据库的搜索能力也是一个不错的选择,但考虑到SQLAlchemy不支持这种功能,我将不得不使用原始SQL语句来处理搜索,否则就需要一个包, 它提供一个文本搜索的高级接口,并与SQLAlchemy共存。 基于上述分析,我将使用Elasticsearch,但我将以一种非常容易切换到另一个搜索引擎的方式来实现所有文本索引和搜索功能。 你可以用其他搜索引擎的替代替换我的实现,只需在单个模块中重写一些函数即可。 安装Elasticsearch 有几种方法可以安装Elasticsearch,包括一键安装程序,带有需要自行安装的二进制程序的zip包,甚至是Docker镜像。 该文档有一个安装页面,其中包含所有这些安装选项的详细信息。 如果你使用Linux,你可能会有一个可用于你的发行版的软件包。 如果你使用的是Mac并安装了Homebrew,那么你可以简单地运行brew install elasticsearch。 在计算机上安装Elasticsearch后,你可以在浏览器的地址栏中输入http://localhost:9200来验证它是否正在运行,预期的返回结果是JSON格式的服务基本信息。 由于我使用Python来管理Elasticsearch,因此我会使用其对应的Python客户端库: (venv) $ pip install elasticsearch 当然不要忘记更新requirements.txt文件: (venv) $ pip freeze > requirements.txt Elasticsearch入门 我将在Python shell中为你展示使用Elasticsearch的基础知识。 这将帮助你熟悉这项服务,以便了解稍后将讨论的实现部分。 要建立与Elasticsearch的连接,需要创建一个Elasticsearch类的实例,并将连接URL作为参数传递: >>> from elasticsearch import Elasticsearch >>> es = Elasticsearch('http://localhost:9200') Elasticsearch中的数据需要被写入索引中。 与关系数据库不同,数据只是一个JSON对象。 以下示例将一个包含text字段的对象写入名为my_index的索引: >>> es.index(index='my_index', doc_type='my_index', id=1, body={'text': 'this is a test'}) 如果需要,索引可以存储不同类型的文档,在本处,可以根据不同的格式将doc_type参数设置为不同的值。 我要将所有文档存储为相同的格式,因此我将文档类型设置为索引名称。 对于存储的每个文档,Elasticsearch使用了一个唯一的ID来索引含有数据的JSON对象。 让我们在这个索引上存储第二个文档: >>> es.index(index='my_index', doc_type='my_index', id=2, body={'text': 'a second test'}) 现在,该索引中有两个文档,我可以发布自由格式的搜索。 在本例中,我要搜索this test: >>> es.search(index='my_index', doc_type='my_index', ... body={'query': {'match': {'text': 'this test'}}}) 来自es.search()调用的响应是一个包含搜索结果的Python字典: { 'took': 1, 'timed_out': False, '_shards': {'total': 5, 'successful': 5, 'skipped': 0, 'failed': 0}, 'hits': { 'total': 2, 'max_score': 0.5753642, 'hits': [ { '_index': 'my_index', '_type': 'my_index', '_id': '1', '_score': 0.5753642, '_source': {'text': 'this is a test'} }, { '_index': 'my_index', '_type': 'my_index', '_id': '2', '_score': 0.25316024, '_source': {'text': 'a second test'} } ] } } 在结果中你可以看到搜索返回了两个文档,每个文档都有一个分配的分数。 分数最高的文档包含我搜索的两个单词,而另一个文档只包含一个单词。 你可以看到,即使是最好的结果的分数也不是很高,因为这些单词与文本不是完全一致的。 现在,如果我搜索单词second,结果如下: >>> es.search(index='my_index', doc_type='my_index', ... body={'query': {'match': {'text': 'second'}}}) { 'took': 1, 'timed_out': False, '_shards': {'total': 5, 'successful': 5, 'skipped': 0, 'failed': 0}, 'hits': { 'total': 1, 'max_score': 0.25316024, 'hits': [ { '_index': 'my_index', '_type': 'my_index', '_id': '2', '_score': 0.25316024, '_source': {'text': 'a second test'} } ] } } 我仍然得到相当低的分数,因为我的搜索与文档中的文本不匹配,但由于这两个文档中只有一个包含“second”这个词,所以不匹配的根本不显示。 Elasticsearch查询对象有更多的选项,并且很好地进行了文档化,其中包含诸如分页和排序这样的和关系数据库一样的功能。 随意为此索引添加更多条目并尝试不同的搜索。 完成试验后,可以使用以下命令删除索引: >>> es.indices.delete('my_index') Elasticsearch配置 将Elasticsearch集成到本应用是展现Flask魅力的绝佳范例。 这是一个与Flask没有任何关系的服务和Python包,然而,我将从配置开始将它们恰如其分地集成,我先在app.config模块中实现这样的操作: config.py:Elasticsearch 配置。 class Config(object): # ... ELASTICSEARCH_URL = os.environ.get('ELASTICSEARCH_URL') 与许多其他配置条目一样,Elasticsearch的连接URL将来自环境变量。 如果变量未定义,我将设置其为None,并将其用作禁用Elasticsearch的信号。 这主要是为了方便起见,所以当你运行应用时,尤其是在运行单元测试时,不必强制Elasticsearch服务启动和运行。 因此,为了确保服务的可用性,我需要直接在终端中定义ELASTICSEARCH_URL环境变量,或者将它添加到 .env 文件中,如下所示: ELASTICSEARCH_URL=http://localhost:9200 使用Elasticsearch面临着非Flask插件如何使用的挑战。 我不能像在上面的例子中那样在全局范围内创建Elasticsearch实例,因为要初始化它,我需要访问app.config,它必须在调用create_app()函数后才可用。 所以我决定在应用程序工厂函数中为app实例添加一个elasticsearch属性: app/__init__.py:Elasticsearch实例。 # ... from elasticsearch import Elasticsearch # ... def create_app(config_class=Config): app = Flask(__name__) app.config.from_object(config_class) # ... app.elasticsearch = Elasticsearch([app.config['ELASTICSEARCH_URL']]) \ if app.config['ELASTICSEARCH_URL'] else None # ... 为app实例添加一个新属性可能看起来有点奇怪,但是Python对象在结构上并不严格,可以随时添加新属性。 你也可以考虑另一种方法,就是定义一个从Flask派生的子类(可以叫Microblog),然后在它的__init__()函数中定义elasticsearch属性。 请留意我设计的条件表达式,如果Elasticsearch服务的URL在环境变量中未定义,则赋值None给app.elasticsearch。 全文搜索抽象化 正如我在本章的介绍中所说的,我希望能够轻松地从Elasticsearch切换到其他搜索引擎,并且我也不希望将此功能专门用于搜索用户动态,我更愿意设计一个可复用的解决方案,如果需要,我可以轻松扩展到其他模型。 出于所有这些原因,我决定将搜索功能抽象化。 我的想法是以通用条件来设计特性,所以不会假设Post模型是唯一需要编制索引的模型,也不会假设Elasticsearch是唯一选择的搜索引擎。 但是如果我不能对任何事情做出任何假设,我是不可能完成这项工作的! 我需要的做的第一件事,是找到一种通用的方式来指定哪个模型以及其中的某个或某些字段将被索引。 我设定任何需要索引的模型都需要定义一个__searchable__属性,它列出了需要包含在索引中的字段。 对于Post模型来说,变化如下: app/models.py: 为Post模型添加一个__searchable__属性。 class Post(db.Model): __searchable__ = ['body'] # ... 需要说明的是,这个模型需要有body字段才能被索引。 不过,为了清楚地确保这一点,我添加的这个__searchable__属性只是一个变量,它没有任何关联的行为。 它只会帮助我以通用的方式编写索引函数。 我将在app/search.py模块中编写与Elasticsearch索引交互的所有代码。 这么做是为了将所有Elasticsearch代码限制在这个模块中。 应用的其余部分将使用这个新模块中的函数来访问索引,而不会直接访问Elasticsearch。 这很重要,因为如果有一天我不再喜欢Elasticsearch并想切换到其他引擎,我所需要做的就是重写这个模块中的函数,而应用将继续像以前一样工作。 对于本应用,我需要三个与文本索引相关的支持功能:我需要将条目添加到全文索引中,我需要从索引中删除条目(假设有一天我会支持删除用户动态),还有就是我需要执行搜索查询。 下面是app/search.py模块,它使用我在Python控制台中向你展示的功能实现Elasticsearch的这三个函数: app/search.py: Search functions. from flask import current_app def add_to_index(index, model): if not current_app.elasticsearch: return payload = {} for field in model.__searchable__: payload[field] = getattr(model, field) current_app.elasticsearch.index(index=index, doc_type=index, id=model.id, body=payload) def remove_from_index(index, model): if not current_app.elasticsearch: return current_app.elasticsearch.delete(index=index, doc_type=index, id=model.id) def query_index(index, query, page, per_page): if not current_app.elasticsearch: return [], 0 search = current_app.elasticsearch.search( index=index, doc_type=index, body={'query': {'multi_match': {'query': query, 'fields': ['*']}}, 'from': (page - 1) * per_page, 'size': per_page}) ids = [int(hit['_id']) for hit in search['hits']['hits']] return ids, search['hits']['total'] 这些函数都是通过检查app.elasticsearch是否为None开始的,如果是None,则不做任何事情就返回。 当Elasticsearch服务器未配置时,应用会在没有搜索功能的状态下继续运行,不会出现任何错误。 这都是为了方便开发或运行单元测试。 这些函数接受索引名称作为参数。 在传递给Elasticsearch的所有调用中,我不仅将这个名称用作索引名称,还将其用作文档类型,一如我在Python控制台示例中所做的那样。 添加和删除索引条目的函数将SQLAlchemy模型作为第二个参数。 add_to_index()函数使用我添加到模型中的__searchable__变量来构建插入到索引中的文档。 回顾一下,Elasticsearch文档还需要一个唯一的标识符。 为此,我使用SQLAlchemy模型的id字段,该字段正好是唯一的。 在SQLAlchemy和Elasticsearch使用相同的id值在运行搜索时非常有用,因为它允许我链接两个数据库中的条目。 我之前没有提到的一点是,如果你尝试添加一个带有现有id的条目,那么Elasticsearch会用新的条目替换旧条目,所以add_to_index()可以用于新建和修改对象。 在remove_from_index()中的es.delete()函数,我之前没有展示过。 这个函数删除存储在给定id下的文档。 下面是使用相同id链接两个数据库中条目的便利性的一个很好的例子。 query_index()函数使用索引名称和文本进行搜索,通过分页控件,还可以像Flask-SQLAlchemy结果那样对搜索结果进行分页。 你已经从Python控制台中看到了es.search()函数的示例用法。 我在这里发布的调用非常相似,但不是使用match查询类型,而是使用multi_match,它可以跨多个字段进行搜索。 通过传递*的字段名称,我告诉Elasticsearch查看所有字段,所以基本上我就是搜索了整个索引。 这对于使该函数具有通用性很有用,因为不同的模型在索引中可以具有不同的字段名称。 es.search()查询的body参数还包含分页参数。 from和size参数控制整个结果集的哪些子集需要被返回。 Elasticsearch没有像Flask-SQLAlchemy那样提供一个很好的Pagination对象,所以我必须使用分页数学逻辑来计算from值。 query_index()函数中的return语句有点复杂。 它返回两个值:第一个是搜索结果的id元素列表,第二个是结果总数。 两者都从es.search()函数返回的Python字典中获得。 用于获取ID列表的表达式,被称为列表推导式,是Python语言的一个奇妙功能,它允许你将列表从一种格式转换为另一种格式。 在本例,我使用列表推导式从Elasticsearch提供的更大的结果列表中提取id值。 这样看起来是否太混乱? 也许从Python控制台演示这些函数可以帮助你更好地理解它们。 在接下来的会话中,我手动将数据库中的所有用户动态添加到Elasticsearch索引。 在我的测试数据库中,我有几条用户动态中包含数字“one”,“two”, “three”, “four” 和“five”,因此我将其用作搜索查询。 你可能需要调整你的查询以匹配数据库的内容: >>> from app.search import add_to_index, remove_from_index, query_index >>> for post in Post.query.all(): ... add_to_index('posts', post) >>> query_index('posts', 'one two three four five', 1, 100) ([15, 13, 12, 4, 11, 8, 14], 7) >>> query_index('posts', 'one two three four five', 1, 3) ([15, 13, 12], 7) >>> query_index('posts', 'one two three four five', 2, 3) ([4, 11, 8], 7) >>> query_index('posts', 'one two three four five', 3, 3) ([14], 7) 我发出的查询返回了七个结果。 当我以每页100项查询第1页时,我得到了全部的七项,但接下来的三个例子显示了我如何以与Flask-SQLAlchemy类似的方式对结果进行分页,当然,结果是ID列表而不是SQLAlchemy对象。 如果你想保持数据的清洁,可以在做实验之后删除posts索引: >>> app.elasticsearch.indices.delete('posts') 集成SQLAlchemy到搜索 我在前面的章节中给出的解决方案是可行的,但它仍然存在一些问题。 最明显的问题是结果是以数字ID列表的形式出现的。 这非常不方便,我需要SQLAlchemy模型,以便我可以将它们传递给模板进行渲染,并且我需要用数据库中相应模型替换数字列表的方法。 第二个问题是,这个解决方案需要应用在添加或删除用户动态时明确地发出对应的索引调用,这并非不可行,但并不理想,因为在SQLAlchemy侧进行更改时错过索引调用的情况是不容易被检测到的,每当发生这种情况时,两个数据库就会越来越不同步,并且你可能在一段时间内都不会注意到。 更好的解决方案是在SQLAlchemy数据库进行更改时自动触发这些调用。 用对象替换ID的问题可以通过创建一个从数据库读取这些对象的SQLAlchemy查询来解决。 这在实践中听起来很容易,但是使用单个查询来高效地实现它实际上有点棘手。 对于自动触发索引更改的问题,我决定用SQLAlchemy 事件驱动Elasticsearch索引的更新。 SQLAlchemy提供了大量的事件,可以通知应用程序。 例如,每次提交会话时,我都可以定义一个由SQLAlchemy调用的函数,并且在该函数中,我可以将SQLAlchemy会话中的更新应用于Elasticsearch索引。 为了实现这两个问题的解决方案,我将编写mixin类。 记得mixin类吗? 在第五章中,我将Flask-Login中的UserMixin类添加到了User模型,为它提供Flask-Login所需的一些功能。 对于搜索支持,我将定义我自己的SearchableMixin类,当它被添加到模型时,可以自动管理与SQLAlchemy模型关联的全文索引。 mixin类将充当SQLAlchemy和Elasticsearch世界之间的“粘合”层,为我上面提到的两个问题提供解决方案。 让我先告诉你实现,然后再来回顾一些有趣的细节。 请注意,这使用了多种先进技术,因此你需要仔细研究此代码以充分理解它。 app/models.py:SearchableMixin类。 from app.search import add_to_index, remove_from_index, query_index class SearchableMixin(object): @classmethod def search(cls, expression, page, per_page): ids, total = query_index(cls.__tablename__, expression, page, per_page) if total == 0: return cls.query.filter_by(id=0), 0 when = [] for i in range(len(ids)): when.append((ids[i], i)) return cls.query.filter(cls.id.in_(ids)).order_by( db.case(when, value=cls.id)), total @classmethod def before_commit(cls, session): session._changes = { 'add': [obj for obj in session.new if isinstance(obj, cls)], 'update': [obj for obj in session.dirty if isinstance(obj, cls)], 'delete': [obj for obj in session.deleted if isinstance(obj, cls)] } @classmethod def after_commit(cls, session): for obj in session._changes['add']: add_to_index(cls.__tablename__, obj) for obj in session._changes['update']: add_to_index(cls.__tablename__, obj) for obj in session._changes['delete']: remove_from_index(cls.__tablename__, obj) session._changes = None @classmethod def reindex(cls): for obj in cls.query: add_to_index(cls.__tablename__, obj) 这个mixin类有四个函数,都是类方法。复习一下,类方法是与类相关联的特殊方法,而不是实例的。 请注意,我将常规实例方法中使用的self参数重命名为cls,以明确此方法接收的是类而不是实例作为其第一个参数。 例如,一旦连接到Post模型,上面的search()方法将被调用为Post.search(),而不必将其实例化。 search()类方法封装来自app/search.py的query_index()函数以将对象ID列表替换成实例对象。你可以看到这个函数做的第一件事就是调用query_index(),并传递cls .__tablename__作为索引名称。这将是一个约定,所有索引都将用Flask-SQLAlchemy模型关联的表名。该函数返回结果ID列表和结果总数。通过它们的ID检索对象列表的SQLAlchemy查询基于SQL语言的CASE语句,该语句需要用于确保数据库中的结果与给定ID的顺序相同。这很重要,因为Elasticsearch查询返回的结果不是有序的。如果你想了解更多关于这个查询的工作方式,你可以参考这个StackOverflow问题的接受答案。search()函数返回替换ID列表的查询结果集,以及搜索结果的总数。 before_commit()和after_commit()方法分别对应来自SQLAlchemy的两个事件,这两个事件分别在提交发生之前和之后触发。 前置处理功能很有用,因为会话还没有提交,所以我可以查看并找出将要添加,修改和删除的对象,如session.new,session.dirty和session.deleted。 这些对象在会话提交后不再可用,所以我需要在提交之前保存它们。 我使用session._changes字典将这些对象写入会话提交后仍然存在的地方,因为一旦会话被提交,我将使用它们来更新Elasticsearch索引。 当调用after_commit()处理程序时,会话已成功提交,因此这是在Elasticsearch端进行更新的适当时间。 session对象具有before_commit()中添加的_changes变量,所以现在我可以迭代需要被添加,修改和删除的对象,并对app/search.py中的索引函数进行相应的调用。 reindex()类方法是一个简单的帮助方法,你可以使用它来刷新所有数据的索引。 你看到我在上面做的将所有用户动态初始加载到测试索引中,这个操作与Python shell会话中的类似。 有了这个方法,我可以调用Post.reindex()将数据库中的所有用户动态添加到搜索索引中。 为了将SearchableMixin类整合到Post模型中,我必须将它作为Post的基类,并且还需要监听提交之前和之后的事件: app/models.py:添加SearchableMixin类到Post模型。 class Post(SearchableMixin, db.Model): # ... db.event.listen(db.session, 'before_commit', Post.before_commit) db.event.listen(db.session, 'after_commit', Post.after_commit) 请注意,db.event.listen()调用不在类内部,而是在其后面。 这两行代码设置了每次提交之前和之后调用的事件处理程序。 现在Post模型会自动为用户动态维护一个全文搜索索引。 我可以使用reindex()方法来初始化当前在数据库中的所有用户动态的索引: >>> Post.reindex() 我可以通过运行Post.search()来搜索使用SQLAlchemy模型的用户动态。 在下面的例子中,我要求查询第一页的五个元素: >>> query, total = Post.search('one two three four five', 1, 5) >>> total 7 >>> query.all() [<Post five>, <Post two>, <Post one>, <Post one more>, <Post one>] 搜索表单 的确有些激进。 我上面做的保持通用性的工作涉及到几个高级主题,因此可能需要一些时间才能完全理解。 现在我有一套完整的系统来处理用户动态的自然语言搜索。 所以现在需要做的是将所有这些功能与应用集成在一起。 基于网络搜索的一种相当标准的方法是在URL的查询字符串中将搜索词作为q参数的值。 例如,如果你想在Google上搜索Python,并且想要节约少许时间,则只需在浏览器的地址栏中输入以下URL即可直接查看结果: https://www.google.com/search?q=python 允许将搜索完全封装在URL中是很好的,因为这方便了与其他人共享,只要点击链接就可以访问搜索结果。 请允许我向你介绍一种区别于以前的Web表单的处理方式。 我曾经使用POST请求来提交表单数据,但是为了实现上述搜索,表单提交必须以GET请求发送,这是一种请求方法,当你在浏览器中输入网址或点击链接时,就是GET请求。 另一个有趣的区别是搜索表单将存在于导航栏中,因此它将会出现应用的所有页面中。 这里是搜索表单类,只有q文本字段: app/main/forms.py:搜索表单。 from flask import request class SearchForm(FlaskForm): q = StringField(_l('Search'), validators=[DataRequired()]) def __init__(self, *args, **kwargs): if 'formdata' not in kwargs: kwargs['formdata'] = request.args if 'csrf_enabled' not in kwargs: kwargs['csrf_enabled'] = False super(SearchForm, self).__init__(*args, **kwargs) q字段不需要任何解释,因为它与我以前使用的其他文本字段相似。在这个表单中,我不需要提交按钮。对于具有文本字段的表单,当焦点位于该字段上时,你按下Enter键,浏览器将提交表单,因此不需要按钮。我还添加了一个__init__构造函数,它提供了formdata和csrf_enabled参数的值(如果调用者没有提供它们的话)。 formdata参数决定Flask-WTF从哪里获取表单提交。缺省情况是使用request.form,这是Flask放置通过POST请求提交的表单值的地方。通过GET请求提交的表单在查询字符串中传递字段值,所以我需要将Flask-WTF指向request.args,这是Flask写查询字符串参数的地方。你是否还记得的,表单默认添加了CSRF保护,包含一个CSRF标记,该标记通过模板中的form.hidden_tag()构造添加到表单中。为了使搜索表单运作,CSRF需要被禁用,所以我将csrf_enabled设置为False,以便Flask-WTF知道它需要忽略此表单的CSRF验证。 由于我需要在所有页面中都显示此表单,因此无论用户在查看哪个页面,我都需要创建一个SearchForm类的实例。 唯一的要求是用户登录,因为对于匿名用户,我目前不会显示任何内容。 与其在每个路由中创建表单对象,然后将表单传递给所有模板,我将向你展示一个非常有用的技巧,当你需要在整个应用中实现一个功能时,可以消除重复代码。 回到第六章,我已经使用了before_request处理程序, 来记录每个用户上次访问的时间。 我要做的是在同样的功能中创建我的搜索表单,但有一点区别: app/main/routes.py:在请求处理前的处理器中初始化搜索表单。 from flask import g from app.main.forms import SearchForm @bp.before_app_request def before_request(): if current_user.is_authenticated: current_user.last_seen = datetime.utcnow() db.session.commit() g.search_form = SearchForm() g.locale = str(get_locale()) 在这里,当用户已认证时,我会创建一个搜索表单类的实例。当然,我需要这个表单对象一直存在,直到它可以在请求结束时渲染,所以我需要将它存储在某个地方。那个地方就是Flask提供的g容器。这个g变量是应用可以存储需要在整个请求期间持续存在的数据的地方。在这里,我将表单存储在g.search_form中,所以当请求前置处理程序结束并且Flask调用处理请求的URL的视图函数时,g对象将会是相同的,并且表单仍然存在。请注意,这个g变量对每个请求和每个客户端都是特定的,因此即使你的Web服务器一次为不同的客户端处理多个请求,仍然可以依靠g来专用存储各个请求的对应变量。 下一步是将表单渲染成页面。 我在上面说过,我想在所有页面中展示这个表单,所以更有意义的是将其作为导航栏的一部分进行渲染。 事实上,这很简单,因为模板也可以看到存储在g变量中的数据,所以我不需要在所有render_template()调用中将表单作为显式模板参数添加进去。以下是我如何在基础模板中渲染表单的代码: app/templates/base.html:在导航栏中渲染搜索表单。 ... <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1"> <ul class="nav navbar-nav"> ... home and explore links ... </ul> {% if g.search_form %} <form class="navbar-form navbar-left" method="get" action="{{ url_for('main.search') }}"> <div class="form-group"> {{ g.search_form.q(size=20, class='form-control', placeholder=g.search_form.q.label.text) }} </div> </form> {% endif %} ... 只有在定义了g.search_form时才会渲染表单。 此检查是必要的,因为某些页面(如错误页面)可能没有定义它。 这个表单与我之前做过的略有不同。 我将method属性设置为get,因为我希望表单数据作为查询字符串,通过GET请求提交。 另外,我创建的其他表单action属性为空,因为它们被提交到渲染表单的同一页面。 而这个表单很特殊,因为它出现在所有页面中,所以我需要明确告诉它需要提交的地方,这是专门用于处理搜索的新路由。 搜索视图函数 完成搜索功能的最后一项功能是接收搜索表单的视图函数。 该视图函数将被附加到/search路由,以便你可以发送类似http://localhost:5000/search?q=search-words的搜索请求,就像Google一样。 app/main/routes.py:搜索视图函数。 @bp.route('/search') @login_required def search(): if not g.search_form.validate(): return redirect(url_for('main.explore')) page = request.args.get('page', 1, type=int) posts, total = Post.search(g.search_form.q.data, page, current_app.config['POSTS_PER_PAGE']) next_url = url_for('main.search', q=g.search_form.q.data, page=page + 1) \ if total > page * current_app.config['POSTS_PER_PAGE'] else None prev_url = url_for('main.search', q=g.search_form.q.data, page=page - 1) \ if page > 1 else None return render_template('search.html', title=_('Search'), posts=posts, next_url=next_url, prev_url=prev_url) 你已经看到,在其他表单中,我使用form.validate_on_submit()方法来检查表单提交是否有效。 不幸的是,该方法只适用于通过POST请求提交的表单,所以对于这个表单,我需要使用form.validate(),它只验证字段值,而不检查数据是如何提交的。 如果验证失败,这是因为用户提交了一个空的搜索表单,所以在这种情况下,我只能重定向到了显示所有用户动态的发现页面。 SearchableMixin类中的Post.search()方法用于获取搜索结果列表。 分页的处理方式与主页和发现页面非常类似,但如果没有Flask-SQLAlchemy的“分页”对象的帮助,生成下一个和前一个链接会有点棘手。 这是从Post.search()返回的结果总数的用途所在。 一旦计算出搜索结果和分页链接的页面,剩下的就是渲染一个包含所有这些数据的模板。 我已经想出了一种重用index.html模板来显示搜索结果的方法,但考虑到有一些差异,我决定创建一个专用于显示搜索结果的search.html专属模板, 以_post.html子模板的优势来渲染搜索结果: app/templates/search.html:搜索结果模板。 {% extends "base.html" %} {% block app_content %} <h1>{{ _('Search Results') }}</h1> {% for post in posts %} {% include '_post.html' %} {% endfor %} <nav aria-label="..."> <ul class="pager"> <li class="previous{% if not prev_url %} disabled{% endif %}"> <a href="{{ prev_url or '#' }}"> <span aria-hidden="true">&larr;</span> {{ _('Previous results') }} </a> </li> <li class="next{% if not next_url %} disabled{% endif %}"> <a href="{{ next_url or '#' }}"> {{ _('Next results') }} <span aria-hidden="true">&rarr;</span> </a> </li> </ul> </nav> {% endblock %} 如果前一个和下一个链接的渲染逻辑有点混乱,可能查看分页组件的Bootstrap文档会有所帮助。 感想如何? 本章的内容有些激进,因为里面介绍了一些相当先进的技术。 本章中的一些概念可能需要你花一些时间才能有所领悟。本章最重要的一点是,如果你想使用与Elasticsearch不同的搜索引擎,只需要重写app/search.py即可。 通过这项工作的另一个重要好处是,如果我需要为另外的数据库模型添加搜索支持,我可以简单地通过向它添加SearchableMixin类,为__searchable__属性填写要索引的字段列表和SQLAlchemy事件处理程序的监听即可。 我认为这些努力是值得的,因为从现在起,处理全文索引将会变得十分容易。
本文转载自:https://www.jianshu.com/p/c47b05ccc00e 这是Flask Mega-Tutorial系列的第十五部分,我将使用适用于大型应用的风格重构本应用。 Microblog已经是一个初具规模的应用了,所以我认为这是讨论Flask应用如何在持续增长中不会变得混乱和难以管理的好时机。 Flask是一个框架,旨在让你选择以任何方式来组织项目,基于该理念,在应用日益庞大或者技能水平变化的时候,才有可能更改和调整其结构。 在本章中,我将讨论适用于大型应用的一些模式,并且为了演示他们,我将对Microblog项目的结构进行一些更改,目标是使代码更易于维护和组织。 当然,在真正的Flask精神中,我鼓励你在尝试决定组织自己的项目的方式时仅仅将这些更改作为参考。 本章的GitHub链接为:Browse, Zip, Diff. 目前的局限性 目前状态下的应用有两个基本问题。 如果你观察应用的组织方式,你会注意到有几个不同的子系统可以被识别,但支持它们的代码都混合在了一起,没有任何明确的界限。 我们来回顾一下这些子系统是什么: 用户认证子系统,包括app/routes.py中的一些视图函数,app/forms.py中的一些表单,app/templates中的一些模板以及*app/email.py中的电子邮件支持。 错误子系统,它在app/errors.py中定义了错误处理程序并在app/templates中定义了模板。 核心应用功能,包括显示和撰写用户动态,用户个人主页和关注以及用户动态的实时翻译,这些功能遍布大多数应用模块和模板。 思考这三个子系统以及它们组织的方式,你可能会注意到这样一个模式。 到目前为止,我一直遵循的组织逻辑是不同的应用功能归属到其专属的模块。 这些模块之中,一个用于视图函数,一个用于Web表单,一个用于错误,一个用于电子邮件,一个目录用于存放HTML模板等等。 虽然这是一个对小项目有意义的组织结构,但是一旦项目开始增长,它往往会使其中的一些模块变得非常大而且杂乱无章。 要想清晰地看到问题的一种方法,是思考如何通过尽可能多地重复使用这一项目来开始第二个项目。 例如,用户身份验证部分应该在其他应用中也能运行良好,但如果你想按原样使用该代码,则必须进入多个模块并将相关部分复制/粘贴到新项目的新文件中。 看到这是多么不方便了吗? 如果这个项目将所有与认证相关的文件从应用的其余部分中分离出来,会不会更好? Flask的blueprints功能有助于实现更实用的组织结构,从而更轻松地重用代码。 还有第二个问题,虽然它不太明显。 Flask应用实例在app/__init__.py中被创建为一个全局变量,然后又被很多应用模块导入。 虽然这本身并不是问题,但将应用实例作为全局变量可能会使某些情况复杂化,特别是与测试相关的情景。 想象一下你想要在不同的配置下测试这个应用。 由于应用被定义为全局变量,实际上没有办法使用不同配置变量来实例化的两个应用实例。 另一种糟心的情况是,所有测试都使用相同的应用,因此测试可能会对应用进行更改,就会影响稍后运行的其他测试。 理想情况下,你希望所有测试都在原始应用实例上运行的。 你可以在tests.py模块中看到我正在使用的应用实例化之后修改配置的技巧,以指示测试时使用内存数据库而不是默认的SQLite数据库。我真的没有其他办法来更改已配置的数据库,因为在测试开始时已经创建和配置了应用。 对于这种特殊情况,对已配置的应用实例修改配置似乎可以运行,但在其他情况下可能不会,并且在任何情况下,这是一种不推荐的做法,因为这么做可能会导致提示晦涩并且难以找到BUG。 更好的解决方案是不将应用设置为全局变量,而是使用应用工厂函数在运行时创建它。 这将是一个接受配置对象作为参数的函数,并返回一个配置完毕的Flask应用实例。 如果我能够通过应用工厂函数来修改应用,那么编写需要特殊配置的测试会变得很容易,因为每个测试都可以创建它各自的应用。 在本章中,我将通过为上面提到的三个子系统重构应用来介绍blueprints。 展示更改的详细列表有些不切实际,因为几乎应用中每个文件都有少许变化,所以我将讨论重构的步骤,然后你可以下载更改后的应用。 Blueprints 在Flask中,blueprint是代表应用子集的逻辑结构。 blueprint可以包括路由,视图函数,表单,模板和静态文件等元素。 如果在单独的Python包中编写blueprint,那么你将拥有一个封装了应用特定功能的组件。 Blueprint的内容最初处于休眠状态。 为了关联这些元素,blueprint需要在应用中注册。 在注册过程中,需要将添加到blueprint中的所有元素传递给应用。 因此,你可以将blueprint视为应用功能的临时存储,以帮助组织代码。 错误处理Blueprint 我创建的第一个blueprint用于封装对错误处理程序的支持。 该blueprint的结构如下: app/ errors/ <-- blueprint package __init__.py <-- blueprint creation handlers.py <-- error handlers templates/ errors/ <-- error templates 404.html 500.html __init__.py <-- blueprint registration 实质上,我所做的是将app/errors.py模块移动到app/errors/handlers.py中,并将两个错误模板移动到app/templates/errors中,以便将它们与其他模板分开。 我还必须在两个错误处理程序中更改render_template()调用以使用新的errors模板子目录。 之后,我将blueprint创建添加到app/errors/init.py模块,并在创建应用实例之后,将blueprint注册到app/init.py。 我必须提一下,Flask blueprints可以为自己的模板和静态文件配置单独的目录。 我已决定将模板移动到应用模板目录的子目录中,以便所有模板都位于一个层次结构中,但是如果你希望在blueprint中包含属于自己的模板,这也是支持的。 例如,如果向Blueprint()构造函数添加template_folder='templates'参数,则可以将错误blueprint的模板存储在app/errors/templates目录中。 创建blueprint与创建应用非常相似。 这是在blueprint的___init__.py模块中完成的: app/errors/__init__.py:错误blueprint。 from flask import Blueprint bp = Blueprint('errors', __name__) from app.errors import handlers Blueprint类获取blueprint的名称,基础模块的名称(通常在Flask应用实例中设置为__name__)以及一些可选参数(在这种情况下我不需要这些参数)。 Blueprint对象创建后,我导入了handlers.py模块,以便其中的错误处理程序在blueprint中注册。 该导入位于底部以避免循环依赖。 在handlers.py模块中,我放弃使用@app.errorhandler装饰器将错误处理程序附加到应用程序,而是使用blueprint的@bp.app_errorhandler装饰器。 尽管两个装饰器最终都达到了相同的结果,但这样做的目的是试图使blueprint独立于应用,使其更具可移植性。我还需要修改两个错误模板的路径,因为它们被移动到了新errors子目录。 完成错误处理程序重构的最后一步是向应用注册blueprint: app/init.py:向应用注册错误blueprint。 app = Flask(__name__) # ... from app.errors import bp as errors_bp app.register_blueprint(errors_bp) # ... from app import routes, models # <-- remove errors from this import! 为了注册blueprint,将使用Flask应用实例的register_blueprint()方法。 在注册blueprint时,任何视图函数,模板,静态文件,错误处理程序等均连接到应用。 我将blueprint的导入放在app.register_blueprint()的上方,以避免循环依赖。 用户认证Blueprint 将应用的认证功能重构为blueprint的过程与错误处理程序的过程非常相似。 以下是重构为blueprint的目录层次结构: app/ auth/ <-- blueprint package __init__.py <-- blueprint creation email.py <-- authentication emails forms.py <-- authentication forms routes.py <-- authentication routes templates/ auth/ <-- blueprint templates login.html register.html reset_password_request.html reset_password.html __init__.py <-- blueprint registration 为了创建这个blueprint,我必须将所有认证相关的功能移到为blueprint创建的新模块中。 这包括一些视图函数,Web表单和支持功能,例如通过电子邮件发送密码重设token的功能。 我还将模板移动到一个子目录中,以将它们与应用的其余部分分开,就像我对错误页面所做的那样。 在blueprint中定义路由时,使用@bp.route装饰器来代替@app.route装饰器。 在url_for()中用于构建URL的语法也需要进行更改。 对于直接附加到应用的常规视图函数,url_for()的第一个参数是视图函数名称。 但当在blueprint中定义路由时,该参数必须包含blueprint名称和视图函数名称,并以句点分隔。 因此,我不得不用诸如url_for('auth.login')的代码替换所有出现的url_for('login')代码,对于其余的视图函数也是如此。 注册auth blueprint到应用时,我使用了些许不同的格式: app/__init__.py:注册用户认证blueprint到应用。 # ... from app.auth import bp as auth_bp app.register_blueprint(auth_bp, url_prefix='/auth') # ... 在这种情况下,register_blueprint()调用接收了一个额外的参数,url_prefix。 这完全是可选的,Flask提供了给blueprint的路由添加URL前缀的选项,因此blueprint中定义的任何路由都会在其完整URL中获取此前缀。 在许多情况下,这可以用来当成“命名空间”,它可以将blueprint中的所有路由与应用或其他blueprint中的其他路由分开。 对于用户认证,我认为让所有路由以/auth开头很不错,所以我添加了该前缀。 所以现在登录URL将会是http://localhost:5000/auth/login。 因为我使用url_for()来生成URL,所有URL都会自动合并前缀。 主应用Blueprint 第三个blueprint包含核心应用逻辑。 重构这个blueprint和前两个blueprint的过程一样。 我给这个blueprint命名为main,因此所有引用视图函数的url_for()调用都必须添加一个main.前缀。 鉴于这是应用的核心功能,我决定将模板留在原来的位置。 这不会有什么问题,因为我已将其他两个blueprint中的模板移动到子目录中了。 应用工厂模式 正如我在本章的介绍中所提到的,将应用设置为全局变量会引入一些复杂性,主要是以某些测试场景的局限性为形式。 在我介绍blueprint之前,应用必须是一个全局变量,因为所有的视图函数和错误处理程序都需要使用来自app的装饰器来修饰,比如@app.route。 但是现在所有的路由和错误处理程序都被转移到了blueprint中,因此保持应用全局性的理由就不够充分了。 所以我要做的是添加一个名为create_app()的函数来构造一个Flask应用实例,并消除全局变量。 转换并非容易,我不得不理清一些复杂的东西,但我们先来看看应用工厂函数: app/__init__.py:应用工厂函数。 # ... db = SQLAlchemy() migrate = Migrate() login = LoginManager() login.login_view = 'auth.login' login.login_message = _l('Please log in to access this page.') mail = Mail() bootstrap = Bootstrap() moment = Moment() babel = Babel() def create_app(config_class=Config): app = Flask(__name__) app.config.from_object(config_class) db.init_app(app) migrate.init_app(app, db) login.init_app(app) mail.init_app(app) bootstrap.init_app(app) moment.init_app(app) babel.init_app(app) # ... no changes to blueprint registration if not app.debug and not app.testing: # ... no changes to logging setup return app 你已经看到,大多数Flask插件都是通过创建插件实例并将应用作为参数传递来初始化的。 当应用不再作为全局变量时,有一种替代模式,插件分成两个阶段进行初始化。 插件实例首先像前面一样在全局范围内创建,但没有参数传递给它。 这会创建一个未附加到应用的插件实例。 当应用实例在工厂函数中创建时,必须在插件实例上调用init_app()方法,以将其绑定到现在已知的应用。 在初始化期间执行的其他任务保持不变,但会被移到工厂函数而不是在全局范围内。 这包括blueprint和日志配置的注册。 请注意,我在条件中添加了一个not app.testing子句,用于决定是否启用电子邮件和文件日志,以便在单元测试期间跳过所有这些日志记录。 由于在配置中TESTING变量在单元测试时会被设置为True,因此app.testing标志在运行单元测试时将变为True。 那么谁来调用应用程工厂函数呢? 最明显使用此函数的地方是处于顶级目录的microblog.py脚本,它是唯一会将应用设置为全局变量的模块。 另一个调用该工厂函数的地方是tests.py,我将在下一节中更详细地讨论单元测试。 正如我上面提到的,大多数对app的引用都是随着blueprint的引入而消失的,但是我仍然需要解决代码中的一些问题。 例如,app/models.py、app/translate.py和app/main/routes.py模块都引用了app.config。 幸运的是,Flask开发人员试图使视图函数很容易地访问应用实例,而不必像我一直在做的那样导入它。 Flask提供的current_app变量是一个特殊的“上下文”变量,Flask在分派请求之前使用应用初始化该变量。 你之前已经看到另一个上下文变量,即存储当前语言环境的g变量。 这两个变量,以及Flask-Login的current_user和其他一些你还没有看到的东西,是“魔法”变量,因为它们像全局变量一样工作,但只能在处理请求期间且在处理它的线程中访问。 用Flask的current_app变量替换app就不需要将应用实例作为全局变量导入。 通过简单的搜索和替换,我可以毫无困难地用current_app.config替换对app.config的所有引用。 app/email.py模块提出了一个更大的挑战,所以我必须使用一个小技巧: app/email.py:将应用实例传递给另一个线程。 from app import current_app def send_async_email(app, msg): with app.app_context(): mail.send(msg) def send_email(subject, sender, recipients, text_body, html_body): msg = Message(subject, sender=sender, recipients=recipients) msg.body = text_body msg.html = html_body Thread(target=send_async_email, args=(current_app._get_current_object(), msg)).start() 在send_email()函数中,应用实例作为参数传递给后台线程,后台线程将发送电子邮件而不阻塞主应用程序。在作为后台线程运行的send_async_email()函数中直接使用current_app将不会奏效,因为current_app是一个与处理客户端请求的线程绑定的上下文感知变量。在另一个线程中,current_app没有赋值。直接将current_app作为参数传递给线程对象也不会有效,因为current_app实际上是一个代理对象,它被动态地映射到应用实例。因此,传递代理对象与直接在线程中使用current_app相同。我需要做的是访问存储在代理对象中的实际应用程序实例,并将其作为app参数传递。 current_app._get_current_object()表达式从代理对象中提取实际的应用实例,所以它就是我作为参数传递给线程的。 另一个棘手的模块是app/cli.py,它实现了一些用于管理语言翻译的快捷命令。 在这种情况下,current_app变量不起作用,因为这些命令是在启动时注册的,而不是在处理请求期间(这是唯一可以使用current_app的时间段)注册的。 为了在这个模块中删除对app的引用,我使用了另一个技巧,将这些自定义命令移动到一个将app实例作为参数的register()函数中: app/cli.py:注册自定义应用命令。 import os import click def register(app): @app.cli.group() def translate(): """Translation and localization commands.""" pass @translate.command() @click.argument('lang') def init(lang): """Initialize a new language.""" # ... @translate.command() def update(): """Update all languages.""" # ... @translate.command() def compile(): """Compile all languages.""" # ... 然后我从microblog.py中调用这个register()函数。 以下是完成重构后的microblog.py: microblog.py:重构后的主应用模块。 from app import create_app, db, cli from app.models import User, Post app = create_app() cli.register(app) @app.shell_context_processor def make_shell_context(): return {'db': db, 'User': User, 'Post' :Post} 单元测试的改进 正如我在本章开头所暗示的,到目前为止,我所做的很多工作都是为了改进单元测试工作流程。 在运行单元测试时,要确保应用的配置方式不会污染开发资源(如数据库)。 tests.py的当前版本采用了应用实例化之后修改配置的技巧,这是一种危险的做法,因为并不是所有类型的更改都会在修改之后才生效。 我想要的是有机会在添加到应用之前指定我想要的测试配置项。 create_app()函数现在接受一个配置类作为参数。 默认情况下,使用在config.py中定义的Config类,但现在我可以通过将新类传递给工厂函数来创建使用不同配置的应用实例。 下面是一个适用于我的单元测试的示例配置类: tests.py:测试配置。 from config import Config class TestConfig(Config): TESTING = True SQLALCHEMY_DATABASE_URI = 'sqlite://' 我在这里做的是创建应用的Config类的子类,并覆盖SQLAlchemy配置以使用内存SQLite数据库。 我还添加了一个TESTING属性,并设置为True,我目前不需要该属性,但如果应用需要确定它是否在单元测试下运行,它就派上用场了。 你一定还记得,我的单元测试依赖于setUp()和tearDown()方法,它们由单元测试框架自动调用,以创建和销毁每次测试运行的环境。 我现在可以使用这两种方法为每个测试创建和销毁一个测试专用的应用: tests.py:为每次测试创建一个应用。 class UserModelCase(unittest.TestCase): def setUp(self): self.app = create_app(TestConfig) self.app_context = self.app.app_context() self.app_context.push() db.create_all() def tearDown(self): db.session.remove() db.drop_all() self.app_context.pop() 新的应用将存储在self.app中,但光是创建一个应用不足以使所有的工作都成功。 思考创建数据库表的db.create_all()语句。 db实例需要注册到应用实例,因为它需要从app.config获取数据库URI,但是当你使用应用工厂时,应用就不止一个了。 那么db如何关联到我刚刚创建的self.app实例呢? 答案在application context中。 还记得current_app变量吗?当不存在全局应用实例导入时,该变量以代理的形式来引用应用实例。 这个变量在当前线程中查找活跃的应用上下文,如果找到了,它会从中获取应用实例。 如果没有上下文,那么就没有办法知道哪个应用实例处于活跃状态,所以current_app就会引发一个异常。 下面你可以看到它是如何在Python控制台中工作的。 这需要通过运行python启动,因为flask shell命令会自动激活应用程序上下文以方便使用。 >>> from flask import current_app >>> current_app.config['SQLALCHEMY_DATABASE_URI'] Traceback (most recent call last): ... RuntimeError: Working outside of application context. >>> from app import create_app >>> app = create_app() >>> app.app_context().push() >>> current_app.config['SQLALCHEMY_DATABASE_URI'] 'sqlite:////home/miguel/microblog/app.db' 这就是秘密所在! 在调用你的视图函数之前,Flask推送一个应用上下文,它会使current_app和g生效。 当请求完成时,上下文将与这些变量一起被删除。 为了使db.create_all()调用在单元测试setUp()方法中工作,我为刚刚创建的应用程序实例推送了一个应用上下文,这样db.create_all()可以使用 current_app.config知道数据库在哪里。 然后在tearDown()方法中,我弹出上下文以将所有内容重置为干净状态。 你还应该知道,应用上下文是Flask使用的两种上下文之一,还有一个请求上下文,它更具体,因为它适用于请求。 在处理请求之前激活请求上下文时,Flask的request、session以及Flask-Login的current_user变量才会变成可用状态。 环境变量 正如构建此应用时你所看到的,在启动服务器之前,有许多配置选项取决于在环境中设置的变量。 这包括密钥、电子邮件服务器信息、数据库URL和Microsoft Translator API key。 你可能会和我一样觉得,这很不方便,因为每次打开新的终端会话时,都需要重新设置这些变量。 译者注:可以通过将环境变量设置到开机启动中,来保持它们在该计算机中的所有终端中都生效。 应用依赖大量环境变量的常见处理模式是将这些变量存储在应用根目录中的.env文件中。 应用在启动时会从此文件中导入变量,这样就不需要你手动设置这些变量了。 有一个支持.env文件的Python包,名为python-dotenv。 所以让我们安装这个包: (venv) $ pip install python-dotenv 由于config.py模块是我读取所有环境变量的地方,因此我将在创建Config类之前导入.env文件,以便在构造类时设置变量: config.py:导入.env文件中的环境变量。 import os from dotenv import load_dotenv basedir = os.path.abspath(os.path.dirname(__file__)) load_dotenv(os.path.join(basedir, '.env')) class Config(object): # ... 现在你可以创建一个.env文件并在其中写入应用所需的所有环境变量了。不要将.env文件加入到源代码版本控制中,这非常重要。否则,一旦你的密码和其他重要信息上传到远程代码库中后,你就会后悔莫及。 .env文件可以用于所有配置变量,但是不能用于Flask命令行的FLASK_APP和FLASK_DEBUG环境变量,因为它们在应用启动的早期(应用实例和配置对象存在之前)就被使用了。 以下示例显示了.env文件,该文件定义了一个安全密钥,将电子邮件配置为在本地运行的邮件服务器的25端口上,并且不进行身份验证,设置Microsoft Translator API key,使用数据库配置的默认值: SECRET_KEY=a-really-long-and-unique-key-that-nobody-knows MAIL_SERVER=localhost MAIL_PORT=25 MS_TRANSLATOR_KEY=<your-translator-key-here> 依赖文件 此时我已经在Python虚拟环境中安装了一定数量的软件包。 如果你需要在另一台机器上重新生成你的环境,将无法记住你必须安装哪些软件包,所以一般公认的做法是在项目的根目录中写一个requirements.txt文件,列出所有依赖的包及其版本。 生成这个列表实际上很简单: (venv) $ pip freeze > requirements.txt pip freeze命令将安装在虚拟环境中的所有软件包以正确的格式输入到requirements.txt文件中。 现在,如果你需要在另一台计算机上创建相同的虚拟环境,无需逐个安装软件包,可以直接运行一条命令实现: (venv) $ pip install -r requirements.txt
本文转载自:https://www.jianshu.com/p/53bb69847241 这是Flask Mega-Tutorial系列的第十四部分,我将使用Microsoft翻译服务和少许JavaScript来添加实时语言翻译功能。 在本章中,我将从服务器端开发的“安全区域”脱离,研究与服务器端同样重要的客户端组件的功能。 你是否看到过某些网站在用户生成的内容旁边显示的“翻译”链接? 这些链接会触发非用户本地语言内容的实时自动翻译。 翻译的内容通常插入原始版本的下方。 Google将其显示为外语搜索结果。 Facebook在用户动态上使用它。 Twitter在推文上使用它。 今天我将向你展示如何将相同的功能添加到Microblog! 本章的GitHub链接为:Browse, Zip, Diff. 服务器端与客户端 迄今为止,在我遵循的传统服务器端模型中,有一个客户端(由用户驱动的Web浏览器)向应用服务器发出HTTP请求。 请求可以简单地请求HTML页面,例如当你单击“个人主页”链接时,或者它可以触发一个操作,例如在编辑你的个人信息之后单击提交按钮。 在这两种类型的请求中,服务器通过直接发送新的网页或通过发送重定向来完成请求。 然后客户端用新的页面替换当前页面。 只要用户停留在应用的网站上,该周期就会重复。 在这种模式下,服务器完成所有工作,而客户端只显示网页并接受用户输入。 有一种不同的模式,客户端扮演更积极的角色。 在这个模式中,客户端向服务器发出一个请求,服务器响应一个网页,但与前面的情况不同,并不是所有的页面数据都是HTML,页面中也有部分代码,通常用Javascript编写。 一旦客户端收到该页面,它就会显示HTML部分,并执行代码。 从那时起,你就拥有了一个可以独立工作的活动客户端,而无需与服务器进行联系或只有很少联系。 在严格的客户端应用中,整个应用通过初始页面请求下载到客户端,然后应用完全在客户端上运行,只有在查询或者变更数据时才与服务器联系。 这种类型的应用称为单页应用(SPAs)。 大多数应用是这两种模式的混合,并结合了两者的技术特点。 我的Microblog应用主要是服务器端应用,但今天我将添加一些客户端操作。 为了实时翻译用户动态,客户端浏览器将异步请求发送到服务器,服务器将响应该请求而不会导致页面刷新。然后客户端将动态地将翻译插入当前页面。 这种技术被称为Ajax,这是Asynchronous JavaScript和XML的简称(尽管现在XML常常被JSON取代)。 实时翻译的工作流程 由于使用了Flask-Babel,本应用对外语有很好的支持,可以支持尽可能多的语言,只要我找到了对应的译文。 但是遗漏了一个元素,用户将会用他们自己的语言发表动态,所以用户很可能会用应用未知的语言发表动态。 自动翻译的质量大多数情况下不怎么样,但在,如果你只想对另一种语言的文本了解其基本含义,这已经足够了。 这正是Ajax大展身手的好机会! 设想主页或发现页面可能会显示若干用户动态,其中一些可能是外语。 如果我使用传统的服务器端技术实现翻译,则翻译请求会导致原始页面被替换为新页面。 事实是,要求翻译诸多用户动态中的一条,并不是一个足够大的动作来要求整个页面的更新,如果翻译文本可以被动态地插入到原始文本下方,而剩下的页面保持原样,则用户体验更加出色。 实施实时自动翻译需要几个步骤。 首先,我需要一种方法来识别要翻译的文本的源语言。 我还需要知道每个用户的首选语言,因为我想仅为使用其他语言发表的动态显示“翻译”链接。 当提供翻译链接并且用户点击它时,我需要将Ajax请求发送到服务器,服务器将联系第三方翻译API。 一旦服务器发送了带有翻译文本的响应,客户端JavaScript代码将动态地将该文本插入到页面中。 你一定注意到了,这里有一些特殊的问题。 我将逐一审视这些问题。 语言识别 第一个问题是确定一条用户动态的语言。这不是一门精确的科学,因为不能确保监测结果绝对正确,但是对于大多数情况,自动检测的效果相当好。 在Python中,有一个称为guess_language的语言检测库,还算好用。 这个软件包的原始版本相当陈旧,从未被移植到Python 3,因此我将安装支持Python 2和3的派生版本: (venv) $ pip install guess-language_spirit 计划是将每条用户动态提供给这个包,以尝试确定语言。 由于做这种分析有点费时,我不想每次把帖子呈现给页面时重复这项工作。 我要做的是在提交时为帖子设置源语言。 检测到的语言将被存储在post表中。 第一步,添加language字段到Post模型: app/models.py:添加监测到的语言到Post模型: class Post(db.Model): # ... language = db.Column(db.String(5)) 你一定还记得,每当数据库模型发生变化时,都需要生成数据库迁移: (venv) $ flask db migrate -m "add language to posts" INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.autogenerate.compare] Detected added column 'post.language' Generating migrations/versions/2b017edaa91f_add_language_to_posts.py ... done 然后将迁移应用到数据库: (venv) $ flask db upgrade INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.runtime.migration] Upgrade ae346256b650 -> 2b017edaa91f, add language to posts 我现在可以在提交帖子时检测并存储语言: app/routes.py:为新的用户动态保存语言字段。 from guess_language import guess_language @app.route('/', methods=['GET', 'POST']) @app.route('/index', methods=['GET', 'POST']) @login_required def index(): form = PostForm() if form.validate_on_submit(): language = guess_language(form.post.data) if language == 'UNKNOWN' or len(language) > 5: language = '' post = Post(body=form.post.data, author=current_user, language=language) # ... 有了这个变更,每次发表动态时,都会通过guess_language函数测试文本来尝试确定语言。 如果语言监测为未知,或者如果我得到意想不到的长字符串的结果,我会将一个空字符串保存到数据库中以安全地使用它。 我将采用约定,将任何将把语言设置为空字符串的帖子假定为未知语言。 展示一个“翻译”链接 第二步很简单。 我现在要做的是在任何不是当前用户的首选语言的用户动态下,添加一个“翻译”链接。 app/templates/_post.html:给用户动态添加翻译链接。 {% if post.language and post.language != g.locale %} <br><br> <a href="#">{{ _('Translate') }}</a> {% endif %} 我在_post.html子模板中执行此操作,以便此功能出现在显示用户动态的任何页面上。 翻译链接只会出现在检测到语言种类的动态下,并且必须满足的条件是,这种语言与用Flask-Babel的localeselector装饰器装饰的函数选择的语言不匹配。 回想一下第十三章所选语言环境存储为g.locale。 链接文本需要以Flask-Babel可以翻译的方式添加,所以我在定义它时使用了_()函数。 请注意,我还没有关联此链接的操作。 首先,我想弄清楚如何进行实际的翻译。 使用第三方翻译服务 两种主要的翻译服务是Google Cloud Translation API和Microsoft Translator Text API。 两者都是付费服务,但微软为低频少量的翻译提供了免费的入门级选项。 谷歌过去提供免费翻译服务,但现在,即使是最低层次的服务也需要付费。 因为我希望能够在不产生费用的情况下尝试翻译,我将实施Microsoft的解决方案。 在使用Microsoft Translator API之前,你需要先获得微软云服务Azure的帐户。 你可以选择免费套餐,但在注册过程中系统会要求你提供信用卡号,但在你保持该级别的服务时,你的卡不会被收取费用。 获得Azure帐户后,转到Azure门户并单击左上角的“New”按钮,然后键入或选择“Translator Text API”。 当你点击“Create”按钮时,将看到一个表单,并可以在其中定义一个新的翻译器资源,然后将其添加到你的帐户中。 你可以在下面看到我是如何完成表单的: 当你再次点击“Create”按钮时,翻译器API资源将被添加到你的帐户中。几秒钟之后,你将在顶栏中收到通知,说明部署了翻译器资源。 点击通知中的“Go to resource”按钮,然后点击左侧栏上的“Keys”选项。 你现在将看到两个Key,分别标记为“Key 1”和“Key 2”。 将其中一个Key复制到剪贴板,然后将其设置到终端的环境变量中(如果使用的是Microsoft Windows,请用set替换export): (venv) $ export MS_TRANSLATOR_KEY=<paste-your-key-here> 该Key用于验证翻译服务,因此需要将其添加到应用配置中: config.py: 添加Microsoft Translator API key到配置中。 class Config(object): # ... MS_TRANSLATOR_KEY = os.environ.get('MS_TRANSLATOR_KEY') 与很多配置值一样,我更喜欢将它们安装在环境变量中,并从那里将它们导入到Flask配置中。 对于允许访问第三方服务的密钥或密码等敏感信息,这一点尤为重要。 你绝对不想在代码中明确写出它们。 Microsoft Translator API是一个接受HTTP请求的Web服务。 Python中有若干HTTP客户端,但最常用和最简单的就是requests包。 所以让我们将其安装到虚拟环境中: (venv) $ pip install requests 在下面,你可以看到我使用Microsoft Translator API编写翻译文本的功能。 我来新增一个app/translate.py模块: app/translate.py:文本翻译函数。 import json import requests from flask_babel import _ from app import app def translate(text, source_language, dest_language): if 'MS_TRANSLATOR_KEY' not in app.config or \ not app.config['MS_TRANSLATOR_KEY']: return _('Error: the translation service is not configured.') auth = {'Ocp-Apim-Subscription-Key': app.config['MS_TRANSLATOR_KEY']} r = requests.get('https://api.microsofttranslator.com/v2/Ajax.svc' '/Translate?text={}&from={}&to={}'.format( text, source_language, dest_language), headers=auth) if r.status_code != 200: return _('Error: the translation service failed.') return json.loads(r.content.decode('utf-8-sig')) 该函数定义需要翻译的文本、源语言和目标语言为参数,并返回翻译后文本的字符串。 它首先检查配置中是否存在翻译服务的Key,如果不存在,则会返回错误。 错误也是一个字符串,所以从外部看,这将看起来像翻译文本。 这可确保在出现错误时用户将看到有意义的错误消息。 requests包中的get()方法向作为第一个参数给定的URL发送一个带有GET方法的HTTP请求。 我使用/v2/Ajax.svc/Translate URL,它是翻译服务中的一个端点,它将翻译内容荷载为JSON返回。文本、源语言和目标语言都需要在URL中分别命名为text,from和to作为查询字符串参数。 要使用该服务进行身份验证,我需要将我添加到配置中的Key传递给该服务。 该Key需要在名为Ocp-Apim-Subscription-Key的自定义HTTP头中给出。 我创建了auth字典,然后将它通过headers参数传递给requests。 requests.get()方法返回一个响应对象,它包含了服务提供的所有细节。 我首先需要检查和确认状态码是200,这是成功请求的代码。 如果我得到任何其他代码,我就知道发生了错误,所以在这种情况下,我返回一个错误字符串。 如果状态码是200,那么响应的主体就有一个带有翻译的JSON编码字符串,所以我需要做的就是使用Python标准库中的json.loads()函数将JSON解码为我可以使用的Python字符串。 响应对象的content属性包含作为字节对象的响应的原始主体,该属性是UTF-8编码的字符序列,需要先进行解码,然后发送给json.loads()。 下面你可以看到一个Python控制台会话,我演示了如何使用新的translate()函数: >>> from app.translate import translate >>> translate('Hi, how are you today?', 'en', 'es') # English to Spanish 'Hola, ¿cómo estás hoy?' >>> translate('Hi, how are you today?', 'en', 'de') # English to German 'Are Hallo, how you heute?' >>> translate('Hi, how are you today?', 'en', 'it') # English to Italian 'Ciao, come stai oggi?' >>> translate('Hi, how are you today?', 'en', 'fr') # English to French "Salut, comment allez-vous aujourd'hui ?" 很酷,对吧? 现在是时候将此功能与应用集成在一起了。 来自服务器的Ajax 我将从实现服务器端部分开始。 当用户单击动态下方显示的翻译链接时,将向服务器发出异步HTTP请求。 我将在下一节中向你展示如何执行此操作,因此现在我将专注于实现服务器处理此请求的操作。 异步(Ajax)请求类似于我在应用中创建的路由和视图函数,唯一的区别是它不返回HTML或重定向,而是返回数据,格式为XML或更常见的JSON。 你可以在下面看到翻译视图函数,该函数调用Microsoft Translator API,然后返回JSON格式的翻译文本: app/routes.py:文本翻译视图函数。 from flask import jsonify from app.translate import translate @app.route('/translate', methods=['POST']) @login_required def translate_text(): return jsonify({'text': translate(request.form['text'], request.form['source_language'], request.form['dest_language'])}) 如你所见,相当简单。 我以POST请求的形式实现了这条路由。 关于什么时候使用GET或POST(或者还没有见过的其他请求方法),真的没有绝对的规则。 由于客户端将发送数据,因此我决定使用POST请求,因为它与提交表单数据的请求类似。 request.form属性是Flask用提交中包含的所有数据暴露的字典。 当我使用Web表单工作时,我不需要查看request.form,因为Flask-WTF可以为我工作,但在这种情况下,实际上没有Web表单,所以我必须直接访问数据。 所以我在这个函数中做的是调用上一节中的translate()函数,直接从通过请求提交的数据中传递三个参数。 将结果合并到单个键text下的字典中,字典作为参数传递给Flask的jsonify()函数,该函数将字典转换为JSON格式的有效载荷。 jsonify()返回的值是将被发送回客户端的HTTP响应。 例如,如果客户希望将字符串“Hello,World!”翻译成西班牙语,则来自该请求的响应将具有以下有效载荷: { "text": "Hola, Mundo!" } 来自客户端的Ajax 因此,现在服务器能够通过/translate URL提供翻译,当用户单击我上面添加的“翻译”链接时,我需要调用此URL,传递需要翻译的文本、源语言和目标语言。 如果你不熟悉在浏览器中使用JavaScript,这将是一个很好的学习机会。 在浏览器中使用JavaScript时,当前显示的页面在内部被表示为文档对象模型(DOM)。 这是一个引用页面中所有元素的层次结构。 在此上下文中运行的JavaScript代码可以更改DOM以触发页面中的更改。 我们首先需要讨论的是,在浏览器中运行的JavaScript代码如何获取需要发送到服务器中运行的翻译函数的三个参数。 为了获得文本,我需要找到包含用户动态正文的DOM内的节点并获取它的内容。 为了便于识别包含用户动态的DOM节点,我将为它们附加一个唯一的ID。 如果你查看_post.html模板,则呈现用户动态正文的行只会读取{{post.body}}。 我要做的是将这些内容包装在一个<span>元素中。 这不会在视觉上改变任何东西,但它给了我一个可以插入标识符的地方: app/templates/_post.html:给每条用户动态添加ID。 <span id="post{{ post.id }}">{{ post.body }}</span> 这将为每条用户动态分配一个唯一标识符,格式为post1,post2等,其中数字与每条用户动态的数据库标识符相匹配。 现在每条用户动态都有一个唯一的标识符,给定一个ID值,我可以使用jQuery定位<span>元素并提取其中的文本。 例如,如果我想获得ID为123的用户动态的文本,我可以这样做: $('#post123').text() 这里的$符号是jQuery库提供的函数的名称。 这个库被Bootstrap使用,所以它已经被Flask-Bootstrap包含。 #是jQuery使用的“选择器”语法的一部分,这意味着接下来是元素的ID。 我也希望有一个地方可以在我从服务器收到翻译文本后插入翻译文本。 我要做的是将“翻译”链接替换为翻译文本,因此我还需要为该节点提供唯一标识符: app/templates/_post.html:为翻译链接添加ID。 <span id="translation{{ post.id }}"> <a href="#">{{ _('Translate') }}</a> </span> 因此,现在对于一个给定的用户动态ID,我有一个用于用户动态的post <ID>节点和一个对应的translation <ID>节点,我可以在用翻译后的文本替换翻译链接时用到它们。 下一步是编写一个可以完成所有翻译工作的函数。 该函数将利用输入和输出DOM节点以及源语言和目标语言,向服务器发出携带必须的三个参数的异步请求,并在服务器响应后用翻译后的文本替换翻译链接。 这听起来像很多工作,但实现相当简单: app/templates/base.html:客户端翻译函数。 {% block scripts %} ... <script> function translate(sourceElem, destElem, sourceLang, destLang) { $(destElem).html('<img src="{{ url_for('static', filename='loading.gif') }}">'); $.post('/translate', { text: $(sourceElem).text(), source_language: sourceLang, dest_language: destLang }).done(function(response) { $(destElem).text(response['text']) }).fail(function() { $(destElem).text("{{ _('Error: Could not contact server.') }}"); }); } </script> {% endblock %} 前两个参数是用户动态和翻译链接节点的唯一ID,后两个参数是源语言和目标语言代码。 该函数从一个很好的接触开始:它添加一个加载器替换翻译链接,以便用户知道翻译正在进行中。 这是通过使用$(destElem).html()函数完成的,它用基于<img>元素的新HTML内容替换定义为翻译链接的原始HTML。 对于加载器,我将使用一个小的动画GIF,它已添加到Flask为静态文件保留的app/static目录中。 为了生成引用这个图像的URL,我使用url_for()函数,传递特殊的路由名称static并给出图像的文件名作为参数。 你可以在本章的下载包中找到loading.gif图像。 现在我用一个优雅的加载器代替了翻译链接,以便用户知道要等待翻译出现。 下一步是将POST请求发送到我在前一节中定义的/translate URL。 为此,我也将使用jQuery,本处使用$ .post()函数。 这个函数以一种类似于浏览器提交Web表单的格式向服务器提交数据,这很方便,因为它允许Flask将这些数据合并到request.form字典中。 $ .post()的参数是两个,第一个是发送请求的URL,第二个是包含服务器期望的三个数据项的字典(或者称之为对象,因为这些是在JavaScript中调用的)。 你可能知道JavaScript对回调函数(或者称为promises的更高级的回调形式)友好。 现在要做的就是说明一旦这个请求完成并且浏览器接收到响应,我想完成的事情。 在JavaScript中没有需要等待的事情,一切都是异步。 我需要做的是提供一个回调函数,浏览器在接收到响应时调用它。 而且,为了使所有内容尽可能健壮,我想指出在出现错误的情况下该怎么做,以作为处理错误的第二个回调函数。 有几种方法可以指定这些回调,但在这种情况下,使用promises可以使代码更加清晰。 语法如下: $.post(<url>, <data>).done(function(response) { // success callback }).fail(function() { // error callback }) promise语法允许将$ .post()调用的返回值“传入”回调函数作为参数。 在成功回调中,我所需要做的就是使用翻译后的文本调用$(destElem).text(),该文本在字典中text键下。 在出现错误的情况下,我也是这样做的,但是我显示的文本是一条通用的错误消息,我会确保它会作为可翻译的文本编入基础模板中。 所以现在唯一剩下的就是通过用户点击翻译链接来触发具有正确参数的translate()函数。 存在若干方法可以做到这一点,我要做的是将该函数的调用嵌入链接的href属性中: app/templates/_post.html:翻译链接处理器。 <span id="translation{{ post.id }}"> <a href="javascript:translate( '#post{{ post.id }}', '#translation{{ post.id }}', '{{ post.language }}', '{{ g.locale }}');">{{ _('Translate') }}</a> </span> 链接的href元素可以接受任何JavaScript代码,如果它带有javascript:前缀的话,那么这是一种方便的方式来调用翻译函数。 因为这个链接将在客户端请求页面时在服务器端渲染,所以我可以使用{{}}表达式来为函数生成四个参数。 每条用户动态都有自己的翻译链接,以及其唯一生成的参数。 post <ID>和translation <ID>需要渲染具体的ID,它们都需要在被使用时加上#前缀。 现在实时翻译功能已经完成! 如果你在环境中设置了有效的Microsoft Translator API Key,则现在应该能够触发翻译。 假设你的浏览器设置为偏好英语,则需要使用其他语言撰写文章以查看“翻译”链接。 下面你可以看到一个例子: 在本章中,我介绍了一些需要翻译成应用支持的所有语言的新文本,因此有必要更新翻译目录: (venv) $ flask translate update 对于你自己的项目,需要编辑每个语言存储库中的messages.po文件以包含这些新测试的翻译,不过我已经在本章的下载包或GitHub存储库中创建了西班牙语翻译。 要完成新的翻译,还需要执行编译: (venv) $ flask translate compile
本文转载自:https://www.jianshu.com/p/e2923f4042d6 这是Flask Mega-Tutorial系列的第十三部分,我将告诉你如何扩展Microblog应用以支持多种语言。 作为其中的一部分,你还将学习如何为flask命令创建自己的CLI扩展。 本章的主题是国际化和本地化,通常缩写为I18n和L10n。 为了使我的应用对不会英语的人更加友好,我将在语言翻译机制的帮助下,实施翻译工作流程,来使用多种语言向用户提供服务。 本章的GitHub链接为:Browse, Zip, Diff. Flask-Babel简介 你猜对了,Flask-Babel正是用于简化翻译工作的。可以使用pip命令安装它: (venv) $ pip install flask-babel Flask-Babel的初始化与之前的插件类似: app/__init__.py: Flask-Babel实例。 # ... from flask_babel import Babel app = Flask(__name__) # ... babel = Babel(app) 作为本章的一部分,我将向你展示如何将应用翻译成西班牙语,因为我碰巧会这种语言。 我当然也可以与翻译机制合作来支持其他语言。 为了跟踪支持的语言列表,我将添加一个配置变量: config.py:支持的语言列表。 class Config(object): # ... LANGUAGES = ['en', 'es'] 我为本应用使用双字母代码来表示语言种类,但如果你需要更具体,还可以添加国家代码。 例如,你可以使用en-US,en-GB和en-CA来支持美国、英国和加拿大的英语以示区分。 Babel实例提供了一个localeselector装饰器。 为每个请求调用装饰器函数以选择用于该请求的语言: app/__init__.py:选择最匹配的语言。 from flask import request # ... @babel.localeselector def get_locale(): return request.accept_languages.best_match(app.config['LANGUAGES']) 这里我使用了Flask中request对象的属性accept_languages。 request对象提供了一个高级接口,用于处理客户端发送的带Accept-Language头部的请求。 该头部指定了客户端语言和区域设置首选项。 该头部的内容可以在浏览器的首选项页面中配置,默认情况下通常从计算机操作系统的语言设置中导入。 大多数人甚至不知道存在这样的设置,但是这是有用的,因为应用可以根据每个语言的权重,提供优选语言的列表。 为了满足你的好奇心,下面是一个复杂的Accept-Languages头部的例子: Accept-Language: da, en-gb;q=0.8, en;q=0.7 这表示丹麦语(da)是首选语言(默认权重= 1.0),其次是英式英语(en-GB),其权重为0.8,最后是通用英语(en),权重为0.7。 要选择最佳语言,你需要将客户请求的语言列表与应用支持的语言进行比较,并使用客户端提供的权重,查找最佳语言。 这样做的逻辑有点复杂,但它已经全部封装在best_match()方法中了,该方法将应用提供的语言列表作为参数并返回最佳选择。 标记文本以在Python源代码中执行翻译 好吧,坏消息来了。 支持多语言的常规流程是在源代码中标记所有需要翻译的文本。 文本标记后,Flask-Babel将扫描所有文件,并使用gettext工具将这些文本提取到单独的翻译文件中。 不幸的是,这是一个繁琐的任务,并且是启用翻译的必要条件。 我将在这里向你展示标记操作的几个示例,你也可以从下载包获取本章完整的更改集,当然,也可以直接查看GitHub的页面。 为翻译而标记文本的方式是将它们封装在一个函数调用中,该函数调用为_(),仅仅是一个下划线。最简单的情况是源代码中出现的字符串。下面是一个flask()语句的例子: from flask_babel import _ # ... flash(_('Your post is now live!')) _()函数用于原始语言文本(在这种情况下是英文)的封装。 该函数将使用由localeselector装饰器装饰的选择函数,来为给定客户端查找正确的翻译语言。 _()函数随后返回翻译后的文本,在本处,翻译后的文本将成为flash()的参数。 但是不可能每个情况都这么简单,试想如下的另一个flash()调用: flash('User {} not found.'.format(username)) 该文本具有一个安插在静态文本中间的动态组件。 _()函数的语法支持这种类型的文本,但它基于旧版本的字符串替换语法: flash(_('User %(username)s not found.', username=username)) 还有更难处理的情况。 有些字符串文字并非是在发生请求时分配的,比如在应用启动时。因此在评估这些文本时,无法知道要使用哪种语言。 一个例子是与表单字段相关的标签,处理这些文本的唯一解决方案是找到一种方法来延迟对字符串的评估,直到它被使用,比如有实际上的请求发生了。 Flask-Babel提供了一个称为lazy_gettext()的_()函数的延迟评估的版本: from flask_babel import lazy_gettext as _l class LoginForm(FlaskForm): username = StringField(_l('Username'), validators=[DataRequired()]) # ... 在这里,我正在导入的这个翻译函数被重命名为_l(),以使其看起来与原始的_()相似。 这个新函数将文本包装在一个特殊的对象中,这个对象会在稍后的字符串使用时触发翻译。 Flask-Login插件只要将用户重定向到登录页面,就会闪现消息。 此消息为英文,来自插件本身。 为了确保这个消息也能被翻译,我将重写默认消息,并用_l()函数进行延迟处理: login = LoginManager(app) login.login_view = 'login' login.login_message = _l('Please log in to access this page.') 标记文本以在模板中进行翻译 在前面的章节中,你已经看到了如何在Python源代码中标记可翻译的文本,但这只是该过程的一部分,因为模板文件也包含文本。 _()函数也可以在模板中使用,所以过程非常相似。 例如,参考来自404.html的这段HTML代码: <h1>File Not Found</h1> 启用翻译之后的版本是: <h1>{{ _('File Not Found') }}</h1> 请注意,除了用_()包装文本外,还需要添加{{...}}来强制_()进行翻译,而不是将其视为模板中的文本字面量。 对于具有动态组件的更复杂的短语,也可以使用参数: <h1>{{ _('Hi, %(username)s!', username=current_user.username) }}</h1> _post.html中的一个特别棘手的案例让我花了一些时间才理顺: {% set user_link %} <a href="{{ url_for('user', username=post.author.username) }}"> {{ post.author.username }} </a> {% endset %} {{ _('%(username)s said %(when)s', username=user_link, when=moment(post.timestamp).fromNow()) }} 这里的问题是我希望username是一个超链接,指向用户的个人主页,而不仅仅是名字,所以我必须使用set和endset模板指令创建一个名为user_link的中间变量 ,然后将其作为参数传递给翻译函数。 正如我上面提到的,你可以下载该版本的应用,其中的Python源代码和模板中都已被标记成可翻译文本。 提取文本进行翻译 一旦应用所有_()和_l()都到位了,你可以使用pybabel命令将它们提取到一个.pot文件中,该文件代表可移植对象模板。 这是一个文本文件,其中包含所有标记为需要翻译的文本。 这个文件的目的是作为一个模板来为每种语言创建翻译文件。 提取过程需要一个小型配置文件,告诉pybabel哪些文件应该被扫描以获得可翻译的文本。 下面你可以看到我为这个应用创建的babel.cfg: babel.cfg:PyBabel配置文件。 [python: app/**.py] [jinja2: app/templates/**.html] extensions=jinja2.ext.autoescape,jinja2.ext.with_ 前两行分别定义了Python和Jinja2模板文件的文件名匹配模式。 第三行定义了Jinja2模板引擎提供的两个扩展,以帮助Flask-Babel正确解析模板文件。 可以使用以下命令来将所有文本提取到 .pot 文件: (venv) $ pybabel extract -F babel.cfg -k _l -o messages.pot . pybabel extract命令读取-F选项中给出的配置文件,然后从命令给出的目录(当前目录或本处的. )扫描与配置的源匹配的目录中的所有代码和模板文件。 默认情况下,pybabel将查找_()以作为文本标记,但我也使用了重命名为_l()的延迟版本,所以我需要用-k _l来告诉该工具也要查找它 。 -o选项提供输出文件的名称。 我应该注意,messages.pot文件不需要合并到项目中。 这是一个只要再次运行上面的命令,就可以在需要时轻松地重新生成的文件。 因此,不需要将该文件提交到源代码管理。 生成语言目录 该过程的下一步是在除了原始语言(在本例中为英语)之外,为每种语言创建一份翻译。 我要从添加西班牙语(语言代码es)开始,所以这样做的命令是: (venv) $ pybabel init -i messages.pot -d app/translations -l es creating catalog app/translations/es/LC_MESSAGES/messages.po based on messages.pot pybabel init命令将messages.pot文件作为输入,并将语言目录写入-d选项中指定的目录中,-l选项中指定的是翻译语言。 我将在app/translations目录中安装所有翻译,因为这是Flask-Babel默认提取翻译文件的地方。 该命令将在该目录内为西班牙数据文件创建一个es子目录。 特别是,将会有一个名为app/translations/es/LC_MESSAGES/messages.po的新文件,是需要翻译的文件路径。 如果你想支持其他语言,只需要各自的语言代码重复上述命令,就能使得每种语言都有一个包含messages.po文件的存储库。 在每个语言存储库中创建的messages.po文件使用的格式是语言翻译的事实标准,使用的格式为gettext。 以下是西班牙语messages.po开头的若干行: # Spanish translations for PROJECT. # Copyright (C) 2017 ORGANIZATION # This file is distributed under the same license as the PROJECT project. # FIRST AUTHOR <EMAIL@ADDRESS>, 2017. # msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2017-09-29 23:23-0700\n" "PO-Revision-Date: 2017-09-29 23:25-0700\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language: es\n" "Language-Team: es <LL@li.org>\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.1\n" #: app/email.py:21 msgid "[Microblog] Reset Your Password" msgstr "" #: app/forms.py:12 app/forms.py:19 app/forms.py:50 msgid "Username" msgstr "" #: app/forms.py:13 app/forms.py:21 app/forms.py:43 msgid "Password" msgstr "" 如果你跳过首段,可以看到后面的是从_()和_l()调用中提取的字符串列表。 对每个文本,都会展示其在应用中的引用位置。 然后,msgid行包含原始语言的文本,后面的msgstr行包含一个空字符串。 这些空字符串需要被编辑,以使目标语言中的文本内容被填充。 有很多翻译应用程序与.po文件一起工作。 如果你擅长编辑文本文件,量少的时候也就罢了,但如果你正在处理大型项目,可能会推荐使用专门的编辑器。 最流行的翻译应用程序是开源的poedit,可用于所有主流操作系统。 如果你熟悉vim,那么po.vim 插件会提供一些键值映射,使得处理这些文件更加轻松。 在添加翻译后,你可以在下面看到一部分西班牙语messages.po: #: app/email.py:21 msgid "[Microblog] Reset Your Password" msgstr "[Microblog] Nueva Contraseña" #: app/forms.py:12 app/forms.py:19 app/forms.py:50 msgid "Username" msgstr "Nombre de usuario" #: app/forms.py:13 app/forms.py:21 app/forms.py:43 msgid "Password" msgstr "Contraseña" 本章的下载包中包含所有翻译,此文件当然也在其中,所以你不必担心这部分的翻译工作。 messages.po文件是一种用于翻译的源文件。 当你想开始使用这些翻译后的文本时,这个文件需要被编译成一种格式,这种格式在运行时可以被应用程序使用。 要编译应用程序的所有翻译,可以使用pybabel compile命令,如下所示: (venv) $ pybabel compile -d app/translations compiling catalog app/translations/es/LC_MESSAGES/messages.po to app/translations/es/LC_MESSAGES/messages.mo 此操作在每个语言存储库中的messages.po旁边添加messages.mo文件。 .mo文件是Flask-Babel将用于为应用程序加载翻译的文件。 在为西班牙语或任何其他添加到项目中的语言创建messages.mo文件之后,可以在应用中使用这些语言。 如果你想查看应用程序以西班牙语显示的方式,则可以在Web浏览器中编辑语言配置,以将西班牙语作为首选语言。 对Chrome,这是设置页面的高级部分: 如果你不想更改浏览器设置,另一种方法是通过使localeselector函数始终返回一种语言来强制实现。 对西班牙语,你可以这样做: app/__init__.py:选择最佳语言。 @babel.localeselector def get_locale(): # return request.accept_languages.best_match(app.config['LANGUAGES']) return 'es' 使用为西班牙语配置的浏览器运行该应用或返回es的localeselector函数,将使所有文本在使用该应用时显示为西班牙文。 更新翻译 处理翻译时的一个常见情况是,即使翻译文件不完整,你也可能要开始使用翻译文件。 这是非常好的,你可以编译一个不完整的messages.po文件,任何可用的翻译都将被使用,而任何缺失的部分将使用原始语言。 随后,你可以继续处理翻译并再次编译,以便在取得进展时更新messages.mo文件。 如果在添加_()包装器时错过了一些文本,则会出现另一种常见情况。 在这种情况下,你会发现你错过的那些文本将保持为英文,因为Flask-Babel对他们一无所知。 当你检测到这种情况时,会想要将其用_()或_l()包装,然后执行更新过程,这包括两个步骤: (venv) $ pybabel extract -f babel.cfg -k _l -o messages.pot . (venv) $ pybabel update -i messages.pot -d app/translations extract命令与我之前执行的命令相同,但现在它会生成messages.pot的新版本,其中包含所有以前的文本以及最近用_()或_l()包装的文本。 update调用采用新的messages.pot文件并将其合并到与项目相关的所有messages.po文件中。 这将是一个智能合并,其中任何现有的文本将被单独保留,而只有在messages.pot中添加或删除的条目才会受到影响。 messages.po文件更新后,你就可以继续新的测试了,再次编译它,以便对应用生效。 翻译日期和时间 现在,我已经为Python代码和模板中的所有文本提供了完整的西班牙语翻译,但是如果你使用西班牙语运行应用并且是一个很好的观察者,那么会注意到还有一些内容以英文显示。 我指的是由Flask-Moment和moment.js生成的时间戳,显然这些时间戳并未包含在翻译工作中,因为这些包生成的文本都不是应用程序源代码或模板的一部分。 moment.js库确实支持本地化和国际化,所以我需要做的就是配置适当的语言。 Flask-Babel通过get_locale()函数返回给定请求的语言和语言环境,所以我要做的就是将语言环境添加到g对象,以便我可以从基础模板中访问它: app/routes.py:存储选择的语言到flask.g中。 # ... from flask import g from flask_babel import get_locale # ... @app.before_request def before_request(): # ... g.locale = str(get_locale()) Flask-Babel的get_locale()函数返回一个本地语言对象,但我只想获得语言代码,可以通过将该对象转换为字符串来获取语言代码。 现在我有了g.locale,可以从基础模板中访问它,并以正确的语言配置moment.js: app/templates/base.html:为moment.js设置本地语言 ... {% block scripts %} {{ super() }} {{ moment.include_moment() }} {{ moment.lang(g.locale) }} {% endblock %} 现在所有的日期和时间都与文本使用相同的语言了。 你可以在下面看到西班牙语的外观: 此时,除用户在用户动态或个人资料说明中提供的文本外,所有其他的文本均可翻译成其他语言。 命令行增强 你可能会同意我的看法,pybabel命令有点长,难以记忆。 我将利用这个机会向你展示如何创建与flask命令集成的自定义命令。 到目前为止,你已经看到我使用Flask-Migrate扩展提供的flask run、flask shell和几个flask db子命令。 将应用特定的命令添加到flask实际上也很容易。 所以我现在要做的就是创建一些简单的命令,并用这个应用特有的参数触发pybabel命令。 我要添加的命令是: flask translate init LANG用于添加新语言 flask translate update用于更新所有语言存储库 flask translate compile用于编译所有语言存储库 babel export步骤不会设置为一个命令,因为生成messages.pot文件始终是运行init或update命令的先决条件,因此这些命令的执行将会生成翻译模板文件作为临时文件。 Flask依赖Click进行所有命令行操作。 像translate这样的命令是几个子命令的根,它们是通过app.cli.group()装饰器创建的。 我将把这些命令放在一个名为app/cli.py的新模块中: app/cli.py:翻译命令组 from app import app @app.cli.group() def translate(): """Translation and localization commands.""" pass 该命令的名称来自被装饰函数的名称,并且帮助消息来自文档字符串。 由于这是一个父命令,它的存在只为子命令提供基础,函数本身不需要执行任何操作。 update和compile很容易实现,因为它们没有任何参数: app/cli.py:更新子命令和编译子命令: import os # ... @translate.command() def update(): """Update all languages.""" if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): raise RuntimeError('extract command failed') if os.system('pybabel update -i messages.pot -d app/translations'): raise RuntimeError('update command failed') os.remove('messages.pot') @translate.command() def compile(): """Compile all languages.""" if os.system('pybabel compile -d app/translations'): raise RuntimeError('compile command failed') 请注意,这些函数的装饰器是如何从translate父函数派生的。 这似乎令人困惑,因为translate()是一个函数,但它是Click构建命令组的标准方式。 与translate()函数相同,这些函数的文档字符串在--help输出中用作帮助消息。 你可以看到,对于所有命令,运行它们并确保返回值为零(这意味着命令没有返回任何错误)。 如果命令错误,那么我会引发一个RuntimeError,这会导致脚本停止。 update()函数在同一个命令中结合了extract和update步骤,如果一切都成功的话,它会在更新完成后删除messages.pot文件,因为当再次需要这个文件时,可以很容易地重新生成 。 init命令将新的语言代码作为参数。 这是其执行流程: app/cli.py:Init子命令。 import click @translate.command() @click.argument('lang') def init(lang): """Initialize a new language.""" if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'): raise RuntimeError('extract command failed') if os.system( 'pybabel init -i messages.pot -d app/translations -l ' + lang): raise RuntimeError('init command failed') os.remove('messages.pot') 该命令使用@click.argument装饰器来定义语言代码。 Click将命令中提供的值作为参数传递给处理函数,然后将该参数并入到init命令中。 启用这些命令的最后一步是导入它们,以便注册命令。 我决定在顶级目录的microblog.py文件中执行此操作: microblog.py:注册命令。 from app import cli 这里我唯一需要做的就是导入新的cli.py模块,不需要做任何事情,因为导入操作会导致命令装饰器运行并注册命令。 此时,运行flask --help将列出translate命令作为选项。 flask translate --help将显示我定义的三个子命令: (venv) $ flask translate --help Usage: flask translate [OPTIONS] COMMAND [ARGS]... Translation and localization commands. Options: --help Show this message and exit. Commands: compile Compile all languages. init Initialize a new language. update Update all languages. 所以现在工作流程就简便多了,而且不需要记住长而复杂的命令。 要添加新的语言,请使用: (venv) $ flask translate init <language-code> 在更改_()和_l()语言标记后更新所有语言: (venv) $ flask translate update 在更新翻译文件后编译所有语言: (venv) $ flask translate compile
本文转载自:https://www.jianshu.com/p/2c3305d75bf4 这是Flask Mega-Tutorial系列的第十一部分,我将告诉你如何用基于Bootstrap用户界面框架的新模板替换基础的HTML模板。 你把玩Microblog应用也有一段时间了,所以我相信你已经注意到,我没有花太多时间来美化它,说得更具体点,我根本没有花时间。 所有的模板只使用了基础样式,没有任何自定义的展现。 这对于我来说却非常有用,因为我可以专注于应用的实际逻辑,不用分心于编写好看的HTML和CSS代码。 但是我已经长期关注应用的后端部分一段时间了。 因此在本章中,我暂停一下后端的工作,并花点时间向你展示如何使应用看起来更加优雅和专业。 本章将与之前的章节略有不同,因为我不会像平常解说Python那样,事无巨细,一一道来,毕竟Python才是本教程的主要内容。 创建漂亮的网页是一个很广泛的话题,而与Python Web的后端开发很大程度上无关,因此我将讨论一些基本的指导方针和想法,你可以通过重新设计应用的外观来研究和学习它。 本章的GitHub链接为:Browse, Zip, Diff. CSS框架 虽然我们可以争辩说写代码不容易,但是与那些必须让网页在所有Web浏览器上具有良好一致外观的网页设计师相比,我们的痛苦不值一提。 虽然近年来这种情况得到一定程度的缓解,但是在一些浏览器中仍然存在着晦涩的错误或奇怪的设定,这使得设计网页的任务变得非常困难。 如果还需要兼容屏幕限制设备(诸如平板电脑和智能手机)的浏览器,则更加困难。 如果你和我一样,只是一个想创建出规范网页的开发人员,没有时间或兴趣去学习底层机制并通过编写原生HTML和CSS来实现它,那么唯一可行的解决方案是使用CSS框架来简化任务。 通过这种方式,你会失去一些创造性的自由,但另一方面,无需通过太多的功夫就可以让网页在所有浏览器中看起来都不错。 CSS框架为普通类型的用户界面元素提供了高级CSS类的集合,其中包含预定义样式。 大多数这样的框架还提供JavaScript插件,以实现不能纯粹使用HTML和CSS来完成的功能。 Bootstrap简介 最受欢迎的CSS框架之一是由Twitter推出的Bootstrap。 如果你想看看这个框架可以设计的页面类型,文档有一些示例。 这些是使用Bootstrap来设置网页风格的一些好处: 在所有主流网页浏览器中都有相似的外观 自动处理PC桌面,平板电脑和手机屏幕尺寸 可定制的布局 精心设计的导航栏,表单,按钮,警示,弹出窗口等 使用Bootstrap最直接的方法是简单地在你的基本模板中导入bootstrap.min.css文件。 可以下载此文件并将其添加到你的项目中,或直接从CDN导入。 然后,你可以根据其文档开始使用它提供的通用CSS类,实在是太棒了。 你可能还需要导入包含框架JavaScript代码的bootstrap.min.js文件,以便使用最先进的功能。 幸运的是,有一个名为Flask-Bootstrap的Flask插件,它提供了一个已准备好的基础模板,该模板引入了Bootstrap框架。 让我们来安装这个扩展: (venv) $ pip install flask-bootstrap 使用Flask-Bootstrap Flask-Bootstrap需要像大多数其他Flask插件一样被初始化: app/init.py: Flask-Bootstrap实例。 # ... from flask_bootstrap import Bootstrap app = Flask(__name__) # ... bootstrap = Bootstrap(app) 在初始化插件之后,bootstrap/base.html模板就会变为可用状态,你可以使用extends子句从应用模板中引用。 但是,回顾一下,我已经使用了extends子句来继承我的基础模板,这使我可以将页面的公共部分放在一个地方。 base.html模板定义了导航栏,其中包含几个链接,并且还导出了一个content块。 应用中的所有其他模板都从基础模板继承,并为内容块提供页面的主要内容。 那么我怎样才能适配Bootstrap基础模板呢? 解决方案是从使用两个层级到使用三个层级。 bootstrap/base.html模板提供页面的基本结构,其中引入了Bootstrap框架文件。 这个模板为派生的模板定义了一些块,例如title,navbar和content(参见块的完整列表)。 我将更改base.html模板以从bootstrap/base.html派生,并提供title,navbar和content块的实现。 反过来,base.html将为从其派生的模板导出app_content块以定义页面内容。 下面你可以看到从Bootstrap基础模板派生的base.html的代码。 请注意,此列表不包含导航栏的整个HTML,但你可以在GitHub上或下载本章的代码来查看完整的实现。 app/templates/base.html:重新设计后的基础模板。 {% extends 'bootstrap/base.html' %} {% block title %} {% if title %}{{ title }} - Microblog{% else %}Welcome to Microblog{% endif %} {% endblock %} {% block navbar %} <nav class="navbar navbar-default"> ... navigation bar here (see complete code on GitHub) ... </nav> {% endblock %} {% block content %} <div class="container"> {% with messages = get_flashed_messages() %} {% if messages %} {% for message in messages %} <div class="alert alert-info" role="alert">{{ message }}</div> {% endfor %} {% endif %} {% endwith %} {# application content needs to be provided in the app_content block #} {% block app_content %}{% endblock %} </div> {% endblock %} 从中你可以看到我如何从bootstrap/base.html派生此模板,接下来分别实现了页面标题、导航栏和页面内容的这三个模块。 title块需要使用<title>标签来定义用于页面标题的文本。 对于这个块我简单地挪用了原始基本模板中<title>标签内部的逻辑。 navbar块是一个可选块,用于定义导航栏。 对于此块,我调整了Bootstrap导航栏文档中的示例,以便它在左侧展示网站品牌,跟着是Home和Explore的链接。 然后我添加了个人主页和登录或注销链接并使其与页面的右边界对齐。 正如我上面提到的,我在上面的例子中省略了HTML,但是你可以从本章的下载包中获得完整的base.html模板。 最后,在content块中,我定义了一个顶级容器,并在其中设定了呈现闪现消息的逻辑,这些消息现在将显示为Bootstrap警示的样式。 接下来是一个新的app_content块,这个块用于从其派生的模板来定义他们自己的内容。 所有页面模板的原始版本在名为content的块中定义了它们的内容。 正如你在上面看到的,Flask-Bootstrap使用名为content的块,所以我将我的内容块重命名为app_content。 所以我所有的模板都必须重命名为使用app_content作为它们的内容块。 例如,这是404.html模板的修改后版本的展示: app/templates/404.html:重新设计后的404错误模板。 {% extends "base.html" %} {% block app_content %} <h1>File Not Found</h1> <p><a href="{{ url_for('index') }}">Back</a></p> {% endblock %} 渲染Bootstrap表单 Flask-Bootstrap在渲染表单这方面做得非常出色。 Flask-Bootstrap不需要逐个设置表单字段,而是使用一个接受Flask-WTF表单对象作为参数的宏,并以Bootstrap样式渲染出完整的表单。 下面你可以看到重新设计后的register.html模板: app/templates/register.html::用户注册模板。 {% extends "base.html" %} {% import 'bootstrap/wtf.html' as wtf %} {% block app_content %} <h1>Register</h1> <div class="row"> <div class="col-md-4"> {{ wtf.quick_form(form) }} </div> </div> {% endblock %} 是不是很棒? 顶端附近的import语句与Python导入类似。 这增加了一个wtf.quick_form()宏,它在单行代码中渲染完整的表单,包括对显示验证错误的支持,并且适配Bootstrap框架的所有样式。 再一次地,我不会向你展示我为应用中的其他表单所做的所有更改,但这些更改都是可以在GitHub上下载或检查到的。 渲染用户动态 单条用户动态的渲染逻辑被提取到名为_post.html的子模板中。 我只需要在这个模板上做一些很小的调整,就可以使其在Bootstrap下看起来很棒了。 app/templates/_post.html: 重新设计后的用户动态子模板。 <table class="table table-hover"> <tr> <td width="70px"> <a href="{{ url_for('user', username=post.author.username) }}"> <img src="{{ post.author.avatar(70) }}" /> </a> </td> <td> <a href="{{ url_for('user', username=post.author.username) }}"> {{ post.author.username }} </a> says: <br> {{ post.body }} </td> </tr> </table> 渲染分页链接 分页链接是Bootstrap提供直接支持的另一个方面。 为此,我再一次访问Bootstrap 文档,并修改了其中的一个示例。 以下是在index.html页面中的分页链接的代码: app/templates/index.html: 重新设计后的分页链接。 ... <nav aria-label="..."> <ul class="pager"> <li class="previous{% if not prev_url %} disabled{% endif %}"> <a href="{{ prev_url or '#' }}"> <span aria-hidden="true">&larr;</span> Newer posts </a> </li> <li class="next{% if not next_url %} disabled{% endif %}"> <a href="{{ next_url or '#' }}"> Older posts <span aria-hidden="true">&rarr;</span> </a> </li> </ul> </nav> 请注意,在分页链接的实现中,当某个方向没有更多内容时,不是隐藏该链接,而是使用禁用状态,这会使该链接显示为灰色。 类似的更改需要应用于user.html,但我不打算展示在此处。 本章的下载包中包含这些更改。 对比 请下载本章的zip文件并更新应用。 下面你可以对照几张美化前后的图片来观察转变情况。 请记住,这种转变是在不改变一行应用逻辑代码的情况下实现的!
本文转载自:https://www.jianshu.com/p/e9b20e09aa66 这是Flask Mega-Tutorial系列的第八部分,我将告诉你如何实现类似于Twitter和其他社交网络的“粉丝”功能。 在本章中,我将更多地使用应用的数据库。 我希望应用的用户能够轻松便捷地关注其他用户。 所以我要扩展数据库,以便跟踪谁关注了谁,这比你想象的要难得多。 本章的GitHub链接为:Browse, Zip, Diff. 深入理解数据库关系 我上面说过,我想为每个用户维护一个“粉丝”用户列表和“关注”用户列表。 不幸的是,关系型数据库没有列表类型的字段来保存它们,那么只能通过表的现有字段和他们之间的关系来实现。 数据库已有一个代表用户的表,所以剩下的就是如何正确地组织他们之间的关注与被关注的关系。 这正是回顾基本数据库关系类型的好时机: 一对多 我已经在第四章中用过了一对多关系。这是该关系的示意图(译者注:实际表名分别为user和post): 用户和用户动态通过这个关系来关联。其中,一个用户拥有多条用户动态,而一条用户动态属于一个用户(作者)。数据库在多的这方使用了一个外键以表示一对多关系。在上面的一对多关系中,外键是post表的user_id字段,这个字段将用户的每条动态都与其作者关联了起来。 很明显,user_id字段提供了直接访问给定用户动态的作者,但是反向呢? 透过这层关系,我如何通过给定的用户来获得其用户动态的列表?post表中的user_id字段也足以回答这个问题,数据库具有索引,可以进行高效的查询“返回所有user_id字段等于X的用户动态”。 多对多 多对多关系会更加复杂,举个例子,数据库中有students表和teachers表,一名学生学习多位老师的课程,一位老师教授多名学生。这就像两个重叠的一对多关系。 对于这种类型的关系,我想要能够查询数据库来获取教授给定学生的教师的列表,以及某个教师课程中的学生的列表。 想要在关系型数据库中梳理这样的关系并非轻易而举,因为无法通过向现有表添加外键来完成此操作。 展现多对多关系需要使用额外的关联表。以下是数据库如何查找学生和教师的示例: 虽然起初看起来并不明显,但具有两个外键的关联表的确能够有效地回答所有多对多关系的查询。 多对一和一对一 多对一关系类似于一对多关系。 不同的是,这种关系是从“多”的角度来看的。 一对一的关系是一对多的特例。 实现是相似的,但是一个约束被添加到数据库,以防止“多”一方有多个链接。 虽然有这种类型的关系是有用的,但并不像其他类型那么普遍。 译者注:如果读者有兴趣,也可以看看我写的一篇类似的数据库关系文章——Web开发中常用的数据关系 实现粉丝机制 查看所有关系类型的概要,很容易确定维护粉丝关系的正确数据模型是多对多关系,因为用户可以关注多个其他用户,并且用户可以拥有多个粉丝。 不过,在学生和老师的例子中,多对多关系关联了两个实体。 但在粉丝关系中,用户关注其他用户,只有一个用户实体。 那么,多对多关系的第二个实体是什么呢? 该关系的第二个实体也是用户。 一个类的实例被关联到同一个类的其他实例的关系被称为自引用关系,这正是我在这里所用到的。 使用自引用多对多关系来实现粉丝机制的表结构示意图: followers表是关系的关联表。 此表中的外键都指向用户表中的数据行,因为它将用户关联到用户。 该表中的每个记录代表关注者和被关注者的一个关系。 像学生和老师的例子一样,像这样的设计允许数据库回答所有关于关注和被关注的问题,并且足够干净利落。 数据库模型的实现 首先,让我们在数据库中添加粉丝机制吧。这是followers关联表: followers = db.Table('followers', db.Column('follower_id', db.Integer, db.ForeignKey('user.id')), db.Column('followed_id', db.Integer, db.ForeignKey('user.id')) ) 这是上图中关联表的直接翻译。 请注意,我没有像我为用户和用户动态所做的那样,将表声明为模型。 因为这是一个除了外键没有其他数据的辅助表,所以我创建它的时候没有关联到模型类。 现在我可以在用户表中声明多对多的关系了: class User(UserMixin, db.Model): # ... followed = db.relationship( 'User', secondary=followers, primaryjoin=(followers.c.follower_id == id), secondaryjoin=(followers.c.followed_id == id), backref=db.backref('followers', lazy='dynamic'), lazy='dynamic') 建立关系的过程实属不易。 就像我为post一对多关系所做的那样,我使用db.relationship函数来定义模型类中的关系。 这种关系将User实例关联到其他User实例,所以按照惯例,对于通过这种关系关联的一对用户来说,左侧用户关注右侧用户。 我在左侧的用户中定义了followed的关系,因为当我从左侧查询这个关系时,我将得到已关注的用户列表(即右侧的列表)。 让我们逐个检查这个db.relationship()所有的参数: 'User'是关系当中的右侧实体(将左侧实体看成是上级类)。由于这是自引用关系,所以我不得不在两侧都使用同一个实体。 secondary 指定了用于该关系的关联表,就是使用我在上面定义的followers。 primaryjoin 指明了通过关系表关联到左侧实体(关注者)的条件 。关系中的左侧的join条件是关系表中的follower_id字段与这个关注者的用户ID匹配。followers.c.follower_id表达式引用了该关系表中的follower_id列。 secondaryjoin 指明了通过关系表关联到右侧实体(被关注者)的条件 。 这个条件与primaryjoin类似,唯一的区别在于,现在我使用关系表的字段的是followed_id了。 backref定义了右侧实体如何访问该关系。在左侧,关系被命名为followed,所以在右侧我将使用followers来表示所有左侧用户的列表,即粉丝列表。附加的lazy参数表示这个查询的执行模式,设置为动态模式的查询不会立即执行,直到被调用,这也是我设置用户动态一对多的关系的方式。 lazy和backref中的lazy类似,只不过当前的这个是应用于左侧实体,backref中的是应用于右侧实体。 如果理解起来比较困难,你也不必过于担心。我待会儿就会向你展示如何利用这些关系来执行查询,一切就会变得清晰明了。 数据库的变更,需要记录到一个新的数据库迁移中: (venv) $ flask db migrate -m "followers" INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.autogenerate.compare] Detected added table 'followers' Generating /home/miguel/microblog/migrations/versions/ae346256b650_followers.py ... done (venv) $ flask db upgrade INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.runtime.migration] Running upgrade 37f06a334dbf -> ae346256b650, followers 关注和取消关注 感谢SQLAlchemy ORM,一个用户关注另一个用户的行为可以通过followed关系抽象成一个列表来简便使用。 例如,如果我有两个用户存储在user1和user2变量中,我可以用下面这个简单的语句来实现: user1.followed.append(user2) 要取消关注该用户,我可以这么做: user1.followed.remove(user2) 即便关注和取消关注的操作相当容易,我仍然想提高这段代码的可重用性,所以我不会直接在代码中使用“appends”和“removes”,取而代之,我将在User模型中实现“follow”和“unfollow”方法。 最好将应用逻辑从视图函数转移到模型或其他辅助类或辅助模块中,因为你会在本章之后将会看到,这使得单元测试更加容易。 下面是用户模型中添加和删除关注关系的代码变更: class User(UserMixin, db.Model): #... def follow(self, user): if not self.is_following(user): self.followed.append(user) def unfollow(self, user): if self.is_following(user): self.followed.remove(user) def is_following(self, user): return self.followed.filter( followers.c.followed_id == user.id).count() > 0 follow()和unfollow()方法使用关系对象的append()和remove()方法。有必要在处理关系之前,使用一个is_following()方法来确认操作的前提条件是否符合,例如,如果我要求user1关注user2,但事实证明这个关系在数据库中已经存在,我就没必要重复操作了。 相同的逻辑可以应用于取消关注。 is_following()方法发出一个关于followed关系的查询来检查两个用户之间的关系是否已经存在。 你已经看到过我使用SQLAlchemy查询对象的filter_by()方法,例如,查找给定用户名的用户。 我在这里使用的filter()方法很类似,但是更加偏向底层,因为它可以包含任意的过滤条件,而不像filter_by(),它只能检查是否等于一个常量值。 我在is_following()中使用的过滤条件是,查找关联表中左侧外键设置为self用户且右侧设置为user参数的数据行。 查询以count()方法结束,返回结果的数量。 这个查询的结果是0或1,因此检查计数是1还是大于0实际上是相等的。 至于其他的查询结束符all()和first(),你已经看到我使用过了。 查看已关注用户的动态 在数据库中支持粉丝机制的工作几近尾声,但是我却遗漏了一项重要的功能。应用主页中需要展示已登录用户关注的其他所有用户的动态,我需要用数据库查询来返回这些用户动态。 最显而易见的方案是先执行一个查询以返回已关注用户的列表,如你所知,可以使用user.followed.all()语句。然后对每个已关注的用户执行一个查询来返回他们的用户动态。最后将所有用户的动态按照日期时间倒序合并到一个列表中。听起来不错?其实不然。 这种方法有几个问题。 如果一个用户关注了一千人,会发生什么? 我需要执行一千个数据库查询来收集所有的用户动态。 然后我需要合并和排序内存中的一千个列表。 作为第二个问题,考虑到应用主页最终将实现分页,所以它不会显示所有可用的用户动态,只能是前几个,并显示一个链接来提供感兴趣的用户查看更多动态。 如果我要按它们的日期排序来显示动态,我怎么能知道哪些用户动态才是所有用户中最新的呢?除非我首先得到了所有的用户动态并对其进行排序。 这实际上是一个糟糕的解决方案,不能很好地应对规模化。 用户动态的合并和排序操作是无法避免的,但是在应用中执行会导致效率十分低下, 而这种工作是关系数据库擅长的。 我可以使用数据库的索引,命令它以更有效的方式执行查询和排序。 所以我真正想要提供的方案是,定义我想要得到的信息来执行一个数据库查询,然后让数据库找出如何以最有效的方式来提取这些信息。 看看下面的这个查询: class User(db.Model): #... def followed_posts(self): return Post.query.join( followers, (followers.c.followed_id == Post.user_id)).filter( followers.c.follower_id == self.id).order_by( Post.timestamp.desc()) 这是迄今为止我在这个应用中使用的最复杂的查询。 我将尝试一步一步地解读这个查询。 如果你看一下这个查询的结构,你会注意到有三个主要部分,分别是join()、filter()和order_by(),他们都是SQLAlchemy查询对象的方法: Post.query.join(...).filter(...).order_by(...) 联合查询 要理解join操作的功能,我们来看一个例子。 假设我有一个包含以下内容的User表: id username 1 john 2 susan 3 mary 4 david 为了简单起见,我只会保留用户模型的id和username字段以便进行查询,其他的都略去。 假设followers关系表中数据表达的是用户john关注用户susan和 david,用户susan关注用户mary,用户mary关注用户david。这些的数据如下表所示: follower_id followed_id 1 2 1 4 2 3 3 4 最后,用户动态表中包含了每个用户的一条动态: id text user_id 1 post from susan 2 2 post from mary 3 3 post from david 4 4 post from john 1 这张表也省略了一些不属于这个讨论范围的字段。 这是我为该查询再次设计的join()调用: Post.query.join(followers, (followers.c.followed_id == Post.user_id)) 我在用户动态表上调用join操作。 第一个参数是followers关联表,第二个参数是join条件。 我的这个调用表达的含义是我希望数据库创建一个临时表,它将用户动态表和关注者表中的数据结合在一起。 数据将根据参数传递的条件进行合并。 我使用的条件表示了followers关系表的followed_id字段必须等于用户动态表的user_id字段。 要执行此合并,数据库将从用户动态表(join的左侧)获取每条记录,并追加followers关系表(join的右侧)中的匹配条件的所有记录。 如果followers关系表中有多个记录符合条件,那么用户动态数据行将重复出现。 如果对于一个给定的用户动态,followers关系表中却没有匹配,那么该用户动态的记录不会出现在join操作的结果中。 利用我上面定义的示例数据,执行join操作的结果如下: id text user_id follower_id followed_id 1 post from susan 2 1 2 2 post from mary 3 2 3 3 post from david 4 1 4 3 post from david 4 3 4 注意user_id和followed_id列在所有数据行中都是相等的,因为这是join条件。 来自用户john的用户动态不会出现在临时表中,因为被关注列表中没有包含john用户,换句话说,没有任何人关注john。 而来自david的用户动态出现了两次,因为该用户有两个粉丝。 虽然创建了这个join操作,但却没有得到想要的结果。请继续看下去,因为这只是更大的查询的一部分。 过滤 Join操作给了我一个所有被关注用户的用户动态的列表,远超出我想要的那部分数据。 我只对这个列表的一个子集感兴趣——某个用户关注的用户们的动态,所以我需要用filter()来剔除所有我不需要的数据。 这是过滤部分的查询语句: filter(followers.c.follower_id == self.id) 该查询是User类的一个方法,self.id表达式是指我感兴趣的用户的ID。filter()挑选临时表中follower_id列等于这个ID的行,换句话说,我只保留follower(粉丝)是该用户的数据。 假如我现在对id为1的用户john能看到的用户动态感兴趣,这是从临时表过滤后的结果: id text user_id follower_id followed_id 1 post from susan 2 1 2 3 post from david 4 1 4 这正是我想要的结果! 请记住,查询是从Post类中发出的,所以尽管我曾经得到了由数据库创建的一个临时表来作为查询的一部分,但结果将是包含在此临时表中的用户动态, 而不会存在由于执行join操作添加的其他列。 排序 查询流程的最后一步是对结果进行排序。这部分的查询语句如下: order_by(Post.timestamp.desc()) 在这里,我要说的是,我希望使用用户动态产生的时间戳按降序排列结果列表。排序之后,第一个结果将是最新的用户动态。 组合自身动态和关注的用户动态 我在followed_posts()函数中使用的查询是非常有用的,但有一个限制,人们期望看到他们自己的动态包含在他们的关注的用户动态的时间线中,而该查询却力有未逮。 有两种可能的方式来扩展此查询以包含用户自己的动态。 最直截了当的方法是将查询保持原样,但要确保所有用户都关注了他们自己。 如果你是你自己的粉丝,那么上面的查询就会找到你自己的动态以及你关注的所有人的动态。 这种方法的缺点是会影响粉丝的统计数据。 所有人的粉丝数量都将加一,所以它们必须在显示之前进行调整。 第二种方法是通过创建第二个查询返回用户自己的动态,然后使用“union”操作将两个查询合并为一个查询。 深思熟虑之后,我选择了第二个方案。 下面你可以看到followed_posts()函数已被扩展成通过联合查询来并入用户自己的动态: def followed_posts(self): followed = Post.query.join( followers, (followers.c.followed_id == Post.user_id)).filter( followers.c.follower_id == self.id) own = Post.query.filter_by(user_id=self.id) return followed.union(own).order_by(Post.timestamp.desc()) 请注意,followed和own查询结果集是在排序之前进行的合并。 对用户模型执行单元测试 虽然我不担心这个稍显“复杂”的粉丝机制的运行是否无误。 但当我编写举足轻重的代码时,我担心的是我在应用的不同部分修改了代码之后,如何确保本处代码将来会继续工作。 确保已经编写的代码在将来继续有效的最佳方法是创建一套自动化测试,你可以在每次更新代码后执行测试。 Python包含一个非常有用的unittest包,可以轻松编写和执行单元测试。 让我们来为User类中的现有方法编写一些单元测试并存储到tests.py模块: from datetime import datetime, timedelta import unittest from app import app, db from app.models import User, Post class UserModelCase(unittest.TestCase): def setUp(self): app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://' db.create_all() def tearDown(self): db.session.remove() db.drop_all() def test_password_hashing(self): u = User(username='susan') u.set_password('cat') self.assertFalse(u.check_password('dog')) self.assertTrue(u.check_password('cat')) def test_avatar(self): u = User(username='john', email='john@example.com') self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/' 'd4c74594d841139328695756648b6bd6' '?d=identicon&s=128')) def test_follow(self): u1 = User(username='john', email='john@example.com') u2 = User(username='susan', email='susan@example.com') db.session.add(u1) db.session.add(u2) db.session.commit() self.assertEqual(u1.followed.all(), []) self.assertEqual(u1.followers.all(), []) u1.follow(u2) db.session.commit() self.assertTrue(u1.is_following(u2)) self.assertEqual(u1.followed.count(), 1) self.assertEqual(u1.followed.first().username, 'susan') self.assertEqual(u2.followers.count(), 1) self.assertEqual(u2.followers.first().username, 'john') u1.unfollow(u2) db.session.commit() self.assertFalse(u1.is_following(u2)) self.assertEqual(u1.followed.count(), 0) self.assertEqual(u2.followers.count(), 0) def test_follow_posts(self): # create four users u1 = User(username='john', email='john@example.com') u2 = User(username='susan', email='susan@example.com') u3 = User(username='mary', email='mary@example.com') u4 = User(username='david', email='david@example.com') db.session.add_all([u1, u2, u3, u4]) # create four posts now = datetime.utcnow() p1 = Post(body="post from john", author=u1, timestamp=now + timedelta(seconds=1)) p2 = Post(body="post from susan", author=u2, timestamp=now + timedelta(seconds=4)) p3 = Post(body="post from mary", author=u3, timestamp=now + timedelta(seconds=3)) p4 = Post(body="post from david", author=u4, timestamp=now + timedelta(seconds=2)) db.session.add_all([p1, p2, p3, p4]) db.session.commit() # setup the followers u1.follow(u2) # john follows susan u1.follow(u4) # john follows david u2.follow(u3) # susan follows mary u3.follow(u4) # mary follows david db.session.commit() # check the followed posts of each user f1 = u1.followed_posts().all() f2 = u2.followed_posts().all() f3 = u3.followed_posts().all() f4 = u4.followed_posts().all() self.assertEqual(f1, [p2, p4, p1]) self.assertEqual(f2, [p2, p3]) self.assertEqual(f3, [p3, p4]) self.assertEqual(f4, [p4]) if __name__ == '__main__': unittest.main(verbosity=2) 我添加了四个用户模型的测试,包含密码哈希、用户头像和粉丝功能。 setUp()和tearDown()方法是单元测试框架分别在每个测试之前和之后执行的特殊方法。 我在setUp()中实现了一些小技巧,以防止单元测试使用我用于开发的常规数据库。 通过将应用配置更改为sqlite://,我在测试过程中通过SQLAlchemy来使用SQLite内存数据库。 db.create_all()创建所有的数据库表。 这是从头开始创建数据库的快速方法,在测试中相当好用。 而对于开发环境和生产环境的数据库结构管理,我已经通过数据库迁移的手段向你展示过了。 你可以使用以下命令运行整个测试组件: (venv) $ python tests.py test_avatar (__main__.UserModelCase) ... ok test_follow (__main__.UserModelCase) ... ok test_follow_posts (__main__.UserModelCase) ... ok test_password_hashing (__main__.UserModelCase) ... ok ---------------------------------------------------------------------- Ran 4 tests in 0.494s OK 从现在起,每次对应用进行更改时,都可以重新运行测试,以确保正在测试的功能没有受到影响。 另外,每次将另一个功能添加到应用时,都应该为其编写一个单元测试。 在应用中集成粉丝机制 数据库和模型中粉丝机制的实现现在已经完成,但是我没有将它集成到应用中,所以我现在要添加这个功能。 值得高兴的是,实现它没有什么大的挑战,都将基于你已经学过的概念。 让我们来添加两个新的路由和视图函数,它们提供了用户关注和取消关注的URL和逻辑实现: @app.route('/follow/<username>') @login_required def follow(username): user = User.query.filter_by(username=username).first() if user is None: flash('User {} not found.'.format(username)) return redirect(url_for('index')) if user == current_user: flash('You cannot follow yourself!') return redirect(url_for('user', username=username)) current_user.follow(user) db.session.commit() flash('You are following {}!'.format(username)) return redirect(url_for('user', username=username)) @app.route('/unfollow/<username>') @login_required def unfollow(username): user = User.query.filter_by(username=username).first() if user is None: flash('User {} not found.'.format(username)) return redirect(url_for('index')) if user == current_user: flash('You cannot unfollow yourself!') return redirect(url_for('user', username=username)) current_user.unfollow(user) db.session.commit() flash('You are not following {}.'.format(username)) return redirect(url_for('user', username=username)) 视图函数的逻辑不言而喻,但要注意所有的错误检查,以防止出现意外的问题,并尝试在出现问题时向用户提供有用的信息。 我将添加这两个视图函数的路由到每个用户的个人主页中,以便其他用户执行关注和取消关注的操作: ... <h1>User: {{ user.username }}</h1> {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %} {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %} <p>{{ user.followers.count() }} followers, {{ user.followed.count() }} following.</p> {% if user == current_user %} <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p> {% elif not current_user.is_following(user) %} <p><a href="{{ url_for('follow', username=user.username) }}">Follow</a></p> {% else %} <p><a href="{{ url_for('unfollow', username=user.username) }}">Unfollow</a></p> {% endif %} ... 用户个人主页的变更,一是在最近访问的时间戳之下添加一行,以显示此用户拥有多少个粉丝和关注用户。二是当你查看自己的个人主页时出现的“Edit”链接的行,可能会变成以下三种链接之一: 如果用户查看他(她)自己的个人主页,仍然是“Edit”链接不变。 如果用户查看其他并未关注的用户的个人主页,显示“Follow”链接。 如果用户查看其他已经关注的用户的个人主页,显示“Unfollow”链接。 此时,你可以运行该应用,创建一些用户并测试一下关注和取消关注用户的功能。 唯一需要记住的是,需要手动键入你要关注或取消关注的用户的个人主页URL,因为目前没有办法查看用户列表。 例如,如果你想关注susan,则需要在浏览器的地址栏中输入http://localhost:5000/user/susan以访问该用户的个人主页。 请确保你在测试关注和取消关注的时候,留意到了其粉丝和关注的数量变化。 我应该在应用的主页上显示用户动态的列表,但是我还没有完成所有依赖的工作,因为用户不能发表动态。 所以我会暂缓这个页面的完善工作,直到发表用户动态功能的完成。
本文转载自:https://www.jianshu.com/p/9368fa845bba 这是Flask Mega-Tutorial系列的第七部分,我将告诉你如何在Flask应用中进行错误处理。 本章将暂停为microblog应用开发新功能,转而讨论处理BUG的策略,因为它们总是无处不在。为了帮助本章的演示,我故意在第六章新增的代码中遗留了一处BUG。 在继续阅读之前,看看你能不能找到它! 本章的GitHub链接为:Browse, Zip, Diff. Flask中的错误处理机制 在Flask应用中爆发错误时会发生什么? 得到答案的最好的方法就是亲身体验一下。 启动应用,并确保至少有两个用户注册,以其中一个用户身份登录,打开个人主页并单击“编辑”链接。 在个人资料编辑器中,尝试将用户名更改为已经注册的另一个用户的用户名,boom!(爆炸声) 这将带来一个可怕的“Internal Server Error”页面: 如果你查看运行应用的终端会话,将看到stack trace(堆栈跟踪)。 堆栈跟踪在调试错误时非常有用,因为它们显示堆栈中调用的顺序,一直到产生错误的行: (venv) $ flask run * Serving Flask app "microblog" * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) [2017-09-14 22:40:02,027] ERROR in app: Exception on /edit_profile [POST] Traceback (most recent call last): File "/home/miguel/microblog/venv/lib/python3.6/site-packages/sqlalchemy/engine/base.py", line 1182, in _execute_context context) File "/home/miguel/microblog/venv/lib/python3.6/site-packages/sqlalchemy/engine/default.py", line 470, in do_execute cursor.execute(statement, parameters) sqlite3.IntegrityError: UNIQUE constraint failed: user.username 堆栈跟踪指示了BUG在何处。 本应用允许用户更改用户名,但却没有验证所选的新用户名与系统中已有的其他用户有没有冲突。 这个错误来自SQLAlchemy,它尝试将新的用户名写入数据库,但数据库拒绝了它,因为username列是用unique=True定义的。 值得注意的是,提供给用户的错误页面并没有提供关于错误的丰富信息,这是正确的做法。 我绝对不希望用户知道崩溃是由数据库错误引起的,或者我正在使用什么数据库,或者是我的数据库中的一些表和字段名称。 所有这些信息都应该对外保密。 但是也有一些不尽人意之处。错误页面简陋不堪,与应用布局不匹配。 终端上的日志不断刷新,导致重要的堆栈跟踪信息被淹没,但我却需要不断回顾它,以免有漏网之鱼。 当然,我有一个BUG需要修复。 我将解决所有的这些问题,但首先,让我们来谈谈Flask的调试模式。 调试模式 你在上面看到的处理错误的方式对在生产服务器上运行的系统非常有用。 如果出现错误,用户将得到一个隐晦的错误页面(尽管我打算使这个错误页面更友好),错误的重要细节在服务器进程输出或存储到日志文件中。 但是当你正在开发应用时,可以启用调试模式,它是Flask在浏览器上直接运行一个友好调试器的模式。 要激活调试模式,请停止应用程序,然后设置以下环境变量: (venv) $ export FLASK_DEBUG=1 如果你使用Microsoft Windows,记得将export替换成set。 设置环境变量FLASK_DEBUG后,重启服务。相比之前,终端上的输出信息会有所变化: (venv) microblog2 $ flask run * Serving Flask app "microblog" * Forcing debug mode on * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) * Restarting with stat * Debugger is active! * Debugger PIN: 177-562-960 现在让应用再次崩溃,以在浏览器中查看交互式调试器: 该调试器允许你展开每个堆栈框来查看相应的源代码上下文。 你也可以在任意堆栈框上打开Python提示符并执行任何有效的Python表达式,例如检查变量的值。 永远不要在生产服务器上以调试模式运行Flask应用,这一点非常重要。 调试器允许用户远程执行服务器中的代码,因此对于想要渗入应用或服务器的恶意用户来说,这可能是开门揖盗。 作为附加的安全措施,运行在浏览器中的调试器开始被锁定,并且在第一次使用时会要求输入一个PIN码(你可以在flask run命令的输出中看到它)。 谈到调试模式的话题,我不得不提到的第二个重要的调试模式下的功能,就是重载器。 这是一个非常有用的开发功能,可以在源文件被修改时自动重启应用。 如果在调试模式下运行flask run,则可以在开发应用时,每当保存文件,应用都会重新启动以加载新的代码。 自定义错误页面 Flask为应用提供了一个机制来自定义错误页面,这样用户就不必看到简单而枯燥的默认页面。 作为例子,让我们为HTTP的404错误和500错误(两个最常见的错误页面)设置自定义错误页面。 为其他错误设置页面的方式与之相同。 使用@errorhandler装饰器来声明一个自定义的错误处理器。 我将把我的错误处理程序放在一个新的app/errors.py模块中。 from flask import render_template from app import app, db @app.errorhandler(404) def not_found_error(error): return render_template('404.html'), 404 @app.errorhandler(500) def internal_error(error): db.session.rollback() return render_template('500.html'), 500 错误函数与视图函数非常类似。 对于这两个错误,我将返回各自模板的内容。 请注意这两个函数在模板之后返回第二个值,这是错误代码编号。 对于之前我创建的所有视图函数,我不需要添加第二个返回值,因为我想要的是默认值200(成功响应的状态码)。 本处,这些是错误页面,所以我希望响应的状态码能够反映出来。 500错误的错误处理程序应当在引发数据库错误后调用,而上面的用户名重复实际上就是这种情况。 为了确保任何失败的数据库会话不会干扰模板触发的其他数据库访问,我执行会话回滚来将会话重置为干净的状态。 404错误的模板如下: {% extends "base.html" %} {% block content %} <h1>File Not Found</h1> <p><a href="{{ url_for('index') }}">Back</a></p> {% endblock %} 500错误的模板如下: {% extends "base.html" %} {% block content %} <h1>An unexpected error has occurred</h1> <p>The administrator has been notified. Sorry for the inconvenience!</p> <p><a href="{{ url_for('index') }}">Back</a></p> {% endblock %} 这两个模板都从base.html基础模板继承而来,所以错误页面与应用的普通页面有相同的外观布局。 为了让这些错误处理程序在Flask中注册,我需要在应用实例创建后导入新的app/errors.py模块: # ... from app import routes, models, errors 如果在终端界面设置环境变量FLASK_DEBUG=0,然后再次出发重复用户名的BUG,你将会看到一个更加友好的错误页面。 通过电子邮件发送错误 Flask提供的默认错误处理机制的另一个问题是没有通知机制,错误的堆栈跟踪只是被打印到终端,这意味着需要监视服务器进程的输出才能发现错误。 在开发时,这是非常好的,但是一旦将应用部署在生产服务器上,没有人会关心输出,因此需要采用更强大的解决方案。 我认为对错误发现采取积极主动的态度是非常重要的。 如果生产环境的应用发生错误,我想立刻知道。 所以我的第一个解决方案是配置Flask在发生错误之后立即向我发送一封电子邮件,邮件正文中包含错误堆栈跟踪的正文。 第一步,添加邮件服务器的信息到配置文件中: class Config(object): # ... MAIL_SERVER = os.environ.get('MAIL_SERVER') MAIL_PORT = int(os.environ.get('MAIL_PORT') or 25) MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') ADMINS = ['your-email@example.com'] 电子邮件的配置变量包括服务器和端口,启用加密连接的布尔标记以及可选的用户名和密码。 这五个配置变量来源于环境变量。 如果电子邮件服务器没有在环境中设置,那么我将禁用电子邮件功能。 电子邮件服务器端口也可以在环境变量中给出,但是如果没有设置,则使用标准端口25。 电子邮件服务器凭证默认不使用,但可以根据需要提供。 ADMINS配置变量是将收到错误报告的电子邮件地址列表,所以你自己的电子邮件地址应该在该列表中。 Flask使用Python的logging包来写它的日志,而且这个包已经能够通过电子邮件发送日志了。 我所需要做的就是为Flask的日志对象app.logger添加一个SMTPHandler的实例: import logging from logging.handlers import SMTPHandler # ... if not app.debug: if app.config['MAIL_SERVER']: auth = None if app.config['MAIL_USERNAME'] or app.config['MAIL_PASSWORD']: auth = (app.config['MAIL_USERNAME'], app.config['MAIL_PASSWORD']) secure = None if app.config['MAIL_USE_TLS']: secure = () mail_handler = SMTPHandler( mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']), fromaddr='no-reply@' + app.config['MAIL_SERVER'], toaddrs=app.config['ADMINS'], subject='Microblog Failure', credentials=auth, secure=secure) mail_handler.setLevel(logging.ERROR) app.logger.addHandler(mail_handler) 如你所见,仅当应用未以调试模式运行,且配置中存在邮件服务器时,我才会启用电子邮件日志记录器。 设置电子邮件日志记录器的步骤因为处理安全可选项而稍显繁琐。 本质上,上面的代码创建了一个SMTPHandler实例,设置它的级别,以便它只报告错误及更严重级别的信息,而不是警告,常规信息或调试消息,最后将它附加到Flask的app.logger对象中。 有两种方法来测试此功能。 最简单的就是使用Python的SMTP调试服务器。 这是一个模拟的电子邮件服务器,它接受电子邮件,然后打印到控制台。 要运行此服务器,请打开第二个终端会话并在其上运行以下命令: (venv) $ python -m smtpd -n -c DebuggingServer localhost:8025 要用这个模拟邮件服务器来测试应用,那么你将设置MAIL_SERVER=localhost和MAIL_PORT=8025。 译者注:本段中去处了说明设置该端口需要管理员权限的部分,因为这和实际情况不符。原文如下: To test the application with this server, then you will set MAIL_SERVER=localhost and MAIL_PORT=8025. If you are on a Linux or Mac OS system, you will likely need to prefix the command with sudo, so that it can execute with administration privileges. If you are on a Windows system, you may need to open your terminal window as an administrator. Administrator rights are needed for this command because ports below 1024 are administrator-only ports. Alternatively, you can change the port to a higher port number, say 5025, and set MAIL_PORTvariable to your chosen port in the environment, and that will not require administration rights. 保持调试SMTP服务器运行并返回到第一个终端,在环境中设置export MAIL_SERVER=localhost和MAIL_PORT=8025(如果使用的是Microsoft Windows,则使用set而不是export)。 确保FLASK_DEBUG变量设置为0或者根本不设置,因为应用不会在调试模式中发送电子邮件。 运行该应用并再次触发SQLAlchemy错误,以查看运行模拟电子邮件服务器的终端会话如何显示具有完整堆栈跟踪错误的电子邮件。 这个功能的第二个测试方法是配置一个真正的电子邮件服务器。 以下是使用你的Gmail帐户的电子邮件服务器的配置: export MAIL_SERVER=smtp.googlemail.com export MAIL_PORT=587 export MAIL_USE_TLS=1 export MAIL_USERNAME=<your-gmail-username> export MAIL_PASSWORD=<your-gmail-password> 如果你使用的是Microsoft Windows,记住在每一条语句中用set替换掉export。 Gmail帐户中的安全功能可能会阻止应用通过它发送电子邮件,除非你明确允许“安全性较低的应用程序”访问你的Gmail帐户。 可以阅读此处来了解具体情况,如果你担心帐户的安全性,可以创建一个辅助邮箱帐户,配置它来仅用于测试电子邮件功能,或者你可以暂时启用允许不太安全的应用程序来运行此测试,完成后恢复为默认值。 记录日志到文件中 通过电子邮件来接收错误提示非常棒,但在其他场景下,有时候就有些不足了。有些错误条件既不是一个Python异常又不是重大事故,但是他们在调试的时候也是有足够用处的。为此,我将会为本应用维持一个日志文件。 为了启用另一个基于文件类型RotatingFileHandler的日志记录器,需要以和电子邮件日志记录器类似的方式将其附加到应用的logger对象中。 # ... from logging.handlers import RotatingFileHandler import os # ... if not app.debug: # ... if not os.path.exists('logs'): os.mkdir('logs') file_handler = RotatingFileHandler('logs/microblog.log', maxBytes=10240, backupCount=10) file_handler.setFormatter(logging.Formatter( '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]')) file_handler.setLevel(logging.INFO) app.logger.addHandler(file_handler) app.logger.setLevel(logging.INFO) app.logger.info('Microblog startup') 日志文件的存储路径位于顶级目录下,相对路径为logs/microblog.log,如果其不存在,则会创建它。 RotatingFileHandler类非常棒,因为它可以切割和清理日志文件,以确保日志文件在应用运行很长时间时不会变得太大。 本处,我将日志文件的大小限制为10KB,并只保留最后的十个日志文件作为备份。 logging.Formatter类为日志消息提供自定义格式。 由于这些消息正在写入到一个文件,我希望它们可以存储尽可能多的信息。 所以我使用的格式包括时间戳、日志记录级别、消息以及日志来源的源代码文件和行号。 为了使日志记录更有用,我还将应用和文件日志记录器的日志记录级别降低到INFO级别。 如果你不熟悉日志记录类别,则按照严重程度递增的顺序来认识它们就行了,分别是DEBUG、INFO、WARNING、ERROR和CRITICAL。 日志文件的第一个有趣用途是,服务器每次启动时都会在日志中写入一行。 当此应用在生产服务器上运行时,这些日志数据将告诉你服务器何时重新启动过。 修复用户名重复的BUG 利用用户名重复BUG这么久, 现在时候向你展示如何修复它了。 你是否还记得,RegistrationForm已经实现了对用户名的验证,但是编辑表单的要求稍有不同。 在注册期间,我需要确保在表单中输入的用户名不存在于数据库中。 在编辑个人资料表单中,我必须做同样的检查,但有一个例外。 如果用户不改变原始用户名,那么验证应该允许,因为该用户名已经被分配给该用户。 下面你可以看到我为这个表单实现了用户名验证: class EditProfileForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) about_me = TextAreaField('About me', validators=[Length(min=0, max=140)]) submit = SubmitField('Submit') def __init__(self, original_username, *args, **kwargs): super(EditProfileForm, self).__init__(*args, **kwargs) self.original_username = original_username def validate_username(self, username): if username.data != self.original_username: user = User.query.filter_by(username=self.username.data).first() if user is not None: raise ValidationError('Please use a different username.') 该实现使用了一个自定义的验证方法,接受表单中的用户名作为参数。 这个用户名保存为一个实例变量,并在validate_username()方法中被校验。 如果在表单中输入的用户名与原始用户名相同,那么就没有必要检查数据库是否有重复了。 为了使得新增的验证方法生效,我需要在对应视图函数中添加当前用户名到表单的username字段中: @app.route('/edit_profile', methods=['GET', 'POST']) @login_required def edit_profile(): form = EditProfileForm(current_user.username) # ... 现在这个BUG已经修复了,大多数情况下,以后在编辑个人资料时出现用户名重复的提交将被友好地阻止。 但这不是一个完美的解决方案,因为当两个或更多进程同时访问数据库时,这可能不起作用。假如存在验证通过的进程A和B都尝试修改用户名为同一个,但稍后进程A尝试重命名时,数据库已被进程B更改,无法重命名为该用户名,会再次引发数据库异常。 除了有很多服务器进程并且非常繁忙的应用之外,这种情况是不太可能的,所以现在我不会为此担心。 此时,你可以尝试再次重现该错误,以了解新的表单验证方法如何防止该错误。
本文转载自:https://www.jianshu.com/p/add5c65f4dd6 这是Flask Mega-Tutorial系列的第六部分,我将告诉你如何创建个人主页。 本章将致力于为应用添加个人主页。个人主页用来展示用户的相关信息,其个人信息由本人录入。 我将为你展示如何动态地生成每个用户的主页,并提供一个编辑页面给他们来更新个人信息。 本章的GitHub链接为:Browse, Zip, Diff. 个人主页 作为创建个人主页的第一步,让我们为其URL /user/ 新建一个对应的视图函数。 @app.route('/user/<username>') @login_required def user(username): user = User.query.filter_by(username=username).first_or_404() posts = [ {'author': user, 'body': 'Test post #1'}, {'author': user, 'body': 'Test post #2'} ] return render_template('user.html', user=user, posts=posts) 我用来装饰该视图函数的@app.route装饰器看起来和之前的有点不一样。 本例中被<和>包裹的URL <username>是动态的。 当一个路由包含动态组件时,Flask将接受该部分URL中的任何文本,并将以实际文本作为参数调用该视图函数。 例如,如果客户端浏览器请求URL /user/susan,则视图函数将被调用,其参数username被设置为'susan'。 因为这个视图函数只能被已登录的用户访问,所以我添加了@login_required装饰器。 这个视图函数的实现相当简单。 我首先会尝试在数据库中以用户名来查询和加载用户。 之前你见过通过调用all()来得到所有的结果的查询,或是调用first()来得到结果中的第一个或者结果集为空时返回None的查询。 在本视图函数中,我使用了first()的变种方法,名为first_or_404(),当有结果时它的工作方式与first()完全相同,但是在没有结果的情况下会自动发送404 error给客户端。 以这种方式执行查询,我省去检查用户是否返回的步骤,因为当用户名不存在于数据库中时,函数将不会返回,而是会引发404异常。 如果执行数据库查询没有触发404错误,那么这意味着找到了具有给定用户名的用户。 接下来,我为这个用户初始化一个虚拟的用户动态列表,最后用传入的用户对象和用户动态列表渲染一个新的user.html模板。 user.html模板如下所示: {% extends "base.html" %} {% block content %} <h1>User: {{ user.username }}</h1> <hr> {% for post in posts %} <p> {{ post.author.username }} says: <b>{{ post.body }}</b> </p> {% endfor %} {% endblock %} 个人主页虽然已经完成了,但是网站上却没有一个入口链接。我将会在顶部的导航栏中添加这个入口链接,以便用户可以轻松查看自己的个人资料: <div> Microblog: <a href="{{ url_for('index') }}">Home</a> {% if current_user.is_anonymous %} <a href="{{ url_for('login') }}">Login</a> {% else %} <a href="{{ url_for('user', username=current_user.username) }}">Profile</a> <a href="{{ url_for('logout') }}">Logout</a> {% endif %} </div> 这里唯一有趣的变化是用来生成链接到个人主页的url_for()调用。 由于个人主页视图函数接受一个动态参数,所以url_for()函数接收一个值作为关键字参数。 由于这是一个指向当前登录个人主页的链接,我可以使用Flask-Login的current_user对象来生成正确的URL。 尝试点击顶部的Profile链接就能将你带到自己的个人主页。 此时,虽然没有链接来访问其他用户的主页,但是如果要访问这些页面,则可以在浏览器的地址栏中手动输入网址。 例如,如果你在应用中注册了名为“john”的用户,则可以通过在地址栏中键入http:// localhost:5000/user/john来查看该用户的个人主页。 头像 我相信你也觉得我刚刚建立的个人主页非常枯燥乏味。为了使它们更加有趣,我将添加用户头像。与其在服务器上处理大量的上传图片,我将使用Gravatar为所有用户提供图片服务。 Gravatar服务使用起来非常简单。 要请求给定用户的图片,使用格式为https://www.gravatar.com/avatar/的URL即可,其中<hash>是用户的电子邮件地址的MD5哈希值。 在下面,你可以看到如何生成电子邮件为john@example.com的用户的Gravatar URL: >>> from hashlib import md5 >>> 'https://www.gravatar.com/avatar/' + md5(b'john@example.com').hexdigest() 'https://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6' 如果你想看一个实际的例子,我自己的Gravatar URL是https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35。Gravatar返回的图片如下: 默认情况下,返回的图像大小是80x80像素,但可以通过向URL的查询字符串添加s参数来请求不同大小的图片。 例如,要获得我自己128x128像素的头像,该URL是https://www.gravatar.com/avatar/729e26a2a2c7ff24a71958d4aa4e5f35?s=128。 另一个可传递给Gravatar的有趣参数是d,它让Gravatar为没有向服务注册头像的用户提供的随机头像。 我最喜欢的随机头像类型是“identicon”,它为每个邮箱都返回一个漂亮且不重复的几何设计图片。 如下: 请注意,一些Web浏览器插件(如Ghostery)会屏蔽Gravatar图像,因为它们认为Automattic(Gravatar服务的所有者)可以根据你发送的获取头像的请求来判断你正在访问的网站。 如果在浏览器中看不到头像,你在排查问题的时候可以考虑以下是否在浏览器中安装了此类插件。 由于头像与用户相关联,所以将生成头像URL的逻辑添加到用户模型是有道理的。 from hashlib import md5 # ... class User(UserMixin, db.Model): # ... def avatar(self, size): digest = md5(self.email.lower().encode('utf-8')).hexdigest() return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format( digest, size) User类新增的avatar()方法需要传入需求头像的像素大小,并返回用户头像图片的URL。 对于没有注册头像的用户,将生成“identicon”类的随机图片。 为了生成MD5哈希值,我首先将电子邮件转换为小写,因为这是Gravatar服务所要求的。 然后,因为Python中的MD5的参数类型需要是字节而不是字符串,所以在将字符串传递给该函数之前,需要将字符串编码为字节。 如果你对Gravatar服务很有兴趣,可以学习他们的文档。 下一步需要将头像图片插入到个人主页的模板中: {% extends "base.html" %} {% block content %} <table> <tr valign="top"> <td><img src="{{ user.avatar(128) }}"></td> <td><h1>User: {{ user.username }}</h1></td> </tr> </table> <hr> {% for post in posts %} <p> {{ post.author.username }} says: <b>{{ post.body }}</b> </p> {% endfor %} {% endblock %} 使用User类来返回头像URL的好处是,如果有一天我不想继续使用Gravatar头像了,我可以重写avatar()方法来返回其他头像服务网站的URL,所有的模板将自动显示新的头像。 我的个人主页的顶部有一个不错的大头像,不止如此,底下的所有用户动态都会有一个小头像。 对于个人主页而言,所有的头像当然都是对应用户的。我将会在主页面上实现每个用户动态都用其作者的头像来装饰,这样一来看起来就非常棒了。 为了显示用户动态的头像,我只需要在模板中进行一个小小的更改: {% extends "base.html" %} {% block content %} <table> <tr valign="top"> <td><img src="{{ user.avatar(128) }}"></td> <td><h1>User: {{ user.username }}</h1></td> </tr> </table> <hr> {% for post in posts %} <table> <tr valign="top"> <td><img src="{{ post.author.avatar(36) }}"></td> <td>{{ post.author.username }} says:<br>{{ post.body }}</td> </tr> </table> {% endfor %} {% endblock %} 使用Jinja2子模板 我设计的个人主页,使用头像和文字组合的方式来展示了用户动态。 现在我想在主页也使用类似的风格来布局。 我可以复制/粘贴来处理用户动态渲染的模板部分,但这实际上并不理想,因为之后如果我想要对此布局进行更改,我将不得不记住要更新两个模板。 取而代之,我要创建一个只渲染一条用户动态的子模板,然后在user.html和index.html模板中引用它。 首先,我要创建这个只有一条用户动态HTML元素的子模板。 我将其命名为app/templates/_post.html, _前缀只是一个命名约定,可以帮助我识别哪些模板文件是子模板。 <table> <tr valign="top"> <td><img src="{{ post.author.avatar(36) }}"></td> <td>{{ post.author.username }} says:<br>{{ post.body }}</td> </tr> </table> 我在user.html模板中使用了Jinja2的include语句来调用该子模板: {% extends "base.html" %} {% block content %} <table> <tr valign="top"> <td><img src="{{ user.avatar(128) }}"></td> <td><h1>User: {{ user.username }}</h1></td> </tr> </table> <hr> {% for post in posts %} {% include '_post.html' %} {% endfor %} {% endblock %} 应用的主页还没有完善,所以现在我不打算在其中添加这个功能。 更多有趣的个人资料 新增的个人主页存在的一个问题是,真正显示的内容不够丰富。 用户喜欢在个人主页上展示他们的相关信息,所以我会让他们写一些自我介绍并在这里展示。 我也将跟踪每个用户最后一次访问该网站的时间,并显示在他们的个人主页上。 为了支持所有这些额外的信息,首先需要做的是用两个新的字段扩展数据库中的用户表: class User(UserMixin, db.Model): # ... about_me = db.Column(db.String(140)) last_seen = db.Column(db.DateTime, default=datetime.utcnow) 每次数据库被修改时,都需要生成数据库迁移。 在第四章中,我向你展示了如何设置应用以通过迁移脚本跟踪数据库的变更。 现在有两个新的字段我想添加到数据库中,所以第一步是生成迁移脚本: (venv) $ flask db migrate -m "new fields in user model" INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.autogenerate.compare] Detected added column 'user.about_me' INFO [alembic.autogenerate.compare] Detected added column 'user.last_seen' Generating /home/miguel/microblog/migrations/versions/37f06a334dbf_new_fields_in_user_model.py ... done migrate命令的输出表示一切正确运行,因为它显示User类中的两个新字段已被检测到。 现在我可以将此更改应用于数据库: (venv) $ flask db upgrade INFO [alembic.runtime.migration] Context impl SQLiteImpl. INFO [alembic.runtime.migration] Will assume non-transactional DDL. INFO [alembic.runtime.migration] Running upgrade 780739b227a7 -> 37f06a334dbf, new fields in user model 我希望你认识到使用迁移框架是多么有用。 数据库中的用户数据仍然存在,迁移框架如同实施手术教学般地精准执行迁移脚本中的更改并且不损坏任何数据。 下一步,我将会把新增的两个字段增加到个人主页中: {% extends "base.html" %} {% block content %} <table> <tr valign="top"> <td><img src="{{ user.avatar(128) }}"></td> <td> <h1>User: {{ user.username }}</h1> {% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %} {% if user.last_seen %}<p>Last seen on: {{ user.last_seen }}</p>{% endif %} </td> </tr> </table> ... {% endblock %} 请注意,我用Jinja2的条件语句来封装了这两个字段,因为我只希望它们在设置后才可见。 目前,所有用户的这两个字段都是空的,所以如果现在运行应用,则不会看到这些字段。 记录用户的最后访问时间 让我们从更容易实现的last_seen字段开始。 我想要做的就是一旦某个用户向服务器发送请求,就将当前时间写入到这个字段。 为每个视图函数添加更新这个字段的逻辑,这么做非常的枯燥乏味。在视图函数处理请求之前执行一段简单的代码逻辑在Web应用中十分常见,因此Flask提供了一个内置功能来实现它。解决方案如下: from datetime import datetime @app.before_request def before_request(): if current_user.is_authenticated: current_user.last_seen = datetime.utcnow() db.session.commit() Flask中的@before_request装饰器注册在视图函数之前执行的函数。这是非常有用的,因为现在我可以在一处地方编写代码,并让它在任何视图函数之前被执行。该代码简单地实现了检查current_user是否已经登录,并在已登录的情况下将last_seen字段设置为当前时间。我之前提到过,应用应该以一致的时间单位工作,标准做法是使用UTC时区,使用系统的本地时间不是一个好主意,因为如果那么的话,数据库中存储的时间取决于你的时区。最后一步是提交数据库会话,以便将上面所做的更改写入数据库。如果你想知道为什么在提交之前没有db.session.add(),考虑在引用current_user时,Flask-Login将调用用户加载函数,该函数将运行一个数据库查询并将目标用户添加到数据库会话中。所以你可以在这个函数中再次添加用户,但是这不是必须的,因为它已经在那里了。 如果在进行此更改后查看你的个人主页,则会看到“Last seen on”行,并且时间非常接近当前时间。 如果你离开个人主页,然后返回,你会看到时间在不断更新。 事实上,我在存储时间和在个人主页显示时间的时候,使用的都是UTC时区。 除此之外,显示的时间格式也可能不是你所预期的,因为实际上它是Python datetime对象的内部表示。 现在,我不会操心这两个问题,因为我将在后面的章节中讨论在Web应用中处理日期和时间的主题。 个人资料编辑器 我还需要给用户一个表单,让他们输入一些个人资料。 表单将允许用户更改他们的用户名,并且写一些个人介绍,以存储在新的about_me字段中。 让我们开始为它写一个表单类吧: from wtforms import StringField, TextAreaField, SubmitField from wtforms.validators import DataRequired, Length # ... class EditProfileForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) about_me = TextAreaField('About me', validators=[Length(min=0, max=140)]) submit = SubmitField('Submit') 我在这个表单中使用了一个新的字段类型和一个新的验证器。 对于“about_me”字段,我使用TextAreaField,这是一个多行输入文本框,用户可以在其中输入文本。 为了验证这个字段的长度,我使用了Length,它将确保输入的文本在0到140个字符之间,因为这是我为数据库中的相应字段分配的空间。 该表单的渲染模板代码如下: {% extends "base.html" %} {% block content %} <h1>Edit Profile</h1> <form action="" method="post"> {{ form.hidden_tag() }} <p> {{ form.username.label }}<br> {{ form.username(size=32) }}<br> {% for error in form.username.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p> {{ form.about_me.label }}<br> {{ form.about_me(cols=50, rows=4) }}<br> {% for error in form.about_me.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p>{{ form.submit() }}</p> </form> {% endblock %} 最后一步,使用视图函数将它们结合起来: from app.forms import EditProfileForm @app.route('/edit_profile', methods=['GET', 'POST']) @login_required def edit_profile(): form = EditProfileForm() if form.validate_on_submit(): current_user.username = form.username.data current_user.about_me = form.about_me.data db.session.commit() flash('Your changes have been saved.') return redirect(url_for('edit_profile')) elif request.method == 'GET': form.username.data = current_user.username form.about_me.data = current_user.about_me return render_template('edit_profile.html', title='Edit Profile', form=form) 这个视图函数处理表单的方式和其他的视图函数略有不同。如果validate_on_submit()返回True,我将表单中的数据复制到用户对象中,然后将对象写入数据库。但是当validate_on_submit()返回False时,可能是由于两个不同的原因。这可能是因为浏览器刚刚发送了一个GET请求,我需要通过提供表单模板的初始版本来响应。也可能是这种情况,浏览器发送带有表单数据的POST请求,但该数据中的某些内容无效。对于该表单,我需要区别对待这两种情况。当第一次请求表单时,我用存储在数据库中的数据预填充字段,所以我需要做与提交相反的事情,那就是将存储在用户字段中的数据移动到表单中,这将确保这些表单字段具有用户的当前数据。但在验证错误的情况下,我不想写任何表单字段,因为它们已经由WTForms填充了。为了区分这两种情况,我需要检查request.method,如果它是GET,这是初始请求的情况,如果是POST则是提交表单验证失败的情况。 我将个人资料编辑页面的链接添加到个人主页,以便用户使用: {% if user == current_user %} <p><a href="{{ url_for('edit_profile') }}">Edit your profile</a></p> {% endif %} 请注意我巧妙使用的条件,它确保在查看自己的个人主页时出现编辑个人资料的链接,而在查看其他人的个人主页时不会出现。
本文转载自:https://www.jianshu.com/p/cb5e8633e22e 这是Flask Mega-Tutorial系列的第五部分,我将告诉你如何创建一个用户登录子系统。 你在第三章中学会了如何创建用户登录表单,在第四章中学会了运用数据库。本章将教你如何结合这两章的主题来创建一个简单的用户登录系统。 本章的GitHub链接为:Browse, Zip, Diff. 密码哈希 在第四章中,用户模型设置了一个password_hash字段,到目前为止还没有被使用到。 这个字段的目的是保存用户密码的哈希值,并用于验证用户在登录过程中输入的密码。 密码哈希的实现是一个复杂的话题,应该由安全专家来搞定,不过,已经有数个现成的简单易用且功能完备加密库存在了。 其中一个实现密码哈希的包是Werkzeug,当安装Flask时,你可能会在pip的输出中看到这个包,因为它是Flask的一个核心依赖项。 所以,Werkzeug已经安装在你的虚拟环境中。 以下Python shell会话演示了如何哈希密码: >>> from werkzeug.security import generate_password_hash >>> hash = generate_password_hash('foobar') >>> hash 'pbkdf2:sha256:50000$vT9fkZM8$04dfa35c6476acf7e788a1b5b3c35e217c78dc04539d295f011f01f18cd2175f' 在这个例子中,通过一系列已知没有反向操作的加密操作,将密码foobar转换成一个长编码字符串,这意味着获得密码哈希值的人将无法使用它逆推出原始密码。 作为一个附加手段,多次哈希相同的密码,你将得到不同的结果,所以这使得无法通过查看它们的哈希值来确定两个用户是否具有相同的密码。 验证过程使用Werkzeug的第二个函数来完成,如下所示: >>> from werkzeug.security import check_password_hash >>> check_password_hash(hash, 'foobar') True >>> check_password_hash(hash, 'barfoo') False 向验证函数传入之前生成的密码哈希值以及用户在登录时输入的密码,如果用户提供的密码执行哈希过程后与存储的哈希值匹配,则返回True,否则返回False。 整个密码哈希逻辑可以在用户模型中实现为两个新的方法: from werkzeug.security import generate_password_hash, check_password_hash # ... class User(db.Model): # ... def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) 使用这两种方法,用户对象现在可以在无需持久化存储原始密码的条件下执行安全的密码验证。 以下是这些新方法的示例用法: >>> u = User(username='susan', email='susan@example.com') >>> u.set_password('mypassword') >>> u.check_password('anotherpassword') False >>> u.check_password('mypassword') True Flask-Login简介 在本章中,我将向你介绍一个非常受欢迎的Flask插件Flask-Login。 该插件管理用户登录状态,以便用户可以登录到应用,然后用户在导航到该应用的其他页面时,应用会“记得”该用户已经登录。它还提供了“记住我”的功能,允许用户在关闭浏览器窗口后再次访问应用时保持登录状态。可以先在你的虚拟环境中安装Flask-Login来做好准备工作: (venv) $ pip install flask-login 和其他插件一样,Flask-Login需要在app/__init__py中的应用实例之后被创建和初始化。 该插件初始化代码如下: # ... from flask_login import LoginManager app = Flask(__name__) # ... login = LoginManager(app) # ... 为Flask-Login准备用户模型 Flask-Login插件需要在用户模型上实现某些属性和方法。这种做法很棒,因为只要将这些必需项添加到模型中,Flask-Login就没有其他依赖了,它就可以与基于任何数据库系统的用户模型一起工作。 必须的四项如下: is_authenticated: 一个用来表示用户是否通过登录认证的属性,用True和False表示。 is_active: 如果用户账户是活跃的,那么这个属性是True,否则就是False(译者注:活跃用户的定义是该用户的登录状态是否通过用户名密码登录,通过“记住我”功能保持登录状态的用户是非活跃的)。 is_anonymous: 常规用户的该属性是False,对特定的匿名用户是True。 get_id(): 返回用户的唯一id的方法,返回值类型是字符串(Python 2下返回unicode字符串). 我可以很容易地实现这四个属性或方法,但是由于它们是相当通用的,因此Flask-Login提供了一个叫做UserMixin的mixin类来将它们归纳其中。 下面演示了如何将mixin类添加到模型中: # ... from flask_login import UserMixin class User(UserMixin, db.Model): # ... 用户加载函数 用户会话是Flask分配给每个连接到应用的用户的存储空间,Flask-Login通过在用户会话中存储其唯一标识符来跟踪登录用户。每当已登录的用户导航到新页面时,Flask-Login将从会话中检索用户的ID,然后将该用户实例加载到内存中。 因为数据库对Flask-Login透明,所以需要应用来辅助加载用户。 基于此,插件期望应用配置一个用户加载函数,可以调用该函数来加载给定ID的用户。 该功能可以添加到app/models.py模块中: from app import login # ... @login.user_loader def load_user(id): return User.query.get(int(id)) 使用Flask-Login的@login.user_loader装饰器来为用户加载功能注册函数。 Flask-Login将字符串类型的参数id传入用户加载函数,因此使用数字ID的数据库需要如上所示地将字符串转换为整数。 用户登入 让我们回顾一下登录视图函数,它实现了一个模拟登录,只发出一个flash()消息。 现在,应用可以访问用户数据,并知道如何生成和验证密码哈希值,该视图函数就可以完工了。 # ... from flask_login import current_user, login_user from app.models import User # ... @app.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: return redirect(url_for('index')) form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() if user is None or not user.check_password(form.password.data): flash('Invalid username or password') return redirect(url_for('login')) login_user(user, remember=form.remember_me.data) return redirect(url_for('index')) return render_template('login.html', title='Sign In', form=form) login()函数中的前两行处理一个非预期的情况:假设用户已经登录,却导航到应用的/login URL。 显然这是一个不可能允许的错误场景。 current_user变量来自Flask-Login,可以在处理过程中的任何时候调用以获取用户对象。 这个变量的值可以是数据库中的一个用户对象(Flask-Login通过我上面提供的用户加载函数回调读取),或者如果用户还没有登录,则是一个特殊的匿名用户对象。 还记得那些Flask-Login必须的用户对象属性? 其中之一是is_authenticated,它可以方便地检查用户是否登录。 当用户已经登录,我只需要重定向到主页。 相比之前的调用flash()显示消息模拟登录,现在我可以真实地登录用户。 第一步是从数据库加载用户。 利用表单提交的username,我可以查询数据库以找到用户。 为此,我使用了SQLAlchemy查询对象的filter_by()方法。 filter_by()的结果是一个只包含具有匹配用户名的对象的查询结果集。 因为我知道查询用户的结果只可能是有或者没有,所以我通过调用first()来完成查询,如果存在则返回用户对象;如果不存在则返回None。 在第四章中,你已经看到当你在查询中调用all()方法时, 将执行该查询并获得与该查询匹配的所有结果的列表。 当你只需要一个结果时,通常使用first()方法。 如果使用提供的用户名执行查询并成功匹配,我可以接下来通过调用上面定义的check_password()方法来检查表单中随附的密码是否有效。 密码验证时,将验证存储在数据库中的密码哈希值与表单中输入的密码的哈希值是否匹配。 所以,现在我有两个可能的错误情况:用户名可能是无效的,或者用户密码是错误的。 在这两种情况下,我都会闪现一条消息,然后重定向到登录页面,以便用户可以再次尝试。 如果用户名和密码都是正确的,那么我调用来自Flask-Login的login_user()函数。 该函数会将用户登录状态注册为已登录,这意味着用户导航到任何未来的页面时,应用都会将用户实例赋值给current_user变量。 然后,只需将新登录的用户重定向到主页,我就完成了整个登录过程。 用户登出 提供一个用户登出的途径也是必须的,我将会通过Flask-Login的logout_user()函数来实现。其视图函数代码如下: # ... from flask_login import logout_user # ... @app.route('/logout') def logout(): logout_user() return redirect(url_for('index')) 为了给用户暴露登出链接,我会在导航栏上实现当用户登录之后,登录链接自动转换成登出链接。修改base.html模板的导航栏部分后,代码如下: <div> Microblog: <a href="{{ url_for('index') }}">Home</a> {% if current_user.is_anonymous %} <a href="{{ url_for('login') }}">Login</a> {% else %} <a href="{{ url_for('logout') }}">Logout</a> {% endif %} </div> 用户实例的is_anonymous属性是在其模型继承UserMixin类后Flask-Login添加的,表达式current_user.is_anonymous仅当用户未登录时的值是True。 要求用户登录 Flask-Login提供了一个非常有用的功能——强制用户在查看应用的特定页面之前登录。 如果未登录的用户尝试查看受保护的页面,Flask-Login将自动将用户重定向到登录表单,并且只有在登录成功后才重定向到用户想查看的页面。 为了实现这个功能,Flask-Login需要知道哪个视图函数用于处理登录认证。在app/__init__.py中添加代码如下: # ... login = LoginManager(app) login.login_view = 'login' 上面的'login'值是登录视图函数(endpoint)名,换句话说该名称可用于url_for()函数的参数并返回对应的URL。 Flask-Login使用名为@login_required的装饰器来拒绝匿名用户的访问以保护某个视图函数。 当你将此装饰器添加到位于@app.route装饰器下面的视图函数上时,该函数将受到保护,不允许未经身份验证的用户访问。 以下是该装饰器如何应用于应用的主页视图函数的案例: from flask_login import login_required @app.route('/') @app.route('/index') @login_required def index(): # ... 剩下的就是实现登录成功之后自定重定向回到用户之前想要访问的页面。 当一个没有登录的用户访问被@login_required装饰器保护的视图函数时,装饰器将重定向到登录页面,不过,它将在这个重定向中包含一些额外的信息以便登录后的回转。 例如,如果用户导航到/index,那么@login_required装饰器将拦截请求并以重定向到/login来响应,但是它会添加一个查询字符串参数来丰富这个URL,如/login?next=/index。 原始URL设置了next查询字符串参数后,应用就可以在登录后使用它来重定向。 下面是一段代码,展示了如何读取和处理next查询字符串参数: from flask import request from werkzeug.urls import url_parse @app.route('/login', methods=['GET', 'POST']) def login(): # ... if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() if user is None or not user.check_password(form.password.data): flash('Invalid username or password') return redirect(url_for('login')) login_user(user, remember=form.remember_me.data) next_page = request.args.get('next') if not next_page or url_parse(next_page).netloc != '': next_page = url_for('index') return redirect(next_page) # ... 在用户通过调用Flask-Login的login_user()函数登录后,应用获取了next查询字符串参数的值。 Flask提供一个request变量,其中包含客户端随请求发送的所有信息。 特别是request.args属性,可用友好的字典格式暴露查询字符串的内容。 实际上有三种可能的情况需要考虑,以确定成功登录后重定向的位置: 如果登录URL中不含next参数,那么将会重定向到本应用的主页。 如果登录URL中包含next参数,其值是一个相对路径(换句话说,该URL不含域名信息),那么将会重定向到本应用的这个相对路径。 如果登录URL中包含next参数,其值是一个包含域名的完整URL,那么重定向到本应用的主页。 前两种情况很好理解,第三种情况是为了使应用更安全。 攻击者可以在next参数中插入一个指向恶意站点的URL,因此应用仅在重定向URL是相对路径时才执行重定向,这可确保重定向与应用保持在同一站点中。 为了确定URL是相对的还是绝对的,我使用Werkzeug的url_parse()函数解析,然后检查netloc属性是否被设置。 在模板中显示已登录的用户 你还记得在实现用户子系统之前的第二章中,我创建了一个模拟的用户来帮助我设计主页的事情吗? 现在,应用实现了真正的用户,我就可以删除模拟用户了。 取而代之,我会在模板中使用Flask-Login的current_user: {% extends "base.html" %} {% block content %} <h1>Hi, {{ current_user.username }}!</h1> {% for post in posts %} <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div> {% endfor %} {% endblock %} 并且我可以在视图函数传入渲染模板函数的参数中删除user了: @app.route('/') @app.route('/index') def index(): # ... return render_template("index.html", title='Home Page', posts=posts) 这正是测试登录和注销功能运作机制的好时机。 由于仍然没有用户注册功能,所以添加用户到数据库的唯一方法是通过Python shell执行,所以运行flask shell并输入以下命令来注册用户: >>> u = User(username='susan', email='susan@example.com') >>> u.set_password('cat') >>> db.session.add(u) >>> db.session.commit() 如果启动应用并尝试访问http://localhost:5000/或http://localhost:5000/index,会立即重定向到登录页面。在使用之前添加到数据库的凭据登录后,就会跳转回到之前访问的页面,并看到其中的个性化欢迎。 用户注册 本章要构建的最后一项功能是注册表单,以便用户可以通过Web表单进行注册。 让我们在app/forms.py中创建Web表单类来开始吧: from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import ValidationError, DataRequired, Email, EqualTo from app.models import User # ... class RegistrationForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) email = StringField('Email', validators=[DataRequired(), Email()]) password = PasswordField('Password', validators=[DataRequired()]) password2 = PasswordField( 'Repeat Password', validators=[DataRequired(), EqualTo('password')]) submit = SubmitField('Register') def validate_username(self, username): user = User.query.filter_by(username=username.data).first() if user is not None: raise ValidationError('Please use a different username.') def validate_email(self, email): user = User.query.filter_by(email=email.data).first() if user is not None: raise ValidationError('Please use a different email address.') 代码中与验证相关的几处相当有趣。首先,对于email字段,我在DataRequired之后添加了第二个验证器,名为Email。 这个来自WTForms的另一个验证器将确保用户在此字段中键入的内容与电子邮件地址的结构相匹配。 由于这是一个注册表单,习惯上要求用户输入密码两次,以减少输入错误的风险。 出于这个原因,我提供了password和password2字段。 第二个password字段使用另一个名为EqualTo的验证器,它将确保其值与第一个password字段的值相同。 我还为这个类添加了两个方法,名为validate_username()和validate_email()。 当添加任何匹配模式validate_ <field_name>的方法时,WTForms将这些方法作为自定义验证器,并在已设置验证器之后调用它们。 本处,我想确保用户输入的username和email不会与数据库中已存在的数据冲突,所以这两个方法执行数据库查询,并期望结果集为空。 否则,则通过ValidationError触发验证错误。 异常中作为参数的消息将会在对应字段旁边显示,以供用户查看。 我需要一个HTML模板以便在网页上显示这个表单,我其存储在app/templates/register.html文件中。 这个模板的构造与登录表单类似: {% extends "base.html" %} {% block content %} <h1>Register</h1> <form action="" method="post"> {{ form.hidden_tag() }} <p> {{ form.username.label }}<br> {{ form.username(size=32) }}<br> {% for error in form.username.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p> {{ form.email.label }}<br> {{ form.email(size=64) }}<br> {% for error in form.email.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p> {{ form.password.label }}<br> {{ form.password(size=32) }}<br> {% for error in form.password.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p> {{ form.password2.label }}<br> {{ form.password2(size=32) }}<br> {% for error in form.password2.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p>{{ form.submit() }}</p> </form> {% endblock %} 登录表单模板需要在其表单之下添加一个链接来将未注册的用户引导到注册页面: <p>New User? <a href="{{ url_for('register') }}">Click to Register!</a></p> 最后,我来实现处理用户注册的视图函数,存储在app/routes.py中,代码如下: from app import db from app.forms import RegistrationForm # ... @app.route('/register', methods=['GET', 'POST']) def register(): if current_user.is_authenticated: return redirect(url_for('index')) form = RegistrationForm() if form.validate_on_submit(): user = User(username=form.username.data, email=form.email.data) user.set_password(form.password.data) db.session.add(user) db.session.commit() flash('Congratulations, you are now a registered user!') return redirect(url_for('login')) return render_template('register.html', title='Register', form=form) 这个视图函数的逻辑也是一目了然,我首先确保调用这个路由的用户没有登录。表单的处理方式和登录的方式一样。在if validate_on_submit()条件块下,完成的逻辑如下:使用获取自表单的username、email和password创建一个新用户,将其写入数据库,然后重定向到登录页面以便用户登录。 精雕细琢之后,用户已经能够在此应用上注册帐户,并进行登录和注销。 请确保你尝试了我在注册表单中添加的所有验证功能,以便更好地了解其工作原理。 我将在未来的章节中再次更新用户认证子系统,以增加额外的功能,比如允许用户在忘记密码的情况下重置密码。 不过对于目前的应用来讲,这已经无碍于继续构建了。
本文转载自:https://www.jianshu.com/p/54c74c565de3 这是Flask Mega-Tutorial系列的第三部分,我将告诉你如何使用Web表单。 在第二章中我为应用主页创建了一个简单的模板,并使用诸如用户和用户动态的模拟对象。在本章中,我将解决这个应用程序中仍然存在的众多遗漏之一,那就是如何通过Web表单接受用户的输入。 Web表单是所有Web应用程序中最基本的组成部分之一。 我将使用表单来为用户发表动态和登录认证提供途径。 在继续阅读本章之前,确保你的microblog应用程序状态和上一章完结时一致,并且运行时不会报任何错误。 本章的GitHub链接为:Browse, Zip, Diff. Flask-WTF简介 我将使用Flask-WTF插件来处理本应用中的Web表单,它对WTForms进行了浅层次的封装以便和Flask完美结合。这是本应用引入的第一个Flask插件,但绝不是最后一个。插件是Flask生态中的举足轻重的一部分,Flask故意设计为只包含核心功能以保持代码的整洁,并暴露接口以对接解决不同问题的插件。 Flask插件都是常规的Python三方包,可以使用pip安装。 那就继续在你的虚拟环境中安装Flask-WTF吧: (venv) $ pip install flask-wtf 配置 到目前为止,这个应用程序都非常简单,因此我不需要考虑它的配置。 但是,除了最简单的应用,你会发现Flask(也可能是Flask插件)为使用者提供了一些可自由配置的选项。你需要决定传入什么样的配置变量列表到框架中。 有几种途径来为应用指定配置选项。最基本的解决方案是使用app.config对象,它是一个类似字典的对象,可以将配置以键值的方式存储其中。例如,你可以这样做: app = Flask(__name__) app.config['SECRET_KEY'] = 'you-will-never-guess' # ... add more variables here as needed 上面的代码虽然可以为应用创建配置,但是我有松耦合的癖好。因此,我不会让配置和应用代码处于同一个部分,而是使用稍微复杂点的结构,将配置保存到一个单独的文件中。 使用类来存储配置变量,才是我真正的风格。我会将这个配置类存储到单独的Python模块,以保持良好的组织结构。下面就让你见识一下这个存储在顶级目录下,名为config.py的模块的配置类吧: import os class Config(object): SECRET_KEY = os.environ.get('SECRET_KEY') or 'you-will-never-guess' 简单的不像话,有没有? 配置设置被定义为Config类中的属性。 一旦应用程序需要更多配置选项,直接依样画葫芦,附加到这个类上即可,稍后如果我发现需要多个配置集,则可以创建它的子类。现在则不用操心。 SECRET_KEY是我添加的唯一配置选项,对大多数Flask应用来说,它都是极其重要的。Flask及其一些扩展使用密钥的值作为加密密钥,用于生成签名或令牌。Flask-WTF插件使用它来保护网页表单免受名为Cross-Site Request Forgery或CSRF(发音为“seasurf”)的恶意攻击。顾名思义,密钥应该是隐密的,因为由它产生的令牌和签名的加密强度保证,取决于除了可信维护者之外,没有任何人能够获得它。 密钥被定义成由or运算符连接两个项的表达式。第一个项查找环境变量SECRET_KEY的值,第二个项是一个硬编码的字符串。这种首先检查环境变量中是否存在这个配置,找不到的情况下就使用硬编码字符串的配置变量的模式你将会反复看到。在开发阶段,安全性要求较低,因此可以直接使用硬编码字符串。但是,当应用部署到生产服务器上的时候,我将设置一个独一无二且难以揣摩的环境变量,这样,服务器就拥有了一个别人未知的安全密钥了。 拥有了这样一份配置文件,我还需要通知Flask读取并使用它。可以在生成Flask应用之后,利用app.config.from_object()方法来完成这个操作: from flask import Flask from config import Config app = Flask(__name__) app.config.from_object(Config) from app import routes 导入Config类的方式,乍一看可能会让人感到困惑,不过如果你注意到从flask包导入Flask类的过程,就会发现这其实是类似的操作。 显而易见,小写的“config”是Python模块config.py的名字,另一个含有大写“C”的是类。 正如我上面提到的,可以使用app.config中的字典语法来访问配置项。 在下面的Python交互式会话中,你可以看到密钥的值: >>> from microblog import app >>> app.config['SECRET_KEY'] 'you-will-never-guess' 用户登录表单 Flask-WTF插件使用Python类来表示Web表单。表单类只需将表单的字段定义为类属性即可。 为了再次践行我的松耦合原则,我会将表单类单独存储到名为app/forms.py的模块中。就让我们来定义用户登录表单来做一个开始吧,它会要求用户输入username和password,并提供一个“remember me”的复选框和提交按钮: from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired class LoginForm(FlaskForm): username = StringField('Username', validators=[DataRequired()]) password = PasswordField('Password', validators=[DataRequired()]) remember_me = BooleanField('Remember Me') submit = SubmitField('Sign In') 大多数Flask插件使用flask_ <name>命名约定来导入,Flask-WTF的所有内容都在flask_wtf包中。在本例中,app/forms.py模块的顶部从flask_wtf导入了名为FlaskForm的基类。 由于Flask-WTF插件本身不提供字段类型,因此我直接从WTForms包中导入了四个表示表单字段的类。每个字段类都接受一个描述或别名作为第一个参数,并生成一个实例来作为LoginForm的类属性。 你在一些字段中看到的可选参数validators用于验证输入字段是否符合预期。DataRequired验证器仅验证字段输入是否为空。更多的验证器将会在未来的表单中接触到。 表单模板 下一步是将表单添加到HTML模板以便渲染到网页上。 令人高兴的是在LoginForm类中定义的字段支持自渲染为HTML元素,所以这个任务相当简单。 我将把登录模板存储在文件app/templates/login.html 中,代码如下: {% extends "base.html" %} {% block content %} <h1>Sign In</h1> <form action="" method="post" novalidate> {{ form.hidden_tag() }} <p> {{ form.username.label }}<br> {{ form.username(size=32) }} </p> <p> {{ form.password.label }}<br> {{ form.password(size=32) }} </p> <p>{{ form.remember_me() }} {{ form.remember_me.label }}</p> <p>{{ form.submit() }}</p> </form> {% endblock %} 一如第二章,在这个模板中我再次使用了extends来继承base.html基础模板。事实上,我将会对所有的模板继承基础模板,以保持顶部导航栏风格统一。 这个模板需要一个form参数的传入到渲染模板的函数中,form来自于LoginForm类的实例化,不过我现在还没有编写它。 HTML<form>元素被用作Web表单的容器。 表单的action属性告诉浏览器在提交用户在表单中输入的信息时应该请求的URL。 当action设置为空字符串时,表单将被提交给当前地址栏中的URL,即当前页面。 method属性指定了将表单提交给服务器时应该使用的HTTP请求方法。 默认情况下是用GET请求发送,但几乎在所有情况下,使用POST请求会提供更好的用户体验,因为这种类型的请求可以在请求的主体中提交表单数据, GET请求将表单字段添加到URL,会使浏览器地址栏变得混乱。 form.hidden_tag()模板参数生成了一个隐藏字段,其中包含一个用于保护表单免受CSRF攻击的token。 对于保护表单,你需要做的所有事情就是在模板中包括这个隐藏的字段,并在Flask配置中定义SECRET_KEY变量,Flask-WTF会完成剩下的工作。 如果你以前编写过HTML Web表单,那么你会发现一个奇怪的现象——在此模板中没有HTML表单元素,这是因为表单的字段对象的在渲染时会自动转化为HTML元素。 我只需在需要字段标签的地方加上{{ form.<field_name>.label }},需要这个字段的地方加上{{ form.<field_name>() }}。 对于需要附加HTML属性的字段,可以作为关键字参数传递到函数中。 此模板中的username和password字段将size作为参数,将其作为属性添加到<input> HTML元素中。 你也可以通过这种手段为表单字段设置class和id属性。 表单视图 完成这个表单的最后一步就是编写一个新的视图函数来渲染上面创建的模板。 函数的逻辑只需创建一个form实例,并将其传入渲染模板的函数中即可,然后用/login URL来关联它。这个视图函数也存储到app/routes.py模块中,代码如下: from flask import render_template from app import app from app.forms import LoginForm # ... @app.route('/login') def login(): form = LoginForm() return render_template('login.html', title='Sign In', form=form) 我从forms.py导入LoginForm类,并生成了一个实例传入模板。form=form的语法看起来奇怪,这是Python函数或方法传入关键字参数的方式,左边的form代表在模板中引用的变量名称,右边则是传入的form实例。这就是获取表单字段渲染结果的所有代码了。 在基础模板templates/base.html的导航栏上添加登录的链接,以便访问: <div> Microblog: <a href="/index">Home</a> <a href="/login">Login</a> </div> 此时,你可以验证结果了。运行该应用,在浏览器的地址栏中输入http://localhost:5000/,然后点击顶部导航栏中的“Login”链接来查看新的登录表单。 是不是非常炫酷? 接收表单数据 点击提交按钮,浏览器将显示“Method Not Allowed”错误。为什么呢? 这是因为之前的登录视图功能到目前为止只完成了一半的工作。 它可以在网页上显示表单,但没有逻辑来处理用户提交的数据。Flask-WTF可以轻松完成这部分工作, 以下是视图函数的更新版本,它接受和验证用户提交的数据: from flask import render_template, flash, redirect @app.route('/login', methods=['GET', 'POST']) def login(): form = LoginForm() if form.validate_on_submit(): flash('Login requested for user {}, remember_me={}'.format( form.username.data, form.remember_me.data)) return redirect('/index') return render_template('login.html', title='Sign In', form=form) 这个版本中的第一个新东西是路由装饰器中的methods参数。 它告诉Flask这个视图函数接受GET和POST请求,并覆盖了默认的GET。 HTTP协议规定对GET请求需要返回信息给客户端(本例中是浏览器)。 本应用的所有GET请求都是如此。 当浏览器向服务器提交表单数据时,通常会使用POST请求(实际上用GET请求也可以,但这不是推荐的做法)。之前的“Method Not Allowed”错误正是由于视图函数还未配置允许POST请求。 通过传入methods参数,你就能告诉Flask哪些请求方法可以被接受。 form.validate_on_submit()实例方法会执行form校验的工作。当浏览器发起GET请求的时候,它返回False,这样视图函数就会跳过if块中的代码,直接转到视图函数的最后一句来渲染模板。 当用户在浏览器点击提交按钮后,浏览器会发送POST请求。form.validate_on_submit()就会获取到所有的数据,运行字段各自的验证器,全部通过之后就会返回True,这表示数据有效。不过,一旦有任意一个字段未通过验证,这个实例方法就会返回False,引发类似GET请求那样的表单的渲染并返回给用户。稍后我会在添加代码以实现在验证失败的时候显示一条错误消息。 当form.validate_on_submit()返回True时,登录视图函数调用从Flask导入的两个新函数。 flash()函数是向用户显示消息的有效途径。 许多应用使用这个技术来让用户知道某个动作是否成功。我将使用这种机制作为临时解决方案,因为我没有基础架构来真正地登录用户。 显示一条消息来确认应用已经收到登录认证凭据,我认为对当前来说已经足够了。 登录视图函数中使用的第二个新函数是redirect()。这个函数指引浏览器自动重定向到它的参数所关联的URL。当前视图函数使用它将用户重定向到应用的主页。 当你调用flash()函数后,Flask会存储这个消息,但是却不会奇迹般地直接出现在页面上。模板需要将消息渲染到基础模板中,才能让所有派生出来的模板都能显示出来。更新后的基础模板代码如下: <html> <head> {% if title %} <title>{{ title }} - microblog</title> {% else %} <title>microblog</title> {% endif %} </head> <body> <div> Microblog: <a href="/index">Home</a> <a href="/login">Login</a> </div> <hr> {% with messages = get_flashed_messages() %} {% if messages %} <ul> {% for message in messages %} <li>{{ message }}</li> {% endfor %} </ul> {% endif %} {% endwith %} {% block content %}{% endblock %} </body> </html> 此处我用了with结构在当前模板的上下文中来将get_flashed_messages()的结果赋值给变量messages。get_flashed_messages()是Flask中的一个函数,它返回用flash()注册过的消息列表。接下来的条件结构用来检查变量messages是否包含元素,如果有,则在<ul>元素中,为每条消息用<li>元素来包裹渲染。这种渲染的样式结果看起来不会美观,之后会有主题讲到Web应用的样式。 闪现消息的一个有趣的属性是,一旦通过get_flashed_messages函数请求了一次,它们就会从消息列表中移除,所以在调用flash()函数后它们只会出现一次。 时机成熟,再次测试表单吧,将username和password字段留空并点击提交按钮来观察DataRequired验证器是如何中断提交处理流程的。 完善字段验证 表单字段的验证器可防止无效数据被接收到应用中。 应用处理无效表单输入的方式是重新显示表单,以便用户进行更正。 如果你尝试过提交无效的数据,相信你会注意到,虽然验证机制查无遗漏,却没有给出表单错误的具体线索。下一个任务是通过在验证失败的每个字段旁边添加有意义的错误消息来改善用户体验。 实际上,表单验证器已经生成了这些描述性错误消息,所缺少的不过是模板中的一些额外的逻辑来渲染它们。 这是给username和password字段添加了验证描述性错误消息渲染逻辑之后的登录模板: {% extends "base.html" %} {% block content %} <h1>Sign In</h1> <form action="" method="post" novalidate> {{ form.hidden_tag() }} <p> {{ form.username.label }}<br> {{ form.username(size=32) }}<br> {% for error in form.username.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p> {{ form.password.label }}<br> {{ form.password(size=32) }}<br> {% for error in form.password.errors %} <span style="color: red;">[{{ error }}]</span> {% endfor %} </p> <p>{{ form.remember_me() }} {{ form.remember_me.label }}</p> <p>{{ form.submit() }}</p> </form> {% endblock %} 我做的唯一的改变是,在username和password字段之后添加for循环以便用红色字体来渲染验证器添加的错误信息。通常情况下,拥有验证器的字段都会用form.<field_name>.errors来渲染错误信息。 一个字段的验证错误信息结果是一个列表,因为字段可以附加多个验证器,并且多个验证器都可能会提供错误消息以显示给用户。 如果你尝试在未填写username和password字段的情况下提交表单,就可以看到显眼的红色错误信息了。 生成链接 现在的登录表单已经相当完整了,但在结束本章之前,我想讨论在模板和重定向中包含链接的妥当方法。 到目前为止,你已经看到了一些定义链接的例子。 例如,这是当前基础模板中的导航栏代码: <div> Microblog: <a href="/index">Home</a> <a href="/login">Login</a> </div> 登录视图函数同样定义了一个传入到redirect()函数作为参数的链接: @app.route('/login', methods=['GET', 'POST']) def login(): form = LoginForm() if form.validate_on_submit(): # ... return redirect('/index') # ... 直接在模板和源文件中硬编码链接存在隐患,如果有一天你决定重新组织链接,那么你将不得不在整个应用中搜索并替换这些链接。 为了更好地管理这些链接,Flask提供了一个名为url_for()的函数,它使用URL到视图函数的内部映射关系来生成URL。 例如,url_for('login')返回/login,url_for('index')返回/index。 url_for()的参数是endpoint名称,也就是视图函数的名字。 你可能会问,为什么使用函数名称而不是URL? 事实是,URL比起视图函数名称变更的可能性更高。 稍后你会了解到的第二个原因是,一些URL中包含动态组件,手动生成这些URL需要连接多个元素,枯燥乏味且容易出错。 url_for()生成这种复杂的URL就方便许多。 因此,从现在起,一旦我需要生成应用链接,我就会使用url_for()。基础模板中的导航栏部分代码变更如下: <div> Microblog: <a href="{{ url_for('index') }}">Home</a> <a href="{{ url_for('login') }}">Login</a> </div> login()视图函数也做了相应变更: from flask import render_template, flash, redirect, url_for # ... @app.route('/login', methods=['GET', 'POST']) def login(): form = LoginForm() if form.validate_on_submit(): # ... return redirect(url_for('index')) # ...
本文转载自:https://www.jianshu.com/p/967e75e6dd5b 在Flask Mega-Tutorial系列的第二部分中,我将讨论如何使用模板。 学习完第一章之后,你已经拥有了一个虽然简单,但是可以成功运行Web应用,它的文件结构如下: microblog\ venv\ app\ __init__.py routes.py microblog.py 在终端会话中设置环境变量FLASK_APP=microblog.py,然后执行flask run命令来运行应用。 包含这个应用的Web服务启动之后,你可以通过在Web浏览器的地址栏中键入URL http://localhost:5000/ 来验证。 本章将沿用这个应用,在此之上,你将学习如何生成包含复杂结构和诸多动态组件的网页。如果对这个应用和相关开发流程有所遗忘,请回顾第一章。 本章的GitHub链接为:Browse, Zip, Diff. 什么是模板? 我设计的微博应用程序的主页会有一个欢迎用户的标题。虽然目前的应用程序还没有实现用户概念,但这不妨碍我使用一个Python字典来模拟一个用户,如下所示: user = {'username': 'Miguel'} 创建模拟对象是一项实用的技术,它可以让你专注于应用程序的一部分,而无需为系统中尚不存在的其他部分分心。 在设计应用程序主页的时候,我可不希望因为没有一个用户系统来分散我的注意力,因此我使用了模拟用户对象,来继续接下来的工作。 原先的视图函数返回简单的字符串,我现在要将其扩展为包含完整HTML页面元素的字符串,如下所示: from app import app @app.route('/') @app.route('/index') def index(): user = {'username': 'Miguel'} return ''' <html> <head> <title>Home Page - Microblog</title> </head> <body> <h1>Hello, ''' + user['username'] + '''!</h1> </body> </html>''' 对HTML标记语言不熟悉的话,建议阅读一下Wikipedia上的简介HTML Markup 利用上述的代码更新这个视图函数,然后再次在浏览器打开它的URL看看结果。 如果我说这个函数返回HTML的方式并不友好的话,你可能会觉得诧异。设想一下,当这个视图函数中的用户和博客不断变化时,里面的代码将会变得多么的复杂。应用的视图函数及其关联的URL也会持续增长。如果哪天我决定更改这个应用的布局,那就不得不更新每个视图函数的HTML字符串。显然,随着应用的扩张,这种方式完全不可行。 将应用程序的后台逻辑和网页布局划分开来,你不觉得更容易组织管理吗?甚至你可以聘请一位Web设计师来设计一个杀手级的网站前端,而你只需要用Python编写后台应用逻辑。 模板有助于实现页面展现和业务逻辑之间的分离。 在Flask中,模板被编写为单独的文件,存储在应用程序包内的templates文件夹中。 在确定你在microblog目录后,创建一个存储模板的目录: (venv) $ mkdir app/templates 在下面可以看到你的第一个模板,它的功能与上面的index()视图函数返回的HTML页面相似。 把这个文件写在app/templates/index.html中: <html> <head> <title>{{ title }} - Microblog</title> </head> <body> <h1>Hello, {{ user.username }}!</h1> </body> </html> 这个HTML页面看起来非常简单,唯一值得关注的地方是{{ ... }}。{{ ... }}包含的内容是动态的,只有在运行时才知道具体表示成什么样子。 网页渲染转移到HTML模板之后,视图函数就能被简化: from flask import render_template from app import app @app.route('/') @app.route('/index') def index(): user = {'username': 'Miguel'} return render_template('index.html', title='Home', user=user) 看起来好多了吧? 赶紧试试这个新版本的应用程序,看看模板是如何工作的。 在浏览器中加载页面后,你需要从浏览器查看HTML源代码并将其与原始模板进行比较。 将模板转换为完整的HTML页面的操作称为渲染。 为了渲染模板,需要从Flask框架中导入一个名为render_template()的函数。 该函数需要传入模板文件名和模板参数的变量列表,并返回模板中所有占位符都用实际变量值替换后的字符串结果。 render_template()函数调用Flask框架原生依赖的Jinja2模板引擎。 Jinja2用render_template()函数传入的参数中的相应值替换{{...}}块。 条件语句 在渲染过程中使用实际值替换占位符,只是Jinja2在模板文件中支持的诸多强大操作之一。 模板也支持在{%...%}块内使用控制语句。 index.html模板的下一个版本添加了一个条件语句: <html> <head> {% if title %} <title>{{ title }} - Microblog</title> {% else %} <title>Welcome to Microblog!</title> {% endif %} </head> <body> <h1>Hello, {{ user.username }}!</h1> </body> </html> 现在,模板变得聪明点儿了,如果视图函数忘记给渲染函数传入一个名为title的关键字参数,那么模板将显示一个默认的标题,而不是显示一个空的标题。 你可以通过在视图函数的render_template()调用中去除title参数来试试这个条件语句是如何生效的。 循环 登录后的用户可能想要在主页上查看其他用户的最新动态,针对这个需求,我现在要做的是丰富这个应用来满足它。 我将会故技重施,使用模拟对象的把戏来创建一些模拟用户和动态: from flask import render_template from app import app @app.route('/') @app.route('/index') def index(): user = {'username': 'Miguel'} posts = [ { 'author': {'username': 'John'}, 'body': 'Beautiful day in Portland!' }, { 'author': {'username': 'Susan'}, 'body': 'The Avengers movie was so cool!' } ] return render_template('index.html', title='Home', user=user, posts=posts) 我使用了一个列表来表示用户动态,其中每个元素是一个具有author和body字段的字典。 未来设计用户和其动态时,我将尽可能地保留这些字段名称,以便在使用真实用户和其动态的时候不会出现问题。 在模板方面,我必须解决一个新问题。 用户动态列表拥有的元素数量由视图函数决定。 那么模板不能对有多少个用户动态进行任何假设,因此需要准备好以通用方式渲染任意数量的用户动态。 Jinja2提供了for控制结构来应对这类问题: <html> <head> {% if title %} <title>{{ title }} - Microblog</title> {% else %} <title>Welcome to Microblog</title> {% endif %} </head> <body> <h1>Hi, {{ user.username }}!</h1> {% for post in posts %} <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div> {% endfor %} </body> </html> 大道至简,对吧? 玩玩这个新版本的应用程序,一定要逐步添加更多的内容到用户动态列表,看看模板如何调度以展现视图函数传入的所有用户动态。 模板的继承 绝大多数Web应用程序在页面的顶部都有一个导航栏,其中带有一些常用的链接,例如编辑配置文件,登录,注销等。我可以轻松地用HTML标记语言将导航栏添加到index.html模板上,但随着应用程序的增长,我将需要在其他页面重复同样的工作。尽量不要编写重复的代码,这是一个良好的编程习惯,毕竟我真的不想在诸多HTML模板上保留同样的代码。 Jinja2有一个模板继承特性,专门解决这个问题。从本质上来讲,就是将所有模板中相同的部分转移到一个基础模板中,然后再从它继承过来。 所以我现在要做的是定义一个名为base.html的基本模板,其中包含一个简单的导航栏,以及我之前实现的标题逻辑。 您需要在模板文件app/templates/base.html中编写代码如下: <html> <head> {% if title %} <title>{{ title }} - Microblog</title> {% else %} <title>Welcome to Microblog</title> {% endif %} </head> <body> <div>Microblog: <a href="/index">Home</a></div> <hr> {% block content %}{% endblock %} </body> </html> 在这个模板中,我使用block控制语句来定义派生模板可以插入代码的位置。 block被赋予一个唯一的名称,派生的模板可以在提供其内容时进行引用。 通过从基础模板base.html继承HTML元素,我现在可以简化模板index.html了: {% extends "base.html" %} {% block content %} <h1>Hi, {{ user.username }}!</h1> {% for post in posts %} <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div> {% endfor %} {% endblock %} 自从基础模板base.html接手页面的布局之后,我就可以从index.html中删除所有这方面的元素,只留下内容部分。 extends语句用来建立了两个模板之间的继承关系,这样Jinja2才知道当要求呈现index.html时,需要将其嵌入到base.html中。 而两个模板中匹配的block语句和其名称content,让Jinja2知道如何将这两个模板合并成在一起。 现在,扩展应用程序的页面就变得极其方便了,我可以创建从同一个基础模板base.html继承的派生模板,这就是我让应用程序的所有页面拥有统一外观布局而不用重复编写代码的秘诀。
本文转载自:https://www.jianshu.com/p/fcbd137f308b 一趟愉快的学习之旅即将开始,跟随它你将学会用Python和Flask来创建Web应用。上面的视频包含了整个教程的内容预览(译者注:视频见原文)。通过学习本章内容,你将学会如何创建一个Flask项目,并在自己的电脑上运行一个简单的Flask Web应用。 教程中所有的代码示例都托管在GitHub上。虽然直接从GitHub下载代码可以节省写代码的步骤,但是我强烈建议你至少在前几章自己动手书写这些代码。一旦你熟悉了Flask和示例应用,一些繁琐重复的代码就可以直接从GitHub复制了。 在每章的开头,我都将提供三个GitHub的链接来帮助你顺畅地学习本章的内容。点击Browse链接会打开GitHub上Microblog项目在本章的对应代码库页面,不会包含之后章节的任何新增代码。而Zip链接则提供了这份代码库的zip打包文件的下载地址。如果点击Diff链接,打开的将会是本章节的代码变更信息。 本章的GitHub链接为: Browse, Zip, Diff. 安装Python 你说你还没有安装Python?那还等什么!立马安装吧。如果操作系统默认没有提供Python安装包,可以从Python官方网站下载。如果你使用Microsoft Windows操作系统并且打算使用WSL或者Cygwin,需要注意,不要在上面使用Windows版本的Python,而要使用类Unix版本,比如从Ubuntu获取(对应WSL)或从Cygwin上获取。 为了验证Python是否正确安装,你可以打开一个终端窗口并输入python3(如果不存在这个命令,那就输入python)。预期的输出如下: $ python3 Python 3.5.2 (default, Nov 17 2016, 17:05:23) [GCC 5.4.0 20160609] on linux Type "help", "copyright", "credits" or "license" for more information. >>> _ Python解释器中,光标不断闪烁,等待着你输入Python语句。在未来的章节中,你可以充分体会到交互式解释器的魅力。至少现在它能够帮你确认Python已经成功安装的事实。可以输入exit()并回车来退出交互式解释器。在Linux和Mac OS X操作系统上,按下快捷键Ctrl-D也可以快速退出交互式解释器。在Windows操作系统上,则是通过按下Ctrl-Z后跟上Enter快捷键来快速退出。 安装Flask 下一步开始安装Flask,在这之前我要告诉你安装Python三方包的最佳实践。 Python将所有三方包托管到一个公共仓库,任何人都能从这个公共仓库下载并安装所有的三方包。Python将三方包公共仓库命名为PyPI以表示Python Package Index的缩写(被一些人戏称为”cheese shop”)。从PyPI上安装三方包非常简单,Python专门提供了一个名为pip的工具来解决这个问题(Python2.7中不含pip工具,需要单独安装)。 安装三方包时,使用pip命令如下: $ pip install <package-name> 有趣的是,这个方法在大多数情况下不适用。假如Python解释器是全局安装的,所有用户都能使用,那么普通用户则没有权限来修改它,因此只能用管理员账户来执行安装操作。即使忽略操作的复杂性,使用这种全局安装的方式会发生什么?pip工具从PyPI上下载三方包并安装到全局Python目录下,即刻起,所有Python脚本都可以访问到这个三方包。想象这样一个场景,你之前用当时的最新版本Flask——0.11版本的Flask开发了一个Web应用,现在Flask已经更新到了0.12版本,你想要使用0.12版本的Flask开发第二个Web应用。但是,如果将Flask从0.11版本升级到0.12版本可能会导致第一个Web应用出现故障。解决这个问题的方法最好不过为旧Web应用安装和使用Flask0.11版本,为新Web应用安装和使用Flask0.12版本。 为了解决维护不同应用程序对应不同版本的问题,Python使用了虚拟环境的概念。 虚拟环境是Python解释器的完整副本。在虚拟环境中安装三方包时只会作用到虚拟环境,全局Python解释器不受影响。 那么,就为每个应用程序安装各自的虚拟环境吧。 虚拟环境还有一个好处,即它们由创建它们的用户所拥有,所以不需要管理员帐户。 我们先创建项目目录,我将这个应用命名为microblog: $ mkdir microblog $ cd microblog 如果你正在使用Python3,虚拟环境已经成为内置模块,可以直接通过如下命令来创建它: $ python3 -m venv venv 译者注:这个命令不一定能够执行成功,比如译者在Ubuntu16.04环境下执行,提示需要先安装对应的依赖。sudo apt-get install python3-venv 使用这个命令来让Python运行venv包,它会创建一个名为venv的虚拟环境。 命令中的第一个“venv”是Python虚拟环境包的名称,第二个是要用于这个特定环境的虚拟环境名称。 如果你觉得这样很混乱,可以用你自定义的虚拟环境名字替换第二个venv。我习惯在项目目录中创建了名为venv的虚拟环境,所以无论何时cd到一个项目中,都会找到相应的虚拟环境。 请注意,在一些操作系统中,你可能需要在上面的命令中使用python而不是python3。 一些安装规范对Python 2.x版本使用python,对3.x版本使用python3,而另一些则将python映射到3.x版本。 命令执行完成后,当前目录下就会新增一个名为venv的目录来存储这个虚拟环境的相关文件。 如果你使用的Python版本低于3.4(包括2.7版本),则不会默认支持虚拟环境。 对于这些版本的Python,在创建虚拟环境之前,需要下载并安装称为virtualenv的第三方工具。 一旦安装了virtualenv,你可以使用以下命令创建一个虚拟环境: $ virtualenv venv 不管你用什么方法创建虚拟环境,创建完毕之后还需要激活才能够进入这个虚拟环境。 要激活你的全新虚拟环境,需使用以下命令: $ source venv/bin/activate (venv) $ _ 如果你使用的是Microsoft Windows命令提示符窗口,则激活命令稍有不同: $ venv\Scripts\activate (venv) $ _ 激活一个虚拟环境,终端会话的环境配置就会被修改,之后你键入python的时候,实际上是调用的虚拟环境中的Python解释器。 此外,终端提示符也被修改成包含被激活的虚拟环境的名称的格式。这种激活是临时的和私有的,因此在关闭终端窗口时它们将不会保留,也不会影响其他的会话。 那么,当你需要同时打开多个终端窗口来调试不同的应用时,每个终端窗口都可以激活不同的虚拟环境而不会相互影响。 成功创建和激活了虚拟环境之后,你可以安装Flask了,命令如下: (venv) $ pip install flask 想要验证安装是否成功,可以打开Python解释器,并用import语句来导入它: >>> import flask >>> _ 如果语句没有报错,那么恭喜你,Flask安装成功了! “Hello, World” Flask应用 Flask网站展示了一个仅有五行代码的简单示例应用程序。 而我会告诉你一个稍微更复杂的例子,它将为你编写更大的应用程序提供一个很好的基础结构。 应用程序是存在于包中的。 在Python中,包含__init__.py文件的子目录被视为一个可导入的包。 当你导入一个包时,__init__.py会执行并定义这个包暴露给外界的属性。 那就创建一个名为app的包来存放整个应用吧。记得切换到microblog目录下,并执行如下命令: (venv) $ mkdir app 并在其下创建文件__init__.py,输入如下的代码: from flask import Flask app = Flask(__name__) from app import routes 上面的脚本仅仅是从flask中导入的类Flask,并以此类创建了一个应用程序对象。 传递给Flask类的__name__变量是一个Python预定义的变量,它表示当前调用它的模块的名字。当需要加载相关的资源,如我将在第二章讲到的模板文件,Flask就使用这个位置作为起点来计算绝对路径。 代码的最后,应用程序导入尚未存在的routes模块。 这段代码,乍一看可能会让人迷惑。 其一,这里有两个实体名为app。 app包由app目录和__init__.py脚本来定义构成,并在from app import routes语句中被引用。 app变量被定义为__init__.py脚本中的Flask类的一个实例,以至于它成为app包的属性。 其二,routes模块是在底部导入的,而不是在脚本的顶部。 最下面的导入是解决循环导入的问题,这是Flask应用程序的常见问题。 你将会看到routes模块需要导入在这个脚本中定义的app变量,因此将routes的导入放在底部可以避免由于这两个文件之间的相互引用而导致的错误。 那么在routes模块中有些什么? 路由是应用程序实现的不同URL。 在Flask中,应用程序路由的处理逻辑被编写为Python函数,称为视图函数。 视图函数被映射到一个或多个路由URL,以便Flask知道当客户端请求给定的URL时执行什么逻辑。 这是需要写入到app/routes.py中的第一个视图函数的代码: from app import app @app.route('/') @app.route('/index') def index(): return "Hello, World!" 这个视图函数简单到只返回一个字符串作为问候用语。 函数上面的两个奇怪的@app.route行是装饰器,这是Python语言的一个独特功能。 装饰器会修改跟在其后的函数。 装饰器的常见模式是使用它们将函数注册为某些事件的回调函数。 在这种情况下,@app.route修饰器在作为参数给出的URL和函数之间创建一个关联。 在这个例子中,有两个装饰器,它们将URL /和/index索引关联到这个函数。 这意味着,当Web浏览器请求这两个URL中的任何一个时,Flask将调用该函数并将其返回值作为响应传递回浏览器。这样做是为了在运行这个应用程序的时候会稍微有一点点意义。 要完成应用程序,你需要在定义Flask应用程序实例的顶层(译者注:也就是microblog目录下)创建一个命名为microblog.py的Python脚本。 它仅拥有一个导入应用程序实例的行: from app import app 还记得两个app实体吗? 在这里,你可以在同一句话中看到两者。 Flask应用程序实例被称为app,是app包的成员。from app import app语句从app包导入其成员app变量。 如果你觉得这很混乱,你可以重命名包或者变量。 只要确保所做的操作完全正确,那么你就可以看到如下面的项目结构图: microblog/ venv/ app/ __init__.py routes.py microblog.py 不管你信不信,这个应用的第一个版本现在完成了! 但是在运行之前,需要通过设置FLASK_APP环境变量告诉Flask如何导入它: (venv) $ export FLASK_APP=microblog.py 如果你使用Microsoft Windows操作系统,在上面的命令中使用set替换export。 万事俱备,只欠东风!运行如下命令来运行你的第一个Web应用吧: (venv) $ flask run * Serving Flask app "microblog" * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 服务启动后将处于阻塞监听状态,将等待客户端连接。 flask run的输出表明服务器正在运行在IP地址127.0.0.1上,这是本机的回环IP地址。 这个地址很常见,并有一个更简单的名字,你可能已经看过:localhost。 网络服务器监听在指定端口号等待连接。 部署在生产Web服务器上的应用程序通常会在端口443上进行监听,如果不执行加密,则有时会监听80,但启用这些端口需要root权限。 由于此应用程序在开发环境中运行,因此Flask使用自由端口5000。 现在打开您的网络浏览器并在地址栏中输入以下URL: http://localhost:5000/ 或者,你也可以使用另一个URL: http://localhost:5000/index 应用程序路由映射执行了吗? 第一个URL映射到/,而第二个映射到/ index。 这两个路由都与应用程序中唯一的视图函数相关联,所以它们产生相同的输出,即函数返回的字符串。 如果你输入任何其他网址,则会出现错误,因为只有这两个URL被应用程序识别。 完成演示之后,你可以按下Ctrl-C来停止Web服务。 真是可喜可贺!你已经成功地向成为一名Web开发者的道路上迈出了重要的第一步!
一、第一个K近邻算法应用:鸢尾花分类 import numpy as np from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.neighbors import KNeighborsClassifier # 加载数据 iris_dataset = load_iris() # 实例化模型 knn = KNeighborsClassifier(n_neighbors=1) # 切分训练和测试数据集 X_train,X_test,y_train,y_test = train_test_split(iris_dataset["data"], iris_dataset["target"],random_state=0) #训练 knn.fit(X_train, y_train) # 评估模型 print("Test set score:{:.2f}".format(knn.score(X_test,y_test))) # 预测 X_new = np.array([[5,2.9,1,0.2]]) prediction = knn.predict(X_new) print("Predicted target name:{}".format(iris_dataset["target_names"][prediction])) 以上代码段包含了应用scikit-learn中人和机器学习算法的核心代码。 fit、predict和score方法是scikit-learn监督学习模型中最常用的接口。
一、引言: 机器学习(machine learning):从数据中提取知识。分为:监督学习(supervised learning)和无监督学习(unsupervised learning)。 二、监督机器学习应用: 1、识别信封上面的手写的邮政编码。 2、基于医学影像判断肿瘤是否为良性。 3、检测信用卡交易中的诈骗行为。 三、无监督机器学习应用: 1、确定一系列博客文章的主题。 2、将客户分成具有相似偏好的群组。 3、检测网站的异常访问模式。 四、构建机器学习解决方案过程中的思考: 1、要回答的问题是什么?已收集到的数据能够回答这个问题吗? 2、要将我的问题表示成机器学习问题,用哪种方法最好? 3、我收集的数据是否足够表达我想要解决的问题? 4、我提取了数据的哪些特征?这些特征能否实现正确的预测? 5、如何衡量应用是否成功? 6、机器学习解决方案与我的研究或商业产品中的其他部分是如何相互影响的? 五、编程环境Anaconda3.4.2+(python3.5+) anaconda安装遇到的问题请查看 conda httperror http none none for url none Anaconda更新失败解决办法 https://www.jianshu.com/p/c74668743932 pandas==0.18.1 matplotlib==1.5.1 numpy==1.11.1 scipy==0.17.1 scikit-learn==0.18
自己用Flask写的淘宝天猫优惠券搜索引擎【淘宝券 www.tbquan.cn 】谢谢支持,代码免费领取:http://www.tbquan.cn/share,教程地址:https://www.jianshu.com/c/905dd533e07d CentOS6.5 安装Python3.6+python虚拟环境virtualenv安装 问题描述: CentOS 6.5上默认安装的python版本是2.6.6,现在python3的程序越来越多,所以对python进行升级。 1、下载python(链接:https://www.python.org/ftp/python/3.6.0/Python-3.6.0.tgz )到 /usr/local/目录下 2、以root权限打开终端,进入安装包的存放路径,解压安装包: cd /usr/local/ tar -xzvf Python-3.6.0.tgz 3、进入解压好的安装包文件夹: cd Python-3.6.0 4、编译安装包,指定安装路径,并执行安装命令: 注意:prefix参数用于指定将Python安装在新目录,防止覆盖系统默认安装的python ./configure --prefix=/usr/local/python36 make && make install 5、修改系统默认的Python路径,因为在终端中输入Python命令时默认是指向Python2.6.6 mv /usr/bin/python /usr/bin/python-2.6.6 6、建立新的软连接,指向Python-3.6.0: 注:这里的python36是第4步指定的安装路径,python3.6是Python包里的可执行程序 ln -s /usr/local/python36/bin/python3.6 /usr/bin/python 7、因为yum是依赖python的,所以我们修改了默认的python,就要修改yum,让其运行指向旧的版本: vi /usr/bin/yum 将第一行中的“#!/usr/bin/python” 修改为“#!/usr/bin/python-2.6.6”,保存即可 8、可以打开一个新的终端,通过python命令进入python环境,就可以看到已经指向了新安装的python3.6.0: [root@localhost:~]$ python Python 3.6.0 (default, Jul 30 2016, 19:40:32) [GCC 4.2.1 Compatible Apple LLVM 8.0.0 (clang-800.0.34)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> 9、安装virtualenvwrapper pip install virtualenv pip install virtualenvwrapper #确保virtualenv已安装 pip install virtualenvwrapper-win #Windows使用该命令 10、安装完成后,在~/.bashrc写入以下内容: export WORKON_HOME=~/Envs source /usr/local/bin/virtualenvwrapper.sh 第一行:virtualenvwrapper存放虚拟环境目录 第二行:virtrualenvwrapper会安装到python的bin目录下,所以该路径是python安装目录下bin/virtualenvwrapper.sh 11、执行下面命令,是配置立即生效: source ~/.bashrc 12、virtualenvwrapper基本使用: #创建虚拟环境 [root@localhost ~]# mkvirtualenv venv #指定python版本创建虚拟环境 [root@localhost ~]# mkvirtualenv --python=/usr/local/python36/bin/python venv #查看当前虚拟环境目录 [root@localhost ~]# workon py2 py3 #切换虚拟环境 [root@localhost ~]# workon py3 (py3) [root@localhost ~]# #退出虚拟环境 (py3) [root@localhost ~]# deactivate [root@localhost ~]# #删除虚拟环境 [root@localhost ~]# rmvirtualenv venv ****【注】原创内容转载请注明 : CentOS6.5 安装Python3.6+python虚拟环境virtualenv安装 https://www.jianshu.com/p/7b9908b0bbb9 ****
优惠券、百度网盘搜索引擎【it快速自学导航 so.kszixue.com 】谢谢支持,优惠券搜索引擎教程地址:https://www.jianshu.com/c/905dd533e07d 今日更新:优惠券、百度网盘搜索引擎 it快速自学导航 so.kszixue.com(你懂的!) conda httperror http none none for url none Anaconda更新失败 问题描述: 1、在conda安装好之后,默认的镜像是官方的,由于官网的镜像在境外,访问太慢或者不能访问,为了能够加快访问的速度,这里选择了清华的的镜像。 在命令行中运行(设置清华的3个镜像) conda config --add channels https://mirrors.ustc.edu.cn/anaconda/pkgs/free/ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/ conda config --set show_channel_urls yes Anaconda更新还是失败 2、通过分析,问题只可能是出在default channel上面。于是索性删掉channels下面的 -defaults一行,果然更新成功了。 image 按上面的设置镜像源后需打开C:\Users\lqk.condarc这个文件删除 -defaults这一行 附上:C:\Users\lqk.condarc 修改后.condarc文件里应该是下面这样的内容 channels: - https://mirrors.ustc.edu.cn/anaconda/pkgs/free/ - https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ - https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/ show_channel_urls: true 【2018-5-20更新】如果仍然有错误 1、删除上面添加的3个镜像链接(可能已经失效了) 可直接修改.condarc文件或执行如下命令: conda config --remove channels 'https://mirrors.ustc.edu.cn/anaconda/pkgs/free/ ' conda config --remove channels 'https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/' conda config --remove channels 'https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/' 2、将channels:下的链接更新为https://mirror.tuna.tsinghua.edu.cn/help/anaconda/连接下的anconda源,比如: channels: - https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/ - https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/ show_channel_urls: true ****【注】原创内容转载请注明 : Anaconda更新失败解决方法 https://www.jianshu.com/p/c74668743932 ****