阅读 281

koa以及express

git

express

  • 功能全,内置了很多的中间件,集成了路由,静态服务,模版引擎等功能,基于es5,全部基于回调实现
  • 错误只能在回调内部处理
  • app.all, app.get,app.post,app.use
  • 在原生的req,res中拓展属性
  • 和express一样基于http模块

express使用

const express = require('express') // 导出的是一个函数
const app = express()
app.use('/', (req, res, next) => {
    //  在中间件中处理公共路径
    next()
})
// 内部不会将回调函数包装成promise,内部集成了路由
app.get('/', (req, res)=> {
    res.end('ok')
})
app.listen(3000, () => {
    console.log('start')
})
复制代码

源码解析

路由系统
// 主要代码
// 'application.js'
const http = require('http')
const Router = require('./router')
/*
  @ 在初始化application的时候,需要创建一个router实例,中间的请求处理都让router去完成
@*/

function Application() {
  // 在创建应用的时候,初始化一个router
  this._router = new Router()
}
Application.prototype.get = function (path, ...handlers) {
  this._router.get(path, handlers) // 
},
Application.prototype.listen = function (...arg) {
  const server = http.createServer((req, res) => {
    function done() {
      // 如果router处理不了,直接执行done
      res.end('not xx `Found')
    }
    this._router.handle(req, res, done)
  })
  server.listen(...arg)
}

module.exports = Application

// 'layer.js'
function Layer(path, handle) {
  this.path = path
  this.handle = handle
}
module.exports = Layer

// 'route.js'
const Layer = require("./layer");

function Route() {
  // route也有一个stack,存放内层的layer(方法类型与用户传入函数的对应)
  this.stack = []
}
/*
@ route有一个diapatch属性,调用一次执行内层的stack
@ 还有一个get方法,用来标记内层layer函数与方法类型的对应关系
*/
Route.prototype.dispatch = function(req, res, out) {
  let idx = 0
  const next = () => {
    
    if (idx >= this.stack.length) return out()
    const routeLayer = this.stack[idx++]
    // 比较方法
    if (routeLayer.method ===  req.method.toLowerCase()) {
      routeLayer.handle(req, res, next)
    } else {
      next()
    }
  }
  next()
}
Route.prototype.get = function(handles) {
  handles.forEach(handle => {
    const layer = new Layer('', handle)
    
    layer.method = 'get'
    this.stack.push(layer)
  });
}
module.exports = Route



// 'index.js'
/* 
  @router有route属性和layer属性,layer存路径和route.dispatch的对应关系,route存放用户真实的回调
  @ router是真个路由系统,route是一条条的路由
  @ 在express的路由系统中,又一个外层的栈,存放着路径与回调函数的对应关系,这个回调函数是route的diapatch,
  @ 当调用外层的handle时,会让route.dispatch执行,dispatch找到route中存放的layer,根据方法进行匹配,一次执行
  @ 内层的layer存放的是方法类型与真实回调的对应关系
*/
const Layer = require('./layer')
const Route = require('./route')
const url = require('url')
function Router() {
  this.stack = []
}
Router.prototype.route = function (path) {
  const route = new Route() // 创建route
  const layer = new Layer(path, route.dispatch.bind(route)) // 创建外层layer
  layer.route = route // 为layer设置route属性
  // 将layer存放到路由系统的stack中
  this.stack.push(layer)
  return route
}
// router中有get方法与handle放法
Router.prototype.get = function (path, handlers) {
  // 当调用get时我们创建一个layer,给他添加对应关系
  // 创建对应关系
  const route = this.route(path)
  // 让route调用get方法标记route中的每个layer是何种方法
  route.get(handlers)
}
Router.prototype.handle = function (req, res, done) {
  /*
    @ 当有请求过来时,我们先拿到path找到外层的layer,然后执行他的handle,让内层的route,根据method依次执行内层的layer的handle
    @
  */
  let { pathname } = url.parse(req.url)
  let idx = 0
  let next = () => {
    if (idx >= this.stack.length) return done() // 遍历完后还是没找到,那就直接走出路由系统即可
    let layer = this.stack[idx++]

    // 需要查看 layer上的path 和 当前请求的路径是否一致,如果一致调用dispatch方法
    if (layer.path === pathname) {
      // 路径匹配到了 需要让layer上对应的dispatch执行
      layer.handle(req, res, next) // 将遍历路由系统中下一层的方法传入
    } else {
      next()
    }
  }
  next()
}
module.exports = Router

复制代码
  • express导出的是一个函数,在执行这个函数的时候我们创建一个应用,在创建应用的同时创建一个路由系统,让路由系统处理接下来的请求
  • 针对实例化出来的路由系统router,存着一层一层layer,以下统称为外层的layer,在外层的每个layer有个route属性,这个属性中存着一个一个的内层layer,在内层的layer中存放着方法名和用户传来的真实的回调的对应关系,一个layer存着一个回调,外层layer存放着路径与route.dispatch的对应关系,当匹配到外城laryer路径时,触发route.dispatch执行,然后让route中存放的内层layer依次执行
路由的懒加载策略
function Application() {
  // 在创建应用的时候,初始化一个router
}
Application.prototype.lazy_router = function() {
  if (!this._router) {
    this._router = new Router() // 在调用了对应的方法或者listen的创建路由
  }
}
methods.forEach(method => {
  Application.prototype[method] = function (path, ...handlers) {
    this.lazy_router()
    ...
  }
})
Application.prototype.listen = function (...arg) {
  const server = http.createServer((req, res) => {
    this.lazy_router() // 实现路由的懒加载
    ...
}

module.exports = Application
复制代码
  • 不在一创建应用就生成一个路由系统,而是用户独傲了express对应的方法或者listen的时候采取创建路由系统
其他部分的优化

我们在拿到一个请求的路径和方法后,先去外层layer匹配path,path匹配之后,在去内层匹配methods,这就存在内层匹配不到对应的方法,造成浪费,因此我们在外层layer的route属性中加一个methods属性,这个属性包括route中所有存在的方法,在当匹配到外层,先去methods映射去找是否存在,如果存在就触发route.dispatch,不存在则直接跳过,匹配下一个layer

// index.js
Router.prototype.handle = function (req, res, done) {
    ...
  let next = () => {
    ...

    // 需要查看 layer上的path 和 当前请求的路径是否一致,如果一致调用dispatch方法
    if (layer.match(pathname)) {
      // 路径匹配到了 需要让layer上对应的dispatch执行
      if (layer.route.methods[req.method.toLowerCase()]) {
        layer.handle(req, res, next) // 将遍历路由系统中下一层的方法传入
      } else {
        next()
      }
    } else {
      next()
    }
  }
  next()
}
module.exports = Router

// route.js

function Route() {
    ...
  this.methods = {}
}
...无用的代码省略
methods.forEach(method => {
  Route.prototype[method] = function(handles) {
    handles.forEach(handle => {
      const layer = new Layer('', handle)
      this.methods[method] = true
        ...
    });
  }

})

module.exports = Route

复制代码
中间件
洋葱模型
...
app.get('/', function(req, res, next) {
    console.log(1)
    setTimeout(() => {
        next();
        console.log('xxx')
    }, 6000);
}, function(req, res, next) {
    next();
    console.log(11)
}, function(req, res, next) {
    next();
    console.log(111);

})
app.get('/', function(req, res, next) {
    console.log('2');
    res.end('end')
})
app.post('/', function(req, res, next) {
  res.end('post ok')
})
app.listen(3001, () => {
    console.log('server start 3000');
})
// 1, 2, 111, 11,xxx
解析: 洋葱模型,其实就是从外面一层层的深入,再一层层的穿出来,中间件的执行顺序严格遵循洋葱模型,next可以理解为先执行下一个中间件,next之后的代码会在执行完下一个中间件再回来执行
上述代码的输出: 
    + 1输出毫无疑问,
    + next在setTimeOut中等待,到时间后先进入下一个中间件,在下一个中间件中next在输出以前,继续先执行下一个回调,输出2
    + 再回到上一层,输出111
    + 回到上一层输出11
    + 回到最开始的那一层,输出xxx
复制代码
use的实现
中间件可以用来做什么
  • 中间件可以决定是否向下执行,拦截请求进行中间操作
  • 权限校验
  • 扩展属性
用法
// + 第一个参数,匹配以path开头的路径 ,第二个参数是回调
+ 中间件不匹配方法,只匹配路径
app.use('/', (req, res, next) => {
  // todo
})
复制代码
实现
  • 中间件和路由放在同一个路由系统中,他的layer只有path(以path开头)以及用户传递的回调函数,与路由不同的是中间件没有route属性
  • 中间件需要在内部调用next才会调用下一步
  • 中间件的位置在路由系统中出于路由的前方
// application.js
Application.prototype.use = function() {
  // 中间件的实现逻辑,只要用到路由就要判断一次路由是否加载
  this.lazy_router()
  // 将逻辑交给路由系统中实现
  this._router.use(...arguments)
}

// index.js
...
Router.prototype.use = function(path, ...handles) {
  if(typeof path == 'function') {
    // 只传递了一种参数
    handles.unshift(path)
    path = '/'
  }

  // 遍历handles创建layer
  handles.forEach(handle => {
    let layer = new Layer(path, handle)
    // 对于中间件,没有route属性,为了区分,设置route属性为undeinfed,并且把这个layer放入到路由系统中
    layer.route = undefined
    this.stack.push(layer)
  })
}


Router.prototype.handle = function (req, res, done) {
  let { pathname } = url.parse(req.url)
  let idx = 0
  let next = () => {
    ...
    if (layer.match(pathname)) {
      // 当匹配到路径的后,可能为中间件,可能为路由
      if (!layer.route) { // 判断是否是中间件
        // 中间件的话,直接去执行
        layer.handle_request(req, res, next)
      } else {
        // 路由
        if (layer.route.methods[req.method.toLowerCase()]) {
          layer.handle(req, res, next) // 将遍历路由系统中下一层的方法传入
        } else {
          next()
        }

      }
      // 路径匹配到了 需要让layer上对应的dispatch执行
    } else {
      next()
    }
  }
  next()
}

// layer.js

function Layer(path, handle) {
  this.path = path
  this.handle = handle
}
Layer.prototype.match = function (pathname) {
  // 路由和中间件的匹配规则不同
  if (this.path === pathname) return true
  if (!this.route) {
    // 中间件
    if (this.path === '/') return true
    return pathname.startsWith(this.path + '/')
  }
  return false

}
Layer.prototype.handle_request = function(req, res, next) {
  this.handle(req, res, next)

}
module.exports = Layer
复制代码

express的错误处理

  • 与koa监听error时间相比,express采取的是使用中间件处理错误
  • 错误处理中间件的特点是,参数有四个(err, req, res, next)
用法
const express = require('express');

const app = express();
app.use('/', (req, res, next) => {
  // todo
  console.log(1)
  next('出错了')
})
app.use('/', (req, res, next) => {
  // todo
  console.log(2)
  next()
})
app.get('/', function(req, res, next) {
    console.log(1)
    setTimeout(() => {
        next();
        console.log('xxx')
    }, 10000);
}, function(req, res, next) {
    next();
    console.log(11)
}, function(req, res, next) {
    next();
    console.log(111);

})
复制代码

  • express基于异步回调,因此不可以使用tryCatch捕获异常,在使用expres中,如果在next中传递参数,就认为是出错了,不会继续往下执行
实现
// route.js
...
Route.prototype.dispatch = function(req, res, out) {
  let idx = 0
  const next = (err) => {
    if(err) { return out(err)} // out就是外层的next,如果内层有错误,直接跳出
    
    if (idx >= this.stack.length) return out()
    const routeLayer = this.stack[idx++]
    // 比较方法
    if (routeLayer.method ===  req.method.toLowerCase()) {
      routeLayer.handle(req, res, next)
    } else {
      next()
    }
  }
  next()
}
...


// index.js
Router.prototype.handle = function (req, res, done) {
  /*
    @ 当有请求过来时,我们先拿到path找到外层的layer,然后执行他的handle,让内层的route,根据method依次执行内层的layer的handle
    @
  */
  let { pathname } = url.parse(req.url)
  let idx = 0
  let next = (err) => {
    // 在这里进行统一的监听
    if (idx >= this.stack.length) return done() // 遍历完后还是没找到,那就直接走出路由系统即可
    let layer = this.stack[idx++]
    if (err) { //错误处理。一般放在最后
      // 进入到错误可以是中间件也可以是路由
      if (!layer.route) {
        // 找中间件
        layer.handle_error(err, req, res, next)
      } else {
        // 路由,继续携带错误信息匹配中间件
        next(err)
      }

    } else {
      if (layer.match(pathname)) {
        // 当匹配到路径的后,可能为中间件,可能为路由
        if (!layer.route) {
          // 中间件的化,直接去执行
          // 排除错误中间件
          if (layer.handle.length !== 4) {
            layer.handle_request(req, res, next) // 普通中间件
          } else {
            // 是错误中间件,跳出
            next()
          }
        } else {
        ...
 
        }
      } else {
        next()
      }
    }
  }
  next()
}

// layer.js
...
Layer.prototype.handle_error = function(err, req, res, next) {
  // 找到错误处理中间件所在的layer,如果不是带着错误信息继续next
  if(this.handle.length === 4) {
    // 错误处理中间件,让handle执行
    return this.handle(err, req, res, next)
  }
  next(err) // 普通的中间件
}
...


测试代码
server.js

app.use('/', (req, res, next) => {
  // todo
  console.log(1)
  next()
})

app.use('/', (req, res, next) => {
  // todo
  console.log(2)
  next()
})
app.use('/', (req, res, next) => {
  // todo
  console.log(3)
  next()
})
// 路由的中间件  将处理逻辑 拆分成一个个的模块
app.get('/', function(req, res, next) {
    console.log(1)
    next()
}, function(req, res, next) {
    next();
    console.log(11)
}, function(req, res, next) {
    next('出错了');
    console.log('出错了');

})
app.get('/', function(req, res, next) {
    console.log('2');
    res.end('end')
})
app.use((err,req,res,next)=>{ 
  next();
})
复制代码

  • 在中间件的next是外层layer往下执行的next,而路由是内层layer的next

二级路由的实现

使用
// server.js
const express = require('express');
const LoginRouter =require('./routes/loginRouter');

const app = express();

app.use('/login',LoginRouter);

app.listen(3000);

// loginRouter.js
const express = require('express');
let router = express.Router(); // 是个构造函数
router.get('/add',function (req,res) {
    res.end('/login-add')
})
router.get('/out',function (req,res) {
    res.end('/login-out')
})

module.exports = router;
复制代码
实现
  • 这里的express.Router就是index中定义的Router
// router/index.js
...
// 提供一个Router类,既可以new也可以执行
createApplication.Router = require('./router')
...

复制代码
Router兼容
  • 使得Router既可以new,也可以执行
  • new的时候会返回一个引用类型,this指向这个引用类型
function Router() {
  this.stack = []
  const router = (req, res, next) => {
    router.handle(req, res, next)
  }
  router.stack = []
  router.__proto__ = proto
  return router
}
复制代码

这样改造后,通过new之后,this均指向router这个函数,在这个函数上没有之前的各种放法,因此之前写的逻辑均失效, 需要把这些属性放到原型链上

二级路由
  • 当我们调用express.Router()是会创建一个独立的路由系统,和以前的路由系统结构类型相似,这额路由系统和路由的第一层相对应
  • 当请求到来时,先匹配第一级路由,匹配后进入二级路由系统,匹配二级路由,匹配到,调用dispatch,匹配不到回到一级路由系统中,匹配下一个
  • 当使用中间件的时候,匹配到一级路由时,把一级路由路径剪切掉,取剩下的路径进入二级路由系统中进行匹配,出来的时候再拼上路由
// index.js


proto.handle = function (req, res, done) {
  /*
    @ 当有请求过来时,我们先拿到path找到外层的layer,然后执行他的handle,让内层的route,根据method依次执行内层的layer的handle
    @
  */
  let { pathname } = url.parse(req.url)
  let idx = 0
  let removed = '';
  let next = (err) => {
    // 在这里进行统一的监听
    if (idx >= this.stack.length) return done() // 遍历完后还是没找到,那就直接走出路由系统即可
    let layer = this.stack[idx++]
    // 出来中间件的时候将路径补气
    if (removed) {
      req.url = removed + pathname;// 增加路径 方便出来时匹配其他的中间件
      removed = '';
    }
    if (err) { //错误处理。一般放在最后
        ...
    } else {
      if (layer.match(pathname)) {
        if (!layer.route) {
          if (layer.handle.length !== 4) {
            if (layer.path !== '/') {
              // 进入中间件的时候剪切路径
              removed = layer.path // 中间件的路径
              req.url = pathname.slice(removed.length);
            }
            layer.handle_request(req, res, next) // 普通中间件
          } else {
            // 是错误中间件,跳出
            next()
          }
        } else {
        ...
      } else {
        next()
      }
    }
  }
  next()
}
复制代码

koa

  • 小,基于es6,(promise,async,await),主要关注核心use方法
  • 中间件
  • 洋葱模型
  • 源码思路
    • 构造函数添加了request,reponse,context
    • use的时候将middleware添加到数组中
    • listen的时候,调handleRequest
    • 在handleRequest中,根据req,res创建一个上下文,让中间件一次执行后(返回一个promise),将结果放在ctx.body上
  • 中间件
  • 错误处理,监听onerror
  • ctx有req,res,request,reponse

实现

application
  • 创建koa应用,导出一个类,类上有use,listen等方法
  • 在listen方法中使用http创建一个server,处理请求
    • 根据请求的req,res创建一个上下文
    • 然后一个一个的执行注册的中间件,中间件返回的是一个一个的promise,在中间件执行完成后,将放置在ctx.body返回给用户
  • use中放置的是一个一个的中间件
// applicatin
const EventEmitter = require('events')
const http = require('http')
const context = require('./context.js')
const request = require('./request.js')
const response = require('./response.js')
const Stream = require('stream')

class Application extends EventEmitter {
  constructor() {
    super()
    // 为了实现每new一次有个全新的额context,因此需要使用object.create()赋值一份
    this.context = Object.create(context)
    this.response = Object.create(response)
    this.request = Object.create(request)
    this.middlewares = []
  }
  
  use(middleware) {
    this.middlewares.push(middleware)
  }
  generateContext(req, res) {
    ....
  }
  compose(ctx) {
    ....
  }
  handleRequest(req, res) {
    // 根据req,res以及新的context生成一个ctx
    const ctx = this.generateContext(req, res)
    // 执行中间件
    this.compose(ctx).then(() => {
      // 对返回的处理
      let body = ctx.body; //当组合后的promise完成后,拿到最终的结果 响应回去

      if(typeof body == 'string' || Buffer.isBuffer(body)){
          res.end(body);
      }else if(body instanceof Stream){
          res.setHeader('Content-Disposition',`attachement;filename=${encodeURIComponent('下载1111')}`)
          body.pipe(res);
      }else if(typeof body == 'object'){
          res.end(JSON.stringify(body));
      }
    })
  }
  listen(...args) {
    const server = http.createServer(this.handleRequest.bind(this))
    server.listen(...args)
  }
}
module.exports = Application
复制代码
generateContext,根据req,res创建上下文
  • generateContext会在context中拓展一个request和reponse,在response以及request上有原生的res以及req
  • 复制两次: 第一次是每次new Koa()时保证每一个实例拿到都是新的context,request,response + 第二次复制是为了每次use都拿到一个新的context,request,response
generateContext(req, res)
    // 保证每次use都创建新的上下文
    let context = Object.create(this.context);
    let request = Object.create(this.request);
    let response = Object.create(this.response);
    // 上下文中有一个request对象 是自己封装的的对象
    context.request = request;
    context.response = response;
    // 上下文中还有一个 req属性 指代的是原生的req
    // 自己封装的request对象上有req属性
    context.request.req = context.req = req;
    context.response.res = context.res = res;
    return context;
}
复制代码
compose(中间件)
异步回调使用next类型的
compose(ctx) {
    const dispatch = i => {
        try {
            if(i >= this.middlewares.length) return Promise.reslove()
            const currentMiddle = this.middlewares[i]
            return Promise.reslove(currentMiddle(ctx, () => dispatch(i+1))
            
        } catch(e) {
            
        }
    }
    dispatch(0)
}
复制代码

context

  • 在对象初始化时定义 在对象定义后通过Object的__defineGetter__、__defineSetter__方法来追加定义
  • 访问ctx.query,去找ctx.requesrt.query,相当于做一层代理
  • ctx.body去找 ctx.response.body
  • 设置ctx.body,相当于设置ctx.response.body
const context = {
}
// 代理方法
function defineGetter(target, key) {
  // 定义一个getter, context和this不是一个,this.__proto__.__proto__ = context
  context.__defineGetter__(key, () => {
    return this[target][key]
  })
}

function defineSetter(target, key) {
  context.__defineSetter__(key, (newValue) => {
    this[target][key] = newValue
  })
}
defineGetter('request', 'url')
defineGetter('request', 'path') 
defineGetter('request', 'query') // ctx.query = ctx.requesrt.query

defineGetter('response','body');// ctx.body => ctx.response.body
defineSetter('response','body');// ctx.body => ctx.response.body
module.exports = context
复制代码

request

  • 使用属性访问器设置一层代理,
const url = require('url')
const request = {

  // 属性访问器,类似Object.definedProperty()
  get url() {
    // 这里的this指的是ctx.request
    return this.req.url
  },
  get path() {
    return url.parse(this.req.url).pathname
  },
  // 希望有什么属性就扩展
  get query() {
    return url.parse(this.req.url, true).query
  }
}
module.exports = request
复制代码

response

const response = {
  _body:'',
  get body(){
      return this._body;
  },
  set body(val){
      this._body = val;
  }
}
module.exports = response;
复制代码

body-parse中间件

  • body-parse中间件其实就是在ctx.request.body上把传入的参数解析出来,发来的请求时一个可读流,因此监听data或者end事件
const querystring = require('querystring');
const uuid = require('uuid');
const path = require('path');
const fs = require('fs');
// 中间件的功能可以扩展属性 / 方法
module.exports = function(uploadDir) {
    return async (ctx, next) => {
        await new Promise((resolve,reject)=>{
            const arr = [];
            ctx.req.on('data',function (chunk) {  
                arr.push(chunk);
            })
            ctx.req.on('end',function () {
                if(ctx.get('content-type') === 'application/x-www-form-urlencoded'){
                    let result = Buffer.concat(arr).toString();
                    ctx.request.body = querystring.parse(result);
                }
                if(ctx.get('content-type').includes('multipart/form-data')){ // 二进制不能直接toString 可能会乱码
                    let result = Buffer.concat(arr); // buffer
                    let boundary = '--'+ ctx.get('Content-Type').split('=')[1];
                    let lines = result.split(boundary).slice(1,-1);
                    let obj = {}; // 服务器收到的结果全部放在这个对象中

                    lines.forEach(line=>{
                        let [head,body] = line.split('\r\n\r\n');
                        head = head.toString();
                        let key = head.match(/name="(.+?)"/)[1]
                        if(!head.includes('filename')){
                            obj[key] = body.toString().slice(0,-2);
                        }else{
                            // 是文件  文件上传 名字需要是随机的
                            let content = line.slice(head.length + 4,-2);
                            let filePath = path.join(uploadDir,uuid.v4());
                            obj[key] = {
                                filePath,
                                size:content.length
                            }
                            fs.writeFileSync(filePath,content);
                        }
                    });
                    ctx.request.body = obj;
                }
                resolve();
            })
        });
        await next(); // 完成后需要继续向下执行
    }   
}
Buffer.prototype.split = function (sep) { // 分隔符可能是中文的,我希望将他转化成buffer来计数
    let sepLen = Buffer.from(sep).length;
    let arr = [];
    let offset = 0;
    let currentIndex = 0;
    while((currentIndex = this.indexOf(sep,offset)) !== -1){
        arr.push(this.slice(offset,currentIndex));
        offset = currentIndex + sepLen;
    }
    arr.push(this.slice(offset));
    return arr;
}
复制代码

koa项目搭建

项目目录

划分路由
const Router = require('koa-router')
const routerA = new Router({prefix: '/login'}) // 划分接口以loagin开头的

复制代码
koa-combine-routers合并路由
const combineRouters = require('koa-combine-routers')
combineRouters(A, B)// 将A和B路由合并,返回一个中间件
复制代码
渲染模版koa-views
const views = require('koa-views')// 在ctx加一个render方法,基于Promise
app.use(views(__dirname + './views', {
    map: {
        html: 'ejs' //使用ejs模版
    }
}))

// 使用
class Controller {
    async add(ctx, next) {
        await ctc.render('a.html', { age: 11, name: 'xx'})  
    }
}
// 模版
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <%=name%> <%=age%>
</body>
</html>

复制代码