3.3.30 • Published 7 months ago

node-web-mvc v3.3.30

Weekly downloads
109
License
MIT
Repository
github
Last release
7 months ago

创建应用

npm create node-web-mvc@latest

spring风格

index.ts

import { SpringApplication, SpringBootApplication } from 'node-web-mvc';

@SpringBootApplication({
  // 代码热更新: 在该目录下的文件改动支持热更新(无需重启服务) 注意:在process.env.NODE_ENV === 'production'时强制无效
  hot: './test',
  // 启动时需要加载的模块目录, 在不配置时默认为 process.cwd()
  scanBasePackages: './test',
  // 配置服务端口相关
  // server: { port: 8080  }
})
export default class DemoApplication {
  static main() {
    SpringApplication.run(DemoApplication);
   // 启动后执行逻辑
  }
}

WebAppConfigurer.ts

import { WebMvcConfigurationSupport } from 'node-web-mvc';

@Configuration
export default class WebAppConfigurer extends WebMvcConfigurationSupport {

  // 这里可以扩展配置...

}

Controller 控制器

完成启动配置后,可以在控制器目录下定义对应的控制器

控制器的定义风格和Spring Mvc风格一致。

例如:

import { RestController, RequestMapping, GetMapping  } from 'node-web-mvc';

@RestController
@RequestMapping('/home')
class HomeController { 

  @GetMapping({ value:'/index',method:'GET' })
  index(){
    return 'Hi i am home index';
  }
}

更多的控制器配置,我们可以阅读后面通过注解来完善控制器。

Route 路由映射

@RequestMapping

该注解用于将请求映射到指定控制器。

有两种使用方式

简要模式

仅配置访问路径,例如: 以下例子中,仅配置 以 /home 来访问HomeController

@RequestMapping('/home')
class HomeController { 

}

详细配置

通过传入一个对象RequestMapping来进行详细映射。

以下例子通过@RequestMapping配置 允许在GET方式下通过/home/index路由来访问 HomeControllerindex函数

@RequestMapping('/home')
class HomeController { 

  @RequestMapping({ value:'/index',method:'GET' })
  index(){
    return 'Hi i am home index';
  }
}

在大多数情况下,我们只会配置路由请求类型 可以通过以下几个注解来进行快捷配置。

  • @GetMapping 映射一个methodGET的请求
@RequestMapping('/home')
class HomeController { 

  @GetMapping('/index')
  index(){
    return 'Hi i am home index';
  }
}
  • @PostMapping 映射一个methodPOST的请求

  • @PutMapping 映射一个methodPUT的请求

  • @DeleteMapping 映射一个methodDELETE的请求

  • @PatchMapping 映射一个methodPATCH的请求

路由风格

通过 @RequestMapping 等注解配置路由时,可以有以下几种配置风格

  • 普通路由
@GetMapping('/detail/index')
  • 参数占位类型

使用 {} 来标识占位

通过占位映射的路由参数,可以通过@PathVariable 注解来提取

@GetMapping('/detail/{id}')

正则风格路由

@GetMapping('/route/{}')

Arguments 参数提取

我们可以通过以下几个注解来定义请求参数的提取方式。

  • @RequestParam 提取类型为urleoncoded的参数

  • @RequestBody 提取整个body内容,通常是提取成为一个json对象

  • @PathVariable 提取路由中的占位参数

  • @RequestHeader 提取请求头中的指定名的请求头做为参数

  • @ServletRequest 用于提取request 对象

  • @ServletResponse 用于提取response 对象

RequestParam

urleoncoded的内容中提取指定名称的参数

@RequestParam 作为参数注解,不进行任何配置,默认会以参数名来作为提取名依据

@RequestMapping('/home')
class HomeController { 

  @GetMapping('/index')
  index(@RequestParam name){
    return `Hi ${name}, i am home index`;
  }
}

同时@RequestParam 也可以进行详细配置ParamAnnotation

例如: 将url中传递过来的userName值提取给index中的 name参数

@RequestMapping('/home')
class HomeController { 

  @GetMapping('/index')
  index(@RequestParam({ value:'userName', required:true }) name){
    return `Hi ${name}, i am home index`;
  }
}

文件上传参数提取

import { MultipartFile } from 'node-web-mvc';

@Api({ description:'上传' })
@RequestMapping('/upload')
class HomeController { 

  // 单个文件上传
  @ApiOperation({ value: '上传文件', notes: '上传证书文件' })
  // 配置swagger 生成上传表单
  @ApiImplicitParams([
    { name: 'files', value: '证书', required: true, dataType: 'file' },
    { name: 'id', value: '用户id', required: true },
  ])
  @PostMapping({ value: '/file', produces: 'application/json' })
  async index(@RequestParam file: MultipartFile,@RequestParam id){
    // 保存文件
    await file.transferTo('appqdata/images/' + file.name);

    return {
      code:0,
      message:'上传成功'
    }
  }

  // 多个文件上传
  @PostMapping({ value: '/files', produces: 'application/json' })
  async index(@RequestParam files: Array<MultipartFile>){
    // 保存文件
    for (let file of files) {
      await file.transferTo('appdata/images/' + file.name)
    }

    return {
      code:0,
      message:'上传成功'
    }
  }
}

RequestBody

提取整个body内容,通常是提取成为一个json对象

@RequestMapping('/order')
class OrderController { 

  @GetMapping('/save')
  saveOrder(@RequestBody order){
    console.log(order);
  }
}

PathVariable

从请求路由中提取路径参数

@RequestMapping('/order')
class OrderController { 

  @GetMapping('/detail/:id')
  detail(@PathVariable id){
    return `Order ${id}`;
  }
}

RequestHeader

从请求头中提取参数

@RequestMapping('/home')
class HomeController { 

  @GetMapping('/index')
  detail(@RequestHeader('content-type') ct){
    return `content-type: ${ct}`;
  }
}

ServletRequest

提取request整个对象。

@RequestMapping('/home')
class HomeController { 

  @GetMapping('/index')
  detail(@ServletRequest request){
    
  }
}

ServletResponse

提取response整个对象。

@RequestMapping('/home')
class HomeController { 

  @GetMapping('/index')
  detail(@ServletResponse response){
    
  }
}

Responsee 返回内容

在控制器具体函数中,我们可以返回以下几种类型来将内容返回到客户端。

  • ModelAndView 返回一个视图

  • String 返回一个字符串

  • Object 如果需要正常返回,需要通过RequestMapping指定produces为application/json

  • Promise 返回一个异步结果

  • Middlewares 返回一个类express的中间件执行结果

import { RequestMapping, GetMapping, Middlewares } from 'node-web-mvc';

@RequestMapping('/home')
class HomeController {

  @GetMapping('/index')
  index(){
    return new Middlewares([
      (req,resp,next)=> next()
    ])
  }
}
@RequestMapping('/home')
class HomeController { 

  @GetMapping('/index')
  index(){
    return new ModelAndView('home/index');
  }

  @GetMapping('/string')
  strings(){
    return `output :String`;
  }

  @GetMapping({ value: '/object', produces:'application/json' })
  list(){
    return [
      { name:'张三',id:100 }
    ];
  }
}

异常处理

框架可以通过以下两个注解来进行控制器异常处理

  • ExceptionHandler

  • ControllerAdvice

ExceptionHandler

如果将ExceptionHandler标注在控制器的函数上,则表示当前控制器的函数执行异常时,会使用当前标注的函数来进行异常处理。

import { GetMapping, RequestMapping, ExceptionHandler } from 'node-web-mvc';

@RequestMapping('/home')
export default class HomeController {

  @GetMapping('/index')
  index(){
    throw new Error('error');
  }

  @ExceptionHandler
  handleException(error){
    // 返回一个 json 异常对象
    return { code:error.code,message:error.message };
  }
}

ControllerAdvice

利用ControllerAdvice 来进行全局异常控制

定义一个异常处理类,然后使用ControllerAdvice标注当前类为全局控制器处理,

最后在该类上定义一个异常处理函数,然后通过ExceptionHandler标注成异常处理函数。

例如:

AppException.ts

@ControllerAdvice
class AppException {

  @ExceptionHandler
  handleException(error){
    // 返回一个 json 异常对象
    return { code:error.code,message:error.message };
  }
}

Resource 静态资源

框架也提供了静态资源服务,以及针对静态资源设定缓存策略等,同时也支持gzip压缩处理。

import { Registry } from 'node-web-mvc';

// 启动Mvc  
Registry.launch({
  resource:{
    gzipped:true,// 默认不开启gzip
    // 默认可不填写,默认值为: 
    // application/javascript,text/css,application/json,application/xml,text/html,text/xml,text/plain
    mimeTypes:'text/css,text/html', 
  },
  addResourceHandlers(registry){
    registry
      .addResourceHandler('/swagger-ui/**')
      .addResourceLocations('/a/b/swagger-ui/')
      .setCacheControl({ maxAge:0 })
      // .addResolver(new CustomResolver())
  }
});

自定义ResourceResolver

...

View 视图

框架默认不具备视图渲染功能,不过我们可以自定义视图解析器来支持渲染像ejs ,handlebars等类型的视图。

第一步 实现一个ejs 视图(View)

./EjsView.ts

/**
 * @module EjsView
 * @description Razor视图
 */
import ejs from 'ejs';
import { View } from 'node-web-mvc';

export default class EjsView extends View {

  /**
   * 进行视图渲染
   * @param model 当前视图的内容
   * @param request 当前视图
   * @param response 
   */
  render(model, request, response) {
    return ejs.renderFile(this.url, model).then((html) => {
      response.setHeader('Content-Type', 'text/html');
      response.setStatus(200).end(html, 'utf8');
    })
  }
}

第二步 实现一个ejs视图解析器

EjsViewResolver.ts

通过重写UrlBasedViewResolverinternalResolve 来解析ejs的视图

import fs from 'fs';
import path from 'path';
import { UrlBasedViewResolver,HttpServletRequest,View } from 'node-web-mvc'
import EjsView from './EjsView';

export default class EjsViewResolver extends UrlBasedViewResolver {

  internalResolve(viewName: string, model: any, request: HttpServletRequest): View {
    const file = path.resolve(viewName);
    if (fs.existsSync(file)) {
      return new EjsView(viewName);
    }
    return null;
  }
}

第三步 注册ejs视图解析器

启动时通过addViewResolvers配置来注册视图解析器。

import { Registry } from 'node-web-mvc';

// 启动Mvc  
Registry.launch({
  // ... 其他配置
  // 通过配置,来注册ejs视图解析器s
  addViewResolvers(registry) {
    // 注册ejs视图解析器
    registry.addViewResolver(new EjsViewResolver('test/WEB-INF/', '.ejs'))
  }
});

Interceptor 拦截器

框架同时也内置了拦截器,我们可以通过自定义拦截器来完成一些请求的前置,以及后置处理。

自定义权限校验拦截器

第一步

通过继承于HandlerInterceptorAdapter来实现一个拦截器

AuthorizationInterceptor.ts

import { HandlerInterceptorAdapter } from 'node-web-mvc';

export default class AuthorizationInterceptor extends HandlerInterceptorAdapter {
 /**
   * 在处理action前,进行请求预处理
   * @param { HttpRequest } request 当前请求对象
   * @param { HttpResponse } response 当前响应对象
   * @param { ControllerContext } handler  当前拦截待执行的函数相关信息
   * @returns { boolean }
   *   返回值:true表示继续流程(如调用下一个拦截器或处理器);false表示流程中断(如登录检查失败),不会继续调用其他的拦截器或处理器,此时我们需要通过response来产生响应;
   */
  preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: HandlerMethod): boolean {
    // 假设我们添加了一个UserLogin注解
    const annotation = handler.getAnnotation(UserLogin);
    if (annotation) {
      const nativeAnnotation = annotation.nativeAnnotation;
      // 进行权限校验
    }
    return true;
  }

  /**
   * 在处理完action后的拦截函数,可对执行完的接口进行处理
   * @param { HttpRequest } request 当前请求对象
   * @param { HttpResponse } response 当前响应对象
   * @param { ControllerContext } handler  当前拦截待执行的函数相关信息
   * @param { any } result 执行action返回的结果
   */
  postHandle(request: HttpServletRequest, response: HttpServletResponse, handler: HandlerMethod, result): void {
  }

  /**
   * 在请求结束后的拦截器 (无论成功还是失败都会执行此拦截函数)
   * (这里可以用于进行资源清理之类的工作)
   * @param { HttpRequest } request 当前请求对象
   * @param { HttpResponse } response 当前响应对象
   * @param { ControllerContext } handler  当前拦截待执行的函数相关信息
   * @param { any } ex 如果执行action出现异常时,此参数会有值
   */
  afterCompletion(request: HttpServletRequest, response: HttpServletResponse, handler: HandlerMethod, ex): void {
  }
}

第二步

启动时通过addInterceptors配置来注册拦截器。

import { Registry } from 'node-web-mvc';
import AuthorizationInterceptor from './interceptors/AuthorizationInterceptor';

// 启动Mvc  
Registry.launch({
  // ... 其他配置
  // 通过配置来注册拦截器
  addInterceptors(registry) {
    registry.addInterceptors(new AuthorizationInterceptor())

    // registry
    //   .addInterceptors(new AuthorizationInterceptor())
    //   .excludePathPatterns('/root/a','/root/b')
    //   .addPathPatterns('/root')
  }
});

HttpMessageConverter 内容转换

框架内置了以下几种类型的请求内容转换

  • JsonMessageConverter 将application/json的http.body正文转换成json对象.

  • UrlencodedMessageConverter 用于转换类型为application/x-www-form-urlencoded的请求内容。

  • MultipartMessageConverter 用于转换类型为multipart/form-data的请求内容

如果您需要处理其他类型的请求内容,可以自定义一个转换器

自定义Http转换器

第一步

通过实现HttpMessageConverter接口来实现一个转换器

XmlHttpMessageConverter.ts

import { MediaType, ServletContext, HttpMessageConverter,RequestMemoryStream } from 'node-web-mvc';
import xml2js from 'xml2js';

export default class XmlHttpMessageConverter implements HttpMessageConverter {
  /**
   * 判断当前转换器是否能处理当前内容类型
   * @param mediaType 当前内容类型 例如: application/xml
   */
  canRead(mediaType: MediaType): boolean {
    return mediaType.name === 'application/xml';
  }

  /**
   * 判断当前内容是否能写
   * @param mediaType 当前内容类型 例如: application/xml
   */
  canWrite(mediaType: MediaType): boolean {
    return mediaType.name === 'application/xml';
  }

  // getSupportedMediaTypes(): Array<string>

  /**
   * 读取当前消息内容
   * @param servletRequest
   */
  read(servletContext: ServletContext, mediaType: MediaType): any {
    return new Promise((resolve, reject) => {
      new RequestMemoryStream(servletContext.request, (buffers) => {
        xml2js.parseString(buffers.toString('utf8'), (err, data) => {
          err ? reject(err) : resolve(data);
        });
      });
    })
  }

  /**
   * 写出当前内容
   * @param data 当前数据
   * @param mediaType 当前内容类型
   * @param servletContext 当前请求上下文
   */
  write(data: any, mediaType: MediaType, servletContext: ServletContext) {
    return new Promise((resolve) => {
      const builder = new xml2js.Builder();
      const xml = builder.buildObject(data);
      servletContext.response.write(xml, resolve);
    })
  }
}

第二步

通过addMessageConvertersXmlHttpMessageConverter进行注册。

Launch.ts

import { Registry } from 'node-web-mvc';
import XmlHttpMessageConverter from './interceptors/XmlHttpMessageConverter';

// 启动Mvc  
Registry.launch({
  // ... 其他配置
  // 注册XmlHttpMessageConverter
  addMessageConverters(converters) {
    converters.addMessageConverters(new XmlHttpMessageConverter());
  }
});

第三步

这样就可以在控制器中使用了

DataController.ts

import { RequestMapping, PostMapping, RequestBody } from 'node-web-mvc';

@RequestMapping('/data')
export default class DataController {

  // 这里:同时测试 读取xml 以及返回xml
  @PostMapping({ value: '/receieve', consumes: 'application/xml', produces: 'application/xml' })
  receieve(@RequestBody data){
    console.log('xml data',data);
    return data;
  }
}

ArgumentResolver 参数解析

框架内置了以下几种类型的请求参数解析

  • @RequestParam 提取类型为urleoncoded的参数

  • @RequestBody 提取整个body内容,通常是提取成为一个json对象

  • @PathVariable 提取路由中的占位参数

  • @RequestHeader 提取请求头中的指定名的请求头做为参数

  • @ServletRequest 用于提取request 对象

  • @ServletResponse 用于提取response 对象

如果您需要处理其他类型的请求内容,可以自定义一个参数解析器

自定义参数解析器

例如,以下实现通过 UserId 注解来提取当前登录用户id。

第一步

定义一个UserId注解

UserId.ts

import { Target, ElementType } from 'node-web-mvc';

class UserId {
  constructor(){
    // 注解构造函数
  }
}

// 公布注解
export default Target(ElementType.PARAMETER)(UserId);

第二步

通过实现HandlerMethodArgumentResolver接口来实现一个解析器

UserIdArgumentResolver.ts

import { ServletContext,MethodParameter, HandlerMethodArgumentResolver } from 'node-web-mvc';
import UserIdAnnotation from './UserIdAnnotation';

export default class UserIdArgumentResolver implements HandlerMethodArgumentResolver {

  supportsParameter(paramater: MethodParameter, servletContext: ServletContext) {
    return paramater.hasParameterAnnotation(UserIdAnnotation)
  }

  resolveArgument(parameter: MethodParameter, servletContext: ServletContext): any {
    const cookies = servletContext.request.cookies;
    const token = cookies.token;
    // 从token中解析出用户id
    return TokenService.decode(token).userId;
  }
}

第三步

通过addArgumentResolversPathVariableMapMethodArgumentResolver进行注册。

import { Registry } from 'node-web-mvc';
import UserIdArgumentResolver from './UserIdArgumentResolver';

// 启动Mvc  
Registry.launch({
  // ... 其他配置
  // 注册
  addArgumentResolvers(resolvers) {
    resolvers.addArgumentResolvers(new UserIdArgumentResolver());
  }
});

第四步

这样就可以在控制器中使用了

import { RequestMapping, PostMapping } from 'node-web-mvc';
import UserId from './UserId';

@RequestMapping('/data')
export default class DataController {

  @PostMapping('/home')
  receieve(@UserId id){
    console.log('id',id);
  }
}

热更新

在启动时,可通过配置hot配置启用热更新服务,

在热更新服务下,控制器代码以及及依赖模块改动,无需重启服务器。

hot.preload

在修改一个文件时,会触发热更,在执行热更新前,会触发preload,如果您希望 您的某个依赖模块需要进行特定处理,则可以再该文件中订阅hot.preload

例如: ControllerFactory.ts 再一些控制器模块修改时,需要进行一些前置处理

import { hot } from 'node-web-mvc';

// 订阅preload
hot.create(module).preload((old) => {
  // old 为当前即将进行热更新的模块旧模块,此时可以根据old来进行一些清理操作
})

hot.accept

在模块热更新后,同此此函数来接受更新

import { hot } from 'node-web-mvc';

// 订阅preload
hot.create(module).preload((new,old) => {
  // new 为当前热更新后的新模块对象
  // old 为热更新前的模块对象
})

Swagger

框架支持swagger文档生成功能

可通过以下注解来完成文档元数据定义

  • @Api 定义一个接口服务
@Api({ description: '首页控制器' })
class HomeConntroller {

}
  • @ApiOperation 定义一个接口操作
@Api({ description: '首页控制器' })
class HomeConntroller {

  @ApiOperation({ value: '首页列表数据', notes: '这是备注' })
  index(){
  }
}
  • @ApiImplicitParams 定义接口操作参数信息

如果不需要配置参数详细设定,一般可以不使用ApiImplicitParams 因为框架会自动根据每个参数的提取类型来自动生成swagger参数配置。

@Api({ description: '首页控制器' })
class HomeConntroller {

  @ApiOperation({ value: '首页列表数据', notes: '这是备注',returnType:'返回数据类型' })
  @GetMapping('/index')
  index(){
  }

  @ApiOperation({ value: '上传文件', notes: '上传证书文件' })
  @ApiImplicitParams([
    { value: 'file', desc: '证书', required: true, dataType: MultipartFile },
    { value: 'desc', desc: '描述', required: true, paramType: 'formData' },
    { value: 'id', desc: '用户id', required: true }
  ])
  @PostMapping('/upload')
  upload(file: MultipartFile,@RequestParam desc,@RequestParam id) {
    return file.transferTo('appdata/images/' + file.name);
  }
}
  • @ApiModel 定义一个实体类
@ApiModel({ value: '用户信息', description: '用户信息。。' })
export default class UserInfo {

}
  • @ApiModelProperty 定义实体类属性
@ApiModel({ value: '用户信息', description: '用户信息。。' })
export default class UserInfo {

  @ApiModelProperty({ value: '用户名', required: true, example: '张三' })
  public userName: string


  @ApiModelProperty({ value: '用户编码', required: true, example: 1 })
  public userId: number
}
3.3.30

7 months ago

3.3.27

8 months ago

3.3.28

8 months ago

3.3.29

8 months ago

3.3.9

1 year ago

3.3.8

1 year ago

3.3.7

1 year ago

3.3.6

1 year ago

3.3.13

1 year ago

3.3.14

1 year ago

3.3.15

1 year ago

3.3.16

1 year ago

3.3.17

1 year ago

3.3.19

1 year ago

3.3.10

1 year ago

3.3.11

1 year ago

3.3.12

1 year ago

3.3.24

12 months ago

3.3.25

12 months ago

3.3.26

11 months ago

3.3.5

1 year ago

3.3.20

1 year ago

3.3.21

1 year ago

3.3.22

1 year ago

3.3.23

12 months ago

3.2.2

1 year ago

3.2.1

1 year ago

3.2.0

1 year ago

3.2.6

1 year ago

3.2.4

1 year ago

3.2.3

1 year ago

3.2.9

1 year ago

3.2.8

1 year ago

3.2.7

1 year ago

3.1.3

1 year ago

3.1.2

1 year ago

3.1.1

1 year ago

3.1.0

1 year ago

3.1.7

1 year ago

3.1.6

1 year ago

3.1.5

1 year ago

3.1.4

1 year ago

3.1.11

1 year ago

3.1.10

1 year ago

3.1.9

1 year ago

3.1.8

1 year ago

3.0.1

1 year ago

3.0.0

1 year ago

3.3.1

1 year ago

3.3.0

1 year ago

3.3.3

1 year ago

3.3.2

1 year ago

2.20.2

2 years ago

2.20.0

2 years ago

2.20.1

2 years ago

2.19.1

2 years ago

2.19.0

2 years ago

2.18.1

2 years ago

2.18.0

2 years ago

2.16.3

2 years ago

2.16.2

2 years ago

2.15.0

3 years ago

2.14.3

3 years ago

2.14.4

3 years ago

2.14.1

3 years ago

2.14.2

3 years ago

2.14.0

3 years ago

2.17.0

3 years ago

2.16.1

3 years ago

2.16.0

3 years ago

2.13.8

3 years ago

2.13.9

3 years ago

2.13.7

4 years ago

2.13.6

4 years ago

2.13.5

4 years ago

2.13.4

4 years ago

2.13.3

4 years ago

2.12.3

5 years ago

2.12.1

5 years ago

2.12.2

5 years ago

2.10.1

5 years ago

2.10.0

5 years ago

2.9.3

5 years ago

2.9.2

5 years ago

2.8.31

5 years ago

2.8.30

5 years ago

2.8.29

5 years ago

2.8.28

5 years ago

2.8.27

5 years ago

2.8.26

5 years ago

2.8.25

5 years ago

2.8.23

5 years ago

2.8.22

5 years ago

2.8.24

5 years ago

2.8.21

5 years ago

2.8.19

5 years ago

2.8.18

5 years ago

2.8.20

5 years ago

2.8.17

5 years ago

2.8.16

5 years ago

2.8.15

5 years ago

2.8.14

5 years ago

2.8.13

5 years ago

2.8.12

5 years ago

2.8.11

5 years ago

2.8.9

5 years ago

2.8.8

5 years ago

2.8.6

5 years ago

2.8.5

5 years ago

2.8.4

5 years ago

2.8.3

5 years ago

2.8.2

5 years ago

2.8.1

5 years ago

2.8.0

5 years ago

2.7.3

5 years ago

2.7.2

5 years ago

2.7.1

5 years ago

2.7.0

5 years ago

2.6.7

5 years ago

2.6.8

5 years ago

2.6.6

5 years ago

2.6.5

5 years ago

2.6.4

5 years ago

2.6.3

5 years ago

2.6.2

5 years ago

2.6.1

5 years ago

2.6.0

5 years ago

2.5.0

5 years ago

2.4.3

5 years ago

2.4.4

5 years ago

2.4.2

5 years ago

2.4.1

5 years ago

2.4.0

5 years ago

2.3.2

5 years ago

2.3.0

5 years ago

2.3.1

5 years ago

2.2.28

5 years ago

2.2.29

5 years ago

2.2.26

5 years ago

2.2.27

5 years ago

2.2.25

5 years ago

2.2.23

5 years ago

2.2.30

5 years ago

2.2.18

5 years ago

2.2.19

5 years ago

2.2.22

5 years ago

2.2.20

5 years ago

2.2.21

5 years ago

2.2.17

5 years ago

2.2.15

5 years ago

2.2.16

5 years ago

2.2.14

5 years ago

2.2.13

5 years ago

2.2.12

5 years ago

2.2.11

5 years ago

2.2.3

5 years ago

2.2.4

5 years ago

2.2.7

5 years ago

2.2.6

5 years ago

2.2.10

5 years ago

2.2.9

5 years ago

2.2.8

5 years ago

2.2.1

5 years ago

2.2.0

5 years ago

2.2.2

5 years ago

2.1.2

5 years ago

2.1.0

5 years ago

2.0.9

5 years ago

2.0.8

5 years ago

2.0.7

5 years ago

2.0.6

5 years ago

2.0.5

5 years ago

2.0.4

5 years ago

2.0.3

5 years ago

2.0.2

5 years ago

2.0.1

5 years ago

2.0.0

5 years ago

1.0.5

6 years ago

1.0.4

6 years ago

1.0.3

6 years ago

1.0.2

6 years ago

1.0.1

6 years ago

1.0.0

6 years ago