首页
关于
留言
接口
搜索
首页
登录
登录
搜索
KAKA 梦很美
累计撰写
47
篇文章
累计收到
0
条评论
首页
栏目
首页
登录
页面
首页
关于
留言
接口
自定义幻灯片
置顶
史上最全 PhpStorm Xdebug 远程连接 Docker 断点调试及单元测试
Why? PHP 程序报错,仔细检查代码后找不到问题。需要重点检查 switch 语句分支判断,确保符合预期。建议使用 Xdebug 断点调试,可以方便地定位错误和查看变量值,帮助快速解决问题。 启用 Xdebug,逐行进入代码,深入了解其执行流程。这将揭示代码的真实运行步骤和调用关系。 此外,Xdebug 可让你实时跟踪每个变量的值和变化,消除 var_dump 代码注入(忘记移除将导致尴尬的代码提交,成为团队耻辱)。 Xdebug 说明 Xdebug 是一个 PHP 扩展,提供调试和性能分析功能。 Xdebug 调试信息, 异常消息中的堆栈和函数调用跟踪 用户定义函数的完整参数列表 函数名称、文件名和行号 对成员函数的支持 内存分配 保护用户免受无限递归的影响 其他功能 PHP 脚本的概要分析信息 代码覆盖率分析 交互式调试脚本的功能,通过调试器前端进行交互 开始使用 Xdebug 扩展安装就不介绍了, 这里假设您已经安装好了。接下来我们就开始配置 PHPStorm: 找到如图所示的位置 或 点击设置->构建、执行、部署->Docker->添加服务, 提示连接成功表示远程连接 Docker 完成 (当然你也可以用 TCP套接字 连接) 连接完成后如下图打开连接的 Docker 服务, 此时已经可以看到 Docker 容器等信息了 接下来配置 PHP-CLI 解析器, 废话少说 如下图 到此, PHP-CLI 解析器已经配置完成。 配置单元测试 (PHPUnit) 直接上图, 按图的步骤操作即可。随便打开一个 Test.php 文件, 配置 PHP 单元测试 (PHPUnit) , 编写测试代码后运行即可。 配置断点调试 配置 php.ini 文件的 Xdebug 参数 这里安装的 Xdebug 版本是 2.9.6 , 如果是 3.0 以上版本的 Xdebug 配置参数会有所不同 (自行谷歌, 稍作修改即可)。 附带一个Jetbrains 官方 的方式 点击查看。 如下是 Xdebug 2.9.6 扩展的配置参数, 打开 php.ini 配置文件: [xdebug] ;开启 Xdebug 支持远程调试 xdebug.remote_enable=1 ;远程调试的主机,一般都是 Docker 宿主机器,本地调试就是本机,IP 可以通过 `docker inspect 容器名` 获得 xdebug.remote_host=host.docker.internal ;远程调试机器的端口,一般是9000 xdebug.remote_port=9000 ;idekey 对接调试的密钥,调试时候和 PhpStorm 里面的务必保持一致 xdebug.idekey=PHPSTORM ;自动触发调试,可以将这个值设为1 xdebug.remote_autostart=1 xdebug.remote_handler="dbgp" ;启用代码自动跟踪 xdebug.auto_trace = On ;启用性能检测分析 xdebug.profiler_enable = On xdebug.profiler_enable_trigger = On xdebug.profiler_output_name = profiler.out.%t.%p 配置完成后重启 php 或 容器 。 配置 PhpStorm, 打开设置按照下图配置 打开项目, 增加远程 Debug 配置 配置浏览器插件 最后安装浏览器插件, 这里用的 Google 浏览器 (如果是其他浏览器也可以通过对应的插件市场查找), 需要到插件市场下载 Xdebug Helper, 如果无法翻墙可以通过下载安装包然后导入即可。 安装完 Xdebug Helper 扩展后, 右键 Xdebug 图标选择选项点击进入配置页面, 配置为如下效果 然后在浏览器中输入即将调试的接口地址,在点击左键 Xdebug 图标,点击 Debug 按钮将 Xdebug 变为绿色 开始调试 第一步: 打开调试按钮, 设置代码断点和选择需要调试的服务 第二步: 点击开始调试按钮 第三步: 打开网站刷新, 将自动跳转至 PhpStorm, 显示如下调试内容 结尾!!! 整个 PhpStorm Xdebug 远程连接 Docker 断点调试及单元测试 教程完成了, 如果觉得本文对您有帮助, 记得点点赞哦~
2024年-2月-28日
126 阅读
0 评论
PHP
置顶
WebSocket 分布式架构解决方案
前导 近期遇到一件很奇怪的事情,时不时收到同事反馈说 部分用户无法接收到聊天室(WebSocket 服务)消息,然而在测试服以各种方式测试都无法复现这种现象。于是陷入沉思,因为这个问题必须解决,用户必须要退出聊天室再重新进去才能看到这些丢失的消息,已经影响到业务间客服与用户的正常沟通。 这到底是什么原因呢?而且没法在测服复现。 有人说,当脑袋混乱的时候要休息一会,于是我决定先放下这些思考去玩别的东西。在晚上睡觉之余,突然的灵感让我感觉要破案了!服务采用的是 PHP Swoole , 用户与客户端FD 的关系绑定是通过 Swoole Table (服务进程间内存共享) 实现, 我在各个环节确认了关系绑定都没问题情况下还出现 客户端FD 丢失,可能就是因为服务器被负载均衡 (SLB)了,因为测试服是单机。 第二天一早, 为了验证自己的猜测,我查看了在阿里云上的负载均衡服务配置,果然破案了!!!这个项目此前一直是单机服务,也不知道从何时开始 变成多节点服务了。 我来描述下为什么分布式服务的 WebSocket 会存在这种现象,而 分布式服务的 HTTP 却没有这样的问题呢?因为 WebSocket 有个用户与客户端标识(FD)关系需要绑定,而 HTTP 服务一般是不需要关注客户端标识(FD)的。WebSocket 服务端需要推送消息到用户所连接的客户端时,例如A、B两台服务器,用户1连接到聊天室(服务器A),客服1也连接到聊天室(服务器B), 这种情况下 显然用户1发消息给客服1 是对牛弹琴了,因为用户1发送消息后,服务器A会遍历该服务器内的所有用户与客户端标识(FD),然后取出所有客服1的FD 进行消息推送,而客服1连接的是服务器B,则对于用户1来说 客服1是不在线的, 所以用户1推送消息是推了个寂寞啊!!! 再如 你的服务是支持用户多设备、多平台同时在线也是一样的道理,这种情况下也就意味着可能用户的客户端标识(FD)会同时分布在 服务器A、服务器B、服务器C ...,那么用户在其中一台设备发送消息,在其他端登陆的该用户都应该要收到这条消息,单纯的根据用户所连接的服务去发送消息 那么其他端在线的该用户都无法收到此消息了,群发也是一样的道理。 多节点问题 在开始思考分布式会有什么问题时,先来回答一个问题: 服务端如何与客户端交流? 在 WebSocket 服务端,每当与客户端连接成功后,会生成一个 唯一的客户端标识符 FD,WebSocket 会维护一个与客户端所有连接的 Connections。在业务层,你需要将每个连接进来的客户端标识(FD)与项目的用户ID绑定起来,比如用 Redis 将用户和客户端标识(FD) 保存起来,当客户端断开连接时解绑(删除掉对应的客户端标识(FD)),因为我的是用的PHP Swoole,所以我用的是 Swoole Table (服务进程间内存共享) 实现用户与客户端标识(FD)绑定关系。这样你就可以知道某个用户在不在线,并且这个用户的客户端标识(FD)有哪些,然后遍历 Swoole Table 把用户的所有客户端标识(FD)取出来循环推送消息给客户端。 那如何给所有人广播消息呢? 服务器只需要与它自身的所有客户端连接 Server.Connections 挨个发消息就是广播,所以它只是一个伪广播: 我要给群里所有人发消息,但我不能在群里发,只能挨个私发。 单节点 当单节点时,流程如下: 这时所有用户都能收到消息通知。 多节点 当多节点时,就会有部分用户无法正常收到通知 (就是我文中开头所描述的现象),从以下流程图中可以很清楚地看到问题所在: 负载到节点B 的所有用户都没有收到消息通知。 如何解决 说了这么多,怎么解决这个问题呢? 网上的很多教程,有些是通过 WebSocket 中间服务转发器、网关转发器 等实现方案,但这些实现方式有局限性,因为这些方案大部分是需要判断用户在哪台服务器上(需要知道IP),然后转发层将请求转发到用户所在服务器上。这种方案用户单端登录还好,如果用户多端登录 请求被转发到多服务器上同时处理相关逻辑显然是有问题的,比如新增数据、修改数据...这些操作等,这种架构解决方案 用户多点平台登录时调整复杂度会变得较高。 将 Swoole Table (服务进程间内存共享) 改造为 Redis 哈希 来实现用户与客户端标识(FD)绑定关系,主要目的是在单节点处理逻辑的时候经常需要判断对端用户是否在线,单服务内的共享内存并不能知道其他服务内该用户是否在线,所以这个方案不可取了。改用 分布式缓存 就可以判断出对端用户是否在线了。 分布式缓存实现用户与客户端标识(FD)绑定关系大致做法为: 在服务启动时创建一个 全局唯一ID,保证多服务下这个 ID的唯一性,比如启动5个服务时,每个服务的ID都不能有相同,目的是用来分布式缓存的客户端FD标识所在的服务ID,当然 你也可以使用IP作为唯一性(可能会更直观点)。 将 唯一ID_FD 作为哈希键存储,在某个事件或定时清除不活跃的哈希键。要当前某个服务的所有哈希键的时候可以使用 hScan 循环迭代模糊匹配实现,必要时使用 hGetAll 获取所有哈希键值(并发高服务 在此提醒谨慎使用哈)。 多节点服务器就会有分布式问题,解决分布式问题就找一个大家都能找到的地,比如说 MQTT、Kafka、RabbitMQ 等消息中间件,另外使用 Redis 的发布\订阅(pub\sub)功能 也一样可以实现,不过在此我选择的是用 RabbitMQ 来实现。 改进后流程图如下: 负载均衡(SLB) 内所有服务启动时都绑定同一个RabbitMQ Fanout(广播模式) 交换机, 如果该交换机不存在则创建。然后每个服务都生成一个唯一的该交换机队列(生成的交换机队列不能相同, 比如可以服务器1生成的队列名为 S1, 服务器2生成的队列名为 S2), 可以将生成的队列设置为 auto_delete: true, 这样就可以达到当 队列没有消费者的时候该队列会自动删除, 服务重启时又重新生成的效果。接下来就是每个服务都注册该交换机队列的监听消费,当队列的每一条息出栈时都会广播到该交换机下的所有队列(即所有服务的队列监听事件都能收到PUSH进来的消息)。 客户端请求到 负载均衡(SLB) 任意一台服务器 该服务器逻辑处理完后将要发送给客户端的消息推送至 RabbitMQ 消息队列 消息队列将该消息广播到所有服务器的监听消费事件内 所有服务器的监听消费事件内 Redis hScan 迭代遍历当前服务内所有客户端连接,取出所有符合用户ID对应的客户端标识(FD)进行推送消息。(并发高时对 Redis 冲击很大,需要预估支撑力,对缓存哈希的读要求随并发高低而上升 O(n)) 这种 WebSocket 分布式架构解决方案同时 实现了支持单个用户多设备、多平台同时在线的场景,不需要知道有多少台服务器(也就是说服务器可以无限动态扩容),不需要知道用户对应哪些服务器,也不需要知道各个服务器的IP地址,只需要处理各自服务器内的监听消费队列即可。相对于一些通过搭建转发服务器、网关服务器等实现的 WebSocket 分布式架构 有着天然的优势,这些架构解决方案要复杂很多,特别是要实现多设备、多平台同时在线的场景时 更加、更加、更加复杂。
2023年-8月-13日
229 阅读
0 评论
服务架构
置顶
App Store Server API 实践总结
最近售后反馈一个问题,用户苹果支付了3笔订单 (截图也是3笔订单),其中一笔订单的虚拟币未到账。整个支付流程大概是这样的: 用户通过苹果支付后,客户端将苹果支付凭证传递给后端 后端通过支付凭证调用苹果服务的验签接口校验是否已支付 校验通过后将判断订单是否已支付,凭证是否唯一的(曾经未被校验过) 上报支付订单 充值虚拟币 我通过后台订单及日志发现实际上是2笔订单,那么还有一笔是啥情况?要么是用户支付完成客户端未调用支付接口,要么是用户支付完成后客户端没有拿到支付凭证。将这个问题反馈到客户端,客户端通过日志排查也未发现问题,通过苹果后台查看到账信息也不太可靠(不是实时的) 而且也不确定支付人的信息是否此人。于是想到截图中有个订单号(客户端告诉我这个是订单号的,其实我不知道 HAHAHA) 对于这种问题涉及到金额的问题,必须要给用户一个合理解释,哪怕是花上几个小时的时间也得搞清楚来龙去脉! 接下来我就得考虑通过这个订单看能不能调用苹果服务API获取到有效信息了。 翻阅了下 App Store Server API 发现了线索 查询用户订单的收据 # 使用订单ID从收据中获取用户的应用内购买项目收据信息 GET https://api.storekit.itunes.apple.com/inApps/v1/lookup/{orderId} 查询用户历史收据 # 获取用户在您的 app 的应用内购买交易历史记录 GET https://api.storekit.itunes.apple.com/inApps/v1/history/{originalTransactionId} 查询用户内购退款 # 获取 app 中为用户退款的所有应用内购买项目的列表 GET https://api.storekit.itunes.apple.com/inApps/v1/refund/lookup/{originalTransactionId} 查询用户订阅项目状态 # 获取您 app 中用户所有订阅的状态 GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId} 提交防欺诈信息 # 当用户申请退款时,苹果通知(CONSUMPTION_REQUEST)开发者服务器,开发者可在12小时内,提供用户的信息(比如游戏金币是否已消费、用户充值过多少钱、退款过多少钱等),最后苹果收到这些信息,协助“退款决策系统” 来决定是否允许用户退款 PUT https://api.storekit.itunes.apple.com/inApps/v1/transactions/consumption/{originalTransactionId} 延长用户订阅的时长 # 使用原始交易标识符延长用户有效订阅的续订日期。(相当于免费给用户增加订阅时长) PUT https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/extend/{originalTransactionId} App Store Server API 是苹果提供给开发者,通过服务器来管理用户在 App Store 应用内购买的一套接口(REST API) 线上环境的 URL https://api.storekit.itunes.apple.com/ 沙盒环境测试 URL https://api.storekit-sandbox.itunes.apple.com/ 在这里我们需要调用的是 查询用户订单的收据 接口,整个流程比较麻烦,需要鉴权、苹果后台获取密钥串、数据解密等,在这里就不一一表述了,当时是写了个 Python 拿到了具体的信息作对比,我直接贴生成密钥的方式和实现代码吧。 生成密钥 ID(kid) kid 和 iss 值是从 App Store Connect 后台创建和获取。 要生成密钥,您必须在 App Store Connect 中具有管理员角色或帐户持有人角色。登录 App Store Connect 并完成以下步骤: 选择 “用户和访问”,然后选择 “密钥” 子标签页。 在 “密钥类型” 下选择 “App内购买项目”。 单击 “生成API内购买项目密钥”(如果之前创建过,则点击 “添加(+)” 按钮新增。)。 输入密钥的名称。该名称仅供您参考,名字不作为密钥的一部分。 单击 “生成”。 生成的密钥,有一列名为 “密钥 ID” 就是 kid 的值,鼠标移动到文字就会显示 拷贝密钥 ID,点击按钮就可以复制 kid 值。 生成 Issuer(iss) 同理,iss 值的生成,类似: issuer ID 值就是 iss 的值。 生成和签名 JWT 获取到这里参数后,就需要签名,那么还需要签名的密钥文件。 下载并保存密钥文件 App Store Connect 密钥文件,在刚才生成 kid 时,列表右边有 下载 App 内购买项目密钥 按钮(仅当您尚未下载私钥时,才会显示下载链接) 此私钥只能一次性下载!Apple 不保留私钥的副本,将您的私钥存放在安全的地方。 注意:将您的私钥存放在安全的地方。不要共享密钥,不要将密钥存储在代码仓库中,不要将密钥放在客户端代码中。如果您怀疑私钥被盗,请立即在 App Store Connect 中撤销密钥。 API密钥有两个部分:苹果保留的公钥和您下载的私钥。开发者使用私钥对授权 API 在 App Store 中访问数据的令牌进行签名。 App Store Server API 密钥是 App Store Server API 所独有的,不能用于其他 Apple 服务(比如 Sign in with Apple 服务或 App Store Connet API 服务等)。 import base64 import json import jwt import requests import time from builtins import int, print from io import open # 读取密钥文件证书内容 (这个私钥证书在苹果后台获取, 上述获取密钥时有讲到) f = open("/Users/linshan/Downloads/SubscriptionKey_Y8MPN22MHA.p8") key_data = f.read() f.close() # JWT Header header = { # App Store Server API 的所有 JWT 都必须使用 ES256 加密进行签名 "alg": "ES256", # 您的私钥ID,值来自 App Store Connect "kid": "Y8MPN22MHA", "typ": "JWT" } # JWT Payload payload = { # 您的发卡机构ID,值来自 App Store Connect 的密钥页面 "iss": "6e043b68-462d-4e0a-a9a3-7c8d344af428", # 固定值 "aud": "appstoreconnect-v1", # 秒,以 UNIX 时间(例如:1623085200)发布令牌的时间 "iat": int(time.time()), # 秒,令牌的到期时间,以 UNIX 时间为单位。在iat中超过 60 分钟过期的令牌无效(例如:1623086400) "exp": int(time.time()) + 60 * 60, # 您仅创建和使用一次的任意数字(例如: "6edffe66-b482-11eb-8529-0242ac130003")。可以理解为 UUID 值。 "nonce": time.time(), # 您的 app 的套装ID(例如:“com.example.testbundleid2021”), 可以向客户端提供。 "bid": "com.ls.stxl" } # JWT token token = jwt.encode(headers=header, payload=payload, key=key_data, algorithm="ES256") print("JWT Token:", token) # 请求链接和参数 (`MNHX98BS4X` 值为订单号) url = "https://api.storekit.itunes.apple.com/inApps/v1/lookup/" + "MNHX98BS4X" header = { "Authorization": f"Bearer {token}" } # 请求和响应 rs = requests.get(url, headers=header) data = json.loads(rs.text) print("Result:", data) if data['status'] == 0: for item in data['signedTransactions']: item = item.split('.') item = base64.b64decode(item[1]+"=") value = json.loads(item) print("Order Info", value) 运行得到信息 具体里面的内容通过简单的分析就能得到,这里不再解释,主要就是根据用户订单支付时间作对比。最终发现是苹果订单支付回调给用户的问题(用户使用的是信用卡支付),即今天支付后有个明细,可能第二天才回调到用户的实际支付明细,给用户产生了被多次扣费的假象。 嗯,终于可以给售后答复了。。。
2023年-4月-2日
78 阅读
0 评论
其他
置顶
数据结构与算法之稀疏数组
概念 当一个数组中存储了大量为0或者大量相同的数时, 为了缩小程序的规模, 我们可以使用稀疏数组来保存该数组。 处理方式是 通过记录数组一共有几行几列, 有多少个不同的值, 把具有不同值的元素的 行列值 记录在一个小规模数组(稀疏数组)中, 从而缩小程序的规模。 如果不太好理解,这里就举个最经典的五子棋场景。在编写一个五子棋的程序当中,要实现存盘退出和续上盘的功能。如下图所示: 存在一个棋盘, 退出保存的时候应该怎么存储呢? 使用一个二维数组, 1表示红棋, 2表示黑棋, 0表示没有下过的棋。 因为该二维数组的大部分值都是默认的0, 因此记录了很多没有意义的数据, 这个时候就可以使用到 稀疏数组。 实现思路 步骤一 创建二维数组作为源数据, 我们看下源数据效果: 步骤二 遍历原始的二维数组, 将源数据中的有效数据转换为稀疏数组, 并把总行数、总列数和默认值也作为一个元素写入到稀疏数组第一个元素位置。可以看到, 实际有效数据有7个, 但是会产生8个元素, 用来记录总行总列。 步骤三 将稀疏数组复原为源数据, 先单独处理第一行, 创建原始的默认二维数组, 然后再读取稀疏数组中的其他行数据并赋值给二维数组即可。 废话不多说, 直接上代码(Go语言编写) package main import ( "fmt" ) /******* 稀疏数组 - 五子棋案例 *******/ const ( ROW = 11 COL = 11 ) var ( classmap [ROW][COL]int ) type nodeval struct { row int col int val interface{} } func main() { // 输出原始数据 originalData() // 转换为稀疏数组存档 sparsearray := transformationSparseArrayStorage() // 转换为原始数据 transformationOriginalData(sparsearray) } // 原始数据 func originalData() { classmap[1][3] = 2 classmap[2][3] = 1 classmap[5][2] = 1 classmap[6][8] = 2 classmap[7][3] = 1 classmap[4][5] = 1 classmap[6][6] = 2 // 查看数据 for _, v := range classmap { for _, v1 := range v { fmt.Printf("%d\t", v1) } fmt.Println() // 换行输出, 避免影响数据显示 } // 输出: /** 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 2 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 */ } // 转换为稀疏数组存档 func transformationSparseArrayStorage() []nodeval { // 转为稀疏数组 (这里使用切片实现) var sparsearray []nodeval // 创建数据模型 sparsearray = append(sparsearray, nodeval{ row: ROW, col: COL, val: 0, }) // 存档 for row, val := range classmap { for col, val1 := range val { if val1 == 0 { continue } // 加入到切片 sparsearray = append(sparsearray, nodeval{ row: row, col: col, val: val1, }) } } fmt.Println(sparsearray) // 此数据可存档入文件、缓存、数据库等, 输出: [{11 11 0} {1 3 2} {2 3 1} {4 5 1} {5 2 1} {6 6 2} {6 8 2} {7 3 1}] return sparsearray } // 转换为原始数据 func transformationOriginalData(sparsearray []nodeval) { var arr [][]int for key, nv := range sparsearray { var v int switch nv.val.(type) { case int: v = nv.val.(int) default: } if key == 0 { // 制作原始表格初始数据 for i := 0; i < nv.row; i++ { var temp []int for j := 0; j < nv.col; j++ { temp = append(temp, v) } arr = append(arr, temp) } // fmt.Println(arr) /** [ [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] [0 0 0 0 0 0 0 0 0 0 0] ] */ } else { // 棋盘数组内容赋值 arr[nv.row][nv.col] = v } } // 输出数据 for _, v := range arr { for _, v1 := range v { fmt.Printf("%d\t", v1) } fmt.Println() // 换行输出, 避免影响数据显示 } /** 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 2 0 2 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 */ }
2023年-3月-1日
91 阅读
0 评论
基础原理
2023-2-11
Golang 切片与内存布局
切片(Slice) - 定义说明 在 Golang 中切片是一个 引用类型,在 进行传递时, 遵守引用传递机制。这种数据结构更 便于使用和管理数据集合。 切片是围绕 动态数组 的概念构建的, 可以 按需自动增长和缩小。切片的动态增长是通过内置函数 append() 来实现的, 这个函数可以快速且高效地增长切片(容量自动以切片长度的倍数扩容), 也可以通过对切片再次切割, 缩小一个切片的大小。 因为切片的底层也是在连续的内存块中分配的, 所以 切片还能获得索引、迭代以及为垃圾回收优化的好处。切片的使用和数组类似, 遍历切片、访问切片的元素和求切片的长度 len() 都一样。 我简单描述下切片的使用姿势和演示说明 package main import ( "fmt" ) func main() { // 声明/定义 var slice []int var intArr [5]int = [...]int{1, 2, 3, 4, 5} // 定义一个切片, 让切片去引用一个已经创建好的数组, 将数组下标为1的元素到下标为3的元素(不包含3) 转为切片 slice := intArr[1:3] fmt.Println(slice) // 输出: [2 3] // 改变 slice 值 slice[1] = 10 fmt.Println(slice) // 输出: [2 10] fmt.Println(intArr) // 输出: [1 2 10 4 5] (由于切片是引用类型, 所以如果切片内将这块引用内容值做修改, 将会改变 intArr 数组内容值, 下面将用内存示意图证明) fmt.Println(reflect.TypeOf(slice).Kind()) // 数据类型为切片 slice fmt.Println(len(slice), cap(slice)) // 大小为:2 容量为:4 // slice 底层可以理解为数据结构 struct 结构体 /* type slice struct { ptr *[2]int // [2]int 根据切片而变化 len int cap int } */ 就以上代码我这边整理成了内存布局, 看看内存结构是怎样的 从上面的内存图我们可以看到 (切片内存规则和数组内存规则类似, 可查阅! golang 数组与内存布局, 方便以下内容的理解): slice := intArr[1:3] 基于intArr 数组变量 将下标为1-2的元素创建一个新的切片。图中可以看到, slice 切片变量 的两个元素内存地址其实就是对应(指向)到 intArr 数组变量 为下标1-2的元素, slice 切片变量下标为0和1的元素内存地址 = intArr 数组变量下标为1和2的元素内存地址 = 0xc00011e038 和 0xc00011e040, 从而当切片这部分元素值发生变化时, 数组的值也同时发生变化(因为都是指向同个内存地址); 相反, 当数组的值发生变化, 切片的这部分元素也发生变化, 这个应该不难理解。 切片的长度 len 表示元素的数量, 而 cap 容量则是个 倍数扩容, 比如: 当元素数量为2的时候, 容量为4; 元素数量达到4的时候, 容量为8; 元素数量达到8的时候, 容量为16 ... 依此类推 切片的声明/定义有三种方式 定义一个切片, 让切片去引用一个已经创建好的数组, 比如上述的案例就是这种方式 通过 make 来创建切片, 基本语法: var 切片名(变量名) []type = make([], len, [cap]) 参数说明: type: 数据类型 | len: 大小 | cap: 指定切片容量, 可选(如果分配了cap, 则要求 cap >= len), 用处不大, 当切片大小达到这个容量时还是可以继续 append, cap 依然会发生变化 定义一个切片, 直接指定具体数组, 使用原理类似 make 方式, 例如: var slice []string = []string{"tom", "jack", "marry"} 特别说明下 make 创建切片的内存示意图 从上面的切片声明/定义来看, ·方式1· 和 ·方式2· 是有点区别的: 方式1 是直接引用数组, 这个数组是事先存在的, 程序员是可见的。 方式2 是通过make 来创建切片, make 其实也会创建一个数组, 由切片在底层进行维护, 程序员是不可见的。 而使用append扩容的时候, 内存的变化也是不一样的。 因为底层是数组维护, 数组是没办法扩容的。 1) 如果是添加的已存在的切片变量, 比如: var vnode_slice []int vnode_slice = append(vnode_slice, 1) var slice [][]int slice = append(slice, vnode_slice) 这种情况下, 底层会直接将内存地址同时指向 vnode_slice 变量的内存地址 2) 如果是添加新的数据扩容, 比如: var slice []int slice = append(slice, 1) 这种情况下, 底层会新创建一个数组并计算 len和 cap, 将原本的数据拷贝到该新数组中, 然后把原本指向的内存地址改为指向新创建的数组内存地址, (GC)垃圾回收掉之前的数组。 这里就不一一画图了, 脑海中联想出来即可。 好了, 就先介绍到这。
2023年-2月-11日
95 阅读
0 评论
GoLang
置顶
HTTP 常见面试题
在面试过程中,HTTP 被提问的概率还是比较高的。目前提及最多的问题大概如下: HTTP 基本概念 HTTP 是什么? HTTP 是 超文本传输协议,也就是 HyperText Transfer Protocol。 能否详细解释「超文本传输协议」? HTTP的名字 「超文本协议传输」,它可以拆成三个部分: 1.「协议」 在生活中,我们也能随处可见「协议」,例如: 刚毕业时会签一个「三方协议」; 找房子时会签一个「租房协议」; 生活中的协议,本质上与计算机中的协议是相同的,协议的特点: 「协」 字,代表的意思是 必须有两个以上的参与者。例如三方协议里的参与者有三个:你、公司、学校三个;租房协议里的参与者有两个:你和房东。 「议」 字,代表的意思是 对参与者的一种行为约定和规范。例如三方协议里规定试用期期限、毁约金等;租房协议里规定租期期限、每月租金金额、违约如何处理等。 针对 HTTP 协议,我们可以这么理解。 HTTP 是一个用在 计算机世界里的协议。它使用计算机能够理解的语言确立了一种计算机之间交流通信的规范(两个以上的参与者),以及相关的各种控制和错误处理方式(行为约定和规范)。 2.「传输」 所谓的「传输」,很好理解,就是把一堆东西从 A 点搬到 B 点,或者从 B 点 搬到 A 点。 别轻视了这个简单的动作,它至少包含两项重要的信息。 HTTP 协议是一个双向协议。 我们在上网冲浪时,浏览器是请求方 A ,百度网站就是应答方 B。双方约定用 HTTP 协议来通信,于是浏览器把请求数据发送给网站,网站再把一些数据返回给浏览器,最后由浏览器渲染在屏幕,就可以看到图片、视频了。 数据虽然是在 A 和 B 之间传输,但 允许中间有中转或接力。 就好像第一排的同学想传递纸条给最后一排的同学,那么传递的过程中就需要经过好多个同学(中间人),这样的传输方式就从「A < --- > B」,变成了「A <-> N <-> M <-> B」。 而在 HTTP 里,需要中间人遵从 HTTP 协议,只要不打扰基本的数据传输,就可以添加任意额外的东西。 针对传输,我们可以进一步理解了 HTTP。 HTTP 是一个在计算机世界里专门用来 在两点之间传输数据的约定和规范。 3.「超文本」 HTTP 传输的内容是「超文本」。 我们先来理解「文本」,在互联网早期的时候只是简单的字符文字,但现在「文本」的涵义已经可以扩展为图片、视频、压缩包等,在 HTTP 眼里这些都算作「文本」。 再来理解「超文本」,它就是 超越了普通文本的文本,它是文字、图片、视频等的混合体,最关键有超链接,能从一个超文本跳转到另外一个超文本。 HTML 就是最常见的超文本了,它本身只是纯文字文件,但内部用很多标签定义了图片、视频等的链接,再经过浏览器的解释,呈现给我们的就是一个文字、有画面的网页了。 OK,经过了对 HTTP 里这三个名词的详细解释,就可以给出比「超文本传输协议」这七个字更准确更有技术含量的答案: HTTP 是一个在计算机世界里专门 在「两点」之间「传输」文字、图片、音频、视频等「超文本」数据的「约定和规范」。 那「HTTP 是用于从互联网服务器传输超文本到本地浏览器的协议 ,这种说法正确吗? 这种说法是 不正确 的。因为也可以是「服务器< -- >服务器」,所以采用 两点之间 的描述会更准确。
2023年-2月-2日
82 阅读
0 评论
基础原理
2023-1-20
深入理解Golang中的chan特性之上篇
Golang 的一大特色就是其简单高效的 天然并发机制。 channel 是 Golang 语言中的一个 核心数据类型, 可以把他看成通道(管道), 所以他非常重要。主要用来解决go程的同步问题以及协程之间数据共享(数据传递)的问题。并发核心单元通过它就可以发送或者接收数据进行通讯,这在一定程度上又进一步降低了编程的难度。 Golang 中使用 goroutine 和 channel 实现了 CSP (Communicating Sequential Processes) 模型, channel 在 goroutine 的通信和同步中承担着重要的角色。 goroutine 运行在相同的地址空间,因此访问共享内存必须做好同步。 goroutine 奉行通过通信来共享内存,而不是共享内存来通信。 引用类型 channel 可用于多个 goroutine 通讯。其内部实现了同步,确保并发安全。 Channel 的定义与使用 和 map 类似,channel 也是一个对应 make 创建的底层数据结构的引用。 当我们复制一个channel或用于函数参数传递时,我们只是拷贝了一个channel 引用,因此调用者和被调用者将引用同一个channel对象。和其它的引用类型一样,channel的零值也是nil。 定义一个channel时,也需要定义发送到channel的值的类型。channel可以使用内置的make()函数来创建。 我们看一段代码: package main import ( "fmt" ) func main() { ch := make(chan int) // 这里就是创建一个无缓冲的 channel go func() { for i := 0; i <= 6; i++ { ch <- i // 循环写入管道数据 fmt.Println("写入管道数据", i) } }() for j := 0; j <= 6; j++ { value := <- ch // 循环读出管道数据 fmt.Println("读出管道数据", value) } } 我们在代码中 先创建了一个匿名函数的子go程,和main的主go程一起争夺 CPU,但是我们在里面创建了一个管道,无缓冲管道有一个规则那就是必须读写同时操作才会有效果,如果只进行读或者只进行写那么会被阻塞,被暂时停顿等待另外一方的操作,在这里我们定义了一个容量为0的通道,这就是无缓冲通道。 我们再看下面的代码加深 无缓冲通道 的印象 var c = make(chan int) go func() { // 等待 3秒后循环写入值到通道 time.Sleep(3 * time.Second) for i := 0; i < 5; i++ { c <- i } }() select { case value := <-c: fmt.Println(value) } fmt.Println("到此为止") // 输出结果: select 阻塞了 channel 通道, 将会先等待 3秒后打印 0, 然后打印 '到此为止' var c = make(chan int) go func() { // 等待 3秒后循环写入值到通道 time.Sleep(3 * time.Second) for i := 0; i < 5; i++ { c <- i } }() select { case value := <-c: fmt.Println(value) default: fmt.Println("我不等了") } fmt.Println("到此为止") // 输出结果: select 阻塞了 channel 通道, 但是有带 default, 将会直接打印 '我不等了', 然后打印 '到此为止' var c = make(chan int) go func() { // 等待 3秒后循环写入值到通道 time.Sleep(3 * time.Second) for i := 0; i < 5; i++ { c <- i } }() fmt.Println("等待中") <-c fmt.Println("到此为止") // 输出结果: 先打印 '等待中', 等待 3秒后打印 '到此为止' var c = make(chan int) go func() { // 等待 3秒后循环写入值到通道 time.Sleep(3 * time.Second) for i := 0; i < 5; i++ { c <- i } }() fmt.Println("等待中") value := <-c fmt.Println(value) fmt.Println("到此为止") // 输出结果: 先打印 '等待中', 等待 3秒后打印 0, 然后打印 '到此为止' 使用无缓冲的通道在 Goroutine 之间同步数据 (一次只能传输一个数据) 总结一下就是无缓冲特性: 同一时刻,同时有 读、写两端把持 channel 如果只有读端,没有写端,那么 “读端”阻塞 如果只有写端,没有读端,那么 “写端”阻塞 读channel: <- channel 写channel: channel <- 数据 使用有缓冲的通道在 Goroutine 之间同步数据 (读写数据可以同时进行) 我来描述下上面的步骤: 第一步 | 右侧的 goroutine 正在从通道接收一个值。 第二步 | 右侧的这个 goroutine 独立完成了接收值的动作,而左侧的 goroutine 正在发送一个新值到通道里。 第三步 | 左侧的 goroutine 还在向通道发送新值,而右侧的 goroutine 正在从通道接收另外一个值。这个步骤里的两个操作既不是同步的,也不会互相阻塞。 第四步 | 所有的发送和接收都完成,而通道里还有几个值,也有一些空间可以存更多的值。 有缓冲通道就是图中所示,一方可以写入很多数据,不用等对方的操作,而另外一方也可以直接拿出数据,不需要等对方写,但是注意一点(如果写入的一方把channel写满了,那么如果要继续写就要等对方取数据后才能继续写入,这也是一种阻塞,读出数据也是一样,如果里面没有数据则不能取,就要等对方写入)。 有缓冲channel的定义很简单: ch := make(chan int, 10) // chan int 只能写入读入int类型的数据, 10代表容量。 总结一下就是有缓冲特性: 写数据时, 直到缓冲区被填满后,“写端”才会阻塞 读数据时, 缓冲区被读空,“读端”才会阻塞 len: 代表缓冲区中,剩余元素个数 cap: 代表缓冲区的容量 最后, 举个简单的例子再次帮助理解 channel: 同步通信 (无缓冲channel): 数据发送端,和数据接收端,必须同时在线。 比如打电话, 只有等对方接收才会通,要不然只能阻塞。 异步通信 (有缓冲channel): 数据发送端 发送完数据后立即返回。数据接收端 有可能立即读取,也可能延迟处理。 不用等对方接受, 只需发送过去就行, 比如发短信, 发邮件...
2023年-1月-20日
122 阅读
0 评论
GoLang
2023-1-12
数据结构与算法之冒泡排序
概念 冒泡排序(Bubble Sort),是一种计算机科学领域的较简单的排序算法。 原理 比较两个相邻的元素,将值大的元素交换到右边。如果遇到相等的值不进行交换,那这种排序方式是稳定的排序方式。 思路 依次比较相邻的两个数,将比较小的数放在前面,比较大的数放在后面 1) 比较第1和第2个数,将小数放在前面,将大数放在后面。 2) 比较第2和第3个数,将小数放在前面,将大数放在后面。 ...依此类推 3) 当比较到第一轮最后两个数时候,将小数放在前面,将大数放在后面;其中最后一个数肯定是整个数组中的最大数,所以在接下来的轮次中是不需要参与比较的。 4) 在上面一轮比较完成后,需要重复如上步骤,每一轮比较次数依次减少,并且每一轮过后都会有一个不需要参与下轮比较的值,直至全部排序完成。 图解冒泡排序 需要参与冒泡排序的数组: [24, 0, 300, 69, 80, 4, 293, 57, 13] 算法分析可以看到: 逻辑处理: N个数字要排序完成,总共进行 N-1 轮次排序,每一次的排序次数为(N-i)次。所以可以用双重循环语句,外层控制循环总轮次,内层控制每一轮的循环次数。 规则原理: 每进行一轮次排序,就会少比较一次,因为每进行一轮排序都会找出一个较大值。如上例: 第一轮比较之后,排在最后的一个数一定是最大的一个数,第二趟排序的时候,只需要比较除了最后一个数以外的其他的数,同样也能找出一个最大的数排在参与第二轮比较的数后面,第三轮比较的时候,只需要比较除了最后两个数以外的其他的数,以此类推…… 也就是说,每进行一轮比较,每一轮少比较一次,一定程度上减少了算法的量。 时间复杂度: (1) 如果我们的数据正序,只需要走一轮即可完成排序。所需的比较次数C和记录移动次数M均达到最小值,即:Cmin=n-1; Mmin=0; 所以,冒泡排序最好的时间复杂度为O(n)。 (2) 如果很不幸我们的数据是反序的,则需要进行n-1轮次排序。每轮次排序要进行n-i次比较(1≤i≤n-1),且每次比较都必须移动记录三次来达到交换记录位置。在这种情况下,比较和移动次数均达到最大值。 冒泡排序总的平均时间复杂度为:O(n^2) [n的平方] , 时间复杂度和数据状况无关。 接下来用 Go 实现冒泡排序算法代码 package main import "fmt" /******* 冒泡排序法 *******/ func main() { var arr = [9]int{24, 0, 300, 69, 80, 4, 293, 57, 13} for j := 0; j < len(arr) - 1; j++ { var oldArr = arr var nextTotalLen = len(arr) - j - 1 for i := 0; i < len(arr); i++ { if i < nextTotalLen { var nextKey = i + 1 if arr[i] > arr[nextKey] { // 前面值大于后面值时, 位置置换 var nextVal = arr[i] arr[i] = arr[nextKey] arr[nextKey] = nextVal } } } // 值完全相同则不需要继续循环下去了 if oldArr == arr { break } } fmt.Println(arr) /** 输出: [0 4 13 24 57 69 80 293 300] */ }
2023年-1月-12日
74 阅读
0 评论
基础原理
2023-1-12
GoLang 数组与内存布局
数组(Array) - 定义说明 在 Golang 中数组是一个 值类型。如果将数组作为函数的参数类型,则在函数调用时该参数将发生数据复制。因此,在函数体中无法修改传入的数组的内容,因为函数内操作的只是所传入数组的一个副本。 我简单描述下数组的使用姿势和演示说明 package main import "fmt" func main() { /* 1) 四种初始化数组的方式 var arr1 [3]int = [3]int{1, 2, 3} var arr2 = [3]int{1, 2, 3} var arr3 = [...]int{1, 2, 3} // 程序自动判断数组长度 var arr4 = [3]string{1: "tom", 2: "marry", 0: "jack"} 2) 常规遍历数组 var arr = [3]int{1, 2, 3} for i := 0; i < len(arr); i++ { ... } 3) for-range 结构遍历数组 var arr = [3]int{1, 2, 3} for index, value := range arr { ... } 说明 1. 第一个返回值 index 是数组的下标 2. 第二个 value 是该下标位置对应的值 3. 他们都是仅在 for 循环内部可见的局部变量 4. 遍历数组元素的时候, 如果不想使用下标 index, 可以直接把下标 index 标为下划线 _ 表示 */ var ( arr [5]int // 数组长度是定长的(固定的), 当我们定义完数组后, 数组的各个元素的默认值都是0 ) arr[0], arr[1], arr[2], arr[3], arr[4] = 2, 3, 10, 21, 23 arrValue := 0 for i := 0; i < len(arr); i++ { arrValue += arr[i] } fmt.Println(arrValue) // 输出: 59 } 数组使用的注意事项和细节 数组是多个 相同类型 数据的集合, 一个数组一旦声明/定义, 其 长度是固定的, 不能动态变化。(如果要动态变化, 可以使用切片) 数组中的元素可以是任何数据类型, 包括值类型和引用类型, 但是 不能混用。 数组创建后, 如果没有赋值, 有默认值(零值)。 数组的下标都是从 0 开始的 数组的下标必须在指定的范围内使用, 否则报 panic 数组越界, 比如: var arr [5]int 则有效下标为 0-4 Go 的数组属 值类型, 在默认情况下是值传递, 因此会进行值拷贝, 数组间不会相互影响。 如果想在其他函数中去修改原来的数组值, 可以使用引用传递(指针方式) - [可以自行了解下栈原理], 例如: var arr [3]int func updateArr(arr [3]int) { (arr)[0] = 10 // arr[0] = 10 } updateArr(&arr) // 此时 arr 变量的内容为 {10, 0, 0}, 通过指针方式将数组数据修改 长度是数组类型的一部分, 在传递函数参数的时候, 需要考虑数组的长度。 从使用上来说没有什么难度, 在这也就不细说, 我将把重点放在内存原理上。 我们看看上述代码的数组在内存里是怎么存放的 从上面的内存图我们可以看到: 数组变量 arr 的内存地址和 数组下标为0 的内存地址是一样的, 由此分析出 数组的第一个元素内存地址就是数组变量的内存地址, 这个应该不会太难理解, 因为数组的第一个元素标志着该数组的存在。 可以看到这5个元素的内存地址其实是有规律的 元素1 0xc00001a0c0 元素2 0xc00001a0c8 元素3 0xc00001a0d0 元素4 0xc00001a0d8 元素5 0xc00001a0e0 尾数都是 0 和 8, 当尾数到 8 时, 前位数 进1, 比如 c 进 d, 2 进 3 ... 主要原因就是因为 int 类型是 8字节 的。如果数组是其他的数据类型也是一样, 根据不同的数据类型占用的字节数 满字节后进1。 (例如: int8 是占用1个字节, int16 是占用2个字节, string 是占用16个字节, 你可以通过 unsafe.Sizeof(arr) 函数查看字节占用) 如果有第六个元素, 那么它的地址应该是 0xc00001a0e8。 好了, 就先介绍到这。
2023年-1月-12日
85 阅读
0 评论
GoLang
2023-1-11
玩转 GoLang 指针
什么是指针? 指针其实就是一个变量, 用于存储另一个变量的内存地址。 那么什么是变量呢?在现代计算机体系结构中所有的需要执行的信息代码都需要存储在内存中,为了管理存储在内存的数据,内存是划分为不同的区域的,不同区域都用内存地址来标识。一个典型的内存地址是个16进制的8位数(一个字节)比如0xAFFFF(这是一个内存地址的十六进制表示)。 要访问数据,我们需要知道它的存储地址。我们可以跟踪存储与我们的程序相关的数据的所有内存地址。但是要记住这些内存地址,非常费劲,怎么办呢? 于是我们引入了变量的概念。变量只是给存储数据的内存地址的好记的别名。指针也是一个变量。但它是一种特殊的变量,因为它存储的数据不仅仅是一个普通的值(如整数或字符串),而是另一个变量的内存地址。 在上面的图中,指针p 指向 变量a 的地址值0x0001, 那么 指针p 的值是跟随 变量a 的地址值变化而变化的。 指针声明 T类型的指针使用以下语法声明: var p *int // 表示 p指针 只能保存int变量的内存地址。 指针的零值,不是0,而是nil。任何未初始化的指针值都为nil 初始化指针、指针解引用及指针值修改 初始化一个指针,只需给他赋予其他变量的内存地址。变量的地址可以使用使用&运算符获得: var x int = 199 var p *int = &x // 也可以简写为: var p = &x -> 编译器会自动推断指针变量的数据类型 fmt.Println(p) // 输出指针p的值, 即变量x的内存地址: 0xc0000b2008 // 获得指针指向地址的值, 也叫做解引用 fmt.Println(*p) // 输出指针p对应变量x 内存地址的值: 199 (也可以说是指针p的内存地址对应的值). fmt.Println(&p) // 输出指针p的内存地址: 0xc00001a0b8 fmt.Println(*&p) // 输出指针p的内存地址对应的值, 即变量x的内存地址: 0xc0000b2008 , 同 fmt.Println(&*p) 的结果是一样的 // 修改指针变量的值 *p += 100 fmt.Println(*p) // 输出: 299 通过上面代码的解释, 应该也基本理解了指针。 多重指针 指针可以指向任何类型的变量, 所以也可以指向另一个指针。 var x int = 199 var p *int = &x var pp **int = &p fmt.Println(**pp) // 输出: 199 (原理其实是一样的, 只是一层层取值而已) Go 中没有指针算术 在 C/C++ 中是可以对指针做计算的,但是 Golang 就不支持那样做了。 var x int = 199 var p *int = &x var p1 = p + 1 // Compiler Error: invalid operation 但是,Golang 中可以使用 == 运算符来比较两个相同类型的指针是否相等。 var x int = 199 var p *int = &x var p1 *int = &x if p == p1 { fmt.Println("ok") } else { fmt.Println("no") } 当然, 对于 Golang 算术, 仅仅依靠 == 运算符来说事, 确实显得很勉强。
2023年-1月-11日
84 阅读
0 评论
GoLang
1
2
3