跳到主要内容

函数编写指南

函数式托管提供了一种在云托管服务中运行函数式代码的能力,开发者可以通过编写函数式代码来实现自己的业务逻辑。本文档将介绍如何编写函数式代码。

本文将介绍如何编写函数代码,如何进行错误处理,以及函数结构、出入参等基本信息,并提供一系列的示例以供参考。

快速开始

第一步:编写函数

  1. 在本地创建一个空的文件夹,作为项目的根目录。
  2. 新建 index.js 文件,作为函数的入口文件。
  3. index.js 文件中,填写以下内容:
exports.main = function (event, context) {
const { httpContext } = context
const { url, httpMethod } = httpContext
return `[${httpMethod}][${url}] Hello world!`
}

第二步:运行函数

云函数代码编写完毕后,既可以将函数运行起来,之后便可以通过 HTTP 请求来调用函数。

运行函数的方式有两种:

1. 通过命令行工具本地运行,通常在开发调试阶段

tcb fun run -w --source .

2. 在云托管环境中运行函数,通常在生产环境中

tcb fun deploy -e <环境 ID> -s <服务名称> --appId <微信 AppId>

更多用法:见 将函数部署到云托管

第三步:调用函数

当部署函数之后,即可通过 HTTP 请求来调用函数。

1. 调用本地运行的云函数

curl http://localhost:3000

2. 调用云托管环境中的云函数

常规调用

登录微信云托管控制台, 在 微信云托管-服务设置-基础信息 页面获取公网访问地址,通过 浏览器curl 等客户端软件 访问改地址即可。

curl https://{}.run.tcloudbase.com/

也可以在 微信云托管-云端调试 页面 调试或访问 服务。

微信小程序 callContainer 调用

// 容器调用必填环境id,不能为空
var c1 = new wx.cloud.Cloud({
resourceEnv: '环境id'
})
await c1.init()

const r = await c1.callContainer({
path: '/', // 填入业务自定义路径
header: {
'X-WX-SERVICE': 'xxx', // 填入服务名称
},
// 其余参数同 wx.request
method: 'POST',
})
console.log(r)

通过该方式调用,您可以省去配置自定义域名、证书等操作,快速调用函数,并获得微信私密链路的安全性提升。

wx.cloud.callContainer 相关文档:

了解函数

函数的输入参数 eventcontext

函数的基本结构如下:

exports.main = function (event, context) {
// 函数逻辑
}

exports.main = async function (event, context) {
// 函数逻辑
}

该文件导出一个 main 函数,该函数即为函数的入口方法,当请求到达时,该方法会作为代码执行的起点。该方法接受两个固定的参数 eventcontext,分别代表函数的输入和上下文。

其中,event 为函数的触发事件,在当前的 HTTP 访问场景下,可以认为是 HTTP 请求体,例如 POST 请求的 bodymultipart/form-data 请求的 form-dataPUT 请求所传输的二进制文件等。如果 HTTP 请求没有传递请求体,event 将会是一个空对象。

context 为函数执行的上下文信息,包含以下属性和方法:

Name类型含义
eventIDstring事件唯一标识,用于关联上下文
eventTypestring事件类型,当前固定为 http
timestampnumber请求时间戳
httpContexthttpBasisHTTP 请求的相关信息
extendedContextRecord<string, unknown>扩展的上下文信息,平台提供,包含 环境相关信息
sse()ISeverSentEvent返回用于发送 Server-Sent Event 格式响应的对象

httpBasis 定义如下:

名称类型含义
urlstring本次请求的完整 URL
httpMethodstring本次请求的 HTTP 方法,如 GET
headersIncomingHttpHeaders本次请求的 HTTP 头部

其中,从 extendedContext 中可以获得以下属性:

{
envId: "xxx", // 环境 ID
uin: "xxx", // 请求的 UIN
userId: "xxx", // 请求的用户 ID
accessToken: "xxx", // 调用请求时的 AccessToken
serviceName: "xxx", // 云托管服务名称
serviceVersion: "xxx", // 云托管服务版本
source: "xxx" // 请求来源,如 wx
wechatContext: {
callId: "xxx", // 微信调用 ID
source: "xxx", // 请求来源,如 wx
appId: "xxx", // 小程序的 AppID
openId: "xxx", // 用户的 OpenID
unionId: "xxx", // 资源共享时的 UnionID
fromOpenId: "xxx", // 资源共享时的 fromOpenID
fromUnionId: "xxx", // 资源共享时的 fromUnionID
fromAppId: "xxx" // 资源共享时的 fromAppID
}
}

云函数支持 Server-Sent Event 格式的响应,通过调用 sse() 即可以切换到 SSE 模式并得到 ISeverSentEvent 实例。

interface ISeverSentEvent {
setEncoder(encoder: TextEncoder): void;
send(event: SseEvent): boolean;
end(msg: SseEvent): void;
}

interface SseEvent<T = string | Record<string, unknown>> {
data: T;
comment?: string;
event?: string;
id?: string;
retry?: number;
}

可以通过 send() 方法来发送 SSE 响应,例如:

const { sse } = context
sse.send({
data: "Hello world!"
})

当函数执行完毕(即从入口函数返回)时,返回值将会被直接返回给客户端,不会进行额外的序列化处理。

函数的输出(即返回值)

数执行最后的返回值将作为最终返回值返回给客户端,此时,HTTP 将使用默认的状态码(如正常返回时200、未带有响应体时204、出错时500)和默认的响应头返回给客户端。

函数支持 同步、异步 两种形式的写法,支持 普通类型 | Promise 类型 的返回值,通过 Buffer | Stream 类型的返回值,可以直接返回二进制数据,实现下载文件的能力。

集成响应

通过集成响应,可以自定义 HTTP 响应的结构,包括响应状态码、头部字段等,可以采用集成响应的返回结构。集成响应的定义如下:

interface IntegrationResponse {
statusCode: number
headers: { [key: string]: string | string[] }
body?: unknown
}

statusCode 为 HTTP 响应状态码,headers 为自定义的 HTTP 响应头部,body 为 HTTP 响应体。例如返回下面的集成响应:

exports.main = function(event, context) {
return {
statusCode: 200,
headers: {
"Content-Type": "text/html"
},
body: {
message: "<h1>Hello world!</h1>"
}
}
}

将可以在浏览器中渲染出 HTML。

错误处理

函数运行过程中可能或抛出异常,异常整体可以分为以下几类:

  1. 不可恢复的错误:如启动时未加载成功代码、Out of Memory(OOM)、堆栈溢出,这类错误会导致函数无法启动或从运行中退出,此类错误是严重错误,会导致函数实例重启,应当尽量规避;
  2. 请求处理过程中的错误,即业务逻辑报错:这类错误通常是可捕获的,函数需要对这些错误进行处理,记录相关日志并返回相关信息给客户端。如果用户代码未捕获相关异常,函数框架将会捕获该错误,记录相关日志并返回框架定义的错误信息给客户端;
  3. 未被捕获的异常 UncaughtExceptionUnhandledRejection:通常发生在异步代码逻辑中,因为是未捕获异常,业务上是感知不到的,这类错误会被框架捕获,并打印相关信息到日志中。因为通常发生在异步逻辑中,该错误不会体现在函数返回值中;
  4. 函数框架报错:函数框架内部可能发生一些错误,可能是函数框架代码存在 Bug,也可能是函数框架的限制,这类错误会被框架捕获,并打印相关信息到日志中;
  5. 网络错误:在客户端往返函数实例的网络链路中也可能出现异常,导致一些错误。该类错误需结合具体部署平台进行排查。

函数的模块化和依赖安装

如通常的 Node.js 代码一样,函数也可以通过 Javascript 模块化方式来组织代码。对于遵循 CommonJS 规范的模块,可以通过 require 来引入依赖。例如:

// index.js
const { add } = require('./sum')

exports.main = function(event, context) {
return add(1, 2)
}

// sum.js
exports.add = function(a, b) {
return a + b
}

对使用 ECMAScript 模块化(需在 package.json 中声明模块化方式)的代码,可以通过 import 语句来引入依赖。例如:

// index.mjs
import { add } from './sum.mjs'

export function main(event, context) {
return add(1, 2)
}

// sum.mjs
export function add(a, b) {
return a + b
}

// package.json
{
"name": "example",
"version": "1.0.0",
"main": "index.mjs", // 指定入口文件
"type": "module", // 声明模块化方式
"dependencies": {
}
}

对于外部依赖安装,可在 package.json 中声明依赖,函数构建时会根据依赖声明自动安装依赖包。如果目录下存在 package-lock.json 等 lock 文件,可以起到锁定依赖版本的作用。

对于通过云托管部署的云函数,构建过程支持 npmyarnpnpm 包依赖管理工具,会识别代码使用的包管理工具进行依赖安装。

注:目前不支持关闭云端安装依赖。

函数日志

在函数代码中打印的日志可以分为两类,一类是函数实例级别日志,即主入口函数之外的日志,这部分日志在函数实例启动时、模块被加载时被打印,跟某次具体的请求无关;另一类是请求级别日志,即某次请求触发、代码执行流程进入主入口函数内部之后才会打印的日志,这部分日志往往跟某次具体的请求相关联,一般可以通过请求的 eventID 搜索得到。

请求级别的日志从内容上划分,又可以分为以下几类:

  1. 每次请求自动打印的结构化访问日志(access log),包括该次请求的基本信息,如处理请求的主机名、访问的 URL、请求方法、响应结果、请求处理耗时等;
  2. 函数代码中主动打印的日志,即代码中使用 console.log 等方法打印的日志,这部分日志会跟 eventID 所关联,有助于排查某次请求的具体执行流程;
  3. 函数框架捕获的异常日志,如 UncaughtExceptionUnhandledRejection ,这部分日志会自动跟 eventID 关联,并打印出异常的堆栈信息等便于排查问题。

日志的逻辑级别如下所示:

函数日志
├── 函数实例日志
├── 请求级别日志
│ ├── 访问日志
│ ├── 代码中打印的日志
│ ├── 框架捕获的异常日志

例如下面的函数代码:

// 函数实例日志
console.log("func initialization started.")

// 函数实例日志
setTimeout(() => {
console.log("timeout log.")
}, 1000)

exports.main = function(event, context) {
// 请求级别日志
console.log({ event, context })

setTimeout(() => { throwReject() }, 1000)
return "Hello world"
}

async function throwReject() {
// 这里会触发未捕获的异常,因为没有捕获,会被框架捕获
// 该异常在 main 中 setTimeout 中抛出,因此不会体现在 main 的返回值中
// 该异常由 函数框架捕获并打印到日志中
Promise.reject(new Error('This is an error.'))
}

// 函数实例日志
console.log("func initialization finish.")

在函数实例启动时,App started. 会被打印。请求到达并处理完毕后,客户端会收到 Hello world 响应,1s 后该请求抛出异常被框架捕获,这次请求的访问日志形如:

{
"@timestamp": "2024-xx-xxTxx:xx:xx.xxxZ", // 日志打印的时间戳
"startAt": "2024-xx-xxTxx:xx:xx.xxxZ", // 请求开始处理的时间
"endAt": "2024-xx-xxTxx:xx:xx.xxxZ", // 请求处理结束的时间
"logType": "accesslog", // 日志类型
"hostname": "host", // 处理请求的主机名
"pid": 13506, // 进程 ID
"version": "x.y.z", // 函数框架版本
"nodeEnv": "", // Node.js 相关的环境变量
"tcbEnv": "", // 云开发相关的环境变量
"eventId": "b0900934-79d7-4441-856f-dd46392a5f91", // 本次请求的 eventID
"method": "GET", // 请求方法
"url": "http://127.0.0.1/", // 请求 URL
"path": "/", // 请求路径
"status": 200, // 响应状态码
"httphost": "127.0.0.1", // 请求的主机名
"ua": "PostmanRuntime/7.39.0", // 请求的 User-Agent
"connection": "keep-alive",
"contentType": "application/json", // 请求的 Content-Type
"clientIp": "::1", // 请求的客户端 IP
"resSize": 12, // 响应体大小
"resContentType": "text/plain", // 响应的 Content-Type
"timeCost": 11, // 本次请求的总耗时
"userTimeCost": 0 // 从函数主入口开始到从入口返回的耗时
}

随后异常会被框架捕获,可通过 eventID(b0900934-79d7-4441-856f-dd46392a5f91) 关联,并打印出如下堆栈:

Unhandled Rejection for: Error: This is an error.
at throwReject (/path/to/your/project/index.js:9:18)
at Timeout._onTimeout (/path/to/your/project/index.js:4:22)

注意:日志对于问题排查是非常重要的,建议在函数代码中适当打印日志,以便排查问题。 但是打印大量日志将会带来其他问题,如日志消耗磁盘空间可能导致磁盘可用空间不足,日志采集组件采集解析文件消耗较多 CPU,日志上报到存储系统会消耗大量网络带宽,存储大量日志导致成本增加,日志写入和查询性能下降延迟高等诸多问题,因此建议适度打印日志。

函数示例

引入外部模块

在函数中引入外部模块,例如 lodash,并使用其提供的方法:

函数代码:

// index.js
const _ = require('lodash')

exports.main = function(event, context) {
return _.kebabCase('Hello world')
}
// package.json
{
"name": "example",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"lodash": "^4.17.21"
}
}

执行该函数后客户端可以收到 hello-world 的响应。

在函数中路由

可根据 context 中获取到的 HTTP 相关路径、query 等信息,实现简单的路由功能。

函数代码:

exports.main = function(event, context) {
const { httpContext } = context
const { url } = httpContext
const path = new URL(url).pathname

// 根据访问路径返回不同的内容
switch (path) {
case '/':
return {
statusCode: 200,
body: 'Hello world!'
}
case '/index.html':
return {
statusCode: 200,
headers: {
'Content-Type': 'text/html'
},
body: '<h1>Hello world!</h1>'
}
default:
return {
statusCode: 404,
body: 'Not found'
}
}
}

返回不同类型的响应

函数代码:

exports.main = function(event, context) {
const { httpContext } = context
const { url } = httpContext
const path = new URL(url).pathname

// 根据访问路径返回不同的内容
switch (path) {
// 直接返回字符串
case '/':
return 'Hello world!'
// 返回当前时间戳
case '/now':
return new Date().getTime()
// 使用集成响应返回 HTML
case '/index.html':
return {
statusCode: 200,
headers: {
'Content-Type': 'text/html'
},
body: '<h1>Hello world!</h1>'
}
// 使用集成响应返回 JSON
default:
return {
statusCode: 404,
headers: {
'Content-Type': 'application/json'
},
body: {
message: 'Not found'
}
}
}
}

可综合使用集成响应、非集成响应,获得更丰富的响应类型。

使用 Server-sent Event 推送消息

函数代码:

exports.main = async function (event, context) {
// 切换到 SSE 模式
const sse = context.sse()

sse.on('close', () => {
console.log('sse closed')
})

// 发送事件到客户端,发送前先检查是否已经关闭,如未关闭可发送
if (!sse.closed) {
// 多次发送多个事件
sse.send({ data: 'No.1 message' })
sse.send({ data: 'No.2 message with\n\r\r\n\r\r\rtwo lines.' })

// 单次发送多个事件
sse.send([
{ data: 'No.1 message' },
{ data: 'No.2 message with\n\r\r\n\r\r\rtwo lines.' }
])

// 以下为发送原始消息的示例
// 该方式用于扩展 SSE 协议,例如发送其他 Event Field 字段
// 注意:末尾必须有换行符数据才会立即发送
sse.send('message: This is a raw message. ')
sse.send(['message: This is another raw message.\n\n'])

// 函数执行时间以函数返回时间计算
// 函数返回后,HTTP 请求处理完成,函数内的异步逻辑继续进行处理,不影响函数返回时间
// TCP 网络连接依然被 SEE 占用,在 SSE 连接被客户端或服务端关闭之前,可以继续发送消息到客户端
// SSE 协议已经将 HTTP 转换到长连接模式,需要客户端或服务端在适当的时候主动关闭连接,否则将导致连接一直占用,消耗网络资源
// 因TCP主动关闭的一方将进入TIME_WAIT 状态,大量 TIME_WAIT 状态的连接将导致网络资源耗尽,无法建立新的连接,所以客户端主动关闭连接更符合最佳实践
// 因客户端可能并不知晓在什么时间关闭连接,服务端可以发送一个特殊的消息,告诉客户端消息已经结束,可以关闭连接了
// 浏览器中调用 EventSource#close 关闭连接,见:https://developer.mozilla.org/en-US/docs/Web/API/EventSource/close
return ''
}
}

使用 WebSocket 长连接收发消息

函数代码:

export function main (event, context) {
console.log({ event, context })
if (context.ws) {
context.ws.on('close', (msg) => {
console.log('close: ', msg)
})
context.ws.on('message', (msg) => {
console.log('message: ', msg)
})
setInterval(() => {
context.ws.send(`now: ${new Date().toISOString()}`)
}, 100)
}
}

// 支持同步异步
main.handleUpgrade = async function (upgradeContext) {
console.log(upgradeContext, 'upgradeContext')
if (upgradeContext.httpContext.url === '/upgrade-handle-throw-error') {
throw new Error('test throw error')
} else if (upgradeContext.httpContext.url === '/upgrade-handle-reject-error') {
return Promise.reject(new Error('test reject error'))
} else if (upgradeContext.httpContext.url === '/allow-websocket-false') {
return {
allowWebSocket: false,
statusCode: 403,
body: JSON.stringify({ code: 'code', message: 'message' }),
contentType: 'appliaction/json; charset=utf-8'
}
}
return { allowWebSocket: true }
}

node.js 客户端代码:

import WebSocket from 'ws'

function run () {
const ws = new WebSocket('ws://127.0.0.1:3000/')

ws.on('close', (code, reason) => {
console.log('close:', code, `${reason}`)
})
ws.on('error', (err) => {
console.error('error: ', err)
})
ws.on('upgrade', () => {
console.log('upgrade')
})
ws.on('ping', () => {
console.log('recv ping message')
})
ws.on('pong', () => {
console.log('recv pong message')
setTimeout(() => {
ws.ping()
}, 1000)
})
ws.on('unexpected-response', (ws, req, res) => {
// 非 upgrade 响应和 3xx 重定向响应认为是 unexpected-response
console.log('recv unexpected-response message')
})

ws.on('message', (data) => {
console.log('received: %s', data)
})

ws.on('open', () => {
ws.ping()
ws.send('string data')
ws.send(Buffer.from('buffer data'))
})
}

run()

使用 multipart/form-data 提交表单数据(文件)

函数代码:

const path = require('path')
const fs = require('fs')

exports.main = async function (event, context) {
// 从 event.file 属性上获取要保存的文件(与传参对应)
// event.file 类型为 PersistentFile,见 https://www.npmjs.com/package/formidable#file
// 如有其他传参,可用 form data 传参中的对应名称 event.[your param name] 获取,如 event.name, event.size 等
const file = event.file
// 获取原始文件名
const fileName = file.originalFilename;
// 保存文件的目录
const fileDir = path.join(process.cwd(), 'tmp')
if (!fs.existsSync(fileDir)) {
fs.mkdirSync(fileDir)
}
const filePath = path.join(fileDir, fileName)
// 从参数中读取文件流
const readStream = fs.createReadStream(file.filepath)
// 尝试保存文件到指定目录
try {
await fs.promises.writeFile(filePath, readStream)
} catch (error) {
return {
statusCode: 500,
body: `Error saving file: ${error.message}`
}
}

// 注意:删除临时文件

return {
statusCode: 200,
body: `File saved to: ${filePath}`
}
}

发送上传文件请求:

curl --location 'url' \
--form 'file=@file.png'

注意:

  1. 文件上传完成后会保存到本地文件,您应该在函数执行结束后删除文件,以免占用过多磁盘空间。
  2. 上传的文件如需持久化,应该保存到云存储或其他服务中,以免文件丢失,避免保存在本地。

使用 PUT 上传二进制数据或文件

函数代码:

const path = require('path')
const fs = require('fs')

exports.main = async function(event, context) {
const { httpContext } = context
const { url } = httpContext
// 从 query 中获取文件名
const filename = new URL(url).searchParams.get('filename')
// 保存文件的目录
const fileDir = path.join(process.cwd(), 'tmp')
if (!fs.existsSync(fileDir)) {
fs.mkdirSync(fileDir)
}
// 保存文件的路径
const filePath = path.join(fileDir, filename)

try {
// 从 event 中获取文件内容
const buffer = Buffer.from(event, 'binary')
await fs.promises.writeFile(filePath, buffer);
return {
statusCode: 200,
body: `File saved to: ${filePath}`,
};
} catch (error) {
return {
statusCode: 500,
body: `Error saving file: ${error.message}`,
};
}
}

发送上传文件请求:

curl --location --request PUT 'url?filename=file.png' \
--header 'Content-Type: application/octet-stream' \
--data 'file.png'

使用 TypeScript 编写函数代码

相比 JavaScript 通过 TypeScript 编写代码可以获得诸多好处,例如:

  • 更好的开发体验:编辑器可以提供更好的代码补全、重构和导航功能。这使得开发过程更加高效
  • 语法特性TypeScript 支持最新的 ECMAScript 特性,如装饰器、泛型、异步函数等
  • 静态类型检查:可以在编译时捕获类型错误,减少运行时错误,提高代码的可靠性
  • 接口和类型别名TypeScript 支持接口和类型别名,允许开发者定义复杂的数据结构。这使得代码更加模块化和可重用
  • 可读性和可维护性:通过明确的类型定义,TypeScript 代码通常更易于理解和维护
  • 更好的文档:类型定义本身可以作为文档,帮助开发者理解函数和模块的用法,而不需要额外的文档

推荐使用 TypeScript 编写函数代码,尤其是相对复杂一点儿的项目中。

@cloudbase/functions-typings 提供了云函数的类型定义,可以增强对函数 event 参数类型的约束和感知能力,可以增强对函数返回值的约束能力。

npm install @cloudbase/functions-typings@v-1

如下为使用示例:

import { TcbEventFunction, File, IntegrationResponse } from '@cloudbase/functions-typings'

// GET no boyd
export const main: TcbEventFunction = function(event, context) {
}

// Content-Type: application/json
type jsonEvent = {a: number, b: string}
export const main: TcbEventFunction<jsonEvent> = function(event, context) {
event.a
event.b
}

// Content-Type: multipart/form-data
type formEvent = {str: string, file: File}
export const main: TcbEventFunction<formEvent> = function(event, context) {
event.str
event.file.filepath
}

// Content-Type: application/octet-stream
export const main: TcbEventFunction<Buffer> = function(event, context) {
event.byteLength
}

// 访问 Context 信息
export const main: TcbEventFunction<void, void> = function(event, context) {
context.extendedContext?.envId
context.extendedContext?.userId
}

// 普通响应-无返回值
export const main: TcbEventFunction = function(event, context) {
return
}

// 普通响应-有返回值
export const main: TcbEventFunction<void, string> = function(event, context) {
return 'done.'
}

// 集成响应
export const main: TcbEventFunction<void, IntegrationResponse<string>> = function(event, context) {
return {
statusCode: 200,
headers: {},
body: ''
}
}

// 异步函数
export const main: TcbEventFunction<void, Promise<string>> = async function(event, context) {
return new Promise((resolve) => {
setImmediate(() => {
resolve('done.')
})
})
}

// SSE
export const main: TcbEventFunction<void, Promise<string>> = async function(event, context) {
const sse = context.sse?.()

if (sse && !sse.closed) {
sse.on('close', () => {
console.log('sse closed')
})

sse.send({ data: 'hello from sse function, abcedfg..., €€€€€⭐⭐⭐⭐⭐' })

sse.send({ data: 'message with linebreak symbol:\\nThis is the first line.\\n\\nThis is the second line.\\r\\r\\r\n' })

// 单次发送多个事件
sse.send([
{ data: 'This is the first message.' },
{ data: 'This is the second message, it\n\r\r\n\r\r\rhas two lines.' },
{ data: 'This is the third message.' }
])

// 以下为发送原始消息的示例
// 该方式用于扩展 SSE 协议,例如发送其他 Event Field 字段
// 注意:末尾必须有换行符数据才会立即发送

sse.send('message: This is a raw message. ')
sse.send(['message: This is another raw message.\n\n'])

const msgs = [
'This is a raw message. \n',
'This is another raw message.\n\n'
]
sse.send(msgs)
}
return ''
}

// WebSocket
export const main: TcbEventFunction = async function(event, context) {
if (context.ws) {
context.ws.on('open', (msg) => {
console.log('open: ', msg)
})
context.ws.on('error', (msg) => {
console.log('error: ', msg)
})
context.ws.on('close', (msg) => {
console.log('close: ', msg)
})
context.ws.on('message', (msg) => {
console.log('message: ', msg)
})
context.ws.send('hello from websocket function')
}
}
main.handleUpgrade = async function(context) {
return {
allowWebSocket: true
}
}