koa2源码解析

Koa源码解析

整体架构

核心文件只有4个,在lib文件夹下:

  1. application.js koa框架的入口文件
  2. context.js 创建网络请求的上下文对象
  3. request.js 用于包装koa的request对象
  4. response.js 用于包装koa的response对象

简单demo

const koa = require('koa');
const app = new koa();
// application文件中有一个use方法,其接收的参数是一个fn
app.use(async (ctx,next) => {
    ctx.body = 'hi';
})
app.listen(3000,()=>{

})

application.js源码解析

生成 application 对象

constructor() {
    super();    // 因为继承于 EventEmitter,这里需要调用 super

    // 代理设置,为true时表示获取真正的客户端ip地址
    this.proxy = false; 
    // 数组,用于存储所有的中间件函数的,所有的app.use()中调用的fn(中间件)都会被push进去
    this.middleware = [];  
    // 子域名偏移设置 
    this.subdomainOffset = 2;   
    this.env = process.env.NODE_ENV || 'development';   // 环境变量
    // 声明koa的几个核心对象
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
    if (util.inspect.custom) {
      this[util.inspect.custom] = this.inspect;
    }
}

上述代码中额外讲解下 subdomainOffset 配置:
该属性会改变获取 subdomain 时返回数组的值,
比如: test.page.example.com 域名,如果设置subdomainOffset为2,那么返回的数组值为 ["page","test"], 如果为3时,返回的数组是 ["test"]

use 与中间件
源码:

use(fn) {
    // 传入的不是function报错
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    // 这一步是对koa1.x的兼容,判断当前函数是否是 generator函数,如果是就转化(将generator函数包装成Promise),如果不是则不转化。
    if (isGeneratorFunction(fn)) {
      deprecate('Support for generators will be removed in v3. ' +
                'See the documentation for examples of how to convert old middleware ' +
                'https://github.com/koajs/koa/blob/master/docs/migration.md');
      fn = convert(fn);
    }
    debug('use %s', fn._name || fn.name || '-');
    // 转化or不转化之后将该中间件函数 push 进实例对象的 middleware 数组中
    this.middleware.push(fn);
    return this;
}

上述use的功能主要是判断参数是否为function,再进行generator函数转化包装成Promise,用于兼容koa1.x,最后将包装好的fn(传入的参数)push进实例的中间件列表中。
所以,所谓koa的中间件函数的串联其实就是通过数组来逐个执行的。

listen原理
源码解析:

listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
}

listen很简单,就是用node中的http模块起吊一个http的服务。这里面的 callback是重点。

核心的 callback 函数

callback() {
    // 使用koa-compose建立中间件机制
    const fn = compose(this.middleware);
    // 如果没有对 error 时间进行监听,那么绑定 error 事件监听处理
    if (!this.listenerCount('error')) this.on('error', this.onerror);
    // handleRequest相当于是 上面listen中的http.createServer 的回调函数。
    // 有req和res两个参数,代表原生的 request,response对象
    const handleRequest = (req, res) => {
        // 每次接受一个新的请求就是生成一次全新的 context
        // 创建一个新的 context 对象,建立koa中context、request、response属性之间和原生http对象的关系
        // 然后将创建的ctx对象带入中间件函数们中执行
        const ctx = this.createContext(req, res);
        return this.handleRequest(ctx, fn);
    };
    return handleRequest;
}
/**
* Handle request in callback.
*
* @api private
*/
handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);    // 错误处理
    const handleResponse = () => respond(ctx);  // 响应处理
    // 为res 对象添加错误处理响应,当res响应结束时,执行context中的onerror函数
    // 这里需要注意区分 context 与 koa 实例中的onerror
    onFinished(res, onerror);
    // 执行中间件数组中的所有函数,并结束时调用上面的respond函数
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

koa-compose如何解析
koa-compose模块可以将多个中间件函数合并成一个大的中间件函数,然后调用这个中间件函数就可以依次执行添加的中间件函数,执行一系列的任务。

const compose = require('koa-compose');

function one(ctx,next){
  console.log('第一个');
  next();
}
function two(ctx,next){
  console.log('第2个');
  next();
}
function three(ctx,next){
  console.log('第3个');
  next();
}
// 就是将koa的middlewares数组传入依次执行
const middlewares = compose([one, two, three]);
// 执行的中间件函数,函数执行后返回的是Promise对象
middlewares().then(()=>{
  console.log('队列执行完毕');
})

看下compose的源码:

module.exports = compose
function compose (middleware) {
  // compose接收的middleware是一个又中间件函数组成的数组,所以先检测是否为数组,不是则报错
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  // 然后检测middleware中的项是否都为function,如果不是则报错
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  // 最后返回一个匿名函数
  return function (context, next) {
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      let fn = middleware[i]
      // 执行到最后一个中间件之后,执行next,中间件都执行完毕之后fn置为undefined
      if (i === middleware.length) fn = next
      // 但是next是undefined,所以此时返回Promise.resolve()
      if (!fn) return Promise.resolve()
      try {
        // 使用递归的方式返回当前传入ctx的参数,并在中间件执行完毕之后执行的next(),next()其实就是调用自己实现递归。
        return Promise.resolve(fn(context, function next () {
          return dispatch(i + 1)
        }))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

所以中间件函数在compose中是不会处理任何context的相关操作的。其只是实现一个递归去执行众多的中间件函数而已。但是难点在调用next()上。
看一个demo:

const koa = require('koa');
const app = new koa();
app.use((ctx, next) => {
  console.log('第一个中间件函数')
  next();
  console.log('第一个中间件函数next之后');
})
app.use(async (ctx, next) => {
  console.log('第二个中间件函数')
  next();
  console.log('第二个中间件函数next之后');
})
app.use(ctx => {
  console.log('响应');
  ctx.body = 'hello'
})
app.listen(3000)

启动上述代码最后执行顺序如下:

第一个中间件函数
第二个中间件函数
响应
第二个中间件函数next之后
第一个中间件函数next之后

上述代码的执行过程是这样的:

  1. 执行第一个中间件函数,打印出“第一个中间件函数”
  2. 调用了next,不再继续向下执行
  3. 执行第二个中间件函数,打印出“第二个中间件函数”
  4. 调用了next,不再向下继续执行
    ...
  5. 最后一个中间件执行后,上一个中间件函数收回控制权,继续执行,打印出“第二个中间件函数next之后”
  6. 倒数第二个中间件执行后,上一个中间件函数收回控制权,继续执行,打印出“第一个中间件函数next之后”

看一张图片来说明:


compose.png

这个也说明了koa的洋葱模型~~

看了compose的源码解析也正好能解释了为什么app.use中间件时候的写法

app.use((ctx,next)=>{
  console.log(111);
  next();
  console.log(2);
})

这里面的next可以表示接着往下执行,也可以表示执行下一个中间件。

this.createContext
其作用就是生成一个新的context对象,并建立 koa 中context、request、response属性之间和原生http对象的关系。而handleRequest函数只是负责执行。

createContext(req, res) {
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
}

图表示一下:


applicaton.png

图型解释:
koa通过创建context将node原生的req和res对象都集中到了一起。

respond函数
该函数在中间件执行之后被执行。

function respond(ctx) {
  // allow bypassing koa
  if (false === ctx.respond) return;

  const res = ctx.res;
  // writable 是原生的node的response对象上的 writable 属性,其作用是用于检查是否是可写流
  if (!ctx.writable) return;

  let body = ctx.body;
  const code = ctx.status;

  // ignore body
  // statuses是一个模块方法,用于判断响应的 statusCode是否属于body为空的类型。
  // 例如:204,205,304,此时将body置为null
  if (statuses.empty[code]) {
    // strip headers
    ctx.body = null;
    return res.end();
  }
  // 如果是HEAD方法
  if ('HEAD' == ctx.method) {
    // headersSent 属性是Node原生的 response对象上的,用于检查 http 响应头不是否已经被发送
    // 如果没有被发送,那么添加 length 头部
    if (!res.headersSent && isJSON(body)) {
      ctx.length = Buffer.byteLength(JSON.stringify(body));
    }
    return res.end();
  }

  // status body
  // 如果body为null时
  if (null == body) {
    // httpVersionMajor是node原生对象response上的一个属性,用于返回当前http的版本,这里是对http2版本以上做的一个兼容
    if (ctx.req.httpVersionMajor >= 2) {
      body = String(code);
    } else {
      body = ctx.message || String(code);
    }
    // headersSent也是原生属性,为ture表示响应头已经被发送
    // 如果响应报文头还没有被发送出去,就为ctx添加一个length属性,length属性记录这当前报文主体body的字节长度
    if (!res.headersSent) {
      ctx.type = 'text';
      ctx.length = Buffer.byteLength(body);
    }
    return res.end(body);
  }

  // responses
  // 对 body 为Buffer类型的进行处理
  if (Buffer.isBuffer(body)) return res.end(body);
  // 对 body 为字符串类型的进行处理
  if ('string' == typeof body) return res.end(body);
  // 对 body 为流类型的进行处理,是流的话合并
  if (body instanceof Stream) return body.pipe(res);

  // body: json
  // 最后将为Json格式的body进行字符串处理,将其转化成字符串
  // 同时添加length头部信息
  body = JSON.stringify(body);
  if (!res.headersSent) {
    ctx.length = Buffer.byteLength(body);
  }
  // 最后将转化成字符串的body吐出去
  res.end(body);
}

respond函数中,主要是运用 node http模块中的响应对象中的end方法与koa context对象中代理的属性进行最终响应对象的设置。

至此application.js文件分析完毕。好像挺简单的样子。

request.js

request.js主要是对原生的 http 模块的request 对象进行封装,其实就是对原生request对象的某些属性和方法通过重新getter、setter函数进行代理。

reuqest.png

referrer
这个属性用于返回当前request的来源url。
源码是这样的

get(field) {
  const req = this.req;
  switch (field = field.toLowerCase()) {
    case 'referer':
    case 'referrer':
      return req.headers.referrer || req.headers.referer || '';
    default:
      return req.headers[field] || '';
  }
}

表示当request的header中没有referrer或referer属性的之后就返回空字符串。这个不是koa特别设置的。而是真实会有referrer为空的情况。

  • 修改Location对象进行页面导航
    window.location.hostname = 'baidu.com';
    
    在IE下会丢失Referrer
  • window.open方式打开新窗口
    <a href="#" onclick="window.open('http://baidu.com')">百度</a>
    
  • 鼠标拖拽打开新窗口
  • 点击Flash内部链接
  • HTTPS跳转到HTTP(同一网址)

referrer丢失,Web Analytics就会丢失很重要的一部分信息了,特别对于广告流量来说,就无法知道实际来源了。
所以不要下次说referrer为空是koa某些时候特定造成的了。

内容协商
request中有很多accept相关的方法。

response.js

response和request.js一样,也是对http模块中的response对象进行封装,通过对response对象的某些属性和方法通过重写 getter/setter 函数进行代理


koaresponse.png

Content-disposition
在 response.js中的attachment方法中,其对HTTP头部的 Content-dispostion字段进行了处理。
Content-dispostion是用于说明这个返回的信息是以什么形式展示的,例如如果值是inline,那么就是以网页的一部分或者整个页面展示,如果是 attachment的话,就是以下载附件的形式展示;

attachment(filename) {
    if (filename) this.type = extname(filename);
    this.set('Content-Disposition', contentDisposition(filename));
}
Content-Disposition: inline; // 网页一部分或者整个网页展示
Content-Disposition: attachment; // 下载网页附件的形式
Content-Disposition: attachment, filename="xxx.ext"; // 还可以使用 filename 来指定文件名

缓存协商
在koa中的request中使用 fresh 字段来判断这个请求需要的内容是否是最新的,其原理也就是我们熟知的 http 缓存机制,内部通过 fresh 这个库来判断请求头中的 if-modify-sinceif-match-since 对于此响应头部中的 last-Modified字段与ETag字段。
当然, 在检查这两个字段之前, 还需要检查一下请求头部的 Cache-Control 头部, 如果 Cache-Control 头部是 no-cache, 那么就代表请求信息必须是最新的。

context.js

context的核心是通过 delegates这一个库,将requestresponse对象上的属性方法代理到context 对象上。

var delegate = require('delegates');
var proto = module.exports = {}; // 一些自身方法,被我删了

/**
 * Response delegation.
 */

delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('set')
  .method('append')
  .access('status')
  .access('message')
  .access('body')
  .access('length')
  .access('type')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable');

/**
 * Request delegation.
 */

delegate(proto, 'request')
  .method('acceptsLanguages')
  .method('acceptsEncodings')
  .method('acceptsCharsets')
  .method('accepts')
  .method('get')
  .method('is')
  .access('querystring')
  .access('idempotent')
  .access('socket')
  .access('search')
  .access('method')
  .access('query')
  .access('path')
  .access('url')
  .getter('origin')
  .getter('href')
  .getter('subdomains')
  .getter('protocol')
  .getter('host')
  .getter('hostname')
  .getter('header')
  .getter('headers')
  .getter('secure')
  .getter('stale')
  .getter('fresh')
  .getter('ips')
  .getter('ip');

这个看上去其实就是koa的API目录。
下面分析下delegates这个库的源码。

// proto其实就是context对象,target是模块的name,比如request和response
function Delegator(proto, target){
    // 避免this指向错误的兼容
    if(!(this instanceof Delegator)) return new Delegator(proto,target);
    this.proto = proto;
    this.target = target;
    this.methods = [];  // 所有的方法数组集
    this.getters = [];
    this.setters = [];
    this.fluents = [];
}
// 获取koa中所有的方法集
Delegator.prototype.method = function(name){
    // 拿到context对象和当前模块target
    var proto = this.proto;
    var target = this.target;
    this.methods.push(name);
    // 复制request和response中的所有方法
    proto[name] = function(){
        // this[target][name]相当于是request.accepts方法,此时该方法已经从request上委托到了delegator实例上
        return this[target][name].apply(this[target], arguments);
    }

    return this;
}
Delegator.prototype.getter = function(name){
  // this.proto 指向原型。也就是context对象
  var proto = this.proto;
  // target 是指 'response' 字符串
  var target = this.target;
  // 将 name 加入到 delegator 实例对象的 getters 数组中
  this.getters.push(name);
  // 调用原生的 __defineGetter__ 方法进行 getter 代理, 那么 proto[name] 就相当于 proto[target][name]
  // 而 context.response 就相当于 response 对象
  // 由此实现属性代理
  proto.__defineGetter__(name, function(){
    return this[target][name];
  });

  return this;
}
Delegator.prototype.access = function(name){
  return this.getter(name).setter(name);
};

错误处理

在koa中有两个错误处理:application.js中的onerror方法和context.js中的onerror方法。
application中的onerror方法:
绑定在koa实例对象上,其监听的是整个对象的error事件。

context中的onerror方法:
绑定在中间件函数数组生成的Promise的catch中与res对象的onFinished函数的回调的,其就是为了处理请求or响应中出现的error事件的。

application中的onerror

onerror(err) {
  // 判断err是否是 new Error过来的实例
  if (!(err instanceof Error)) throw new TypeError(util.format('non-error thrown: %j', err));
  // 忽略404错误
  if (404 == err.status || err.expose) return;
  // 如果有静默设置也忽略
  if (this.silent) return;
  // 打印错误,定位问题
  const msg = err.stack || err.toString();
  console.error();
  console.error(msg.replace(/^/gm, '  '));
  console.error();
}

context中的onerror

onerror(err) {
    // don't do anything if there is no error.
    // this allows you to pass `this.onerror`
    // to node-style callbacks.
    if (null == err) return;
    // 将错误转化为 Error 实例
    if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));

    let headerSent = false;
    if (this.headerSent || !this.writable) {
      headerSent = err.headerSent = true;
    }

    // delegate
    this.app.emit('error', err, this);

    // nothing we can do here other
    // than delegate to the app-level
    // handler and log.
    if (headerSent) {
      return;
    }

    const { res } = this;

    // first unset all headers
    /* istanbul ignore else */
    if (typeof res.getHeaderNames === 'function') {
      res.getHeaderNames().forEach(name => res.removeHeader(name));
    } else {
      res._headers = {}; // Node < 7.7
    }

    // then set those specified
    this.set(err.headers);

    // force text/plain
    this.type = 'text';

    // ENOENT support
    if ('ENOENT' == err.code) err.status = 404;

    // default to 500
    if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;

    // respond
    const code = statuses[err.status];
    const msg = err.expose ? err.message : code;
    this.status = err.status;
    this.length = Buffer.byteLength(msg);
    this.res.end(msg);
  }

其他

1. koa 中 proxy 属性真正用途是什么?
要知道, 我们在实际运用中, 可能会使用很多的代理服务器, 包括我们常见的正向代理与反向代理, 虽然代理的用处很大, 但是无法避免地我们有时需要知晓真正的客户端的请求 ip,
而其实实际上, 服务器并不知道真正的客户端请求 ip, 即使你使用 socket.remoteAddrss 属性来查看, 因为这个请求是代理服务器转发给服务器的, 幸好代理服务器例如 nginx 提供了一个
HTTP 头部来记录每次代理服务器的源 IP 地址, 也就是 X-Forwarded-For 头部.形式如下:

X-Forwarded-For: 192.168.210.13, 210.112.40.13, 43.56.210.10

如果一个请求跳转了很多代理服务器, 那么 X-Forwarded-For 头部的 ip 地址就会越多, 第一个就是原始的客户端请求 ip, 第二个就是第一个代理服务器 ip, 以此类推.
当然, X-Forwarded-For 并不完全可信, 因为中间的代理服务器可能会"使坏"更改某些 IP. 而 koa 中 proxy 属性的设置就是如果使用 true, 那么就是使用 X-Forwarded-For 头部的第一个
ip 地址, 如果使用 false, 则使用 server 中的 socket.remoteAddress 属性值.
除了 X-Forwarded-For 之外, proxy 还会影响 X-Forwarded-proto 的使用, 和 X-Forwarded-For 一样, X-Forwarded-proto 记录最开始的请求连接使用的协议类型(HTTP/HTTPS), 因为客户端与
服务端之间可能会存在很多层代理服务器, 而代理服务器与服务端之间可能只是使用 HTTP 协议, 并没有使用 HTTPS, 所以 proxy 属性为 true 的话, koa 的 protocol 属性会去取 X-Forwarded-proto 头部
的值(koa 中 protocol 属性会先使用 tlsSocket.encrypted 属性来判断是否是 https 协议, 如果是则直接返回 ‘https’).

关于co
既然已经浏览了koa2的源码,那就再花一点点时间了解一下koa的时候中间件机制是怎么完成的吧。
先看koa中context.js的关于中间件的部分源码

app.callback = function(){
  // this.experimental是用来判断是否开启ES7的,开启之后中间件就能使用async函数了
  // 这部分已经在koa2中实现了,另外Tj大神还做了个generator的兼容,并提示大家koa3版本不再兼容generator了
  if (this.experimental) {
      console.error('Experimental ES7 Async Function support is deprecated. Please look into Koa v2 as the middleware signature has changed.')
  }
  // 这是第一部分,用来绑定中间件的
  var fn = this.experimental 
      ? compose_es7(this.middleware) 
      : co.wrap(compose(this.middleware));
  var self = this;

  return function(req,res){
      res.statusCode = 404;
      var ctx = self.createContext(req,res);
      onFinished(res, ctx.onerror);
      fn.call(ctx).then(function(){
          respond.call(ctx);
      }).catch(ctx.onerror);
  }
}

代码逻辑和koa2中的差不多,重点是这个co.wrap(compose(this.middleware))

前面就说了compose是用来把一个个的koa中间件串联起来的

// generator组合而成的数组
this.middleware = [function *m1(){},function *m2(){},function *m3(){}];
var middleware = compose(this.middleware);
// 转换后得到的middleware其实是这样的
function *(){
  yield *m1(m2(m3(noop())));
}

ES6的generator函数的特性是,第一次执行并不会执行函数中的代码,而是生成一个generator对象,这个对象有nextthrow等方法。这样就是每一个中间件都会有一个参数,这个参数就是下一个中间件执行后,生成出来的generator对象,也就是next。这里的数据结构有点像是单链表。
再看下compose的源码

module.exports = compose;

function compose(middleware){
    return function *(next){
        // 执行第一个中间的时候指定next为noop函数
        if(!next) next = noop();
        var i = middleware.length;
        // 中间件从后往前一次执行
        while(i--){
            // 把每一个中间件执行后得到的generator对象赋值给next,当下一次执行中间件的时候(也就是执行前一个中间件的时候)把next传递给前一个中间件。这样就保证了前中间件的参数是后中间件生成的generator对象。
            // 而执行第一个中间的时候指定next为noop函数
            next = middleware[i].call(this,next);
        }
        // 最后返回第一个中间件的generator对象
        return yield *next;
    }
}

function *noop(){}

上述代码可以看出来,中间件数组经过compose之后会得到一个函数,执行这个函数就与执行第一个中间件的效果是一样的。同时中间件的状态就变成了不可用的中间件是一个数组,可用的中间件是一个generator函数。
一层层的执行下去,就像剥洋葱一样~~

分析完compose之后,那这个co又是干嘛的呢?
白话文说明一下就是:把异步变成同步的模块。

co.wrap = function(fn){
    createPromise.__generatorFunction__ = fn;
    return createPromise;
    function createPromise(){
        return co.call(this, fn.apply(this, arguments));
    }
}

其接收一个函数,这个函数其实就是最后返回的yeild *next(),也就是可用状态下的中间件。最后返回一个函数createPromise,而当执行createPromise函数的时候,调用co并传入一个参数,这个参数是中间件函数执行后生成的Generator对象。

它就是一个“皮”,通过co来包装Generator和yeild。将异步的写法改成了同步。所以Tj大神已经在koa2中async/await代替了。并在koa2去除了co。

var co = require('co');
co(function *(){
  var result = yield Promise.resolve(true);
}).catch(onerror);

co(function *(){
  var a = Promise.resolve(1);
  var b = Promise.resolve(2);
  var c = Promise.resolve(3);
  var res = yield [a, b, c];
  console.log(res);
}).catch(onerror);

co(function *(){
  try {
    yield Promise.reject(new Error('boom'));
  } catch (err) {
    console.error(err.message); // "boom" 
 }
}).catch(onerror);
 
function onerror(err) {
  // log any uncaught errors 
  // co will not throw any errors you do not handle!!! 
  // HANDLE ALL YOUR ERRORS!!! 
  console.error(err.stack);
}

推荐阅读更多精彩内容

  • 概要 64学时 3.5学分 章节安排 电子商务网站概况 HTML5+CSS3 JavaScript Node 电子...
    阿啊阿吖丁阅读 3,081评论 0 2
  • 1.简书 koa是由Express原班人马打造,致力于成为一个更小、更富有表现力、更健壮的Web框架。使用koa编...
    不去解释阅读 2,010评论 0 11
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 106,429评论 12 127
  • 今天这飞机早点也是醉了,居然天亮才到家,不过有我最亲爱的们陪伴着我我特别开心,接下来的日子就是努力进行下一个目标,...
    皮红丽阅读 65评论 0 0
  • 作业布置下去一直没收回来,吉老师的答复拖延;因为“肆意”了一下加赶路、主要是因为这几天的身体疲乏、人懒懒的~国泰王...
    Molly喵小北阅读 74评论 0 0