暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

PostgreSQL中pldebugger调试原理简析

内核开发者 2023-10-17
833

1. 功能简介

PostgreSQL 中 pldebugger 插件主要用于实现服务器端 PL/pgSQL 调试器,它提供了一种调试服务器端 PL/pgSQL 程序的方法。


通过 pldebugger 调试 PL/pgSQL 程序,需要至少两个数据库 session:一个是用于执行目标程序的 target session,另外一个是用于调试目标程序的 debug session。Target session 用于执行被调试 PL/pgSQL 存储过程,包括函数、过程、包存储过程等。同时,Debug session还必须使用 pldebugger 进行自身初始化,用来了解它要监视哪个目标会话。之后,调试会话可以调用 pldebugger 的入口点来读取从目标会话发布的事件并与目标会话进行通信。

2. 原理简析

2.1 pldebugger 整体结构

pldebugger 内置包整体结构体为 Client/Server 结构,实现上为通过 socket 方式实现,整体结构如下:

2.2 如何获取到 PL/pgSQL 执行信息?

PL/pgSQL 中包含一个类型为 PLpgSQL_plugin 的全局指针变量 plpgsql_plugin_ptr,结构如下:

/*
 * A PLpgSQL_plugin structure represents an instrumentation plugin.
 * To instrument PL/pgSQL, a plugin library must access the rendezvous
 * variable "PLpgSQL_plugin" and set it to point to a PLpgSQL_plugin struct.
 * Typically the struct could just be static data in the plugin library.
 * We expect that a plugin would do this at library load time (_PG_init()).
 * It must also be careful to set the rendezvous variable back to NULL
 * if it is unloaded (_PG_fini()).
 *
 * This structure is basically a collection of function pointers --- at
 * various interesting points in pl_exec.c, we call these functions
 * (if the pointers are non-NULL) to give the plugin a chance to watch
 * what we are doing.
 *
 * func_setup is called when we start a function, before we've initialized
 * the local variables defined by the function.
 *
 * func_beg is called when we start a function, after we've initialized
 * the local variables.
 *
 * func_end is called at the end of a function.
 *
 * stmt_beg and stmt_end are called before and after (respectively) each
 * statement.
 *
 * Also, immediately before any call to func_setup, PL/pgSQL fills in the
 * error_callback and assign_expr fields with pointers to its own
 * plpgsql_exec_error_callback and exec_assign_expr functions.  This is
 * a somewhat ad-hoc expedient to simplify life for debugger plugins.
 */
typedef struct PLpgSQL_plugin
{
	/* Function pointers set up by the plugin */
	void		(*func_setup) (PLpgSQL_execstate *estate, PLpgSQL_function *func);
	void		(*func_beg) (PLpgSQL_execstate *estate, PLpgSQL_function *func);
	void		(*func_end) (PLpgSQL_execstate *estate, PLpgSQL_function *func);
	void		(*stmt_beg) (PLpgSQL_execstate *estate, PLpgSQL_stmt *stmt);
	void		(*stmt_end) (PLpgSQL_execstate *estate, PLpgSQL_stmt *stmt);

	/* Function pointers set by PL/pgSQL itself */
	void		(*error_callback) (void *arg);
	void		(*assign_expr) (PLpgSQL_execstate *estate, PLpgSQL_datum *target,
								PLpgSQL_expr *expr);
} PLpgSQL_plugin;

在 PL/pgSQL 初始化时,pl_handle r会将这个结构体指针放到 Rendezvous 哈希表中,key 为该 plugin ptr 的名字,entry 为该全局指针变量。

pldebugger 插件初始化的时候,通过 rendezvous hash table 利用 "PLpgSQL_plugin" 查询到 plpgsql_plugin_ptr 指针,然后将插件中的初始化后的结构体指针写入到相应位置。

此过程中,pldebugger 已经将实现的函数进行了注册,即在 pldebugger 插件中实现了 dbg_startup  函数用于在存储过程被调用时进行初始化处理以及 dbg_newstmt 函数用户存储过程每行执行时处理 PL/pgSQL 中包含的执行信息。

static void 		 dbg_startup( PLpgSQL_execstate * estate, PLpgSQL_function * func );
static void 		 dbg_newstmt( PLpgSQL_execstate * estate, PLpgSQL_stmt * stmt );

static PLpgSQL_plugin plugin_funcs = { dbg_startup, NULL, NULL, dbg_newstmt, NULL };

在 PL/pgSQL 存储过程执行时,plpgsql_estate_setup 会初始化对应的结构体部分,并添加部分 plpgsql 部分函数指针,此过程中主要为 plpgsql_exec_error_callback 和 exec_assign_expr 两个函数,分别用于处理 plpgsql 错误回调函数以及对存储过程中的变量进行赋值使用。

/* ----------
 * Initialize a mostly empty execution state
 * ----------
 */
static void
plpgsql_estate_setup(PLpgSQL_execstate *estate,
					 PLpgSQL_function *func,
					 ReturnSetInfo *rsi,
					 EState *simple_eval_estate,
					 ResourceOwner simple_eval_resowner)
{
	HASHCTL		ctl;

	      ...

	/*
	 * Let the plugin see this function before we initialize any local
	 * PL/pgSQL variables - note that we also give the plugin a few function
	 * pointers so it can call back into PL/pgSQL for doing things like
	 * variable assignments and stack traces
	 */
	if (*plpgsql_plugin_ptr)
	{
		(*plpgsql_plugin_ptr)->error_callback = plpgsql_exec_error_callback;
		(*plpgsql_plugin_ptr)->assign_expr = exec_assign_expr;

		if ((*plpgsql_plugin_ptr)->func_setup)
			((*plpgsql_plugin_ptr)->func_setup) (estate, func);
	}
}

exec_stmt 会执行每行存储过程,此时会通过 plpgsql_plugin_ptr 指针是否存在进行相应的调用,在 pldebugger 中即为调用 dbg_newstmt 进行信息处理。

exec_stmt(PLSQL_execstate * estate, PLSQL_stmt * stmt)
{
	PLSQL_stmt *save_estmt = estate->err_stmt;
	int			rc;
	TimestampTz save_ts;

	estate->err_stmt = stmt;

	/* Let the plugin know that we are about to execute this statement */
	if (*plpgsql_plugin_ptr && (*plpgsql_plugin_ptr)->stmt_beg)
		((*plpgsql_plugin_ptr)->stmt_beg) (estate, stmt);

               ...
}

2.3 调试过程是如何实现的?

考虑使用gdb调试,通过会包含以下基本操作:

  • break xxx

  • next(step over)

  • step(step into)

  • continue

  • print

  • info break

  • list

  • set

  1. break 操作函数签名如下:

select pldbg_set_breakpoint(session INTEGER, func OID, linenumber INTEGER);

在使用 pldebugger 插件进行调试时,target session 中维护了一个 break point 的哈希表,debug session 通过 pldbg_set_breakpoint 打断点时,就是直接将该断点添加到哈希表中。然后在每行存储过程执行时,在 dbg_newstmt 函数中会检查该行是否存在断点,存在的话则停下,否则直接跳过该行。

注:这里的断点形式是 { function_oid, line_number },即使用存储过程的 oid 和 行数 进行标识的。

  1. next 操作函数签名如下:

select pldbg_step_over(session INTEGER);

target session 处理的时候直接跳过即可,到下一行处理。这里面会存在以下两种情况:

  • 该行是一个函数/过程调用:如果该函数没有正在被调试,那么不会进入该函数/过程,直接跳过该行;如果该函数正在被调试,那么会进入到该函数内部;

  • 该行是一个普通行,直接跳过该行。

3. step 操作对应函数签名如下:

select pldbg_step_into(session INTEGER);

step 在 target session 处理时与 next 几乎一致,但是会额外在 per_session_ctx 中将 step_into_next_func 设置为 true,此时会在出现其他函数调用时进入该函数,判断逻辑如下:

static void
dbg_startup(PLpgSQL_execstate *estate, PLpgSQL_function *func)
{
	if( func == NULL )
	{
		/*
		 * In general, this should never happen, but it seems to in the
		 * case of package constructors
		 */
		estate->plugin_info = NULL;
		return;
	}

	if( !breakpointsForFunction( func->fn_oid ) && !per_session_ctx.step_into_next_func)
	{
		estate->plugin_info = NULL;
		return;
	}
	initialize_plugin_info(estate, func);
}

4. continue 操作对应函数签名如下:

select pldbg_continue(session INTEGER);

continue 在处理的时候会在跳过该行(next操作)的基础上,设置 dbg_info.stepping = false,此时target session 不会进入 while 循环等待 client 的消息,会直接执行到结束。

/*
 * When the debugger decides that it needs to step through (or into) a
 * particular function invocation, it allocates a dbg_ctx and records the
 * address of that structure in the executor's context structure
 * (estate->plugin_info).
 *
 * The dbg_ctx keeps track of all of the information we need to step through
 * code and display variable values
 */
typedef struct
{
	PLpgSQL_function *	func;		/* Function definition */
	bool				stepping;	/* If TRUE, stop at next statement */
	var_value	     *  symbols;	/* Extra debugger-private info about variables */
	char			 ** argNames;	/* Argument names */
	int					argNameCount; /* Number of names pointed to by argNames */
	void 			 (* error_callback)(void *arg);
	void 			 (* assign_expr)( PLpgSQL_execstate *estate, PLpgSQL_datum *target, PLpgSQL_expr *expr );
} dbg_ctx;

5. print 操作对应函数签名:

select pldbg_get_variables(session INTEGER);

在处理 print 的时候,需要显示出来此时所有变量的值,包括存储过程的参数和 declare 出来的变量,该类变量均会存储在 PL/pgSQL 执行状态结构体中,包括变量的数量以及变量的值:

/*
 * Runtime execution data
 */
typedef struct PLpgSQL_execstate
{
	PLpgSQL_function *func;		/* function being executed */

  ...
 
	/*
	 * The datums representing the function's local variables.  Some of these
	 * are local storage in this execstate, but some just point to the shared
	 * copy belonging to the PLpgSQL_function, depending on whether or not we
	 * need any per-execution state for the datum's dtype.
	 */
	int			ndatums;
	PLpgSQL_datum **datums;
  
  ...

	void	   *plugin_info;	/* reserved for use by optional plugin */
} PLpgSQL_execstate;

变量的值包含以下类型,目前仅支持 PLPGSQL_DTYPE_VAR 和 PLPGSQL_DTYPE_PROMISE 类型的打印,其余类型暂未支持:

/*
 * Datum array node types
 */
typedef enum PLpgSQL_datum_type
{
	PLPGSQL_DTYPE_VAR,  <-------  显示这个
	PLPGSQL_DTYPE_ROW,
	PLPGSQL_DTYPE_REC,
	PLPGSQL_DTYPE_RECFIELD,
	PLPGSQL_DTYPE_PROMISE     <-------  显示这个
} PLpgSQL_datum_type;

6. info break 操作对应的函数签名:

select pldbg_get_breakpoints(session INTEGER);

显示所有的 breakpoints 的时候就是遍历 breakpoints 的哈希表,然后返回所有的 breakpoints。

7. list 操作对应的函数签名:

select pldbg_get_source(session INTEGER, func OID); 

target session 会通过 pg_proc 读取 prosrc 字段并返回。

8. set 操作对应的函数签名:

select pldbg_deposit_value(session INTEGER, varName TEXT, lineNumber INTEGER, value TEXT);

在进行 set_value 的时候,回想下在初始化的时候,初始化了以下结构(*plpgsql_plugin_ptr)->assign_expr = exec_assign_expr;

static void
plpgsql_estate_setup(PLpgSQL_execstate *estate,
					 PLpgSQL_function *func,
					 ReturnSetInfo *rsi,
					 EState *simple_eval_estate,
					 ResourceOwner simple_eval_resowner)
{
	HASHCTL		ctl;

	      ...

	if (*plpgsql_plugin_ptr)
	{
		(*plpgsql_plugin_ptr)->assign_expr = exec_assign_expr;
	}
}

这里是通过调用 exec_assign_expr 进行处理,先构造一个 PLpgSQL_expr,然后将 value 部分先作为一个表达式进行处理,构造一个 SELECT value; 以此计算表达式的值。如果这里设置失败的话, 会将 value 作为字符串再处理一次,结束。

3. 总结

PostgreSQL 中 pldebugger 插件通常通过 pgAdmin 客户端进行调用进行使用,了解其中原理对于存储过程的调试将会有更加深入的理解和认识。

1. 功能简介

PostgreSQL 中 pldebugger 插件主要用于实现服务器端 PL/pgSQL 调试器,它提供了一种调试服务器端 PL/pgSQL 程序的方法。

通过 pldebugger 调试 PL/pgSQL 程序,需要至少两个数据库 session:一个是用于执行目标程序的 target session,另外一个是用于调试目标程序的 debug session。Target session 用于执行被调试 PL/pgSQL 存储过程,包括函数、过程、包存储过程等。同时,Debug session还必须使用 pldebugger 进行自身初始化,用来了解它要监视哪个目标会话。之后,调试会话可以调用 pldebugger 的入口点来读取从目标会话发布的事件并与目标会话进行通信。

2. 原理简析

2.1 pldebugger 整体结构

pldebugger 内置包整体结构体为 Client/Server 结构,实现上为通过 socket 方式实现,整体结构如下:

2.2 如何获取到 PL/pgSQL 执行信息?

PL/pgSQL 中包含一个类型为 PLpgSQL_plugin 的全局指针变量 plpgsql_plugin_ptr,结构如下:

/*
 * A PLpgSQL_plugin structure represents an instrumentation plugin.
 * To instrument PL/pgSQL, a plugin library must access the rendezvous
 * variable "PLpgSQL_plugin" and set it to point to a PLpgSQL_plugin struct.
 * Typically the struct could just be static data in the plugin library.
 * We expect that a plugin would do this at library load time (_PG_init()).
 * It must also be careful to set the rendezvous variable back to NULL
 * if it is unloaded (_PG_fini()).
 *
 * This structure is basically a collection of function pointers --- at
 * various interesting points in pl_exec.c, we call these functions
 * (if the pointers are non-NULL) to give the plugin a chance to watch
 * what we are doing.
 *
 * func_setup is called when we start a function, before we've initialized
 * the local variables defined by the function.
 *
 * func_beg is called when we start a function, after we've initialized
 * the local variables.
 *
 * func_end is called at the end of a function.
 *
 * stmt_beg and stmt_end are called before and after (respectively) each
 * statement.
 *
 * Also, immediately before any call to func_setup, PL/pgSQL fills in the
 * error_callback and assign_expr fields with pointers to its own
 * plpgsql_exec_error_callback and exec_assign_expr functions.  This is
 * a somewhat ad-hoc expedient to simplify life for debugger plugins.
 */
typedef struct PLpgSQL_plugin
{
	/* Function pointers set up by the plugin */
	void		(*func_setup) (PLpgSQL_execstate *estate, PLpgSQL_function *func);
	void		(*func_beg) (PLpgSQL_execstate *estate, PLpgSQL_function *func);
	void		(*func_end) (PLpgSQL_execstate *estate, PLpgSQL_function *func);
	void		(*stmt_beg) (PLpgSQL_execstate *estate, PLpgSQL_stmt *stmt);
	void		(*stmt_end) (PLpgSQL_execstate *estate, PLpgSQL_stmt *stmt);

	/* Function pointers set by PL/pgSQL itself */
	void		(*error_callback) (void *arg);
	void		(*assign_expr) (PLpgSQL_execstate *estate, PLpgSQL_datum *target,
								PLpgSQL_expr *expr);
} PLpgSQL_plugin;

在 PL/pgSQL 初始化时,pl_handle r会将这个结构体指针放到 Rendezvous 哈希表中,key 为该 plugin ptr 的名字,entry 为该全局指针变量。

pldebugger 插件初始化的时候,通过 rendezvous hash table 利用 "PLpgSQL_plugin" 查询到 plpgsql_plugin_ptr 指针,然后将插件中的初始化后的结构体指针写入到相应位置。

此过程中,pldebugger 已经将实现的函数进行了注册,即在 pldebugger 插件中实现了 dbg_startup  函数用于在存储过程被调用时进行初始化处理以及 dbg_newstmt 函数用户存储过程每行执行时处理 PL/pgSQL 中包含的执行信息。

static void 		 dbg_startup( PLpgSQL_execstate * estate, PLpgSQL_function * func );
static void 		 dbg_newstmt( PLpgSQL_execstate * estate, PLpgSQL_stmt * stmt );

static PLpgSQL_plugin plugin_funcs = { dbg_startup, NULL, NULL, dbg_newstmt, NULL };

在 PL/pgSQL 存储过程执行时,plpgsql_estate_setup 会初始化对应的结构体部分,并添加部分 plpgsql 部分函数指针,此过程中主要为 plpgsql_exec_error_callback 和 exec_assign_expr 两个函数,分别用于处理 plpgsql 错误回调函数以及对存储过程中的变量进行赋值使用。

/* ----------
 * Initialize a mostly empty execution state
 * ----------
 */
static void
plpgsql_estate_setup(PLpgSQL_execstate *estate,
					 PLpgSQL_function *func,
					 ReturnSetInfo *rsi,
					 EState *simple_eval_estate,
					 ResourceOwner simple_eval_resowner)
{
	HASHCTL		ctl;

	      ...

	/*
	 * Let the plugin see this function before we initialize any local
	 * PL/pgSQL variables - note that we also give the plugin a few function
	 * pointers so it can call back into PL/pgSQL for doing things like
	 * variable assignments and stack traces
	 */
	if (*plpgsql_plugin_ptr)
	{
		(*plpgsql_plugin_ptr)->error_callback = plpgsql_exec_error_callback;
		(*plpgsql_plugin_ptr)->assign_expr = exec_assign_expr;

		if ((*plpgsql_plugin_ptr)->func_setup)
			((*plpgsql_plugin_ptr)->func_setup) (estate, func);
	}
}

exec_stmt 会执行每行存储过程,此时会通过 plpgsql_plugin_ptr 指针是否存在进行相应的调用,在 pldebugger 中即为调用 dbg_newstmt 进行信息处理。

exec_stmt(PLSQL_execstate * estate, PLSQL_stmt * stmt)
{
	PLSQL_stmt *save_estmt = estate->err_stmt;
	int			rc;
	TimestampTz save_ts;

	estate->err_stmt = stmt;

	/* Let the plugin know that we are about to execute this statement */
	if (*plpgsql_plugin_ptr && (*plpgsql_plugin_ptr)->stmt_beg)
		((*plpgsql_plugin_ptr)->stmt_beg) (estate, stmt);

               ...
}

2.3 调试过程是如何实现的?

考虑使用gdb调试,通过会包含以下基本操作:

  • break xxx
  • next(step over)
  • step(step into)
  • continue
  • print
  • info break
  • list
  • set
  1. break 操作函数签名如下:
select pldbg_set_breakpoint(session INTEGER, func OID, linenumber INTEGER);

在使用 pldebugger 插件进行调试时,target session 中维护了一个 break point 的哈希表,debug session 通过 pldbg_set_breakpoint 打断点时,就是直接将该断点添加到哈希表中。然后在每行存储过程执行时,在 dbg_newstmt 函数中会检查该行是否存在断点,存在的话则停下,否则直接跳过该行。

注:这里的断点形式是 { function_oid, line_number },即使用存储过程的 oid 和 行数 进行标识的。

  1. next 操作函数签名如下:
select pldbg_step_over(session INTEGER);

target session 处理的时候直接跳过即可,到下一行处理。这里面会存在以下两种情况:

  • 该行是一个函数/过程调用:如果该函数没有正在被调试,那么不会进入该函数/过程,直接跳过该行;如果该函数正在被调试,那么会进入到该函数内部;

  • 该行是一个普通行,直接跳过该行。

3. step 操作对应函数签名如下:

select pldbg_step_into(session INTEGER);

step 在 target session 处理时与 next 几乎一致,但是会额外在 per_session_ctx 中将 step_into_next_func 设置为 true,此时会在出现其他函数调用时进入该函数,判断逻辑如下:

static void
dbg_startup(PLpgSQL_execstate *estate, PLpgSQL_function *func)
{
	if( func == NULL )
	{
		/*
		 * In general, this should never happen, but it seems to in the
		 * case of package constructors
		 */
		estate->plugin_info = NULL;
		return;
	}

	if( !breakpointsForFunction( func->fn_oid ) && !per_session_ctx.step_into_next_func)
	{
		estate->plugin_info = NULL;
		return;
	}
	initialize_plugin_info(estate, func);
}

4. continue 操作对应函数签名如下:

select pldbg_continue(session INTEGER);

continue 在处理的时候会在跳过该行(next操作)的基础上,设置 dbg_info.stepping = false,此时target session 不会进入 while 循环等待 client 的消息,会直接执行到结束。

/*
 * When the debugger decides that it needs to step through (or into) a
 * particular function invocation, it allocates a dbg_ctx and records the
 * address of that structure in the executor's context structure
 * (estate->plugin_info).
 *
 * The dbg_ctx keeps track of all of the information we need to step through
 * code and display variable values
 */
typedef struct
{
	PLpgSQL_function *	func;		/* Function definition */
	bool				stepping;	/* If TRUE, stop at next statement */
	var_value	     *  symbols;	/* Extra debugger-private info about variables */
	char			 ** argNames;	/* Argument names */
	int					argNameCount; /* Number of names pointed to by argNames */
	void 			 (* error_callback)(void *arg);
	void 			 (* assign_expr)( PLpgSQL_execstate *estate, PLpgSQL_datum *target, PLpgSQL_expr *expr );
} dbg_ctx;

5. print 操作对应函数签名:

select pldbg_get_variables(session INTEGER);

在处理 print 的时候,需要显示出来此时所有变量的值,包括存储过程的参数和 declare 出来的变量,该类变量均会存储在 PL/pgSQL 执行状态结构体中,包括变量的数量以及变量的值:

/*
 * Runtime execution data
 */
typedef struct PLpgSQL_execstate
{
	PLpgSQL_function *func;		/* function being executed */

  ...
 
	/*
	 * The datums representing the function's local variables.  Some of these
	 * are local storage in this execstate, but some just point to the shared
	 * copy belonging to the PLpgSQL_function, depending on whether or not we
	 * need any per-execution state for the datum's dtype.
	 */
	int			ndatums;
	PLpgSQL_datum **datums;
  
  ...

	void	   *plugin_info;	/* reserved for use by optional plugin */
} PLpgSQL_execstate;

变量的值包含以下类型,目前仅支持 PLPGSQL_DTYPE_VAR 和 PLPGSQL_DTYPE_PROMISE 类型的打印,其余类型暂未支持:

/*
 * Datum array node types
 */
typedef enum PLpgSQL_datum_type
{
	PLPGSQL_DTYPE_VAR,  <-------  显示这个
	PLPGSQL_DTYPE_ROW,
	PLPGSQL_DTYPE_REC,
	PLPGSQL_DTYPE_RECFIELD,
	PLPGSQL_DTYPE_PROMISE     <-------  显示这个
} PLpgSQL_datum_type;

6. info break 操作对应的函数签名:

select pldbg_get_breakpoints(session INTEGER);

显示所有的 breakpoints 的时候就是遍历 breakpoints 的哈希表,然后返回所有的 breakpoints。

7. list 操作对应的函数签名:

select pldbg_get_source(session INTEGER, func OID); 

target session 会通过 pg_proc 读取 prosrc 字段并返回。

8. set 操作对应的函数签名:

select pldbg_deposit_value(session INTEGER, varName TEXT, lineNumber INTEGER, value TEXT);

在进行 set_value 的时候,回想下在初始化的时候,初始化了以下结构(*plpgsql_plugin_ptr)->assign_expr = exec_assign_expr;

static void
plpgsql_estate_setup(PLpgSQL_execstate *estate,
					 PLpgSQL_function *func,
					 ReturnSetInfo *rsi,
					 EState *simple_eval_estate,
					 ResourceOwner simple_eval_resowner)
{
	HASHCTL		ctl;

	      ...

	if (*plpgsql_plugin_ptr)
	{
		(*plpgsql_plugin_ptr)->assign_expr = exec_assign_expr;
	}
}

这里是通过调用 exec_assign_expr 进行处理,先构造一个 PLpgSQL_expr,然后将 value 部分先作为一个表达式进行处理,构造一个 SELECT value; 以此计算表达式的值。如果这里设置失败的话, 会将 value 作为字符串再处理一次,结束。

3. 总结

PostgreSQL 中 pldebugger 插件通常通过 pgAdmin 客户端进行调用进行使用,了解其中原理对于存储过程的调试将会有更加深入的理解和认识。

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论