第8章 函数编写

第8章 函数编写

PostgreSQL 同大多数数据库一样,可以把若干 SQL 语句组合在一起,然后将其作为一个单元来处理,并且每次运行时可以输入不同的参数。这种机制在不同数据库中的名称不一样,有的叫存储过程,有的叫用户自定义函数,而 PostgreSQL 统一称之为函数。

8.1 PostgreSQL函数功能剖析

PostgreSQL 的函数可分为基本函数、聚合函数、窗口函数和触发器函数四大类。

8.1.1 函数功能基础知识介绍

函数的基本结构

CREATE OR REPLACE FUNCTION func_name(arg1 arg1_datatype DEFAULT arg1_default)
RETURNS some type | set of some type | TABLE (..) AS
$$
BODY of function
$$
LANGUAGE language_of_function

参数可以有默认值。函数调用者可以忽略有默认值的参数,即不用为其输入值,直接采用默认值即可。在函数定义中,可选参数必须排列在必选参数的后面。

入参支持命名参数和匿名参数两种形式,前者必须为参数起个名字,而后者不需要。我们建议使用命名参数,因为这样可以在函数体内通过参数名引用它,非常方便和直观。如果参数是匿名的,那么只能通过序列号的方式来访问它:$1、$2 和 $3。

big_elephant(ear_size numeric, skin_color text DEFAULT 'blue',
name text DEFAULT 'Dumbo')

如果使用了命名参数,那么调用函数时还可以采用以下带入参名的调用方式,该方式的好处是参数输入顺序与定义时的顺序不必完全相同:

big_elephant(name => 'Wooly', ear_size => 1.2)

在 PostgreSQL 9.5 以及更高的版本中,带参数名的调用语法是类似 name => ‘Wooly’ 这样的,但是在 PostgreSQL 9.4 以及之前的版本中,语法是 name := ‘Wooly’。

LANGUAGE(使用的编程语言)
  指明本函数使用的编程语言,当然该语言必须在当前函数所在的 database 中已安装。执行 SELECT lanname FROM pg_language; 即可查到已安装的语言列表。
  
VOLATILITY(结果的稳定性)
  该标记符可以告诉查询规划器,当该函数执行完毕后,得到的结果是否可以缓存下来以供下次使用。它有以下几个可选值。

  • IMMUTABLE(结果恒定不变)
      任何情况下,只要调用该函数时使用相同的输入,就总会得到相同的输出。也就是说,该函数的内部逻辑对外界完全无依赖。这类函数最典型的例子是数学计算函数。注意,定义函数索引时必须使用 IMMUTABLE 函数。
  • STABLE(结果相对稳定)
      如果在同一个查询语句中多次调用该函数,则每次调用时只要使用相同的输入就总会得到相同的输出。也就是说,该函数的内部逻辑在当前 SQL 的上下文环境内是有恒定输出的。
  • VOLATILE(结果不稳定)
      每次调用该函数得到的结果可能都不同,即便每次都使用相同的输入也是这样。那些更改数据的函数和那些依赖系统时间这类环境设置的函数就属于 VOLATILE 类型。该项也是默认值。
      请注意,VOLATILITY 标记符仅仅是给规划器提供了一个提示信息,规划器并不一定会按照此设置来进行处理。如果函数被标记为 VOLATILE,那么规划器每次遇到此函数都会重新解析并重新执行一遍;如果被标记为别的类型,那么规划器也可能不会对其执行结果进行缓存,因为规划器可能认为重新计算一遍反而会更快。

STRICT(严格模式)
  对于一个严格模式的函数来说,如果有任何输入为 NULL,则规划器根本不会执行这个函数,直接返回 NULL。如果未显式指定为 STRICT 模式,则函数默认都是非严格模式的。写函数时,务必慎用 STRICT,因为用了以后可能会导致规划器不使用索引。
  
COST(执行成本估计)
  这是标记函数中计算操作密集程度的一个相对度量值。如果使用的是 SQL 或 PL/pgSQL 语言,则该值为 100;如果使用 C 语言,则该值为 1。该值会影响到规划器执行 WHERE 子句中的函数时的优先级,也会影响到是否对此函数进行结果集缓存的可能性判定。此值越大,则规划器会认为执行该函数需要耗费的时间越多。
  
ROWS(返回结果集的行数估计)
  仅当函数返回的是一个结果集时,此标记符才有用。该值是返回的结果集中记录数的一个估计值。规划器会利用此数值来为此函数分析得出最佳的执行策略。(可做估计统计值,异常情况下统计估计值非常不准确,例来不及清理死数据时,对原表频繁操作时,误差很大)。
  
SECURITY DEFINER(安全控制符)
  如果设置了安全控制符,则会以创建此函数的用户的权限执行此函数;如果未设置,则会以调用此函数的用户的权限执行此函数。如果某用户对某张表没有操作权限而又需要操作该表,那么就可以让创建该表的用户提供一个带 SECURITY DEFINER 标识的函数来对此表进行操作。可以看出,当需要进行表的访问权控制时,这个安全控制符还是很有用的。
  
PARALLEL(并行度)
  该标记符是 PostgreSQL 9.6 新引入的。该标记表示允许规划器以并行模式运行。默认情况下,函数会被设置为 PARALLEL UNSAFE,这意味着任何调用该函数的语句都不会被分布到多个工作进程上去并发执行。支持的选项如下。

  • SAFE
      该选项表示允许该函数被并行执行。如果函数是 IMMUTABLE 类型的,或者函数不更新数据或者不会修改事务状态或其他变量值,那么将其设为 SAFE 一般是没问题的。
  • UNSAFE
      如果函数会修改非临时数据、访问序列号生成器或者事务状态,那么都应被设置为 UNSAFE。UNSAFE 的函数如果以并行模式执行可能会导致表数据被破坏或者其他系统状态被破坏,因此不允许被并行执行。
  • RESTRICTED
      对于使用临时表、预解析语句或者客户端连接状态的函数可以使用该选项。设置为 RESTRICTED 的语句不会被禁止并行执行,但是它只能运行在并行组中的领导组(lead)中,也就是说该函数本身不会被并行执行,但它不会阻止调用它的 SQL 语句被并行执行。PostgreSQL 9.6 之前的版本,在执行例子时把 PARALLEL 标记去掉。

8.1.2 触发器和触发器函数

任何一个功能健全的数据库都支持触发器功能。借助触发器机制,可以实现自动捕捉数据变化事件并进行相应处理。PostgreSQL 既支持对表创建触发器,也支持对视图创建触发器。

对语句级触发器来说,每执行一条 SQL 语句只会被触发一次;对记录级触发器来说,SQL 语句执行过程中每修改一条记录就会被触发一次。

更加精细地设置触发器的触发时机,系统支持 BEFORE、AFTER 以及 INSTEAD OF 这三种时机。BEFORE 类的触发器会在语句执行之前或者记录行被修改之前触发,你可以借此时机来取消此次修改或者对要修改的数据进行预先备份。AFTER 类的触发器会在语句执行之后或者记录行被修改之后触发,你可以借此时机来获得修改后的新值,该类触发器一般用于记录修改日志或者进行数据复制。INSTEAD OF 类的触发器会将原语句的操作内容替换掉。BEFORE 和 AFTER 类的触发器只能用于表,而 INSTEAD OF 类的触发器只能用于视图。

可以在定义触发器时加上 WHEN 条件,以限定只有那些满足筛选条件的记录被修改时才激活该触发器;也可以通过加上“UPDATE OF + 字段列表”子句来指定只有修改了特定的列时才激活该触发器。

触发器函数从不需要参数,因为可以在函数内部访问数据并对其进行修改。触发器函数的返回值永远是 trigger 类型。一个触发器函数可以被多个触发器共用。每个触发器有且仅有一个配套的触发器函数。如果由于业务需要必须将逻辑分散到多个触发器函数中,那么就得创建多个触发器来调用它们,这些触发器的触发事件可以相同也可以不同。如果触发事件相同的话,那么系统会将触发器名称按字典顺序进行排序,然后逐个触发。后一个触发器可以看到前一个触发器的修改结果。每一个触发器并不是一个独立的事务,因此如果在某个触发器中执行了回滚操作,那么在此触发器之前执行过的触发器修改都会被回滚掉。

8.1.3 聚合操作

聚合函数一般是基于一个或者多个子函数实现的。首先至少得有一个状态转换函数,该函数会反复执行多次,以将输入的多行记录聚合为一个单独的结果。你还可以建立用于处理初始状态和终结状态的函数,不过这两个函数是可选的。

不管你使用何种编程语言来编写这些子函数,最终将它们整合为一个聚合函数的语法是一样的。

CREATE AGGREGATE my_agg (input data type) (
SFUNC=state function name,
STYPE=state type,
FINALFUNC=final function name,
INITCOND=initial state value, SORTOP=sort_operator
);

SFUNC 状态切换函数(这个名称不够直观,此处所谓的“状态”是指在聚合运算过程中每处理完一条记录后得到的中间结果)是实现聚合运算的逻辑主体,它会将自身上一次被调用后生成的计算结果作为本次计算的输入,同时输入的还有当前新一条的待处理记录,这样将所有记录一条条累积处理完毕后,就得到了基于整个目标记录集的“状态”,也就是最终的聚合结果。有的情况下,SFUNC 处理得到的结果就是聚合函数需要的最终结果,但另外一些情况下,SFUNC 处理完毕的结果还需要再进行最终加工才是我们想要的聚合结果,FINALFUNC 就是负责这个最终加工步骤的函数。FINALFUNC 是可选的,由于它的作用是对 SFUNC 函数的输出结果做最后加工,因此它的输入一定是 SFUNC 函数的输出。INITCOND 也是可选的,如果设定了该条目,那么其值会被作为 SFUNC 函数的“状态”的初始值。

最后的 SORTOP 也是可选的,其值是类似于 > 或 < 这样的运算符,它的作用是为类似 MAX、MIN 这样的排序操作指定排序运算符。指定了 SORTOP 运算符后,规划器会使用索引来进行 MAX、MIN 这样的聚合运算,由于索引是有序的,所以可以快速定位到索引的头部或者尾部以寻找 MAX、MIN 值,这样就不需要对所有记录逐条进行大小值判断,整体运算速度也得以极大提升。不过 SORTOP 运算符的使用有一个先决条件,那就是在聚合运算的目标表上,以下两条语句的执行结果必须完全相同。

SELECT agg(col) FROM sometable;
SELECT col FROM sometable ORDER BY col USING sortop LIMIT 1;

在 PostgreSQL 9.4 中,新增了对移动窗口聚合函数的支持。
在 PostgreSQL 9.6 中,聚合函数也开始支持并行执行。通过设置函数的 parallel 属性来指定某个函数是否启用并行,具体可以设为 safe、unsafe、restricted 这几个值。如果不设,默认是 unsafe。除了 parallel 属性,还增加了 combinefunc、serialfunc 和deserialfunc 这几个并行聚合相关的属性。

8.1.4 受信与非受信语言

PostgreSQL 支持的函数语言可按照信任级别分为两类:受信语言与非受信语言。

受信语言
  受信语言不具备直接访问数据库服务器底层文件系统的权限,因此在该类语言中不能直接执行操作系统级命令。任何权限级别的用户都可以使用受信语言创建函数。包括 SQL、PL/pgSQL、PL/Perl 和 PL/V8 在内的语言都是受信语言。

非受信语言
  非受信语言可以直接与操作系统进行交互,通过该类语言可以直接调用操作系统提供的函数和 Web服务接口。PostgreSQL 中只有超级用户才有权使用非受信语言编写函数,但超级用户有权将基于非受信语言的函数的执行权限授予普通用户。一般来说,非受信语言的命名会以 U 结尾,比如 PL/PerlU、PL/PythonU 等。这一点并不绝对,比如 PL/R 就是个例外。

8.2 使用SQL语言来编写函数

PostgreSQL 中,将现有的一条 SQL 语句改造为函数是一件又快又简单的事情:只需在现成的 SQL 基础上加上函数头和函数尾就可以了。但编写简单同时也意味着功能有限。SQL 不是一种过程式语言,因此你无法用上条件分支判断、循环或者定义变量等过程式语言的特性。此外还有一个更严重的限制,那就是无法执行使用函数入参动态拼装的 SQL 语句。

当然,SQL 函数也有其优点。查询规划器可以深入到 SQL 函数内部,并对其中每一条 SQL 语句进行分析和优化,该过程被称为 inlining,即内联处理。对于别的语言编写的函数,规划器只能将其当成黑盒处理。只有 SQL 函数可以被内联处理,这使得 SQL 函数能够充分利用索引并减少重复计算。

创建一个 SQL 函数,其返回值为新插入的记录的唯一 ID

CREATE OR REPLACE FUNCTION write_to_log(param_user_name varchar,
param_description text)
RETURNS integer AS
$$
INSERT INTO logs(user_name, description) VALUES($1, $2)
RETURNING log_id;
$$
LANGUAGE 'sql' VOLATILE;

函数的调用语法如下所示。

SELECT write_to_log('alex', 'Logged in at 11:59 AM.') As new_id;

创建一个进行更新操作的 SQL 函数,返回一个标量或者不返回。

CREATE OR REPLACE FUNCTION
update_logs(log_id int, param_user_name varchar, param_description text)
RETURNS void AS
$$
UPDATE logs SET user_name = $2, description = $3
, log_ts = CURRENT_TIMESTAMP WHERE log_id = $1;
$$
LANGUAGE 'sql' VOLATILE;

通过以下语句来调用此函数。

SELECT update_logs(12, 'alex', 'Fell back asleep.');

有三种返回结果集的方法:第一种是 ANSI SQL 标准中规定的 RETURNS TABLE 语法,第二种是使用 OUT 形参,第三种是使用复合数据类型。

在函数中返回结果集

CREATE OR REPLACE FUNCTION select_logs_rt(param_user_name varchar)
RETURNS TABLE (log_id int, user_name varchar(50),
description text, log_ts timestamptz) AS
$$
SELECT log_id, user_name, description, log_ts FROM logs WHERE user_name = $1;
$$
LANGUAGE 'sql' STABLE PARALLEL SAFE;

使用 OUT 形参的方式如下所示。

CREATE OR REPLACE FUNCTION select_logs_out(param_user_name varchar, OUT log_id int
, OUT user_name varchar, OUT description text, OUT log_ts timestamptz)
RETURNS SETOF record AS
$$
SELECT * FROM logs WHERE user_name = $1;
$$
LANGUAGE 'sql' STABLE PARALLEL SAFE;

使用复合数据类型的方式如下所示。

CREATE OR REPLACE FUNCTION select_logs_so(param_user_name varchar)
RETURNS SETOF logs AS
$$
SELECT * FROM logs WHERE user_name = $1;
$$
LANGUAGE 'sql' STABLE PARALLEL SAFE;

以上三种方式实现的函数的调用方法都是一样的。

SELECT * FROM select_logs_xxx('alex');

8.2.2 使用SQL语言编写聚合函数

几何平均值是指个正数的连乘积的n次方根,它在金融、经济以及统计学领域有着广泛的
应用。当样本数字的值域范围变化很大时,可以使用几何平均值来替代更常见的算术平均数。几何平均值可以使用更高效的公式来计算:EXP(SUM(LN(x))/n),该公式使用了对数来将连续的乘法运算转换为连续的加法运算,因此计算机执行的效率更高。在下面的例子中,我们将使用该公式计算几何平均值。

为了构造几何平均值聚合函数,需要创建两个子函数:一个状态转换函数,用于把对数运算结果相加。一个最终处理函数,用于对对数之和进行取幂运算。此外还需要指定状态初始值为 0。

创建几何平均值聚合函数的状态切换函数

CREATE OR REPLACE FUNCTION geom_mean_state(prev numeric[2], next numeric)
RETURNS numeric[2] AS
$$
SELECT
CASE
WHEN $2 IS NULL OR $2 = 0 THEN $1
ELSE ARRAY[COALESCE($1[1],0) + ln($2), $1[2] + 1]
END;
$$
LANGUAGE sql IMMUTABLE PARALLEL SAFE;

此处定义的状态切换函数有两个输入项:第一个是前次调用本状态切换函数计算后得到的结果,其类型为含两个元素的数字型数组;第二个是本轮计算要处理的样本值。如果第二个参数的值为 NULL 或者为 0,则本轮无须计算,直接返回参数 1 的值;否则将本次处理的样本数字的 ln 对数值累加到参数数组的第一个元素上,并对参数数组的第二个元素值加 1。这样最终得到结果就是含所有样本数字的 ln 对数值的总和以及总运算次数。

创建几何平均值聚合函数的最终处理函数

CREATE OR REPLACE FUNCTION geom_mean_final(numeric[2])
RETURNS numeric AS
$$
SELECT CASE WHEN $1[2] > 0 THEN exp($1[1]/$1[2]) ELSE 0 END;
$$
LANGUAGE sql IMMUTABLE PARALLEL SAFE;

基于定义好的子函数来创建几何平均值聚合函数

CREATE AGGREGATE geom_mean(numeric) (
SFUNC=geom_mean_state,
STYPE=numeric[],
FINALFUNC=geom_mean_final,
PARALLEL = safe,
INITCOND='{0,0}'
);

基于几何平均值来统计出种族多样性最好的 5 个县

SELECT left(tract_id,5) As county, geom_mean(val) As div_county
FROM census.vw_facts
WHERE category = 'Population' AND short_name != 'white_alone'
GROUP BY county
ORDER BY div_county DESC LIMIT 5;
county | div_county
-------+---------------------
25025  | 85.1549046212833364
25013  | 79.5972921427888918
25017  | 74.7697097102419689
25021  | 73.8824162064128504
25027  | 73.5955049035237656

直接将上面定义的聚合函数当作窗口函数来试一下,列出 5 个种族多样性最好的人口普查区。

WITH X AS (SELECT
tract_id,
left(tract_id,5) As county,
geom_mean(val) OVER (PARTITION BY tract_id) As div_tract,
ROW_NUMBER() OVER (PARTITION BY tract_id) As rn,
geom_mean(val) OVER(PARTITION BY left(tract_id,5)) As div_county
FROM census.vw_facts WHERE category = 'Population' AND short_name != 'white_alone'
)
SELECT tract_id, county, div_tract, div_county
FROM X
WHERE rn = 1
ORDER BY div_tract DESC, div_county DESC LIMIT 5;
tract_id    | county | div_tract            | div_county
------------+--------+----------------------+------------------
25025160101 | 25025  | 302.6815688785928786 | 85.1549046212833364
25027731900 | 25027  | 265.6136902148147729 | 73.5955049035237656
25021416200 | 25021  | 261.9351057509603296 | 73.8824162064128504
25025130406 | 25025  | 260.3241378371627137 | 85.1549046212833364
25017342500 | 25017  | 257.4671462282508267 | 74.7697097102419689

8.3 使用PL/pgSQL语言编写函数

如果 SQL 语言已经不能满足你编写函数的需求,一般来说常见的解决方案是转为使用 PL/ pgSQL。PL/pgSQL 优于 SQL 的地方在于,它支持通过 DECLARE 语法定义本地变量以及支持流程控制语法。

8.3.1 编写基础的PL/pgSQL函数

使用 PL/pgSQL 编写返回值为表类型的函数

CREATE FUNCTION select_logs_rt(param_user_name varchar)
RETURNS TABLE (log_id int, user_name varchar(50),
description text, log_ts timestamptz) AS
$$
BEGIN
RETURN QUERY
SELECT log_id, user_name, description, log_ts FROM logs
WHERE user_name = param_user_name;
END;
$$
LANGUAGE 'plpgsql' STABLE;

8.3.2 使用PL/pgSQL编写触发器函数

总共需要两个步骤:第一步是写一个触发器函数,第二步是将此触发器函数显式附加到合适的触发器上。第二步将处理触发器的函数与触发器本身分离开,这是 PostgreSQL 的一个强大的功能。你可以将同一个触发器函数附加到多个触发器上,从而实现触发器函数逻辑的重用。

由于触发器函数之间是完全独立的,你可以为每个触发器函数选择不同的编程语言,这些不同语言编写的触发器完全可以协同工作。PostgreSQL 支持通过一个触发事件(INSERT、UPDATE、DELETE)激活多个触发器,而且每个触发器可以基于不同的语言编写。

通过触发器对新插入的记录或者修改的记录打时间戳

CREATE OR REPLACE FUNCTION trig_time_stamper() RETURNS trigger AS ➊
$$
BEGIN
NEW.upd_ts := CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$
LANGUAGE plpgsql VOLATILE;
CREATE TRIGGER trig_1
BEFORE INSERT OR UPDATE OF session_state, session_id ➋
ON web_sessions
FOR EACH ROW EXECUTE PROCEDURE trig_time_stamper();

➊ 定义触发器函数。该函数适用于任何带有 upd_ts 字段的表。该函数会先将 upd_ts 字段的值更新为当前时间戳,然后再返回修改后的记录。

➋ “字段级触发”是 9.0 版开始支持的一个特性,通过该特性可以将触发器的触发时机精确到字段级别。在 9.0 版之前,只要发生了 UPDATE 或者 INSERT 动作,上面示例中的触发器都会被触发。因此,如果要实现字段级触发控制,就必须拿 OLD.some_column 和 NEW.some_column 进行对比,找到发生变化的字段,然后才能判定是否要进行“字段级触发”。(请注意:INSTEAD OF 触发器不支持该特性。)

8.4 使用PL/Python语言编写函数

Python 是一种非常灵活的语言,它支持非常丰富的功能扩展库。据我所知,PostgreSQL 是唯一一种允许用户使用 Python 语言来编写函数的数据库。

你可以在同一个 database 中同时安装 PL/Python2U 和 PL/Python3U 这两个语言包,但在同一个用户会话上不能同时使用这两种语言。这就意味着你不能在同一个语句中同时调用分别由 PL/Python2U 和 PL/Python3U 编写的函数。你在系统中会见到一种叫作 PL/PythonU 的语言,它实际上是系统为了保持前向兼容而为 PL/Python2U 语言创建的一个别名。

在使用 PL/Python 语言之前,要先在服务器上搭建好 Python 运行环境。搭建好 Python 运行环境之后,需要为 PostgreSQL 安装 Python 语言扩展包。注意,例如,假设你的 plpython2u 扩展包是基于 Python 2.7 编译的,那么服务器上就需要安装好 Python 2.7 运行环境。

CREATE EXTENSION plpython2u;
CREATE EXTENSION plpython3u;

编写基本的Python函数
PostgreSQL 会自动在 PostgreSQL 数据类型与 Python 数据类型间进行双向转换。PL/Python 语言编写的函数支持返回数组和复合数据类型。你可以使用 PL/Python 来编写触发器函数和聚合函数。

使用 PL/Python 语言编写的函数来搜索 PostgreSQL 官方手册的内容

CREATE OR REPLACE FUNCTION postgresql_help_search(param_search text)
RETURNS text AS
$$
import urllib, re ➊
response = urllib.urlopen(
'http://www.postgresql.org/search/?u=%2Fdocs%2Fcurrent%2F&q=' + param_search
) ➋
raw_html = response.read() ➌
result =
raw_html[raw_html.find("<!-- docbot goes here -->") :
raw_html.find("<!-- pgContentWrap -->") - 1] ➍
result = re.sub('<[^<]+?>', '', result).strip()return result ➏
$$
LANGUAGE plpython2u SECURITY DEFINER STABLE;

❶ 导入接下来需要使用的功能库。
❷ 在连接搜索词之后执行搜索。
❸ 读取返回的搜索结果并将其保存到一个名为 raw_html 的变量中。
❹ 从 raw_html 中将 <!-- docbot goes here -> 和 之间包含的内容截取出来,并存放到一个名为 result 的新变量中。
❺ 将 result 开头和结尾的 HTML 标记和空格删除。
❻ 返回 result 变量的内容。

在查询语句中使用 Python 函数

SELECT search_term, left(postgresql_help_search(search_term),125) As result
FROM (VALUES ('regexp_match'),('pg_trgm'),('tsvector')) As x(search_term);

前面提到过,PL/Python 是一种非受信语言,而且没有相应的受信版本。这意味着只有超级用户才能使用 PL/Python 编写函数,并且使用该语言编写出来的函数可以直接操作文件系统。请注意,从操作系统的角度来看,PL/Python 函数是以 PostgreSQL 安装时创建的 postgres 操作系统账户身份来执行的,因此你在执行该示例之前需要确保 postgres 账户对使用的目录拥有访问权限。

列出一个目录中的所有文件

CREATE OR REPLACE FUNCTION list_incoming_files()
RETURNS SETOF text AS
$$
import os
return os.listdir('/incoming')
$$
LANGUAGE 'plpython2u' VOLATILE SECURITY DEFINER;
SELECT filename
FROM list_incoming_files() As filename
WHERE filename ILIKE '%.csv';

8.5 使用PL/V8、PL/CoffeeScript以及PL/LiveScript语言来编写函数

略…

猜你喜欢

转载自blog.csdn.net/qq_42226855/article/details/110439805
今日推荐