class2api v4.3.1-se3
概要
- class2api帮把Javascript Class类的静态方法自动向外映射为API接口,创建独立的应用
- 也可以整合到现有的Express应用中,挂载到指定到路由下
Table of Contents generated with DocToc
从脚手架快速创建项目
//先全局安装class2api
$ npm i class2api -g
//从脚手架初始化新项目
$ class2api init
根据提示输入
$ class2api init
脚手架模版类型:
- base ——精简型,不带before/after拦截器
- normal ——普通型,带before/after拦截器、API缓存机制
- super ——增强型,带before/after拦截器、API缓存机制、数据库访问
- admin ——管理后台权限认证型,super的基础上附带管理身份权限校验
选择以上哪种模版?(base/normal/super/admin): super
给创建的新项目取个名字(superDemo27366): class2api_scaffold_super
远程提取模版文件 ...
请配置数据库链接:
数据库IP(127.0.0.1):
数据库端口(3306):
数据库访问用户(root):
数据库密码(默认为空):
创建新数据库的名称/存在则忽略(class2api_demo): class2api_oooooo
数据库编码(utf8_general_ci):
√ 数据库配置成功!
√ 项目创建成功!
开始体验:
$ cd class2api_scaffold_super && npm install && npm start
最终:
//curl请求
$ curl -d 'name=huangyong' 'http://127.0.0.1:3002/a/hello'
//运行单元测试,需全局安装mocha: $ npm i mocha -g
$ mocha test/test.run.js
class2api的由来:
- 目前还没有一套开源的流行的、将业务类映射为API输出的框架
- 让团队专注于业务类的逻辑实现
- 基于Express(添加了很薄的一层路径映射),内核稳定、插件生态丰富
- 省去了繁琐的项目环境配置,ES6、Babel等
- Sequelize的Model定义与加载器
- 内置缓存服务,支持缓存API方法输出的结果,并可反向控制
- 平滑扩展,后续将扩展支持其他更高性能webServer框架
业务静态类的编写约定:
- 类名需为命名类,不能匿名
- 业务静态类,不能拥有实例方法(通常其实也没有需求和场景),所以请参考官方代码,在构造器中throw异常,以确保业务静态类不会被实例化
- 类静态方法实现各业务逻辑,一个方法对应一个业务逻辑
- 类静态方法只有一个形参,并以ES6对象解构的方式书写,调用代码可以获得命名参数形式的可读性益处
- 类静态方法的参数(首个参数)中,框架还注入了几个属性:req:当前的请求信息对象,express的标准request,供方法内部读取;uID:用户的唯一编码,当modelSetting修饰器中指定的身份验证函数(Auth)运行通过时出现;**nocache**:调用方传入的特殊指令,当__nocache有值时,cacheAble修饰器内部会在本次请求中忽略cacheAble缓存策略,继续运行方法。
- 为了增加API返回数据值的可扩展能力,类静态方法的返回值必须用对象形式,不能使用字符串、整数、浮点、布尔等简单值类型。
- 某些场景,只需要API返回操作是否成功的信息,可以使用内置的GKSUCCESS(props)函数,它封装一个简单结构{success: true,...props},其中props参数是附加信息,当props是对象时,自动扩展到结构里,当props是非对象时,以msg属性扩展到结构里
如何使用
创建全新独立的接口应用
- createServer(opts) 创建服务器(使用内建的独立Express实例) ,opts为参数项,具体如下:
createServer({
config:{
apiroot:'/', //[可选],挂载微服务的根路径,如:apiroot设置为'apiv1",则业务类ClassA的method1方法对应的访问入口为: http://yourdomain/apiv1/classa/method1
redis, //[可选],内置API方法缓存所使用的redis实例配置参数,使用clearCache、cacheAble修饰器时必须
cros:true, //[可选],是否允许跨域访问
cros_headers:[], //[可选],允许跨域访问时的headers白名单
cros_origin:[], //[可选],允许跨域访问时的授权源配置,默认为*。当传入有效的cros_origin参数时,以cros_origin中指定的为准
frontpage_default: '' //[可选],放在API方法内部获取前端的域名,与从前端请求传过来header中的frontpage合并,优先获取客户端的,其实采用此默认值。最后封装为标准url对象并绑定到API方法回调的params对象的___frontpageURL属性上
},
modelClasses, //必需,映射的业务类列表,如:[ClassA, {model:XXX,as:'abc'}],建议以mode-as的方式修改业务类暴露的访问路径,以隐藏实际的类命名(建议)
beforeCall, //[可选],API接口请求之前的拦截事件,可以修改、监听post信息,以及身份的验证判断
afterCall, //[可选],API接口请求完成后的拦截事件,可以修改、监听返回结果,典型场景就是记录请求的日志
custom:(expressInstence)=>{
return expressInstence
}, //[可选],对expressInstence实例进行自定义扩展,注:微服务通常是无状态的,所以不建议增加session机制
method404 //[可选],自定义的express的路由中间件,在404场景时,可输出自己定制的返回值
})
在现有Express应用中扩展API路由
- createServerInRouter(opts) 创建服务器(使用外部的Express,只返回router供绑定路由),opts参数与createServer函数基本相同,除了自动忽略custom参数。可参考项目中的demo-src/server-router.js源码
接口返回值的默认结构
class2api对所有请求的返回数据结构,统一为:
{
err,
result
}
其中err代表内部异常或错误,凡是GKErrors和GKErrorWrap result抛出的错误都会捕捉err ,而result保存的是业务类静态方法的执行返回结果,且约定为必须为对象结构,不能是数字、字符串、布尔值等简单数据类型
接口返回值的结构自定义
*特殊情况:如果你的系统有特殊原因,需要接口返回自定义的数据结构,可以使用以下方式来控制反转:
class ClassB {
constructor() {
throw '静态业务功能类无法实例化'
}
static async customResponseResultStruck() {
//TODO:.....
//class2api内部约定,如API方法返回的是Function,则框架会调用函数并把其运行结果返回给客户端,以实现自定义特殊的response结构
return () => {
return {data: {name: 'huangyong'}, errorCode: 123}
}
}
}
接管res输出操作(谨慎使用)
class ClassB {
constructor() {
throw '静态业务功能类无法实例化'
}
static async customResponseResultStruck() {
//TODO:.....res.write(fileStream)
//class2api内部约定,如API方法返回__customResp标记,则框架终止res操作,由方法内部自行控制res操作
return {__customResp:true}
}
}
自定义Appi路径
//创建微服务对象
createServer({
config:{
apiroot: '/api_v2',//如需要时,可以指定API服务的起始路径,特别是在映射路径的方式中
},
//...
})
跨域配置
//创建微服务对象
createServer({
config:{
cros:true,
cros_headers:['customHeader'],
cros_origin:['http://web.domain.com'],
},
//...
})
访问内置的Redis缓存实例
- setting_redisConfig 设置内置redis的连接参数,因为考虑到import声明自动提前的问题,为确保其他自定义业务类内部初始化时依赖redis实例的场景。建议的,最佳方法是定义个独立的init.js,并在server.js顶部第一行引用
//init.js
import {setting_redisConfig} from 'class2api'
setting_redisConfig({
host: "127.0.0.1",
port: 6379,
cache_prefx:'dev_class2api_',//必须的参数,且针对每个应用不同配置,以避免各应用之间发生key碰撞与覆盖
defaultExpireSecond:10*60 //可缺省,class2api内部的默认混存时长为1分钟,可自定义
})
- getRedisClient 获得redis访问实例,以进行直接读取操作。注:读写时,在redis中实际存取的key会携带redis配置中cache_prefx属性定义的前缀
let cache = getRedisClient()
cache.set('keyABC','this is message!', (err,data)=>{})
await cache.setAsync('keyABC','this is message!')
自定义错误常量对象
- GKErrorWrap 错误包装器,以快速创建以下约定结构的错误信息:
return {
_gankao: 1,//固定标志位,以区别普通的error对象
code: errCode,//错误码,内置错误类型为负数,通过GKErrorWrap自定义的code请务必为正数
message: `...`,//错误信息
more: ''//详细的错误信息
}
业务类的修饰器扩展
- modelSetting(props) 类修饰器,将传入的props对象赋值到 Class.modelSetting 上,并传入beforeCall事件函数中,供beforeCall函数内部访问调用 目前class2api内部约定的porps属性有: - **Auth:Function,与业务类相关的身份验证函数,内部进行身份判断,并返回带有uID的用户信息对象。通常在beforeCall中执行调用完成身份验证 - __ruleCategory**:{name//权限组名称,desc//权限组的备注描述}
import {parseAdminAccountFromJWToken} from "class2api/rulehelper";
@modelSetting({
__Auth:async ({req})=>{
//后台的用户验证,解析header中的jwtoken信息,调用class2api/rulehelper的解析,注意与非后台常规用户验证的区别
let jwtoken = req.header('jwtoken') || ''
return await parseAdminAccountFromJWToken({jwtoken})
},
__ruleCategory:{
name:"文章管理",
desc:"对文章进行新建、编辑、删除等操作"
}
})
class ArticleManager {
constructor() {
throw '静态业务功能类无法实例化'
}
}
API方法缓存机制
启用缓存
- cacheAble({cacheKeyGene:()=>{}})
类静态方法修饰器,对修饰的API方法的调用结果进行缓存,缓存的key由cacheKeyGene函数运行时动态返回指定
class GKModelA {
@cacheAble({
cacheKeyGene: ({name}) => {
return `getArticle-${name}`
}
})
static async getArticle({uID, name}) {
return {message: `getArticle.${name},from user. ${uID}`}
}
}
Q:如何判断某次API请求的结果是命中了缓存? A:命中了缓存的调用,在其请求的返回结果中,带有__fromCache=1属性,如:
{
err:null,
result:{
name:'huangyong',
__fromCache:1 //额外的输出标记
}
}
Q:如果需要在某次调用接口时强制禁用(绕过)API缓存机制? A:传入__nocache=1参数,组件内部即会判断并绕过缓存,示例如:
let options = {
uri: remote_api,
body: {
fID:123,
__nocache:1
},
json: true,
}
let {body} = await request.postAsync(options)
清除缓存
- clearCache({cacheKeyGene:()=>{}}) 类静态方法修饰器, 实现在API请求完成之后清空指定key的缓存,删除的key由cacheKeyGene函数运行时动态返回指定;如果需要清除多个key,可以用反转控制的方式,交给类静态方法内部来处理,当cacheKeyGene函数返回''空字符串,会开启反转控制模式,修饰器内部会在类静态方法的第一个调用参数(按约定,类静态方法使用第一个复合对象获取所有的参数)中增加cacheManage属性,cacheManage是一个轻量cache访问器,提供get(akey)、set(akey, avalue, expireTimeSeconds)、delete(akey)三个方法。
//常规的控制方式
class GKModelA {
@clearCache({
cacheKeyGene: (args) => {
let {aID} = args[0]
return `article-${aID}`
}
})
static async deleteArticle({aID}) {
//...
return GKSUCCESS()
}
}
//需要清除多个关联key时,可使用反转控制机制
class GKModelA {
@clearCache({
cacheKeyGene: (args) => {
return ''
}
})
static async deleteArticle({aID, __cacheManage}) {
//...
if(__cacheManage){
await __cacheManage.delete(`article-${aID}`) //删除文章缓存
await __cacheManage.delete(`articleCategory-1`) //删除文章类别的缓存
}
return GKSUCCESS()
}
}
执行中断设置(开发调试用)
- crashAfterMe(hintMsg) 修饰器,运行完类方法就人为抛出异常中断程序,调试用,生产环境下自动失效
常用内置的预设错误(code值统一为负数)
- 位于 class2api/gkerrors 下
- 预设错误有:
import {GKErrors} from 'class2api/gkerrors'
GKErrors._TOKEN_PARSE_FAIL //token解析失败
GKErrors._RULE_VALIDATE_ERROR //权限认证过程中发生异常
GKErrors._TOKEN_LOGIN_INVALID //请先登录
GKErrors._NOT_ACCESS_PERMISSION //无访问权限
GKErrors._NOT_SERVICE //功能即将实现
GKErrors._PARAMS_VALUE_EXPECT//参数不符合预期
GKErrors._NO_RESULT //无匹配结果
GKErrors._SERVER_ERROR //服务发生异常
GKErrors._NOT_PARAMS //缺少参数
sequelize辅助方法
sequelize表Model定义
- 位于class2api/dbhelper下
- 模型创建与管理的助手函数空间,可参考项目中的tableloader.js示例文件
- TableSetting
sequelizeModel定义model时的几个扩展设置,包括: 1、TableSetting.tabelOption设置,含model的几个默认设置,paranoid默认为true,时间相关字段名定义为created_at、updated_at、deleted_at,字符集collate为utf8_general_ci 2、TableSetting.extendDateTimeVirtualFields(DataTypes, customfields)设置,为时间字段扩展出可读性强的时间格式化后的虚字段,名称约定为'*_display',设置内默认为created_at、updated_at扩展,第二个customfields参数追加需要扩展格式化的自定义时间字段
//DemoUser.js
import {TableSetting} from 'class2api/dbhelper';
export default (sequelize, DataTypes)=> {
const User = sequelize.define('demouser', {
name: {type: DataTypes.STRING, allowNull: false, defaultValue: '', comment: `用户的姓名`},
birthday: {type: DataTypes.DATE},
...TableSetting.extendDateTimeVirtualFields(DataTypes, ['birthday'])
}, {
...TableSetting.tabelOption,
classMethods: {
associate: function (DataModel, ass) {
//User.belongsTo(DataModel.Student)
}
},
comment: '学生信息'
});
return User;
}
重置初始化DB
- ResetDB
重置数据库,即执行sequelize.sync({force: (process.env.FORCE=="1")}),内部有启动环境变量的校验,只有当传入的启动环境变量与config中mysql.reset_key的{key1,key2}相符时才执行。默认为软重置,可以通过传入FORCE=1的启动环境变量来强制重置
sequelize模型Model加载器
- DBModelLoader 加载Model定义文件, 参考示例项目中的tableloader.js文件,代码段:
import {DBModelLoader} from 'class2api/dbhelper'
import _config from './config'
import DemoUser from './tables/DemoUser'
//模型定义,在aloader.init内部会动态加载指定的定位文件,替换为真实的object的value值
export const DataModel = {
DemoUser: DBModelLoader.define(DemoUser),
}
//绑定模型关系时,可能需要定义的别名
export const ass = {
subComment: "subComment",
replyToUser: "replyToUser"
}
(async()=>{
await DBModelLoader.INIT(_config.mysql, {model:DataModel, ass})
})();
打印sequelize实例的方法列表
- DBUtils
数据库的辅助工具方法: DBUtils.dumpModelFuns(sequelizeModelClass) 打印指定模型类上的sequelize扩展的操作自身数据实例的、以及操作关联对象的各类函数方法
sequelize内置函数的引用
fn:Function
sequezeli的聚合函数引用col:Function
sequezeli的列函数引用literal:Function
sql语句字面量包装函数,确保sequelize不解析此SQL字符串where:Function
sequezeli的where函数的引用createTransaction:Function 创建一个sequezeli事务
执行自定义SQL语句
- excuteSQL(sql,replaceparam1,replaceparam2,...) 执行指定的SQL语句,一般适用于无法用sequelize表达式的复杂查询或更新操作
权限访问控制相关
在 class2api/rulehelper 下,适用于后台管理系统的身份检验、权限校验的辅助函数库
对API方法施加控制点
- accessRule({ruleName, ruleDesc}) 类静态方法修饰器,提供后台级的权限校验,同时为当前修饰的类静态方法标记了权限名称、描述信息。 运行原理:修饰器内部会拦截类静态方法的调用,并先向class2api.config.js中指定的远程权限认证微服务发送请求进行身份验证,根据请求返回结果来判断是否有权限继续运行对应的方法。 class2api.config.js配置中定义相关信息:
exports.config = {
name: 'courseService',
admin_rule_center: {
auth:"http://127.0.0.1:3002/gkrulemanager/auth", //管理用户的身份验证接口
validator: "http://127.0.0.1:3002/gkrulemanager/validate", //管理用户对指定权限的验证接口
register: "http://127.0.0.1:3002/gkrulemanager/register" //权限配置表的上传注册接口(待完善),在IDE环境下使用
}
}
修饰器向权限中心的权限校验接口(config.admin_rule_center.validator)提交以下参数信息: 1、jwtoken:jsonwebtoken版的管理员身份签名,从req参数中提取,并在header中发送,权限中心会识别解密jwtoken,从而分辨身份并校验权限 2、categoryName:权限组名称,从modelSetting修饰器的配置信息中获得 3、categoryDesc:权限组的描述信息,从modelSetting修饰器的配置信息中获得 4、ruleName:权限名称,从accessRule修饰器函数的参数中获得 5、ruleDesc:权限的描述信息,从accessRule修饰器函数的参数中获得 6、codePath:代码路径,格式如:ClassA.methodA,在框架内部获得 7、sysName:调用方系统名称,在class2api.config.js中配置
class GKModelA {
constructor() {
throw '静态业务功能类无法实例化'
}
@accessRule({ruleName: '删除文章', ruleDesc: '对文章进行删除'})
static async deleteArticle({aID}) {
//...
return GKSUCCESS()
}
}
后台管理jwtoken的身份查询
- parseAdminAccountFromJWToken({jwtoken}) class2api内置的管理员身份校验函数,函数内将jwtoken参数会以header的方式发送给class2api.config.js中指定的config.admin_rule_center.auth接口,auth接口端返回详细的用户信息(按约定,用户信息中含有标识唯一的uID属性)
测试辅助工具
在class2api/testhelper中,测试辅助函数库,为了接近真实环境,以及确保接口调用的全流程,class2api的单元测试原则上都以http调用的方式执行,而避免用类调用。 注:当需要测试业务功能类本身时,还是可以在业务类层面直接调用,以避开微服务框架以及http通讯的干扰
- setApiRoot 设定微服务API的domain,在WebInvokeHepler内部需要使用
- WebInvokeHepler(usertoken)(apiPath, postParams, apiDesc)
向api发起一个post-API请求, postParams是post的JSON内容,apiDesc是API方法描述的函数封装(只是提高可读性,没有实际功能意义),声明了apiDesc的调用的请求的post与res结果数据,将被内部缓存,供save2Doc使用保存输出 - ApiDesc API方法描述的函数封装,只是提高在真实入参列表中的可读性,没有实际功能意义
- save2Doc 保存本次mocha用例中所有提供了ApiDesc参数的API请求的post入参与res返回结果
//testhelper的最小化案例
import {ApiDesc, WebInvokeHepler, setApiRoot, save2Doc} from 'class2api/testhelper'
let _run = {
accounts: {
user1: {
token: 'token-111'
}
}
};
//通过setApiRoot,除了本地开发环境,还可以调用测试环境、正式环境的接口,这会带来极大的线上排查与校验的便利性
const remote_api = process.env.ONLINE==='1'? `https://comment_api_test.gankao.com`
:(process.env.ONLINE==='2'? `https://comment_api.gankao.com`
:`http://127.0.0.1:3002`);
//配置远程请求endpoint
setApiRoot(remote_api);
describe('接口服务', function () {
after(function () {
save2Doc({save2File:'api.MD'})
});
it('/a/hello', async () => {
let response = await WebInvokeHepler(_run.accounts.user1)('/a/hello',
{name: "haungyong"},
ApiDesc(`hello测试方法`)
)
let {err, result} = response
let {message} = result
message.lastIndexOf('haungyong').should.be.above(-1)
})
})
启动环境变量
- $ process.env.NO_API_CACHE=1 强制关闭静态类的API调用缓存,使cacheAble、clearCache失效
- $ process.env.SQL_PRINT=1 开启sequelize的SQL执行日志的打印
- $ process.env.PRINT_API_RESULT=1 开启API方法执行的结果日志打印
- $ process.env.StopOnAnyException=1 在发生任何Error时,暂停进程,包括Gankao自定义错误
- $ process.env.PRINT_API_CACHE=1 打印API缓存相关的调试信息
下一步TODO:
权限控制点信息的自动采集与上传
业务类的方法注释,文档自动化
5 months ago
5 months ago
8 months ago
8 months ago
8 months ago
8 months ago
8 months ago
1 year ago
1 year ago
1 year ago
1 year ago
1 year ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago
7 years ago