系统设计与分析-技术报告-定时清理验证码的一种解决方案

2022-06-23 12:57:45 浏览数 (1)

文章目录
  • 1 背景
  • 2 问题及解决
  • 3 源码
  • 4 总结

1 背景

在系统设计与分析的这门课程中,我们要完成一个项目,是一个大学生通过填写问卷、帮拿快递等方式“挣闲钱”的app。这个App要求实名注册,身份认证的方案如下:以每个学生独有的学校邮箱作为认证注册的凭证,注册之前要先填写邮箱并通过邮箱获取验证码,输入正确验证码才能注册成功。

2 问题及解决

第一个重要的问题就是如何保存验证码?是保存在数据库中,还是直接保存在内存中,显然直接放在内存中效率是更高的。

另一个问题就是验证码的定时清理。由于验证码具有一定的时效性,并且在一次使用之后也不应该继续保存(浪费内存),所以如何定期地进行清理是一个重要的问题。最蠢的方法就是为每个验证码设置一个计时器,到时间就把它删掉,但这样显然很影响性能。那怎样才能使用更少的计时器呢?由于验证码的生成是有时间的先后顺序的,所以只要最老的那个还没过期,那么其余的肯定也没过期,所以思路就是维护一个优先队列,最老的放在前面,并给每个验证码附加一个创建时间戳,只需要定时判断前面的部分是否过期即可(当前时间减去创建时间是否大于某个阈值)。

但根据时间排序也会消耗性能?其实并不用真的维护什么优先队列,就一个链表就足够了,先插入的肯定就是比较老的。然后给邮箱发验证码时需要与将该邮箱与验证码关联起来,以便在收到注册请求是校验相对应的验证码,使用map非常合适。

综合以上需求,使用什么数据结构比较好呢?有没有一种基于hashmap数据结构,并且是按照插入顺序进行排序的,即先插入的在前面?还真有,它就是collections.OrderedDict。它的使用与dict差不多,只是实现的方式不一样,从Ordered就可看出它是有序的。

现在局势似乎明朗了,所以开始写代码:

代码语言:javascript复制
import collections
codes = collections.OrderedDict()
def get_verification_code_(email):
	'''
	通过邮箱获取验证码,
	验证码在一定时间内有效,超过这个期限则会自动删除
	'''
	global is_scheduler_running
	res = {}
	# 验证码还未过期的情况
	if(email in codes):
		res	= {'error': 1, 'error_message': '原验证码未过期'}
		print(str(res))
	# 正常情况
	else:
		# 生成验证码并发送至邮箱
		code = utils.send_email(rcptto=email) # 这是自己实现好的一个方法
		if code == -1:
			return str({'error': 1, 'error_message': '验证码发送失败'})
		codes[email] = (code, time.time()) # 在本地记录验证码值
		print('生成的验证码', codes[email])
		res	= {'error': 0, 'error_message': '验证码已发送'}
	return str(res)

至于验证码的定时删除,这里使用 sched.scheduler 调度器作为计时器,其基本思路就是往调度器加一系列方法,调度器会对其进行调用,可以设定调用的时间。一开始先把删除验证码的方法放入,在该方法执行完之后再把该方法放入调度器,这样就相当于定时地循环调用删除验证码的方法。sched.scheduler的使用可以自行百度。

enter_event_and_run_scheduler的原始实现如下:

代码语言:javascript复制
s = sched.scheduler(time.time, time.sleep) # 用来定时删除过期验证码的调度器
def enter_event_and_run_scheduler():
	s.enter(2, 0, delete_invalid_codes)
	t = Thread(target = s.run)
	t.start()

原始的delete_invalid_codes方法如下:(但会有问题,稍后介绍)

代码语言:javascript复制
def delete_invalid_codes():
	'''
	删除本地保存的过期(无效)的验证码。
	OrderedDict按照插入的顺序排序,所以先创建验证码的一定在前面,从前面遍历删除直至遇到未过期的验证码为止
	'''
	global is_scheduler_running
	for k in list(codes):
		if(time.time() - codes[k][1] < time_limit):
			break 
		if(k in codes):
			try:
				print('删除的验证码:', codes.pop(k))
			except Exception as e:
				print('Error:', e)
	if(s.empty())
		s.enter(time_limit, 0, delete_invalid_codes)

注册的代码如下:

代码语言:javascript复制
def register_(email, password, student_id, sex, collage, grade, name, validate_code):
	res = {}
	if(email not in codes):
		res = {'error': 1, 'error_message': '未获取验证码或验证码过期'}
	elif(codes[email][0] != validate_code):
		res = {'error': 1, 'error_message': '验证码错误'}
	else:
		'''
		判断邮箱是否被注册
		'''
		error_code, error_message, openid = db_helper.sign_up_true(email, password, student_id, sex, collage, grade, name)
		if(error_code == 0):
			try:
				codes.pop(email)
			except Exception as e:
				print('Error:', e)
			res = {'error': str(error_code), 'error_message': error_message, 'data': {'openid': str(openid)}}
		else:
			res = {'error': str(error_code), 'error_message': error_message, 'data': {'openid': str(openid)}}
	return str(res)

注:这里的register_和get_verification_code_是被flask的路由接口调用的,如下:

代码语言:javascript复制
@app.route('/user/register/', methods=['POST'])
def register():
	print(request)
	return register_(request.form['email'], request.form['password'], request.form['student_id'],
					request.form['sex'], request.form['collage'], 
					request.form['grade'], request.form['name'],
					request.form['validate_code'])

@app.route('/user/get_verification_code/', methods=['POST'])
def get_verification_code():
	return get_verification_code_(request.form['email'])

flask的使用也不做过多介绍,可自行学习,现在只需要知道在接收到注册的网络请求时,register_这个函数会被调用,接收到获取验证码的网络请求时get_verification_code_会被调用。

还有一个地方需要注意,就是其中对OrderedDict的删除操作,在注册成功时要删除验证码,也可能在过期时删除,这两者是多线程并发进行的,可能导致KeyError,所以使用try-except 捕获错误以防止程序出错终止。

在这么看来,以上代码好像可以了,跑起来确实也应该是没错的。但在基本功能实现后,考虑一个问题:当没有人进行注册时,验证码为空,那此时调度器就没必要再运行。貌似只需要在delete_invalid_codes中加一个判断:

代码语言:javascript复制
if(len(codes) > 0 and s.empty()): # 若还有验证码,且调度队列为空,则继续将delete_invalid_codes加入调度器
	s.enter(time_limit, 0, delete_invalid_codes)

这样当验证码为空时,调度器就结束了。但这又带来了一个麻烦,就是什么时候再次开启呢?很容易想到就是在下次二维码请求到来时再开启,可以使用一个布尔值 is_scheduler_running 判定调度器是否在运行,并且为其加锁,当每次请求二维码时,若调度器不在运行,在开启调度器,如下:

代码语言:javascript复制
is_scheduler_running = False # 判定调度器是否正在运行

def get_verification_code_(email):
	...
		if(not is_scheduler_running): # 若调度器不在运行
			enter_event_and_run_scheduler()
		res	= {'error': 0, 'error_message': '验证码已发送'}
	return str(res)

在delete_invalid_codes中:

代码语言:javascript复制
if(len(codes) > 0 and s.empty()): # 若还有验证码,且调度队列为空,则继续将delete_invalid_codes加入调度器
	s.enter(2, 0, delete_invalid_codes)
else:
	scheduler_lock.acquire()
	is_scheduler_running = False
	scheduler_lock.release()

修改后的 enter_event_and_run_scheduler 如下,由于 is_scheduler_running 处在临界区,需要对其进行加锁,否则if(s.empty()):之后的代码可能会被多次运行。关于锁的概念,这里也不做过多介绍。

代码语言:javascript复制
from threading import Lock
scheduler_lock = Lock()

def enter_event_and_run_scheduler():
	scheduler_lock.acquire()
	global is_scheduler_running
	if(not is_scheduler_running):
		is_scheduler_running = True
		if(s.empty()):
			s.enter(2, 0, delete_invalid_codes)
			t = Thread(target = s.run)
			t.start()
	scheduler_lock.release()

以上工作做得差不多了,再想想一个问题:调度器在关闭之后,是否能在下次有需要时被正常触发?一般情况下是可以的,但由于这是多线程,会出现一个小问题:考虑一下这种情况,在所有验证码清空后,delete_invalid_codes运行到else:之前,下一句即将执行的代码is_scheduler_running = False还没有执行,然后来了一个验证码请求,线程刚好跳到那边去处理请求,此时is_scheduler_running还是等于true的,所以调度器没有被开启。然而线程切换回delete_invalid_codes继续运行之后,调度器就结束了,之后如果没有验证码请求,那内存中已有的验证码永远不会被删除,当然只要之后再来一个验证码请求,就没问题了,但不管怎样,这属于一个bug,可以解决当然解决了好。既然是在else跳转到别的地方去,然后跳转回来,那么只要在跳转回来之后再做一次判断,就可以把这个bug解决,如下:

代码语言:javascript复制
def delete_invalid_codes():
	...
	else:
		scheduler_lock.acquire()
		is_scheduler_running = False
		if(len(codes) > 0 and not is_scheduler_running): # 应对线程安全,此时可能有验证码加入,但调度器并未开启
			enter_event_and_run_scheduler()
		scheduler_lock.release()

本来到这里,笔者认为已经解决了,但在写这篇博客的时候突然发现,还是有问题的。可以看到enter_event_and_run_scheduler里面是开了一个新线程来运行调度器,而这里在delete_invalid_codes运行还没有结束之前,原来那个线程是还没有结束的,那么在enter新的方法进去之后,原来的线程会继续执行,并且这里又新开了一个线程,相当于有两个线程在执行一样的操作,这自然是十分没有必要的,也不是我们本来的意愿。所以,这里其实没必要重开一个线程,把代码改为:

代码语言:javascript复制
def delete_invalid_codes():
	...
	else:
		scheduler_lock.acquire()
		is_scheduler_running = False
		if(len(codes) > 0 and not is_scheduler_running and s.empty()): # 应对线程安全,此时可能有验证码加入,但调度器即将结束
			is_scheduler_running = True
			s.enter(2, 0, delete_invalid_codes)	
		scheduler_lock.release()

这个其实是犯了逻辑错误,以为调度器结束了要重新开启,但其实这里调度器并没有结束,因为delete_invalid_codes还没执行完呢。

3 源码

代码语言:javascript复制
...
from threading import Thread, Lock
import time
import collections
import sched
...

codes = collections.OrderedDict()
s = sched.scheduler(time.time, time.sleep) # 用来定时删除过期验证码的调度器
scheduler_lock = Lock()
is_scheduler_running = False # 判定调度器是否正在运行

time_limit = 60 * 5

def register_(email, password, student_id, sex, collage, grade, name, validate_code):
	res = {}
	if(email not in codes):
		res = {'error': 1, 'error_message': '未获取验证码或验证码过期'}
	elif(codes[email][0] != validate_code):
		res = {'error': 1, 'error_message': '验证码错误'}
	else:
		'''
		判断邮箱是否被注册
		'''
		error_code, error_message, openid = db_helper.sign_up_true(email, password, student_id, sex, collage, grade, name)
		if(error_code == 0):
			try:
				codes.pop(email)
			except Exception as e:
				print('Error:', e)
			res = {'error': str(error_code), 'error_message': error_message, 'data': {'openid': str(openid)}}
		else:
			res = {'error': str(error_code), 'error_message': error_message, 'data': {'openid': str(openid)}}
	return str(res)


def get_verification_code_(email):
	'''
	通过邮箱获取验证码,
	验证码在一定时间内有效,超过这个期限则会自动删除
	'''
	global is_scheduler_running
	res = {}
	# 验证码还未过期的情况
	if(email in codes):
		res	= {'error': 1, 'error_message': '原验证码未过期'}
		print(str(res))
	# 正常情况
	else:
		# 生成验证码并发送至邮箱
		code = utils.send_email(rcptto=email)
		if code == -1:
			return str({'error': 1, 'error_message': '验证码发送失败'})
		codes[email] = (code, time.time()) # 在本地记录验证码值
		print('生成的验证码', codes[email])
		# print(is_scheduler_running)
		if(not is_scheduler_running): # 若调度器不在运行
			enter_event_and_run_scheduler()
		res	= {'error': 0, 'error_message': '验证码已发送'}
	return str(res)


def delete_invalid_codes():
	'''
	删除本地保存的过期(无效)的验证码。
	OrderedDict按照插入的顺序排序,所以先创建验证码的一定在前面,从前面遍历删除直至遇到未过期的验证码为止
	'''
	global is_scheduler_running
	for k in list(codes):
		if(time.time() - codes[k][1] < time_limit):
			break 
		if(k in codes):
			try:
				print('删除的验证码:', codes.pop(k))
			except Exception as e:
				print('Error:', e)
	if(len(codes) > 0 and s.empty()): # 若还有验证码,且调度队列为空,则继续将delete_invalid_codes加入调度器
		s.enter(time_limit, 0, delete_invalid_codes)
	else:
		scheduler_lock.acquire()
		is_scheduler_running = False
		if(len(codes) > 0 and not is_scheduler_running and s.empty()): # 应对线程安全,此时可能有验证码加入,但调度器即将结束
			is_scheduler_running = True
			s.enter(2, 0, delete_invalid_codes)	
		scheduler_lock.release()

def enter_event_and_run_scheduler():
	scheduler_lock.acquire()
	global is_scheduler_running
	if(not is_scheduler_running):
		is_scheduler_running = True
		if(s.empty()):
			s.enter(2, 0, delete_invalid_codes)
			t = Thread(target = s.run)
			t.start()
	scheduler_lock.release()

4 总结

本文提出的方案主要利用了OrderedDict的先插入在前面的特性,来定时对验证码进行清理,只需要一个计时器;为了能在没有验证码时关闭计时器(调度器结束),需要解决多线程可能引发的一些问题。

虽然这可能并不是一个高效的方案(有许多判断,并且加了锁),并且由于贴贴补补,也不见得可靠,但也算是对定期清理内存和并发问题的一个思考,记录下来以便日后回忆和反思,也希望对读者有一点点帮助,或者让正在读这篇文章的大佬(没错就是你)嘲笑一番,以达到增强读者幸福感的目的,也足够了。

0 人点赞