- 使用Redis,你必须知道的21个注意要点
前言最近在学习Redis相关知识,看了阿里的redis开发规范,以及Redis开发与运维这本书。分使用规范、有坑的命令、项目实战操作、运维配置四个方向。整理了使用Redis的21个注意点,希望对大家有帮助,一起学习哈 [图片] 1、Redis的使用规范 1.1、 key的规范要点 我们设计Redis的key的时候,要注意以下这几个点: 以业务名为key前缀,用冒号隔开,以防止key冲突覆盖。如,live:rank:1确保key的语义清晰的情况下,key的长度尽量小于30个字符。key禁止包含特殊字符,如空格、换行、单双引号以及其他转义字符。Redis的key尽量设置ttl,以保证不使用的Key能被及时清理或淘汰。 1.2、value的规范要点 Redis的value值不可以随意设置的哦。 第一点,如果大量存储bigKey是会有问题的,会导致慢查询,内存增长过快等等。 如果是String类型,单个value大小控制10k以内。如果是hash、list、set、zset类型,元素个数一般不超过5000。 第二点,要选择适合的数据类型。不少小伙伴只用Redis的String类型,上来就是set和get。实际上,Redis 提供了丰富的数据结构类型,有些业务场景,更适合[代码]hash、zset[代码]等其他数据结果。 [图片] 反例: set user:666:name jay set user:666:age 18 正例 hmset user:666 name jay age 18 1.3. 给Key设置过期时间,同时注意不同业务的key,尽量过期时间分散一点因为Redis的数据是存在内存中的,而内存资源是很宝贵的。我们一般是把Redis当做缓存来用,而不是数据库,所以key的生命周期就不宜太长久啦。因此,你的key,一般建议用expire设置过期时间。 如果大量的key在某个时间点集中过期,到过期的那个时间点,Redis可能会存在卡顿,甚至出现缓存雪崩现象,因此一般不同业务的key,过期时间应该分散一些。有时候,同业务的,也可以在时间上加一个随机值,让过期时间分散一些。 1.4.建议使用批量操作提高效率 我们日常写SQL的时候,都知道,批量操作效率会更高,一次更新50条,比循环50次,每次更新一条效率更高。其实Redis操作命令也是这个道理。 Redis客户端执行一次命令可分为4个过程:1.发送命令-> 2.命令排队-> 3.命令执行-> 4. 返回结果。1和4 称为RRT(命令执行往返时间)。 Redis提供了批量操作命令,如mget、mset等,可有效节约RRT。但是呢,大部分的命令,是不支持批量操作的,比如hgetall,并没有mhgetall存在。Pipeline 则可以解决这个问题。 Pipeline是什么呢?它能将一组Redis命令进行组装,通过一次RTT传输给Redis,再将这组Redis命令的执行结果按顺序返回给客户端. 我们先来看下没有使用Pipeline执行了n条命令的模型: [图片] 使用Pipeline执行了n次命令,整个过程需要1次RTT,模型如下: [图片] 2、Redis 有坑的那些命令 2.1. 慎用[代码]O(n)[代码]复杂度命令,如[代码]hgetall[代码]、[代码]smember[代码],[代码]lrange[代码]等 因为Redis是单线程执行命令的。hgetall、smember等命令时间复杂度为O(n),当n持续增加时,会导致 Redis CPU 持续飙高,阻塞其他命令的执行。 hgetall、smember,lrange等这些命令不是一定不能使用,需要综合评估数据量,明确n的值,再去决定。比如hgetall,如果哈希元素n比较多的话,可以优先考虑使用hscan。 2.2 慎用Redis的monitor命令 Redis Monitor 命令用于实时打印出Redis服务器接收到的命令,如果我们想知道客户端对redis服务端做了哪些命令操作,就可以用Monitor 命令查看,但是它一般调试用而已,尽量不要在生产上用!因为monitor命令可能导致redis的内存持续飙升。 monitor的模型是酱紫的,它会将所有在Redis服务器执行的命令进行输出,一般来讲Redis服务器的QPS是很高的,也就是如果执行了monitor命令,Redis服务器在Monitor这个客户端的输出缓冲区又会有大量“存货”,也就占用了大量Redis内存。 [图片] 2.3、生产环境不能使用 keys指令 Redis Keys 命令用于查找所有符合给定模式pattern的key。如果想查看Redis 某类型的key有多少个,不少小伙伴想到用keys命令,如下: keys key前缀* 但是,redis的[代码]keys[代码]是遍历匹配的,复杂度是[代码]O(n)[代码],数据库数据越多就越慢。我们知道,redis是单线程的,如果数据比较多的话,keys指令就会导致redis线程阻塞,线上服务也会停顿了,直到指令执行完,服务才会恢复。因此,一般在生产环境,不要使用keys指令。官方文档也有声明: Warning: consider KEYS as a command that should only be used in production environments with extreme care. It may ruin performance when it is executed against large databases. This command is intended for debugging and special operations, such as changing your keyspace layout. Don't use KEYS in your regular application code. If you're looking for a way to find keys in a subset of your keyspace, consider using sets. 其实,可以使用scan指令,它同keys命令一样提供模式匹配功能。它的复杂度也是 O(n),但是它通过游标分步进行,不会阻塞redis线程;但是会有一定的重复概率,需要在客户端做一次去重。 scan支持增量式迭代命令,增量式迭代命令也是有缺点的:举个例子, 使用 SMEMBERS 命令可以返回集合键当前包含的所有元素, 但是对于 SCAN 这类增量式迭代命令来说, 因为在对键进行增量式迭代的过程中, 键可能会被修改, 所以增量式迭代命令只能对被返回的元素提供有限的保证 。 2.4 禁止使用flushall、flushdb Flushall 命令用于清空整个 Redis 服务器的数据(删除所有数据库的所有 key )。Flushdb 命令用于清空当前数据库中的所有 key。 这两命令是原子性的,不会终止执行。一旦开始执行,不会执行失败的。 2.5 注意使用del命令 删除key你一般使用什么命令?是直接del?如果删除一个key,直接使用del命令当然没问题。但是,你想过del的时间复杂度是多少嘛?我们分情况探讨一下: 如果删除一个String类型的key,时间复杂度就是[代码]O(1)[代码],可以直接del。如果删除一个List/Hash/Set/ZSet类型时,它的复杂度是[代码]O(n)[代码], n表示元素个数。 因此,如果你删除一个List/Hash/Set/ZSet类型的key时,元素越多,就越慢。当n很大时,要尤其注意,会阻塞主线程的。那么,如果不用del,我们应该怎么删除呢? 如果是List类型,你可以执行[代码]lpop或者rpop[代码],直到所有元素删除完成。如果是Hash/Set/ZSet类型,你可以先执行[代码]hscan/sscan/scan[代码]查询,再执行[代码]hdel/srem/zrem[代码]依次删除每个元素。 2.6 避免使用SORT、SINTER等复杂度过高的命令。 执行复杂度较高的命令,会消耗更多的 CPU 资源,会阻塞主线程。所以你要避免执行如[代码]SORT、SINTER、SINTERSTORE、ZUNIONSTORE、ZINTERSTORE[代码]等聚合命令,一般建议把它放到客户端来执行。 3、项目实战避坑操作 3.1 分布式锁使用的注意点 分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现。秒杀下单、抢红包等等业务场景,都需要用到分布式锁。我们经常使用Redis作为分布式锁,主要有这些注意点: 3.1.1 两个命令SETNX + EXPIRE分开写(典型错误实现范例) if(jedis.setnx(key_resource_id,lock_value) == 1){ //加锁 expire(key_resource_id,100); //设置过期时间 try { do something //业务请求 }catch(){ } finally { jedis.del(key_resource_id); //释放锁 } } 如果执行完[代码]setnx[代码]加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老”了,别的线程永远获取不到锁啦,所以一般分布式锁不能这么实现。 3.1.2 SETNX + value值是过期时间 (有些小伙伴是这么实现,有坑) long expires = System.currentTimeMillis() + expireTime; //系统时间+设置的过期时间 String expiresStr = String.valueOf(expires); // 如果当前锁不存在,返回加锁成功 if (jedis.setnx(key_resource_id, expiresStr) == 1) { return true; } // 如果锁已经存在,获取锁的过期时间 String currentValueStr = jedis.get(key_resource_id); // 如果获取到的过期时间,小于系统当前时间,表示已经过期 if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) { // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(不了解redis的getSet命令的小伙伴,可以去官网看下哈) String oldValueStr = jedis.getSet(key_resource_id, expiresStr); if (oldValueStr != null && oldValueStr.equals(currentValueStr)) { // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁 return true; } } //其他情况,均返回加锁失败 return false; } 这种方案的缺点: 过期时间是客户端自己生成的,分布式环境下,每个客户端的时间必须同步没有保存持有者的唯一标识,可能被别的客户端释放/解锁。锁过期的时候,并发多个客户端同时请求过来,都执行了[代码]jedis.getSet()[代码],最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖。 3.1.3: SET的扩展命令(SET EX PX NX)(注意可能存在的问题) if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁 try { do something //业务处理 }catch(){ } finally { jedis.del(key_resource_id); //释放锁 } } 这个方案还是可能存在问题: 锁过期释放了,业务还没执行完。锁被别的线程误删。 3.1.4 SET EX PX NX + 校验唯一随机值,再删除(解决了误删问题,还是存在锁过期,业务没执行完的问题) if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1){ //加锁 try { do something //业务处理 }catch(){ } finally { //判断是不是当前线程加的锁,是才释放 if (uni_request_id.equals(jedis.get(key_resource_id))) { jedis.del(lockKey); //释放锁 } } } 在这里,判断是不是当前线程加的锁和释放锁不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。 [图片] 一般也是用lua脚本代替。lua脚本如下: if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end; 3.1.5 Redisson框架 + Redlock算法 解决锁过期释放,业务没执行完问题+单机问题 Redisson 使用了一个[代码]Watch dog[代码]解决了锁过期释放,业务没执行完问题,Redisson原理图如下: [图片] 以上的分布式锁,还存在单机问题: [图片] 如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。 针对单机问题,可以使用Redlock算法。有兴趣的朋友可以看下我这篇文章哈,七种方案!探讨Redis分布式锁的正确使用姿势 3.2 缓存一致性注意点 如果是读请求,先读缓存,后读数据库如果写请求,先更新数据库,再写缓存每次更新数据后,需要清除缓存缓存一般都需要设置一定的过期失效一致性要求高的话,可以使用biglog+MQ保证。 感兴趣的小伙伴点击链接,了解更多详情~ http://github.crmeb.net/u/yi 3.3 合理评估Redis容量,避免由于频繁set覆盖,导致之前设置的过期时间无效。 我们知道,Redis的所有数据结构类型,都是可以设置过期时间的。假设一个字符串,已经设置了过期时间,你再去重新设置它,就会导致之前的过期时间无效。 [图片] Redis [代码]setKey[代码]源码如下: void setKey(redisDb *db,robj *key,robj *val) { if(lookupKeyWrite(db,key)==NULL) { dbAdd(db,key,val); }else{ dbOverwrite(db,key,val); } incrRefCount(val); removeExpire(db,key); //去掉过期时间 signalModifiedKey(db,key); } 实际业务开发中,同时我们要合理评估Redis的容量,避免频繁set覆盖,导致设置了过期时间的key失效。新手小白容易犯这个错误。 3.4 缓存穿透问题 先来看一个常见的缓存使用方式:读请求来了,先查下缓存,缓存有值命中,就直接返回;缓存没命中,就去查数据库,然后把数据库的值更新到缓存,再返回。 [图片] 缓存穿透:指查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,进而给数据库带来压力。 通俗点说,读请求访问时,缓存和数据库都没有某个值,这样就会导致每次对这个值的查询请求都会穿透到数据库,这就是缓存穿透。 缓存穿透一般都是这几种情况产生的: 业务不合理的设计,比如大多数用户都没开守护,但是你的每个请求都去缓存,查询某个userid查询有没有守护。业务/运维/开发失误的操作,比如缓存和数据库的数据都被误删除了。黑客非法请求攻击,比如黑客故意捏造大量非法请求,以读取不存在的业务数据。 如何避免缓存穿透呢? 一般有三种方法。 如果是非法请求,我们在API入口,对参数进行校验,过滤非法值。如果查询数据库为空,我们可以给缓存设置个空值,或者默认值。但是如有有写请求进来的话,需要更新缓存哈,以保证缓存一致性,同时,最后给缓存设置适当的过期时间。(业务上比较常用,简单有效)使用布隆过滤器快速判断数据是否存在。即一个查询请求过来时,先通过布隆过滤器判断值是否存在,存在才继续往下查。 布隆过滤器原理:它由初始值为0的位图数组和N个哈希函数组成。一个对一个key进行N个hash算法获取N个值,在比特数组中将这N个值散列后设定为1,然后查的时候如果特定的这几个位置都为1,那么布隆过滤器判断该key存在。 3.5 缓存雪奔问题 缓存雪奔: 指缓存中数据大批量到过期时间,而查询数据量巨大,请求都直接访问数据库,引起数据库压力过大甚至down机。 缓存雪奔一般是由于大量数据同时过期造成的,对于这个原因,可通过均匀设置过期时间解决,即让过期时间相对离散一点。如采用一个较大固定值+一个较小的随机值,5小时+0到1800秒酱紫。Redis 故障宕机也可能引起缓存雪奔。这就需要构造Redis高可用集群啦。 3.6 缓存击穿问题 缓存击穿: 指热点key在某个时间点过期的时候,而恰好在这个时间点对这个Key有大量的并发请求过来,从而大量的请求打到db。 缓存击穿看着有点像,其实它两区别是,缓存雪奔是指数据库压力过大甚至down机,缓存击穿只是大量并发请求到了DB数据库层面。可以认为击穿是缓存雪奔的一个子集吧。有些文章认为它俩区别,是区别在于击穿针对某一热点key缓存,雪奔则是很多key。 解决方案就有两种: 1.使用互斥锁方案。缓存失效时,不是立即去加载db数据,而是先使用某些带成功返回的原子操作命令,如(Redis的setnx)去操作,成功的时候,再去加载db数据库数据和设置缓存。否则就去重试获取缓存。2. “永不过期”,是指没有设置过期时间,但是热点数据快要过期时,异步线程去更新和设置过期时间。 3.7、缓存热key问题 在Redis中,我们把访问频率高的key,称为热点key。如果某一热点key的请求到服务器主机时,由于请求量特别大,可能会导致主机资源不足,甚至宕机,从而影响正常的服务。 而热点Key是怎么产生的呢?主要原因有两个: 用户消费的数据远大于生产的数据,如秒杀、热点新闻等读多写少的场景。请求分片集中,超过单Redi服务器的性能,比如固定名称key,Hash落入同一台服务器,瞬间访问量极大,超过机器瓶颈,产生热点Key问题。 那么在日常开发中,如何识别到热点key呢? 凭经验判断哪些是热Key;客户端统计上报;服务代理层上报 如何解决热key问题? Redis集群扩容:增加分片副本,均衡读流量;对热key进行hash散列,比如将一个key备份为key1,key2……keyN,同样的数据N个备份,N个备份分布到不同分片,访问时可随机访问N个备份中的一个,进一步分担读流量;使用二级缓存,即JVM本地缓存,减少Redis的读请求。 4. Redis配置运维 4.1 使用长连接而不是短连接,并且合理配置客户端的连接池 如果使用短连接,每次都需要过 TCP 三次握手、四次挥手,会增加耗时。然而长连接的话,它建立一次连接,redis的命令就能一直使用,酱紫可以减少建立redis连接时间。连接池可以实现在客户端建立多个连接并且不释放,需要使用连接的时候,不用每次都创建连接,节省了耗时。但是需要合理设置参数,长时间不操作 Redis时,也需及时释放连接资源。 4.2 只使用 db0 Redis-standalone架构禁止使用非db0.原因有两个 一个连接,Redis执行命令select 0和select 1切换,会损耗新能。Redis Cluster 只支持 db0,要迁移的话,成本高 4.3 设置maxmemory + 恰当的淘汰策略。 为了防止内存积压膨胀。比如有些时候,业务量大起来了,redis的key被大量使用,内存直接不够了,运维小哥哥也忘记加大内存了。难道redis直接这样挂掉?所以需要根据实际业务,选好maxmemory-policy(最大内存淘汰策略),设置好过期时间。一共有8种内存淘汰策略: volatile-lru:当内存不足以容纳新写入数据时,从设置了过期时间的key中使用LRU(最近最少使用)算法进行淘汰;allkeys-lru:当内存不足以容纳新写入数据时,从所有key中使用LRU(最近最少使用)算法进行淘汰。volatile-lfu:4.0版本新增,当内存不足以容纳新写入数据时,在过期的key中,使用LFU算法进行删除key。allkeys-lfu:4.0版本新增,当内存不足以容纳新写入数据时,从所有key中使用LFU算法进行淘汰;volatile-random:当内存不足以容纳新写入数据时,从设置了过期时间的key中,随机淘汰数据;。allkeys-random:当内存不足以容纳新写入数据时,从所有key中随机淘汰数据。volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的key中,根据过期时间进行淘汰,越早过期的优先被淘汰;noeviction:默认策略,当内存不足以容纳新写入数据时,新写入操作会报错。 4.4 开启 lazy-free 机制 Redis4.0+版本支持lazy-free机制,如果你的Redis还是有bigKey这种玩意存在,建议把lazy-free开启。当开启它后,Redis 如果删除一个 bigkey 时,释放内存的耗时操作,会放到后台线程去执行,减少对主线程的阻塞影响。 [图片] 参考与感谢Redis 千万不要乱用KEYS命令,不然会挨打的阿里云Redis开发规范Redis 最佳实践指南:7个维度+43条使用规范Redis的缓存穿透及解决方法——布隆过滤器BloomFilter Redis 缓存性能实践及总结 作者:捡田螺的小男孩 感兴趣的小伙伴点击链接,了解详情~ http://github.crmeb.net/u/yi
2021-03-24 - 最佳实践丨在云函数内使用 Redis 扩展
什么时候应该使用 Redis?Redis 的适用场景包括但不仅限于: 计数器:因为 Redis 操作是原子性的,通过原子递增或递减来做高并发用户的数据计数,比如点赞数、收藏数、分享数、商品抢购时的库存量、商品文章总数、评论数量等;排行榜:Redis 支持集合和有序集合的数据结构,且运行在内存中,因此可以存储一些类似于排行榜的数据,比如最近、最热、点击率最高、活跃度最高、评论最多等等的文章、商品、用户等;哈希表:用户粉丝列表、用户点赞列表、用户收藏列表、用户关注列表等;自动排序:存储时间戳,随着时间的变化,按照用户关注用户的最新动态列表等自动排序;会话缓存:使用 Redis 进行会话缓存,将 web session 存放在 Redis 中;全页缓存 FPC:可以将服务端渲染结果的缓存在 Redis 中;记录用户操作信息:用户是否点赞、用户是否收藏、用户是否分享等。安装 Redis 拓展1、安装扩展打开腾讯云控制台,进入到环境详情页面,点击左侧的「扩展应用」,进入到扩展能力详情页,并点击 Redis 拓展,安装拓展。 [图片] 2、创建 Redis 实例倘若安装中没有实例(即还没有购买 Redis 数据库,点击新建实例),倘若已经有实例的可以跳过,进入下一步。 [图片] 购买 Redis 数据库,创建实例,配备好私有网络。 [图片] 创建好实例后回到扩展选择刚刚创建(或者已有的)的实例: [图片] 点击完成创建: [图片] 看到有如下扩展即安装成功: [图片] 3、获取 Redis 信息创建好后查看拓展相关信息(在这里面我们便可以看到一起创建好的云函数啦): [图片] 在云函数中使用 Redis云函数内可以通过 Redis 客户端连接和操作 Redis 实例,推荐使用。 1、安装依赖首先进入到 Redis 的云函数目录中,然后执行命令 [代码]npm init -y[代码]初始化一个配置文件。 随后,执行 [代码]npm install --save redis[代码] 来安装相应的依赖。 安装完成后,云函数目录下将会出现 package.json 文件,内容类似以下: { "name": "redis", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "redis": "^3.0.2" } } 2、调用 Redis接下来可以在代码中调用 Redis 数据库了。 由于使用了云开发的 Redis 拓展,系统运行环境中会自动带上相应的配置,你可以直接使用相应的环境变量来链接 Redis 数据库。 'use strict'; const redis = require('redis') let client = redis.createClient({ host: process.env.HOST, port: process.env.PORT, // 需要填写真实的密码 password: 'xxx' }) exports.main = async (event, context, callback) => { let res = await new Promise((resolve, reject) => { client.get('test', function (err, reply) { if (err) { resolve({ err }) } resolve({ data: reply.toString() }) }) }) return { res } } 产品介绍云开发(Tencent CloudBase,TCB)是腾讯云提供的云原生一体化开发环境和工具平台,为开发者提供高可用、自动弹性扩缩的后端云服务,包含计算、存储、托管等serverless化能力,可用于云端一体化开发多种端应用(小程序,公众号,Web 应用,Flutter 客户端等),帮助开发者统一构建和管理后端服务和云资源,避免了应用开发过程中繁琐的服务器搭建及运维,开发者可以专注于业务逻辑的实现,开发门槛更低,效率更高。 开通云开发:https://console.cloud.tencent.com/tcb?tdl_anchor=techsite 产品文档:https://cloud.tencent.com/product/tcb?from=12763 技术文档:https://cloudbase.net?from=10004 技术交流群、最新资讯关注微信公众号【腾讯云云开发】
2021-05-11 - 云开发云函数中使用Redis的最佳实践,包括五种常用数据结构和分布式全局锁
Redis因其拥有丰富的数据结构、基于单线程模型可以实现简易的分布式锁、单分片5w+ ops的超强性能等等特点,成为了大家处理高并发问题的最常用的缓存中间件。 那么云开发能不能使用Redis呢?答案是肯定的。 下面我介绍下云开发中Redis使用的最佳实践: 第一步、购买Redis,安装Redis扩展 参见官方文档:https://developers.weixin.qq.com/community/develop/article/doc/000a4446518488b6002c9fa3651813 吐槽一下,写这篇文章的原因之一就是上面的官方文档中的示例代码是在不堪入目,希望这篇文章能让小伙伴少踩些坑。 第二步、创建并部署测试云函数,配置云函数的网络环境 [图片] 第三步、编写代码 cache.js const Redis = require('ioredis') const redis = new Redis({ port: 6379, host: '1.1.1.1', family: 4, password: 'password', db: 0 }) exports.redis = redis /** * 加redis全局锁 * @param {锁的key} lockKey * @param {锁的值} lockValue * @param {持续时间,单位s} duration */ exports.lock = async function(lockKey, lockValue, duration) { const lockSuccess = await redis.set(lockKey, lockValue, 'EX', duration, 'NX') if (lockSuccess) { return true } else { return false } } /** * 解redis全局锁 * @param {锁的key} lockKey * @param {锁的值} lockValue */ exports.unlock = async function (lockKey, lockValue) { const existValue = await redis.get(lockKey) if (existValue == lockValue) { await redis.del(lockKey) } } 上面是操作redis的工具方法,可以打包放到云函数的层管理中,方便其他云函数引用。层管理使用方式参见官方文档:https://cloud.tencent.com/document/product/876/50940 index.js const cloud = require("wx-server-sdk") const cache = require('/opt/utils/cache.js') // 使用到了云函数的层管理 cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) global.cloud = cloud global.db = cloud.database() global._ = db.command global.$ = _.aggregate exports.main = async (event, context) => { context.callbackWaitsForEmptyEventLoop = false const wxContext = cloud.getWXContext() let appId = wxContext.APPID if (wxContext.FROM_APPID) { appId = wxContext.FROM_APPID } let unionId = wxContext.UNIONID if (wxContext.FROM_UNIONID) { unionId = wxContext.FROM_UNIONID } let openId = wxContext.OPENID if (wxContext.FROM_OPENID) { openId = wxContext.FROM_OPENID } // redis五种常用数据结构 // 字符串 await cache.redis.set('hello', 'world') // 无过期时间 await cache.redis.set('hello', 'world', 'EX', 60) // 过期时间60s let stringValue = await cache.redis.get('hello') console.log('string: ', stringValue) // hash await cache.redis.hset('hash', 'hello', 'world') let hashValue = await cache.redis.hget('hash', 'hello') console.log('hash: ', hashValue) // list await cache.redis.lpush('list', 'hello', 'world') let listList = await cache.redis.lrange('list', 0, -1) // 读取队列所有元素 await cache.redis.ltrim('list', 1, 0) // 清空队列 console.log('listList: ', listList) // set await cache.redis.sadd('set', 'hello', 'world') let setExist = await cache.redis.sismember('set', 'hello') // 检查元素是否在集合中 console.log('set: ', setExist) // zset await cache.redis.zadd('zset', 1, 'hello', 2, 'world') let zsetList = await cache.redis.zrange('zset', 0, -1, 'WITHSCORES') console.log('zsetList: ', zsetList) // redis实现分布式全局锁 // 加全局锁,锁的过期时间应根据实际业务调整 const createOrderLock = `createOrderLock:${unionId}` const ts = Date.now() if (!(await cache.lock(createOrderLock, ts, 3))) { return { code: 4, msg: '操作太频繁了' } } // 这边写全局互斥的业务逻辑代码 // 比如创建订单,一个用户同时只能并发创建一个订单 // 解全局锁 await cache.unlock(createOrderLock, ts) return { code: 0, data: {} } } 上面是测试云函数的入口文件,演示了redis五种常用数据结构和redis全局锁的使用方法。 最后还有个小tips,所有引用到cache.js的云函数需要安装ioredis的依赖,进入云函数目录,使用如下命令: npm install ioredis
2021-06-17 - 数据库性能优化指导
云开发的数据库虽然是高性能、支持弹性扩容,但是很多用户在使用的过程中,更加注重功能的实现,而忽视了数据库的设计、索引的创建以及语句的优化等对性能的影响,因此会遇到很多影响数据库性能的问题,因此这里特意总结一下云开发数据库性能优化的注意事项。 12.7.1 数据库性能与优化建议以下是一些影响数据库性能的优化建议,当然要结合具体的业务情况来处理,不能一概而论。尤其是一些请求量比较大、比较频繁,比如小程序首页的数据请求,数据库的优化要格外重视。 1、要合理使用索引 使用索引可以提高文档的查询、更新、删除、排序操作,所以要结合查询的情况,适当创建索引。要尽量避免全表扫描,首先应考虑在 where 及 order by 涉及的列上建立索引。更多索引的细节在索引的章节里有介绍。 2、擅于结合查询情况创建组合索引 对于包含多个字段(键)条件的查询,创建包含这些字段的组合索引是个不错的解决方案。组合索引遵循最左前缀原则,因此创建顺序很重要,如果对组合索引不了解,可以结合索引的命中情况来判断组合索引是否生效。要善于使用组合索引做到用最少的索引覆盖最多的查询。 3、查询时要尽可能通过条件和 limit 限制数据 在查询里 where 可以限制处理文档的数量,而在聚合运算中 match 要放在 group 前面,减少 group 操作要处理的文档数量。无论是普通查询还是聚合查询都应该使用 limit 限制返回的数据数量。 其实云开发针对普通查询 db.collection('dbName').get()默认都有 limit 限制,在小程序端的限制为 20 条(自定义上限也是 20 条),在云函数端的限制为 100 条(自定义上限可以设置为 1000 条),聚合则在小程序端和云函数端默认都为 20 条(自定义没有上限,几万条都可以,前提是取出来的数据不能大于 16M),也就是云开发数据库已经自带了一些性能优化,我们不应该把这些默认的限制当成是一种束缚,而去随意突破这些限制。 4、推荐在小程序端增删改查数据库 可以结合数据库的安全规则,让数据库的增删改查在小程序端进行,这样速度会更快,而且还可以节省云函数的资源。 云开发数据库的增删改查可以在小程序端进行,也可以在云函数端进行,那到底应该把数据库的增删改查放在小程序端还是云函数端呢?一般情况下建议放在小程序端,这样就只会消耗数据库请求的次数,而不会额外增加消耗云函数的资源使用量 GBs、外网出流量。而云函数虽然有数据库操作的更高的权限,但是小程序端结合安全规则也是可以让数据库的权限粒度更细,也能满足大部分权限要求。 5、尽可能限制返回的字段等数据量 如果查询无需返回整个文档或只是用来判断键值是否存在,普通查询可以通过 filed、聚合查询可以通过 project 来限制返回的字段,减少网络流量和客户端的内存使用。 { "title": "为什么要学习云开发", "content": "云开发是腾讯云为移动开发者提供的一站式后端云服务", "comments": [{ "name": "李东bbsky", "created_on": "2020-03-21T10:01:22Z", "comment": "云开发是微信生态下的最推荐的后台技术解决方案" }, { "name": "小明", "created_on": "2020-03-21T11:01:22Z", "comment": "云开发学起来太简单啦" }] } 云数据库是关系型数据库,一个记录里可以嵌套非常多的数组和对象,如果取出整个记录里的所有嵌套内容就太耗性能流量了,比如上面的嵌套数组,有时候业务上并不需要显示 comments 里的某些字段,是可以通过 field 的点表示法来限制返回的字段的。 //不显示comments里的created_on .field({ "comments.created_on":0 }) //只显示comments里的comment,comments里的其他字段都不显示 .field({ "comments.comment":1 }) 6、查询量大时建议不要用正则查询 正则表达式查询不能使用索引,执行的时间比大多数选择器更长,所以业务量比较大的地方,能不用正则查询就不用正则查询(尽量用其他方式来代替正则查询),即使使用正则查询也一定要尽可能的缩写模糊匹配的范围,比如使用开始匹配符 ^ 或结束匹配符 $ 。 比如有人是这样用正则查询的,他想根据省市来筛选客户来源数据,但是客户来源的地址 address 填写的是”广东省深圳市“或”广东深圳“,省市数据并不规范一致,于是使用正则进行模糊查询,但是如果你需要经常根据地址来筛选客户来源,那你应该在数据库对数据进行处理,比如 province 和 city 来清洗重组数据从而替代模糊查询。 7、尽可能使用更新指令 通过更新指令对文档进行修改,通常可以获得更好的性能,因为更新指令不需要查询到记录就可以直接对文档进行字段级的更新,尤其是不需要更新整个文档只需要更新部分字段的场景。 还是上面的那个记录为例,比如我们需要给文章添加评论,也就是往 comments 数组里添加值,我们可以使用 [代码]_.push[代码]来给数组字段进行字段级别的操作,而不是取出整个记录,然后把评论用数组的 concat 或 push 的方法添加到记录里,再更新整个记录: .update({ data:{ comments:_.push([{ "name": "小明", "created_on": "2020-03-21T11:01:22Z", "comment": "云开发学起来太简单啦" }]) } }) 云开发数据库一个记录可能会嵌套很多层,因此也会很大,使用更新指令进行字段级别的微操比直接使用 update 这种记录级别的更新性能要更好。 8、不要对太多数据进行排序 不要一次性取出太多的数据并对数据进行排序,如果需要排序,请尽量限制结果集中的数据量,比如我们可以先用 where、match 等操作限制数据量,也就是通常要把 orderBy 放在普通查询或聚合查询的最后面。 这里尤其强调的是,发现有不少人由于对数据库的排序 orderBy 与翻页 skip 没有理解,竟然把数据库所有数据使用遍历取出来之后再来排序,哪怕是数据量只有百千条,这也是不正确的处理方式,应该禁止这么干。排序使用数据库的普通查询或聚合查询的 orderBy 就可以做到了,云开发默认的 limit 数据限制不会影响排序的结果,禁止遍历取出所有数据再来排序的愚蠢行为。 当然如果业务会需要经常对同一数据的多个字段来排序,比如商品经常会按最新上架、价格高低、产地、折扣力度等进行排序,则建议一次性取出这些数据,存储在缓存中,使用 JavaScript 的数组来进行排序,而不是用数据库查询。 9、尽量少在业务量大的地方用以下查询指令 查询中的某些查询指令可能会导致性能低下,如判断字段是否存在的[代码]exists[代码],要求值不在给定的数组内的[代码]nin[代码],表示需满足任意多个查询筛选条件[代码]or[代码],表示需不满足指定的条件[代码]not[代码],尽量少在业务使用量比较大的地方用这些查询指令。 这里所说的尽量少用不代表不用,而是能够用最直接的方式就用最直接的方式代替,让数据库查询尽可能的简单而不是搞的过于复杂,尽可能少让查询指令做这些复杂的事情。 10、集合中文档的数量可以定期归档 集合中文档的数据量会影响查询性能,对不用的数据或过期的数据可以进行定期归档并删除。比如我们也可以借助于定时触发器周期性的对数据库里的数据进行备份、删除。 11、不要让数据库请求干多余的事情,尽量少干事 能够使用 JavaScript 替代的计算、数组、对象操作等,就尽量用 JavaScript 处理;能通过数据库设计让数据库查询少计算的就尽量合理设计数据库,要尽可能的让数据库少干活,不能一次查询多个指令、正则查询套来套去的。 12、在数据库设计时可以用内嵌文档来取代 lookup 云开发数据库是非关系型数据库,可以对经常要使用 lookup 跨表查询的情况做反范式化的内嵌文档设计,通过这种方式取代联表查询 lookup 可以提升不少性能。 减少使用联表查询 lookup 的使用的方式要注意两点,一是通过内嵌文档的方式是可以减少关系型数据库那种表与表之间的关联关系的,比如要联表取出博客里最新的 10 篇文章以及文章里相应的评论,这在关系型数据库里原本是需要联表查询的,但是当把评论内嵌到文章的集合里时,就不需要联表了;二是有的时候我们只是需要跨表而不是联表,可以通过多次查询来取代联表。 13、推荐使用短字段名 和关系型数据库不同的是,云开发数据库是文档型数据库,集合中的每一个文档都需要存储字段名,因此字段名的长度相比关系型数据库来说会需要更多的存储空间。 "comments": [{ "name": "李东bbsky", "created_on": "2020-03-21T10:01:22Z", "comment": "云开发是微信生态下的最推荐的后台技术解决方案" }, { "name": "小明", "created_on": "2020-03-21T11:01:22Z", "comment": "云开发学起来太简单啦" }] 这里的字段名 name、created_on、comment 有多少个记录,有多少个嵌套的对象就会被写多少次,有时候比字段的值还要长,是比较占空间的。 12.7.2 数据库设计以及处理的优化建议1、增加冗余字段在业务上有些关键的数据可以通过间接的方式查询获取到,但是由于查询时会存在计算、跨表等问题,这个时候建议新增一些冗余字段。 比如我们要统计文章下面的评论数,可能你将文章的评论独立建了一个集合如 comments,这时候要获取每篇文章的评论数是可以根据文章的 id 条件来 count 该文章有多少条评论的。或者你也可以把每篇文章的评论数组作为子文档内嵌到每个文章记录的 comments 字段,这个时候可以通过数组的长度来算出该文章的评论数。类似于评论数的还有点赞量、收藏量等,这些虽然都是可以通过 count 或数组 length 的方式来间接获取到的,但是在评论数很多的情况下,count 和数组的 length 是非常耗性能的,而且 count 还需要独立占据一个请求。 遇到这种情况,建议在数据库设计时,要用所谓的冗余字段来记录每篇文章的点赞量、评论数、收藏量,在小程序端直接用 inc 原子自增的方式更新该字段的值。 { "title": "为什么要学习云开发", "content": "云开发是腾讯云为移动开发者提供的一站式后端云服务", "commentNum":2, //新增一个评论数的字段 "comments": [{ "name": "李东bbsky", "created_on": "2020-03-21T10:01:22Z", "comment": "云开发是微信生态下的最推荐的后台技术解决方案" }, { "name": "小明", "created_on": "2020-03-21T11:01:22Z", "comment": "云开发学起来太简单啦" }] } 比如我们希望在博客的首页展示文章列表,而每篇文章要显示评论总数。虽然我们可以通过 comments 的数组长度以及如果存在二级三级评论(尤其是这种情况),也是可以通过数组方法获取到评论数,但是不如直接查询新增的冗余字段[代码]commentNum[代码]来得直接。 2、虚假删除有时候我们的业务会需要用户经常删除数据库里面的记录或记录里的数组的情况,但是删除数据是非常耗费性能的一件事,碰到业务高峰期,数据库就会出现性能问题。这个时候,建议新增冗余字段做虚假删除,比如给记录添加 delete 的字段,默认值为 false,当执行删除的时候,可以将字段的值设置 true,查询时只显示 delete 为 false 的记录,这样数据在前端就不显示了,做到了虚假删除,在业务低谷时比如凌晨可以结合定时触发器每天这个时候清理一遍。 3、尽量不要把数据库请求放到循环体内我们经常会有查询数据库里的数据,并对数据进行处理之后再写回数据库的需求,如果查询到的数据有很多条时,就会需要我们进行循环处理,不过这个时候一定要注意,不要把数据库请求放到循环体内,而是先一次性查询多条数据,在循环体内对数据进行处理之后再一次性写回数据库。 当然小程序有些接口不能进行数组操作,只能一条一条执行,比如发送订阅消息、上传文件等操作等,这个避免的不了的例外。但是有些是可以通过数据库的设计来规避这个问题的,比如把经常要新增大量记录的数据库设计为只需要新增内嵌文档的数组数据等。 4、尽量使用一个数据库请求代替多个数据库请求在数据库的设计上以及在数据库请求的代码上,尽可能用一个数据库请求来代替多个数据库请求,尤其是用户最常访问的首页,如果一个页面的数据库请求太多,会导致数据库的并发问题。有些数据能够缓存到小程序端就缓存到小程序度,不必过分强调数据的一致性。 5、规划好文档适时创建空字段我们有这样一个集合 user,最终会用来存储用户的个人信息,比如当我们在用户点击登录时会获取用户的昵称和头像,于是一般的逻辑是我们会在数据库创建一个记录,如下所示: _id:"", userInfo:{ "name":"李东bbsky", "avatarUrl":"头像地址" } 但是更好的方式是,我们应该创建一个完整的记录(按照最终的字段设计),哪怕现在还没有数据,也要一致性建好这些空字段,方便以后直接使用 update 的方式来往里面填充数据。 _id:"", userInfo:{ "name":"李东bbsky", "avatarUrl":"头像地址", "email":"", "address":"" ... }, stars:[],//存储点赞的文章 collect:[] //存储收藏的文章 12.7.3 慢查询与告警目前我们没法直接查看数据库请求所花费的时间,但是有一些其他数据作为佐证,在云函数端进行数据库请求,如果云函数的执行时间超过 100ms 甚至更多,则基本可以判定为慢查询,数据库需要优化。这时,慢查询不仅会影响数据库的性能,还会影响云函数的性能。 我们知道云函数和云数据库的并发都是非常依赖他们的耗时的,如果数据库查询速度变慢,查询一次耗时由几十毫秒增加到几百毫秒,甚至以秒计算,都是十分耗费资源和影响并发的: 云函数资源使用量 GBs:资源使用量 = 函数配置内存 X 运行计费时长,如果云函数里有数据库请求耗费了运行时长,云函数资源使用量也会增加;不过云函数的并发统一上限为 1000,通常是很难达到的;数据库的 QPS = 数据库同时连接数 * 1000ms/数据库请求的执行时间,如果数据库请求的执行时间出现大幅上升,QPS 也就会成倍的下降,非常影响数据库的并发,会出现[代码]Connection num overrun[代码]的报错。我们可以在云开发控制台设置-告警设置来给指定的云函数尤其是业务调用最频繁的云函数设置运行时间以及云函数运行错误的告警,以便随时了解云开发环境的运行状况。
2021-09-10 - 我用webpack4开发小程序
我用webpack4开发小程序 哈,本人是REACT系开发者,工作中需要不停的折腾webpack,为了顺带学习VUE的开发思想和思路,顺理成章的请缨为公司小程序打个框架基础。前期也去了解了下各个小程序开发框架,大体上是通过转义的思路来解决小程序和VUE/REACT的模板、逻辑关系,不做展开讨论了。只是从本人角度分享通过webpack来构建小程序的开发架构。 通过观察小程序的原有架构,不难发现其已经是一套比较完善的mvvm架构了(类VUE),融合了VUE及REACT的一些特点(以VUE为主),但却有一些不足,缺失了前端开发人员常用的npm包的引入,动态样式的编译等等提升开发效率的工作环境、模式。因此我想如果通过webpack4来为原有架构做一个有益的补充,这样原生架构不就很完美了吗? 思路 对等编译输出小程序项目的所有文件(严格按照小程序需要的文件及目录结构输出)。js/wxs通过babel编译输出,wxml/json直接输出,wxss通过stylus编译输出(我们使用stylus开发样式),顺带使用webpack抽离公共模块文件[代码]common.js[代码],并将runtime运行时抽离作为一个独立文件。这样既精简了代码,又享用到了webpack为我们带来的好处。嗯,看上去很简单嘛,实际上却是踩了不少的坑!脚上的茧老厚了~~~ webpack module配置 [代码]module: { rules: [ { test: /\.(wxml|axml)/, // 为支付宝小程序留了个伏笔,哈哈 use: [ relativeFileLoader(isWechat ? 'wxml' : 'axml'), // 这里使用file-loader简单封装了一下 'extract-loader', 'html-loader' ] }, { test: /\.(jp(e?)g|png|gif)$/, use: relativeFileLoader() }, { test: /\.wxss$/, include: SRC, use: relativeFileLoader(), }, { test: /\.wxs$/, include: SRC, exclude: /node_modules/, use: [ relativeFileLoader(), { loader: 'babel-loader', options: { babelrc: false, presets: [ 'es2015', 'stage-0' ] }, } ] }, { test: /\.js$/, use: { loader: 'happypack/loader', options: { id: 'babel' } }, exclude: /node_modules/, }, { test: /\.styl$/, include: SRC, use: [ relativeFileLoader(isWechat ? 'wxss' : 'acss'), 'stylus-loader' ] } ] }, [代码] 熟悉webpack的同学通过上面的moudle配置应该能够看出资源文件编译的思路,当然直接这样配置肯定做不到正确编译,还有一些坑需要踩 全文件entry 为了对等输出,我们需要把所有文件整理为entry给webpack处理,这样的好处是js能够使用npm包,所有文件都能够支持热更新机制(webpack的热更新响应非常快,gulp的热更新很难精细控制,当项目足够大的时候,响应很慢) [代码]function entries(dir) { var jsFiles = {} let _partten = /[\/|\\][_](\w)+/; let re_common = /(.*)\/common\// const accessExts = ['.wxml', '.wxss', '.styl', '.wxs', '.json', '.png', '.jpg', '.jpeg', '.gif'] if (fse.existsSync(dir)) { globby.sync([`${dir}/**/*`, `!${dir}/js/**/cloudfunctions`, '!node_modules', `!${dir}/dist`]).forEach(function (item) { if (!re_common.test(item)) { if (!_partten.test(item)) { const fileObj = path.parse(item) const xcxSrc = path.join(dir, 'js') if (~item.indexOf(xcxSrc)) { const fileStat = fs.statSync(item) const relativeFile = item.replace(xcxSrc, '') let relativeKey = relativeFile.replace(fileObj.ext, '').substring(1) if (fileObj.ext == '.js') { jsFiles[relativeKey] = item } else { if (accessExts.indexOf(fileObj.ext) > -1) { jsFiles['nobuild__' + relativeFile] = item } } } } } }) } return jsFiles } [代码] 上述是entry的生成代码,涵盖了小程序目录结构下的所有需要的文件,并加上了一些特定的标识,以便于后续文件编译输出 非JS文件的输出 在entry方法中我们将wxml,wxss等文件作为entry统统灌给webpack去处理,正常我们使用webpack时是不会把非js文件作为entry输给webpack的。你猜webpack会报错吗,----- 哈哈,报错就讲不下去了,webpack会傻傻的把每个entry文件都当做js来对待,并且正常输出,[代码]*.wxml.js[代码],等等,这是什么鬼,我并不需要这样的东东。加个插件来处理一下 [代码]compiler.hooks.compilation.tap('wpConcatFile', (compilation, params) => { compilation.hooks.beforeChunkAssets.tap('wpConcatFile', () => { compilation.chunks = compilation.chunks.filter(function (item) { return item.name.indexOf('nobuild__') == -1 }) }) ... ... } [代码] [代码]nobuild__[代码]是在生成entry代码是给非js文件加上的prefix前缀,在插件中我们排除掉非js,将正常的js文件重新chunk,js文件就能够正常的输出了,那么那些非js文件呢?webpack并不会编译生成它们,中途它们就会被module中的[代码]xx-loader[代码]处理完,然后被[代码]file-loader[代码]给甩出去了。 全局变量替换 将全局变量替换为微信小程序的[代码]wx[代码],我们通过插件解决 [代码]const globalVar = 'wx' ... ... ... let contentObj = compilation.assets[file] let code = contentObj.source() code = code.replace(windowRegExp, that.globalVar); contentObj = new RawSource(code) compilation.assets[file] = new ConcatSource( contentSource, '\n', '\/**auto import common&runtime js**\/', '\n', contentObj, ); [代码] 通过上述代码不难看出,我们读取了每个文件的源码,并将全局变量[代码]window/global[代码]替换为[代码]wx[代码],再进行源码重组。 运行时文件引入 我们需要引入[代码]runtime.js[代码]和[代码]common.js[代码]文件,[代码]runtime[代码]运行环境是webpack为每个编译文件插入的用于解析[代码]define, require, module[代码]等等这些的文件引入方法,为了精简文件,我们将之抽离为[代码]runtime.js[代码],[代码]common.js[代码]为我们抽离出来的公共模块文件。在web/h5下引入这些资源是不是so easy,但你还记得我们是在小程序环境下嘛,并不能通过[代码]<script>[代码]标签来引入资源文件啊啊啊,你会不会猛拍脑门,一下就慌了(哈哈)。老办法,我们通过插件解决 [代码]const lens = [] let posixPath = '' const matchIt = chunk.name.match(/\//g) if (matchIt) { matchIt.forEach(it => lens.push(this.prePath)) // posixPath = './'+lens.join('') posixPath = lens.join('') } else { posixPath = './' } let posixPathFile = posixPath + 'runtime.js' let contentSource = this.contentSource.replace('~~~~', posixPathFile) if (chunk.name.indexOf('runtime') > -1) { posixPathFile = posixPath + 'common.js' if (hasCommon) { contentSource = this.contentSource.replace('~~~~', posixPathFile) } else { contentSource = '' } } [代码] 上述代码片段中,[代码]posixPath[代码]是我们通过一个小的算法来推算资源引入的路径深度变量,输出并重写源文件chunk,这样我们就解决了资源引入的问题 webpack-dev-server 引入webpack-dev-server能够使得webpack的编译能够简单的输出到硬盘上,webpack默认是内存文件系统,并不输出(当然有其他方法,比如再写个插件或更换文件系统啥的),除了文件输出,webpack-dev-server还能够为我们提供mock数据服务,呵呵~,这里不展开了,大家有兴趣百度一下,还能够为我们访问后台接口作proxy,这里也不展开了。 通过上述操作,我们就能得到小程序结构的对等输出,剩下我们只需要将输出文件导入到小程序编辑器中,接下来就是开发工作了。嗯,这样就可以开始给小程序搬砖了,开心吗? 如果你想参考一下我们的编译代码,可以看这里 https://github.com/webkixi/aotoo-hub/blob/master/build/webpack.xcx.config.js 如果你想了解下我们的架构,可以看这里 https://github.com/webkixi/aotoo-hub 如果你想使用我们的架构,怕不怕?怕的话,你看着办吧,哈哈! 不怕看这里 https://www.npmjs.com/package/aotoo-cli 如果你还想看看我们的小程序,看这里 https://developers.weixin.qq.com/community/develop/article/doc/0006aafd158f40f4e588c546d5d013 [图片]
2020-02-08 - 使用 webpack 打包小程序
前言 针对目前市面上出现的各种小程序,如百度小程序,支付宝小程序,字节跳动小程序,快手小程序。 小程序原生开发对于开发者的开发体验并不是很友好,那如何解决这个问题呢? 问题点 样式预处理 TypeScript支持 多环境打包支持 可扩展性 如在小程序内支持md文档格式,json格式等 多端支持 问题如何解决 使用webpack 来处理以上一系列问题 webpack 官方介绍就是静态文件的打包器 [图片] 上图所做的工作就是 项目中所有的静态文件 如js wxss wxml png jpg 通过webpack处理,可以输出到特定环境下可以运行的代码,webpack就是导入一些入口文件然后通过编译输出结果 [图片] 回顾小程序 首先小程序由项目入口组成 脚本逻辑 [图片] 配置 [图片] 其次小程序也由页面/组件 组成 脚本逻辑 模版 样式 配置 因为小程序的项目中每个文件都是独立的,比如小程序一个页面由js,json,wxml.wxss组件,而不是像web一样可以将文件打包在一起,这样我们使用webpack打包小程序就有一定的限制条件,既然输出的结果一定要按照小程序的规范,那么入口文件也要根据要求去操作,可能webpack 配置就不是一个单页面的配置,就要考虑使用多入口的方式实现,那么就要解决如下问题点: 确定入口文件 在小程序的app.json中会有一个pages 里面都是小程序中所有的页面,那么我们可以通过一个方法getPages获取这些页面的所有内容如js,json,wxss,wxml等信息,但是有个问题就是因为页面中可能包含有很多组件部分,所以还需要去解析页面组件的依赖,可以通过页面json中 usingComponents 配置去解析 [图片] [图片] 如图所示,页面有很多组件组成,我们可以通过一个方法getComponents方式,用递归的方式获取到项目页面中所有的组件信息,包含组件模版,样式 编译模版 问题点:webpack 无法直接去编译小程序的wxml文件 如何解决:可以自定义一个loader去解析这些文件,让webpack能够识别到。 [图片] [图片] 通常导出⼀一个函数,⼊入参为上⼀一个 loader 的返回值,可以有多个⼊入参,通常为源码 返回值⼀一般为处理理后的代码,如要返回多个值,⽤用 this.callback() 使⽤用 loader-utils 来辅助处理理,例例如通过 loader-utils 获取 loader 传⼊入 options,⼀个 loader 只处理理单一事务 处理样式 可以使用如下loader编译小程序样式 saas-loader/less-loader -> postcss-loader -> css-loader -> filer-loader [图片] 处理配置 考虑一个问题,小程序的配置文件是一个json文件,我们是否可以通过webpack的file-loader去编译 [图片] 上图中的确可以处理我们页面的配置文件,但是有个问题就是如果页面文件夹中出现另一个json文件,比如是一些静态数据的json文件,我们知道小程序是不能从本地引用本地文件,所以必须要通过一个loader去做这些事 [图片] 移除不必要的文件 [图片] 通过file-loader 打包之后会出现以下红色框的文件,但是我们小程序是不需要这些文件的,那么如何移除呢?我们知道webpack的编译过程是 [图片] Compiler 负责⽂文件监听和启动编译,包含完整 webpack 配置,全局唯⼀ compiler.hooks.* entryOption run watchRun compilation Compilation 第⼀次以及监听到⽂文件变化创建,包含当前模块信息、编译⽣生成资源等信息 compilation.hooks.* buildModule optimize beforeChunkAssets optimizeChunkAssets [图片] 编译结果输出还不是很优雅还需要完善,后续要通过抽离公共代码,封装一些插件优化。。。
2020-04-28 - 云开发基础NodeJS
云函数的运行环境是 Node.js,我们可以在云函数中使用 Nodejs 内置模块以及使用 npm 安装第三方依赖来帮助我们更快的开发。借助于一些优秀的开源项目,避免了我们重复造轮子,相比于小程序端,能够大大扩展云函数的使用 云函数与 Nodejs由于云函数与 Nodejs 息息相关,需要我们对云函数与 Node 的模块以及 Nodejs 的一些基本知识有一些基本的了解。下面只介绍一些基础的概念,如果你想详细深入了解,建议去翻阅一下 Nodejs 的官方技术文档: 技术文档:Nodejs API 中文技术文档 Nodejs 的内置模块在前面我们已经接触过 Nodejs 的 fs 模块、path 模块,这些我们称之为 Nodejs 的内置模块,内置模块不需要我们使用 npm install 下载,就可以直接使用 require 引入: const fs = require('fs') const path = require('path') Nodejs 的常用内置模块以及功能如下所示,这些模块都是可以在云函数里直接使用的: fs 模块:文件目录的创建、删除、查询以及文件的读取和写入,下面的 createReadStream 方法类似于读取文件,path 模块:提供了一些用于处理文件路径的 APIurl 模块:用于处理与解析 URLhttp 模块:用于创建一个能够处理和响应 http 响应的服务querystring 模块:解析查询字符串until 模块 :提供用于解析和格式化 URL 查询字符串的实用工具;net 模块:用于创建基于流的 TCP 或 IPC 的服务器crypto 模块:提供加密功能,包括对 OpenSSL 的哈希、HMAC、加密、解密、签名、以及验证功能的一整套封装在云函数中使用 HTTP 请求访问第三方服务可以不受域名限制,即不需要像小程序端一样,要将域名添加到 request 合法域名里;也不受 http 和 https 的限制,没有域名只有 IP 都是可以的,所以云函数可以应用的场景非常多,即能方便的调用第三方服务,也能够充当一个功能复杂的完整应用的后端。不过需要注意的是,云函数是部署在云端,有些局域网等终端通信的业务只能在小程序里进行。 常用变量module、exports、require require 用于引入模块、 JSON、或本地文件。 可以从 node_modules 引入模块,可以使用相对路径(例如 ./、)引入本地模块或 JSON 文件,路径会根据 __dirname 定义的目录名或当前工作目录进行处理。 node 模块化遵循的是 commonjs 规范,CommonJs 定义的模块分为: 模块标识(module)、模块导出(exports) 、模块引用(require)。 在 node 中,一个文件即一个模块,使用 exports 和 require 来进行处理。 exports 表示该模块运行时生成的导出对象。如果按确切的文件名没有找到模块,则 Node.js 会尝试带上 .js、 .json 或 .node 拓展名再加载。 .js 文件会被解析为 JavaScript 文本文件, .json 文件会被解析为 JSON 文本文件。 .node 文件会被解析为通过 process.dlopen() 加载的编译后的插件模块。以 '/' 为前缀的模块是文件的绝对路径。 例如, require('/home/marco/foo.js') 会加载 /home/marco/foo.js 文件。以 './' 为前缀的模块是相对于调用 require() 的文件的。 也就是说, circle.js 必须和 foo.js 在同一目录下以便于 require('./circle') 找到它。 module.exports 用于指定一个模块所导出的内容,即可以通过 require() 访问的内容。 // 引入本地模块: const myLocalModule = require('./path/myLocalModule'); // 引入 JSON 文件: const jsonData = require('./path/filename.json'); // 引入 node_modules 模块或 Node.js 内置模块: const crypto = require('crypto'); wx-server-sdk 的模块tcb-admin-node、protobuf、jstslib 第三方模块Nodejs 有 npm 官网地址 Nodejs 库推荐:awesome Nodejs 当没有以 '/'、 './' 或 '../' 开头来表示文件时,这个模块必须是一个核心模块或加载自 node_modules 目录,比如 wx-server-sdk 就加载自 node_modules 文件夹: const cloud = require('wx-server-sdk') Lodash 实用工具库Lodash 是一个一致性、模块化、高性能的 JavaScript 实用工具库,通过降低 array、number、objects、string 等数据类型的使用难度从而让 JavaScript 变得更简单。Lodash 的模块化方法非常适用于:遍历 array、object 和 string;对值进行操作和检测;创建符合功能的函数。 技术文档:Lodash 官方文档、Lodash 中文文档 使用开发者工具新建一个云函数,比如 lodash,然后在 package.json 增加 lodash 最新版 latest 的依赖: "dependencies": { "lodash": "latest" } 在 index.js 里的代码修改为如下,这里使用到了 lodash 的 chunk 方法来分割数组: const cloud = require('wx-server-sdk') var _ = require('lodash'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) exports.main = async (event, context) => { //将数组拆分为长度为2的数组 const arr= _.chunk(['a', 'b', 'c', 'd'], 2); return arr } 右键 lodash 云函数目录,选择“在终端中打开”,npm install 安装模块之后右键部署并上传所有文件。我们就可以通过多种方式来调用它(前面已详细介绍)即可获得结果。Lodash 作为工具,非常好用且实用,它的源码也非常值得学习,更多相关内容则需要大家去 Github 和官方技术文档里深入了解。 在awesome Nodejs页面我们了解到还有 Ramba、immutable、Mout 等类似工具库,这些都非常推荐。借助于 Github 的 awesome 清单,我们就能一手掌握最酷炫好用的开源项目,避免了自己去收集收藏。 moment 时间处理开发小程序时经常需要格式化时间、处理相对时间、日历时间以及时间的多语言问题,这个时候就可以使用比较流行的 momentjs 了。 技术文档:moment 官方文档、moment 中文文档 使用开发者工具新建一个云函数,比如 moment,然后在 package.json 增加 moment 最新版 latest 的依赖: "dependencies": { "moment": "latest" } 在 index.js 里的代码修改为如下,我们将 moment 区域设置为中国,将时间格式化为 十二月 23 日 2019, 4:13:29 下午的样式以及相对时间多少分钟前: const cloud = require('wx-server-sdk') const moment = require("moment"); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) exports.main = async (event, context) => { moment.locale('zh-cn'); time1 = moment().format('MMMM Do YYYY, h:mm:ss a'); time2 = moment().startOf('hour').fromNow(); return { time1,time2} } 不过云函数中的时区为 UTC+0,不是 UTC+8,格式化得到的时间和在国内的时间是有 8 个小时的时间差的,我们可以给小时数+8,也可以修改时区。云函数修改时区我们可以使用 timezone 依赖(和 moment 是同一个开源作者)。 技术文档:timezone 技术文档 在 package.json 增加 moment-timezone 最新版 latest 的依赖,然后修改上面相应的代码即可,使用起来非常方便: const moment = require('moment-timezone'); time1 = moment().tz('Asia/Shanghai').format('MMMM Do YYYY, h:mm:ss a'); 获取公网 IP有时我们希望能够获取到服务器的公网 IP,比如用于 IP 地址的白名单,或者想根据 IP 查询到服务器所在的地址,ipify 就是一个免费好用的依赖,通过它我们也可以获取到云函数所在服务器的公网 IP。 技术文档:ipify Github 地址 使用开发者工具新建一个 getip 的云函数,然后输入以下代码,并在 package.json 的”dependencies”里新增 "ipify":"latest" ,即最新版的 ipify 依赖: const cloud = require('wx-server-sdk') const ipify = require('ipify'); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) exports.main = async (event, context) => { return await ipify({ useIPv6: false }) } 然后右键 getip 云函数根目录,选择在终端中打开,输入 npm install 安装依赖,之后上传并部署所有文件。我们可以在小程序端调用这个云函数,就可以得到云函数服务器的公网 IP,这个 IP 是随机而有限的几个,反复调用 getip,就能够穷举所有云函数所在服务器的 ip 了。 可能你会在使用云函数连接数据库或者用云函数来建微信公众号的后台时需要用到 IP 白名单,我们可以把这些 ip 都添加到白名单里面,这样云函数就可以做很多事情啦。 Buffer 文件流const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) exports.main = async (event, context) => { const fileID = 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/cloudbase/1576500614167-520.png' const res = await cloud.downloadFile({ fileID: fileID, }) const buffer = res.fileContent return buffer.toString('base64') } getServerImg(){ wx.cloud.callFunction({ name: 'downloadimg', success: res => { console.log("云函数返回的数据",res.result) this.setData({ img:res.result }) }, fail: err => { console.error('云函数调用失败:', err) } }) } "400px" height="200px" src="data:image/jpeg;base64,{{img}}">image> Buffer String Buffer JSON 图像处理 sharpsharp 是一个高速图像处理库,可以很方便的实现图片编辑操作,如裁剪、格式转换、旋转变换、滤镜添加、图片合成(如添加水印)、图片拼接等,支持 JPEG, PNG, WebP, TIFF, GIF 和 SVG 格式。在云函数端使用 sharp 来处理图片,而云存储则可以作为服务端和小程序端来传递图片的桥梁。 技术文档:sharp 官方技术文档 使用开发者工具新建一个 const cloud = require('wx-server-sdk') const fs = require('fs') const path = require('path') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) const sharp = require('sharp'); exports.main = async (event, context) => { //这里换成自己的fileID,也可以在小程序端上传文件之后,把fileID传进来event.fileID const fileID = 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/1572315793628-366.png' //要用云函数处理图片,需要先下载图片,返回的图片类型为Buffer const res = await cloud.downloadFile({ fileID: fileID, }) const buffer = res.fileContent //sharp对图片进行处理之后,保存为output.png,也可以直接保存为Buffer await sharp(buffer).rotate().resize(200).toFile('output.png') // 云函数读取模块目录下的图片,并上传到云存储 const fileStream = await fs.createReadStream(path.join(__dirname, 'output.png')) return await cloud.uploadFile({ cloudPath: 'sharpdemo.jpg', fileContent: fileStream, }) } 也可以让 sharp 不需要先 toFile 转成图片,而是直接转成 Buffer,这样就可以直接作为参数传给 fileContent 上传到云存储,如: const buffer2 = await sharp(buffer).rotate().resize(200).toBuffer(); return await cloud.uploadFile({ cloudPath: 'sharpdemo2.jpg', fileContent: buffer2, }) 连接数据库 MySQL公网连接数据库 MySQL技术文档:Sequelize const sequelize = new Sequelize('database', 'username', 'password', { host: 'localhost', //数据库地址,默认本机 port:'3306', dialect: 'mysql', pool: { //连接池设置 max: 5, //最大连接数 min: 0, //最小连接数 idle: 10000 }, }); 无论是MySQL,还是PostgreSQL、Redis、MongoDB等其他数据库,只要我们在 私有网络连接 MySQL默认情况下,云开发的函数部署在公共网络中,只可以访问公网。如果开发者需要访问腾讯云的 Redis、TencentDB、CVM、Kafka 等资源,需要建立私有网络来确保数据安全及连接安全。 连接数据库 Redisconst cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) const Redis = require('ioredis') const redis = new Redis({ port: 6379, host: '10.168.0.15', family: 4, password: 'CloudBase2018', db: 0, }) exports.main = async (event, context) => { const wxContext = cloud.getWXContext() const cacheKey = wxContext.OPENID const cache = await redis.get(cacheKey) if (!cache) { const result = await new Promise((resolve, reject) => { setTimeout(() => resolve(Math.random()), 2000) }) redis.set(cacheKey, result, 'EX', 3600) return result } else { return cache } } 二维码 qrcode技术文档:node-qrcode Github 地址 邮件处理技术文档:Nodemailer Github 地址、Nodemailer 官方文档 使用开发者工具创建一个云函数,比如 nodemail,然后在 package.json 增加 nodemailer 最新版 latest 的依赖: "dependencies": { "nodemailer": "latest" } 发送邮件服务器:smtp.qq.com,使用 SSL,端口号 465 或 587 const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) exports.main = async (event, context) => { const nodemailer = require("nodemailer"); let transporter = nodemailer.createTransport({ host: "smtp.qq.com", //SMTP服务器地址 port: 465, //端口号,通常为465,587,25,不同的邮件客户端端口号可能不一样 secure: true, //如果端口是465,就为true;如果是587、25,就填false auth: { user: "344169902@qq.com", //你的邮箱账号 pass: "你的QQ邮箱授权码" //邮箱密码,QQ的需要是独立授权码 } }); let message = { from: '来自李东bbsky <344169902@qq.com>', //你的发件邮箱 to: '你要发送给谁', //你要发给谁 // cc:'', 支持cc 抄送 // bcc: '', 支持bcc 密送 subject: '欢迎大家参与云开发技术训练营活动', //支持text纯文字,html代码 text: '欢迎大家', html: '你好:' + '欢迎欢迎', attachments: [ //支持多种附件形式,可以是String, Buffer或Stream { filename: 'image.png', content: Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD/' + '//+l2Z/dAAAAM0lEQVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDeNGe4U' + 'g9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQAAAAAElFTkSuQmCC', 'base64' ), }, ] }; let res = await transporter.sendMail(message); return res; } Excel 文档处理Excel 是存储数据比较常见的格式,那如何让云函数拥有读写 Excel 文件的能力呢?我们可以在 Github 上搜索关键词“Node Excel”,去筛选 Star 比较多,条件比较契合的。 Github 地址:node-xlsx 使用开发者工具新建一个云函数,在 package.json 里添加 latest 最新版的 node-xlsx: "dependencies": { "wx-server-sdk": "latest", "node-xlsx": "latest" } 读取云存储的 Excel 文件 const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) const xlsx = require('node-xlsx'); const db = cloud.database() exports.main = async (event, context) => { const fileID = 'cloud://xly-xrlur.786c-xly-xrlur-1300446086/china.csv' const res = await cloud.downloadFile({ fileID: fileID, }) const buffer = res.fileContent const tasks = [] var sheets = xlsx.parse(buffer); sheets.forEach(function (sheet) { for (var rowId in sheet['data']) { console.log(rowId); var row = sheet['data'][rowId]; if (rowId > 0 && row) { const promise = db.collection('chinaexcel') .add({ data: { city: row[0], province: row[1], city_area: row[2], builtup_area: row[3], reg_pop: row[4], resident_pop: row[5], gdp: row[6] } }) tasks.push(promise) } } }); let result = await Promise.all(tasks).then(res => { return res }).catch(function (err) { return err }) return result } 将数据库里的数据保存为 CSV 技术文档:json2CSV HTTP 处理got、superagent、request、axios、request-promise 尽管云函数的 Nodejs 版本比较低(目前为 8.9),但绝大多数模块我们都可以使用 Nodejs 12 或 13 的环境来测试,不过有时候也要留意有些模块不支持 8.9,比如 got 10.0.1 以上的版本。node 中,http 模块也可作为客户端使用(发送请求),第三方模块 request 对其使用方法进行了封装,操作更方便!所以来介绍一下 request 模块 get 请求const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) const rp = require('request-promise') exports.main = async (event, context) => { const options = { url: 'https://news-at.zhihu.com/api/4/news/latest', json: true, method: 'GET', }; return await rp(options) } post 请求结合文件流request('https://www.jmjc.tech/public/home/img/flower.png').pipe(fs.createWriteStream('./flower.png')) // 下载文件到本地 加解密 Cryptocrypto 模块是 nodejs 的核心模块之一,它提供了安全相关的功能,包含对 OpenSSL 的哈希、HMAC、加密、解密、签名、以及验证功能的一整套封装。由于 crypto 模块是内置模块,我们引入它是无需下载,就可以直接引入。 使用开发者工具新建一个云函数,比如 crypto,在 index.js 里输入以下代码,我们来了解一下 crypto 支持哪些加密算法,并以 MD5 加密为例: const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV, }) const crypto = require('crypto'); exports.main = async (event, context) => { const hashes = crypto.getHashes(); //获取crypto支持的加密算法种类列表 //md5 加密 CloudBase2020 返回十六进制 var md5 = crypto.createHash('md5'); var message = 'CloudBase2020'; var digest = md5.update(message, 'utf8').digest('hex'); return { "crypto支持的加密算法种类":hashes, "md5加密返回的十六进制":digest }; } 将云函数部署之后调用从返回的结果我们可以了解到,云函数 crypto 模块支持 46 种加密算法。 发短信“qcloudsms_js”: “^0.1.1” const cloud = require('wx-server-sdk') const QcloudSms = require("qcloudsms_js") const appid = 1400284950 // 替换成您申请的云短信 AppID 以及 AppKey const appkey = "a33b602345f5bb866f040303ac6f98ca" const templateId = 472078 // 替换成您所申请模板 ID const smsSign = "统计小助理" // 替换成您所申请的签名 cloud.init() // 云函数入口函数 exports.main = async (event, context) => new Promise((resolve, reject) => { /*单发短信示例为完整示例,更多功能请直接替换以下代码*/ var qcloudsms = QcloudSms(appid, appkey); var ssender = qcloudsms.SmsSingleSender(); var params = ["1234", "15"]; // 获取发送短信的手机号码 var mobile = event.mobile // 获取手机号国家/地区码 var nationcode = event.nationcode ssender.sendWithParam(nationcode, mobile, templateId, params, smsSign, "", "", (err, res, resData) => { /*设置请求回调处理, 这里只是演示,您需要自定义相应处理逻辑*/ if (err) { console.log("err: ", err); reject({ err }) } else { resolve({ res: res.req, resData }) } } ); }) 使用开发者工具 wx.cloud.callFunction({ name: 'sendphone', data: { // mobile: '13217922526', mobile: '18565678773', nationcode: '86' }, success: res => { console.log('[云函数] [sendsms] 调用成功') console.log(res) }, fail: err => { console.error('[云函数] [sendsms] 调用失败', err) } })
2021-09-10 - 请教主包大小对加载速度的影响
Hello 我从2月8号至2月13日,逐渐开始优化主包大小,主包从1.7MB降到1.26MB 之后从2月13日到今天,主包从1.26MB降低到1.18MB 但是通过小程序助手观察到的下载时长如下: 可以观察到Android的下载时长几乎没有任何变化,iOS则从除夕假期开始下降,随着假期结束逐步回升。 也就是主包大小精简了44%之后从小程序助手没有看到明显的优化效果。 [图片] 想咨询的是 小程序助手里监控的下载时长在整个生命周期里指的是哪一段时长,能否区分用户是否下载过小程序离线包 Android为什么没有随着主包的体积减少,下载时间有所优化 iOS的下载时长为什么会随着节假日开始而有所缩短 如果3是否定的,从图表上看iOS的下载时长似乎是随着主包体积减少而逐渐加大,想了解原因 业界最好的小程序在下载时长上大概是什么水平,能否推荐TOP3的小程序供我们参考学习 使用方有没有小程序助手以外的办法统计主包下载时长,当前我只能从用户从小程序外扫码开始计时到onlaunch统计计时,我理解这个时间既包含了下载又包含了预加载等时间 另外供以参考的通过我自己的业务监控来看(用户从小程序外扫码开始计时到onlaunch的计时),下载时长也基本没有变化。 [图片] 希望哪位大拿可以帮忙答疑解惑,万分感谢,北京方向期待面基约饭 :)
2019-02-19 - 浅析growingio无埋点数据采集的实现原理——剧情版
1. 背景 我厂开发的小程序最近接入了付费产品growingio,号称可以实现无埋点采集用户行为,包含用户操作、页面访问、停留时间等,可直接追踪用户的使用路径。由于我厂用的原生小程序开发方式,所有接了他们的原生小程序的sdk。接入方式也挺简单: [代码]import gio from 'path/to/giosdk' gio('setConfig', ourConfig) App({ // xxx }) [代码] 编译后,看到network里各种搜集到的数据被上传,满心欢喜,心想:哟! 果然是付费的,省心。万事大吉,打完收工!结果,意向不到的事情发生了… 我厂只考虑微信小程序,曾用过wepy,mpvue,效果都不太理想,后来大神空降,自己撸了一套适用原生小程序的框架,事件处理已经有一套封装,然后现在接了gio后就开始糟了😅。 2. 入坑 首先,其他功能测试期间,发生了莫名奇妙的问题:什么视频暂停不了、定时器停不掉、音频播放停不下来,各种匪夷所思。后仔细排查,发现是由于小程序生命周期函数onLoad执行了两次,我插!这什么鬼,在排除我们自身原因后,发现跟接了gio有关,注释gio代码,执行一次,开启就执行2次😟 其次,用户操作事件数据确有上传,但是一次点击,竟然产生了多次数据,导致很多重复。 咋办?产品的🔪还架在脖子上,砍需求,砍需求是不可能的这样子。只能去看gio sdk的源码了,看能否找到解决方案。结果还没开始,就遇到大坑,gio sdk是闭源项目,找他们的对接人又一直不提供项目源码,想想也是,毕竟指着卖钱呢,于是乎,只能在压缩混淆后的代码找办法了 3. 强行排查 首先采用代码反压缩,把压缩后的代码转成勉强能看的代码,其实只是格式化了下,变量名和写法仍然是压缩的表现,就像这种: [代码]if ( VdsInstrumentAgent.initPlatformInfo(gioGlobal.platformConfig), VdsInstrumentAgent.observer = t, VdsInstrumentAgent.pageHandlers.forEach(function (t) { VdsInstrumentAgent.defaultPageCallbacks[t] = function () { VdsInstrumentAgent.observer.pageListener(this, t, arguments) } }), VdsInstrumentAgent.appHandlers.forEach(function (t) { VdsInstrumentAgent.defaultAppCallbacks[t] = function () { VdsInstrumentAgent.observer.appListener(this, t, arguments) } }), gioGlobal.platformConfig.canHook ) { const t = gioGlobal.platformConfig.hooks; t.App && !gioGlobal.growingAppInited && (App = function () { return VdsInstrumentAgent.GrowingApp(arguments[0]) }, gioGlobal.growingAppInited = !0), t.Page && !gioGlobal.growingPageInited && (Page = function () { return VdsInstrumentAgent.GrowingPage(arguments[0]) }, gioGlobal.growingPageInited = !0), t.Component && !gioGlobal.growingComponentInited && (Component = function () { return VdsInstrumentAgent.GrowingComponent(arguments[0]) }, gioGlobal.growingComponentInited = !0), t.Behavior && !gioGlobal.growingBehaviorInited && (Behavior = function () { return VdsInstrumentAgent.GrowingBehavior(arguments[0]) }, gioGlobal.growingBehaviorInited = !0) } [代码] 简直神清气爽,😓 没办法,在说了N句卧槽后,只能按住自己躁动不安的心,强行阅读源码。 最开始很好奇为什么,只写那两行代码,竟然就能实现数据收集,事件不是要在wxml里面写bindtap之类的吗?怎么不写就能知道我点了呢?难道有什么方法知道我写的bindtap的函数,怎么实现的呢?还有onShow,onLoad生命周期函数之类,就那两行,它怎么知道什么时候执行了onShow? 4.实现原理 重写Page,App方法: [代码]Page() { return VdsInstrumentAgent.GrowingPage(arguments[0]); } App() { return VdsInstrumentAgent.GrowingApp(arguments[0]); } VdsInstrumentAgent.GrowingPage = function (t) { return t._growing_page_ = !0, VdsInstrumentAgent.originalPage(VdsInstrumentAgent.instrument(t)) } VdsInstrumentAgent.GrowingApp = function (t) { return t._growing_app_ = !0, VdsInstrumentAgent.originalApp(VdsInstrumentAgent.instrument(t)) } /* * VdsInstrumentAgent.originalPage * VdsInstrumentAgent.originalApp * 重写前的Page和App */ [代码] 处理Page、App的参数,如果是函数,处理函数,并且提供默认的生命周期函数 [代码]VdsInstrumentAgent.instrument = function (t) { for (let e in t){ if("function" == typeof t[e]){ t[e] = this.hook(e, t[e]); } }; return t._growing_app_ && VdsInstrumentAgent.appHandlers.map(function (e) { t[e] || (t[e] = VdsInstrumentAgent.defaultAppCallbacks[e]) }), t._growing_page_ && VdsInstrumentAgent.pageHandlers.map(function (e) { t[e] || e === gioGlobal.platformConfig.lisiteners.page.shareApp || (t[e] = VdsInstrumentAgent.defaultPageCallbacks[e]) }), t; } [代码] 处理函数时,如果函数的第一个参数存在,并且有currentTarget或者target的属性,并且函数type属性(鸭式辩型),且是需要捕获的事件(“onclick”, “tap”, “longpress”, “blur”, “change”, “submit”, “confirm”, “getuserinfo”, “getphonenumber”, “contact”),,就增加监听函数,用于捕获事件,这里便收集了用户的操作事件。 [代码]hook: function (t, e) { return function () {t let i, n = arguments ? arguments[0] : void 0; // 收集用户操作事件 if (n && (n.currentTarget || n.target) && -1 != VdsInstrumentAgent.actionEventTypes.indexOf(n.type)){ try { VdsInstrumentAgent.observer.actionListener(n, t); } catch (t) { console.error(t) } } const o = gioGlobal.platformConfig.lisiteners.app, s = gioGlobal.platformConfig.lisiteners.page; if ( // 非生命周期函数直接调用 this._growing_app_ &&t !== o.appShow ? (i = e.apply(this, arguments)): this._growing_page_ && -1 === [s.pageShow, s.pageClose, s.pageLoad, s.pageHide, s.tabTap].indexOf(t) ? (i = e.apply(this, arguments)) : this._growing_app_ || this._growing_page_ || (i = e.apply(this, arguments)), // 需要收集的App生命周期函数 this._growing_app_ && -1 !== VdsInstrumentAgent.appHandlers.indexOf(t)) { try { VdsInstrumentAgent.defaultAppCallbacks[t].apply(this, arguments) } catch (t) { console.error(t) } t === o.appShow && (i = e.apply(this, arguments)) } // 需要收集的Page生命周期函数 if (this._growing_page_ && -1 !== VdsInstrumentAgent.pageHandlers.indexOf(t)) { let n = Array.prototype.slice.call(arguments); i && n.push(i); try { VdsInstrumentAgent.defaultPageCallbacks[t].apply(this, n) } catch (t) { console.error(t) } - 1 !== [s.pageShow, s.pageClose, s.pageLoad, s.pageHide, s.tabTap].indexOf(t) ? (i = e.apply(this, arguments)) : setShareResult(i) } return i } } [代码] 生命周期函数的处理,也在这里进行统一监听,defaultAppCallbacks,defaultPageCallbacks里面存的是各种监听函数 [代码]// VdsInstrumentAgent.pageHandlers // pageHandlers: ["onLoad", "onShow", "onShareAppMessage", "onTabItemTap", "onHide", "onUnload"], VdsInstrumentAgent.pageHandlers.forEach(function (t) { VdsInstrumentAgent.defaultPageCallbacks[t] = function () { VdsInstrumentAgent.observer.pageListener(this, t, arguments) } }), // VdsInstrumentAgent.appHandlers // appHandlers: ["onShow", "onHide", "onError"], VdsInstrumentAgent.appHandlers.forEach(function (t) { VdsInstrumentAgent.defaultAppCallbacks[t] = function () { VdsInstrumentAgent.observer.appListener(this, t, arguments) } }), [代码] 监听器里处理事件的分发 [代码]pageListener(t, e, i) { const n = gioGlobal.platformConfig.lisiteners.page; if ( t.route || (t.route = this.info.getPagePath(t)), e === n.pageShow) { const e = getDataByPath(t, "$page.query"); Utils.isEmpty(e) || "quickApp" !== gioGlobal.gio__platform || this.currentPage.addQuery(t, e), this.isPauseSession ? this.isPauseSession = !1 : (this.currentPage.touch(t), this.useLastPageTime && (this.currentPage.time = this.lastPageEvent.tm, this.useLastPageTime = !1), this.sendPage(t)) } else if (e === n.pageLoad) { const e = i[0]; Utils.isEmpty(e) || "quickApp" === gioGlobal.gio__platform || this.currentPage.addQuery(t, e) } else if (e === n.pageHide) this.growingio._observer && this.growingio._observer.disconnect(); else if (e === n.pageClose) this.currentPage.pvar[`${this.currentPage.path}?${this.currentPage.query}`] = void 0; else if (e === n.shareApp) { let e = null, n = null; 2 > i.length ? 1 === i.length && (i[0].from ? e = i[0] : i[0].title && (n = i[0])) : (e = i[0], n = i[1]), this.pauseSession(), this.sendPageShare(t, e, n) } else if ("onTabItemTap" === e) { this.sendTabClick(i[0]) } } [代码] 根据生命周期函数名,处理onShow、onHide之类,并传入需要的参数。这里便处理了用户的操作路径的数据,比如:进入某个页面、退出某个页面、在哪个页面调了分享、点了tab之类。 至此,大致的运行原理已经明白了。 5. 解决问题 点击一次按钮产生多次数据:根据上面的运行原理,可知gio事件收集是根据函数的第一个参数来判断的,由于我们内部框架有事件的统一封装,粗略代码如下: [代码]events: { test1: 'fnTest1', test2: 'fnTest2', test3: 'fnTest3', test4: 'fnTest4', }, bindEvent(e){ let id = e.target.dataset.id; if (id in this.events){ this[this.events[id]].call(this, e) } }, fnTest1(e){ console.log(e.target.dataset.id) }, fnTest2(e) { console.log(e.target.dataset.id) }, fnTest2(e) { console.log(e.target.dataset.id) }, fnTest3(e) { console.log(e.target.dataset.id) } [代码] bindEvent是wxml里面统一写的事件方法,根据dataset-id来分发事件按,这里如果点击了test1,按照gio的收集原理,会搜集bindEvent,fnTest1,产生2次数据,而我们最终想要的是fnTest1。在实际情况下,由于bindEvent里还有其他封装,导致数据不止2次。知道原因,这个问题就很好解决,我们为事件对象(第一个参数)增加了一个growingIgnore属性,内部统一封装的事件对象growingIgnore = true,再修改gio,上面运行原理第3步,hook函数、收集用户事件处来过滤。 生命周期函数执行2次:跟我们内部封装和Object.assign有关,示意代码如下: [代码]let a ={name: 'jojo'} let b = Object.assign(a, {age: 27}) // a === b ? // 内部处理 App(appOptions) Page(Object.assign(appOptions, pageOptions)) [代码] 导致,调App时gio加个app标记,在Page里面也能获取到,在gio内部,执行生命周期函数时,区分不开是page还是app。上面运行原理第3步,hook函数、收集用户事件处,非生命周期函数直接调用和需要收集的Page生命周期函数,会存在2次调用。知道了原因,也就很好处理了,在原理第1步重写方法时,调app做app标记时,也要重置page标记,调page时同理: [代码]VdsInstrumentAgent.GrowingPage = function (t) { return t._growing_page_ = !0, t._growing_app_ = !1, VdsInstrumentAgent.originalPage(VdsInstrumentAgent.instrument(t)) } VdsInstrumentAgent.GrowingApp = function (t) { return t._growing_app_ = !0, t._growing_page_ = !1,VdsInstrumentAgent.originalApp(VdsInstrumentAgent.instrument(t)) } [代码] 至此,问题解决…这下终于打完收工了,🍎🍎🍎
2020-07-03 - js异步编程
前言 我们都知道,JS是单线程执行的,天生异步。在开发的过程中会遇到很多异步的场景,只用回调来处理简单的异步逻辑,当然是可以,但是逻辑逐渐复杂起来,回调的处理方式显得力不从心。 接下来会介绍js中处理异步的方式,通过对比了解各自的原理以及优缺点,帮助我们更好的使用这些强大的异步处理方式。 回调 基本用法 回调函数作为参数传进方法中,在合适的时机被调用。 比如调用ajax,或是使用定时器: [代码] // ajax请求 $.ajax({ url: '/ajax/hdportal_h.jsp?cmd=xxx', error: function(err) { console.log(err) }, success: function(data) { console.log(data) } }) // 定时器的回调 setTimeout(function callback() { console.log('hi') }, 1000) [代码] 回调的问题 1. 回调地狱 过深的嵌套,形成回调地狱 使得代码难以阅读和调试 层层嵌套,代码间耦合严重,牵一发而动全身 2.信任缺失,错误处理无法保证 控制反转,回调函数的调用是在请求函数内部,无法保证回调函数一定会被正确调用,回调本身没有错误处理机制,需要额外设计。 可能存在以下问题: 调用回调过早 调用回调过晚 调用回调次数太多或者太少 未能把所需的参数成功传给你的回调函数 吞掉可能出现的错误或异常 Promise 基本用法 Promise对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败) 一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。 [代码] new Promise((resovle, reject) => { setTimeout(() => { resovle('hello promise') }, 1000) }).then(res => { console.log(res) }).catch(err => { console.log(err) }) [代码] Promise与回调的区别 Promise 不是对回调的替代。 Promise 在回调代码和将要执行这个任务的异步代码之间提供了一种可靠的中间机制来管理回调 Promise 并没有完全摆脱回调。它们只是改变了传递回调的位置。我们并不是把回调传递给处理函数,而是从处理函数得到Promise,然后把回调传给这个Promise Promise 保证了行为的一致性,使其变得可信任,我们传递的回调会被正确的执行 Promise如何解决信任缺失问题? 调用时机上,不会调用过早,也不会调用过晚 根据PromiseA+规范,then中的回调会在 then 方法被调用的那一轮事件循环之后的新执行栈中执行。 这个事件队列可以采用“宏任务(macro-task)”机制或者“微任务(micro-task)”机制来实现。 所以提供给then的回调也总会在JavaScript事件队列的当前运行完成后,再被调用,即异步调用。 [代码] var p = Promise.resolve('p'); console.log('A'); p.then(function () { p.then(function () { console.log('E'); }); console.log('C'); }) .then(function () { console.log('D'); }); console.log('B'); [代码] 运行这段代码,会依次打印出ABCED 这里要注意两个点: 会先执行同步代码,再执行then中的代码 then执行回调时,打印D的代码晚于打印E的代码 调用次数上,不会出现回调未调用,也不会出现调用次数太多或者太少 一个Promise注册了一个成功回调和拒绝回调,那么Promise在决议的时候总会调用其中一个。 即使是在决议后调用then注册的回调函数,也会被正确调用,所以不会出现回调未调用的情况。 Promise只能被决议一次。如果处于多种原因,Promise创建代码试图调用多次resolve(…)或reject(…),或者试图两者都调用,那么这个Promise将只会接受第一次决议,忽略任何后续调用,所以调用次数不会太多也不会太少。 错误处理上,不会吞掉可能出现的错误或异常 如果在Promise的创建过程中或在查看其决议结果的过程中的任何时间点上,出现了一个JavaScript异常错误,比如一个TypeError或ReferenceError,这个异常都会被捕捉,并且会使这个Promise被拒绝。 [代码] var p = new Promise(function (resolve, reject) { foo.bar(); // foo未定义 resolve(2); }); p.then(function (data) { console.log(data); // 永远也不会到达这里 }, function (err) { console.log('出错了', err); // err将会是一个TypeError异常对象来自foo.bar()这一行 }); [代码] Promise中的then then方法的设计是promise中最重要的部分之一,可以看promise/A+规范中对then方法的描述 then方法会返回一个新的promise,因此可以链式调用,下面的代码会打印出6 [代码] var p = Promise.resolve(0); p.then(function (data) { return 1; }).then(function (data) { return data + 2; }).then(function (data) { return data + 3; }).then(function (data) { console.log(data); }); [代码] 如果在then中主动返回一个promise,依旧会返回一个新的promise,只是这个promise的状态“跟随”主动返回的pormise [代码] var p1 = new Promise(function (resolve, reject) { resolve('p1'); }); var p2 = new Promise(function (resolve, reject) { resolve('p2'); }); var p3 = p2.then(function (data) { return p1; }); console.log(p3 === p1); // false p3.then(function (data) { console.log(data); // p1 }); [代码] 静态方法 Promise.resolve() Promise.resolve(value)方法返回一个以给定值解析后的 Promise 对象。 但如果这个值是个 thenable(即带有 then 方法),返回的 promise 会“跟随”这个 thenable的对象,采用它的最终状态;否则以该值为成功状态返回 promise 对象。 Promise.reject() Promise.reject(reason)方法返回一个用reason拒绝的Promise。 [代码] // 以下两个 promise 是等价的 var p1 = new Promise( (resolve,reject) => { resolve( "Oops" ); }); var p2 = Promise.resolve( "Oops" ); var p1 = new Promise( (resolve,reject) => { reject( "Oops" ); }); var p2 = Promise.reject( "Oops" ); [代码] Promise.all() Promise.all方法用于将多个 Promise 实例,包装成一个新的 Promise 实例 [代码] const p = Promise.all([p1, p2, p3]); p.then(function (posts) { // ... }).catch(function(reason){ // ... }); [代码] p的状态由p1、p2、p3决定,分成两种情况。 (1)只有p1、p2、p3的状态都变成fulfilled,p的状态才会变成fulfilled,此时p1、p2、p3的返回值组成一个数组,传递给p的回调函数。 (2)只要p1、p2、p3之中有一个被rejected,p的状态就变成rejected,此时第一个被reject的实例的返回值,会传递给p的回调函数。 Promise.race() Promise.race方法同样是将多个 Promise 实例,包装成一个新的 Promise 实例。 [代码] const p = Promise.race([p1, p2, p3]); p.then(function (posts) { // ... }).catch(function(reason){ // ... }); [代码] 只要p1、p2、p3之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数 Generator 名词解释 迭代器 (Iterator) 迭代器是一种对象,它具有一些专门为迭代过程设计的专有接口,所有迭代器对象都有一个 next 方法,每次调用都返回一个结果对象。 结果对象有两个属性,一个是 value,表示下一个将要返回的值;另一个是 done,它是一个布尔类型的值,当没有更多可返回数据时返回 true。 迭代器还会保存一个内部指针,用来指向当前集合中值的位置,每调用一次 next() 方法,都会返回下一个可用的值。 可迭代对象 (Iterable) 可迭代对象具有 Symbol.iterator 属性,是一种与迭代器密切相关的对象。 Symbol.iterator 通过指定的函数可以返回一个作用于附属对象的迭代器。 在 ECMCScript 6 中,所有的集合对象(数组、Set、及 Map 集合)和字符串都是可迭代对象,这些对象中都有默认的迭代器。 生成器 (Generator) 生成器是一种返回迭代器的函数,通过 function 关键字后的 * 号来表示。 此外,由于生成器会默认为 Symbol.iterator 属性赋值,因此所有通过生成器创建的迭代器都是可迭代对象。 for-of 循环 for-of 循环每执行一次都会调用可迭代对象的迭代器接口的 next() 方法,并将迭代器返回的结果对象的 value 属性储存在一个变量中,循环将持续执行这一过程直到返回对象的属性值为 true。 生成器的一般使用形式 [代码] function *foo() { var x = yield 2 var y = x * (yield x + 1) console.log( x, y ) return x + y } var it = foo() it.next() // {value: 2, done: false} it.next(3) // {value: 4, done: false} it.next(3) // 3 9, {value: 12, done: true} [代码] 遍历器对象的next方法的运行逻辑如下: (1)遇到yield表达式,就暂停执行后面的操作,并将紧跟在yield后面的那个表达式的值,作为返回的对象的value属性值。 (2)下一次调用next方法时,再继续往下执行,直到遇到下一个yield表达式。 (3)如果没有再遇到新的yield表达式,就一直运行到函数结束,直到return语句为止,并将return语句后面的表达式的值,作为返回的对象的value属性值。 (4)如果该函数没有return语句,则返回的对象的value属性值为undefined。 需要注意的是,yield表达式后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行,因此等于为 JavaScript 提供了手动的“惰性求值”(Lazy Evaluation)的语法功能。 异步迭代生成器 [代码] function foo() { setTimeout(() => { it.next('success') // 恢复*main() // it.throw('error') // 向*main()抛出一个错误 }, 2000); } function *main() { try { var data = yield foo() console.log(data) } catch(e) { console.log(e) } } var it = main() it.next() // 这里启动! [代码] 本例中我们在 *main() 中发起 foo() 请求,之后暂停;又在 foo() 中相应数据恢复 *mian() 继续运行,并将 foo() 的运行结果通过 next() 传递出来。 我们在生成器内部有了看似完全同步的代码(除了 yield 关键字本身),但隐藏在背后的是,在 foo(…)内的运行可以完全异步。并且在异步代码中实现看似同步的错误处理(通过try…catch)在可读性和合理性方面也都是一个巨大的进步。 Generator + Promise 通过promise来管理异步流程 [代码] function foo() { return new Promise(function(resolve, reject){ setTimeout(() => { resolve('fai'); }, 2000); }); } function *main() { try { var data = yield foo() console.log(data) } catch(e) { console.error(e) } } var it = main(); var p = it.next().value; // p 的值是 foo() // 等待 promise p 决议 p.then( function(data) { it.next(data); // 将 data 赋值给 yield }, function(err) { it.throw(err); } ) [代码] *mian() 中执行 foo() 发起请求,返回promise 根据promise 决议结果,根据结果选择继续运行迭代器或抛出错误 如何执行有多处yield的Generator 函数? [代码] function foo(name) { return new Promise(function(resolve, reject){ setTimeout(() => { resolve('hello ' + name); }, 2000); }); } var gen = function* (){ var r1 = yield foo('jarvis'); var r2 = yield foo('hth'); console.log(r1); console.log(r2); }; var g = gen(); // 手动执行 g.next().value.then(function(data){ g.next(data).value.then(function(data){ g.next(data); }); }); [代码] 手动执行的方式,其实就是用then方法,层层添加回调函数。理解了这一点,就可以写出一个自动执行器 自动执行Generator 函数 [代码] function foo(name) { return new Promise(function(resolve, reject){ setTimeout(() => { resolve('hello ' + name); }, 2000); }); } var gen = function* (){ var r1 = yield foo('jarvis'); var r2 = yield foo('hth'); console.log(r1); console.log(r2); }; function run(gen){ var g = gen(); function next(data){ var result = g.next(data); if (result.done) return result.value; result.value.then(function(data){ next(data); }); } next(); } run(gen); [代码] 只要保证yield后面总是返回promise,就能用run函数自动执行Generator 函数 Async/Await async 函数的一般使用形式 async 函数是什么? 其实就是 promise+自动执行的Generator 函数的语法糖。类似于我们上面的实现 [代码] function foo(p) { return fetch('http://my.data?p=' + p) } async function main(p) { try { var data = await foo(p) return data } catch(e) { console.error(e) } } main(1).then(data => console.log(data)) [代码] 与 Generator 函数不同是,* 变成了async、yeild变成了await,同时我们也不用再定义 run(…) 函数来实现 Promise 与 Generator 的结合。 async 函数执行的时候,一旦遇到 await 就会先返回,等到异步操作完成,再接着执行函数体内后面的语句,并且最终返回一个 Promise 对象。 正常情况下,await 命令后面是一个 Promise 对象。如果不是,会被转成一个立即 resolve 的 Promise 对象。 await 命令后面的 Promise 对象如果变为 reject 状态,则 reject 的参数会被 catch 方法的回调函数接收到。 async 函数的使用注意点 前面已经说过,await 命令后面的 Promise 对象,运行结果可能是 rejected,所以最好把 await 命令放在 try…catch 代码块中。 多个 await 命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。 await 命令只能用在 async 函数之中,如果用在普通函数,就会报错。 [代码] //getFoo 与 getBar 是两个互相独立、互不依赖的异步操作 // 错误写法 let foo = await getFoo(); let bar = await getBar(); // 正确写法一 let [foo, bar] = await Promise.all([getFoo(), getBar()]); // 正确写法二 let fooPromise = getFoo(); let barPromise = getBar(); let foo = await fooPromise; let bar = await barPromise; [代码] async 函数比Promise好在哪? 类同步写法,使得在写复杂逻辑时,可以用一种顺序的方式来书写,大大降低了理解的难度。 错误处理上,可以用try catch来捕获,同时处理同步和异步错误。 总结 JavaScript异步编程的发展历程有以下四个阶段: 回调函数: 有两个问题,回调地狱和信任缺失,回调地狱的坏处主要是代码阅读性和可维护性差,同时不好对异步逻辑进行封装。信任缺失主要体现在调用的时机,调用的次数,对异常的处理上缺乏一致性。 Promise 基于PromiseA+规范的实现解决了控制反转带来的信任问题。 Generator 使用生成器函数Generator,我们得以用同步的方式来书写异步的代码,解决了顺序性的问题,这是一种重大的突破。但是使用比较繁琐,需要手动去调用next(…)去控制流程和传参。 Async/Await Async/Await结合了Promise和Generator,并实现了自动执行生成器函数逻辑。使得使用者通添加少量关键字就可以用同步的方式书写异步代码,大大提高了开发效率和代码可维护性。 可以看到,目前Async/Await方式可以说是处理异步的终极解决方案,在项目中应该优先使用这种方式。
2019-06-11 - 你根本不懂rebase-使用rebase打造可读的git graph
git graph 可读指什么? 这里的可读,主要指的是能够通过看git graph了解每一次版本更迭,每一次hotfix的修改记录.反映到分支上面,有两个要求: 每个分支的历史修改可读(单个分支的层面) 每个分支的分叉合并可读(多个分支的层面) rebase是什么,它是更优雅的merge吗? rebase翻译做[代码]变(re)基(base)[代码]. 讲rebase的文章经常会引用三张图: [图片] 原本的两个分支 [图片] 通过merge的结果 [图片] 通过rebase的结果 用来说明git rebase和git merge的区别的时候确实是足够了,但是 git rabase的用途并非是合并分支,它与merge根本不是同样的性质.(注意,这里的说法是[代码]并非是[代码],不是[代码]并非只是[代码],因为虽然有时rebase替代了merge的工作,但其原理和性质完全不一样.) rebase还有以下几种用处: [代码]git pull —-rebase[代码]处理同一分支上的冲突(如果你能理解其实这是[代码]git fetch&&git rebase[代码]两个操作,并且理解远程分支和本地分支的区分的话,那么其实他跟单纯的rebase用法没什么区别,但是因为其场景不一样,所以单独拆分出来讲) [代码]git rebase -i[代码]修改commit记录 实质上: merge是对目前分叉的两条分支的合并 rebase是对[代码]当前分支[代码]记录基于任何[代码]commit节点[代码](不限于当前分支上的节点)的变更. rebase的[代码]base[代码]不能理解为分叉的基点,而是整个git库中存在的所有commit节点: 在[代码]git pull —-rebase[代码]的时候,这个[代码]当前分支[代码]是本地分支,[代码]commit节点[代码]是远程分支的head 在[代码]git rebase master[代码]的时候,这个[代码]当前分支[代码]是feature分支,[代码]commit[代码]节点是master分支的head 在[代码]git rebase -i[代码]的时候,这个[代码]当前分支[代码]就是当前工作分支,[代码]commit节点[代码]是在 -i后注明的commit rebase是怎么工作的? 上面我们已经说到了: [代码]rebase[代码]是对[代码]当前分支[代码]记录基于任何[代码]commit节点[代码](不限于当前分支上的节点)的变更. 怎么做到呢?我没有深入研究它真的是如何实现的,以下步骤一定是不对的,但足够让你理解rebase干了什么. 我们标注出了两个重点,[代码]当前分支[代码]和[代码]commit节点[代码]. 把[代码]当前分支[代码]branch-A从头到尾列出来,从数据结构的角度来说这是一个链表 把[代码]commit节点[代码]所在的分支branch-B从头到尾列出来,同样是一个链表 找到这两个链表最近相同的节点n 把A在n之后的所有节点拆下来构成L 把B在n之后的所有节点中存在的diff信息都汇总起来构成d 对于L中的每一个节点,把他的diff信息拿出来,看看d中有没有冲突,如果有没法自动处理的冲突抛出错误,等待用户自己处理 可选地,对于[代码]rebase -i[代码]来说,还可以一次取多个节点或者按照不同顺序取,你有更大的处理自由 没冲突和处理完冲突的节点,改一个hash放到branch-B的[代码]commit节点[代码]之后 你可以把之前我们说到的三种rebase用处套在以上步骤看看,是否能够理解. rebase很危险对吗? 对,很危险. 不过就像小马过河一样,光听别人说是没用的,我们需要明白为什么有人说危险,有人说不危险.我看到很多文章说rebase有问题,但他们的说法其实并不让人信服,很多时候只是他们不会用. 很多人听说过一个golden rule,在文末有链接,但是很少有人会明白真正的原因.让我们一层层地剖析: 其他人git push的时候会对比较本地分支和远程分支的区别,把不同的地方推上去 如果远程分支被修改了,那么其他人的本地分支和远程分支就会出现分叉(另外还可能造成其他人之前已经推送的工作被覆盖) 当出现分叉的时候,意味着其他人需要处理冲突,也就是说,你对于远程历史记录的修改使得[代码]冲突扩散到了其他人身上[代码] 所以我们尽量不能修改远程分支,不能[代码]把别人fetch回去的改掉[代码],因为他们的工作就是基于fetch回去的分支开展的(往前推进是必须的,其实也修改了远程分支,所以才会merge产生冲突,但是这个冲突是无法避免的) 针对上面说的这一条,git也做了限制,如果你触犯了上面的原则,会在push的时候被阻挡,但是通过加一个[代码]-f[代码]可以强推 实际上不止rebase这样,任何修改远程分支历史的操作都会造成冲突,并且这个冲突需要所有人都解决一遍. 但是分析还是太长了,记不住怎么办? 只需要记住[代码]-f[代码],只要你不使用[代码]-f[代码],那么就是安全的. 不过仅是安全,并不能保证优雅,如果要使git graph可读,那你还得多想想: 怎么让自己的commit历史清晰(每个commit反应了一个单位的工作,前后顺序合理) 怎么让每次hotfix和feature所做的工作和顺序清晰 rebase如何让git graph可读? 我们还是说回之前提到的三个用法: git rebase master 在把分支合并回master的时候,用[代码]git rebase master[代码]代替[代码]git merge master[代码].(注意,只在合并之前使用,否则多人协作会遇到冲突) 这样的好处有两个: log里不会出现一个[代码]Merge branch 'master' into hotfix/xxx[代码]的节点 master分支上在这次merge之前已经被提交的[代码]上一次工作[代码]和这一次工作的顺序更清晰,因为rebase会让这次feature的分叉节点改到上一次工作后.对于master分支来说,我们并不关心checkout新的feature的顺序,我们[代码]更关心merge新的feature的顺序[代码]. [图片] 比如这里,使用merge master导致的紫色的分叉在提交之前与master多了一次连接,而且主线上在紫色分叉合并之前还经历了一次合并,这个时间顺序并不清晰. 那么在master分支上合并也用rebase吗?不是.因为我们需要master上的分叉让我们更明白master上的改变(所以使用-no-ff).实际上,不管你采用任何git flow模型,我都建议你对不太重要的分支合并采用rebase,对重要的分支合并采用merge.这样会让主干的更改更清晰,而分支不会扩散地太远. git pull —-rebase 多人在同一分支上工作的时候(包含master分支和多人合作的feature等分支),在git pull的时候会遇到冲突,git pull的默认行为是[代码]git fetch&git merge[代码],merge的对象是远程分支和本地分支. 它的好处基本上与上一条无异,还多了一条: 使用merge行为的pull会将其他人的工作作为外来的分叉,从而在graph上产生一个新的分叉, 并且其他人这一段时间所做的所有的工作都会在graph上被抬升出去,如果这段时间其他人做的工作很多,graph的主线会变得丧失了主线的意义(因为它太单薄了,很多工作根本没反应上来). [图片] 比如这里,本来左数第二条玫红色的才是主线,因为不规范地在master上直接提交了一次commit并且采用merge方式的pull做了合并导致主线被抬升到了外层.而这次不规范的commit却成了主线. git rebase -i 使用这条命令可以修改分支的记录,比如觉得之前的commit修改内容不够单元化,像是[代码]修改了文案1为文案2[代码],[代码]修改了文案2为文案3[代码],这种记录对于master分支来说是没必要关注的信息,最好通过[代码]git commit --amend[代码]或者rebase的方式修改掉. 不过并不推荐在提交之前手动做一次整个分支的squash,如果是rebase方式合并的话,也许更有意义.工蜂(腾讯内部的code平台)提供了merge request的标题和内容功能,所以没必要做squash,完全可以不必太聚合,以便反应真实的信息. 为了不影响别人,只用它修改未push的commit,或者如果一条分支只有一个人,你也可以修改已经push的commit. 对于这条命令的更多功能,可以再去查阅其他文章. 可读的graph应该长什么样? 先说一个原则,看graph要先看主线,主线要清晰,再看分叉上信息,这与我们的工作流程是一致的. [图片] 绿色的hotfix或者feature分支每次不是只允许提交一次commit,只是这一段都是一些小更改. 这看起来有点可笑,一点都不高级.说了这么多做了这么多难道只是为了得到这么简单的图? 没错,[代码]为了让东西变简单,本来就要付出很多代价[代码],我们所做的就是要让东西变简单,比如努力工作是为了让赚钱变简单,努力提升是为了让工作变简单.让事情变复杂只会让事情不可控. 当然具体如何还是要取决于你采用的git flow,但是原则很简单: [代码]每个分叉的子分叉尽量是一个串联一个,内部尽量不要再有自己的提交.[代码] 为什么我认为这样的git graph可读性好,因为它把我们的工作也拍平了,不在乎每个工作的开始时间和持续时间,只关心这个工作的完成时间. 假如一个项目需求1是1月1号启动,2月1号上线,需求2是1月20号启动,2月10号上线.1月10号修了一个bug,2月3号修了一个bug. 听起来是不是很绕? 如果你的git graph显示的也是这样的信息,可读性一定不好,所以我们要做的git graph应该反应的是如下信息: 1月10号修补bug 2月1号上线需求1 2月3号修补bug 2月10号上线需求2 rebase的缺点是什么? (这里并不讨论rebase可能带来的冲突问题,有很多文章都会讲,上面也已经提到了rebase的危险性,这里只讨论rebase对于git graph的缺点.实际上,冲突只是rebase不恰当使用导致的问题,而非rebase本身的问题.) 当然也有人会说,工作的开始时间也很重要呀,因为它反映了当时工作开展的基础条件.对,这是rebase master的弊端.他让记录清晰,也让记录丢失了一些信息.记录的加工让可读性变得更好,也让信息量变少了. git rebase 让git graph发生了变化,[代码]每次分叉的检出和并入之间不会再有任何节点[代码].(因为合并到master采取的是merge行为.否则根本没有分叉) [图片] 也就是这种情况不会再出现.因为每次总是[代码]rebase master[代码],把自己的起点抬了上去.[代码]git rebase实际上让检出信息没有意义,换取了主分支分叉的清晰.[代码] 另外值得注意的是,不要在同一个分支上混用[代码]rebase[代码]和[代码]merge[代码](包括pull 的默认merge行为),因为rebase之后的[代码]commit hash[代码]被改变了,再merge的时候两个分支的共同起点被提前了,merge之后的git graph上会出现一左一右两串同样commit信息的一段历史. 如果rebase没有缺点,那么也就没有争议.是否使用rebase也要看真实的需求是什么. 这篇文章要干什么? 通过rebase让git graph更可读.目的和原则我们都已经说过了,没必要再重新说一遍. 多有谬误之处,还望不吝赐教!
2019-04-29 - 路由的封装
小程序提供了路由功能来实现页面跳转,但是在使用的过程中我们还是发现有些不方便的地方,通过封装,我们可以实现诸如路由管理、简化api等功能。 页面的跳转存在哪些问题呢? 与接口的调用一样面临url的管理问题; 传递参数的方式不太友好,只能拼装url; 参数类型单一,只支持string。 alias 第一个问题很好解决,我们做一个集中管理,比如新建一个[代码]router/routes.js[代码]文件来实现alias: [代码]// routes.js module.exports = { // 主页 home: '/pages/index/index', // 个人中心 uc: '/pages/user_center/index', }; [代码] 然后使用的时候变成这样: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { wx.navigateTo({ url: routes.uc, }); }, }); [代码] query 第二个问题,我们先来看个例子,假如我们跳转[代码]pages/user_center/index[代码]页面的同时还要传[代码]userId[代码]过去,正常情况下是这么来操作的: [代码]const routes = require('../../router/routes.js'); Page({ onReady() { const userId = '123456'; wx.navigateTo({ url: `${routes.uc}?userId=${userId}`, }); }, }); [代码] 这样确实不好看,我能不能把参数部分单独拿出来,不用拼接到url上呢? 可以,我们试着实现一个[代码]navigateTo[代码]函数: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, query }) { const queryStr = Object.keys(query).map(k => `${k}=${query[k]}`).join('&'); wx.navigateTo({ url: `${url}?${queryStr}`, }); } Page({ onReady() { const userId = '123456'; navigateTo({ url: routes.uc, query: { userId, }, }); }, }); [代码] 嗯,这样貌似舒服一点。 参数保真 第三个问题的情况是,当我们传递的参数argument不是[代码]string[代码],而是[代码]number[代码]或者[代码]boolean[代码]时,也只能在下个页面得到一个[代码]string[代码]值: [代码]// pages/index/index.js Page({ onReady() { navigateTo({ url: routes.uc, query: { isActive: true, }, }); }, }); // pages/user_center/index.js Page({ onLoad(options) { console.log(options.isActive); // => "true" console.log(typeof options.isActive); // => "string" console.log(options.isActive === true); // => false }, }); [代码] 上面这种情况想必很多人都遇到过,而且感到很抓狂,本来就想传递一个boolean,结果不管传什么都会变成string。 有什么办法可以让数据变成字符串之后,还能还原成原来的类型? 好熟悉,这不就是json吗?我们把要传的数据转成json字符串([代码]JSON.stringify[代码]),然后在下个页面把它转回json数据([代码]JSON.parse[代码])不就好了嘛! 我们试着修改原来的[代码]navigateTo[代码]: [代码]const routes = require('../../router/routes.js'); function navigateTo({ url, data }) { const dataStr = JSON.stringify(data); wx.navigateTo({ url: `${url}?jsonStr=${dataStr}`, }); } Page({ onReady() { navigateTo({ url: routes.uc, data: { isActive: true, }, }); }, }); [代码] 这样我们在页面中接受json字符串并转换它: [代码]// pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(options.jsonStr); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这里其实隐藏了一个问题,那就是url的转义,假如json字符串中包含了类似[代码]?[代码]、[代码]&[代码]之类的符号,可能导致我们参数解析出错,所以我们要把json字符串encode一下: [代码]function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } // pages/user_center/index.js Page({ onLoad(options) { const json = JSON.parse(decodeURIComponent(options.encodedData)); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] 这样使用起来不方便,我们封装一下,新建文件[代码]router/index.js[代码]: [代码]const routes = require('./routes.js'); function navigateTo({ url, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { routes, navigateTo, extract, }; [代码] 页面中我们这样来使用: [代码]const router = require('../../router/index.js'); // page home Page({ onLoad(options) { router.navigateTo({ url: router.routes.uc, data: { isActive: true, }, }); }, }); // page uc Page({ onLoad(options) { const json = router.extract(options); console.log(json.isActive); // => true console.log(typeof json.isActive); // => "boolean" console.log(json.isActive === true); // => true }, }); [代码] route name 这样貌似还不错,但是[代码]router.navigateTo[代码]不太好记,[代码]router.routes.uc[代码]有点冗长,我们考虑把[代码]navigateTo[代码]换成简单的[代码]push[代码],至于路由,我们可以使用[代码]name[代码]的方式来替换原来[代码]url[代码]参数: [代码]const routes = require('./routes.js'); function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const url = routes[name]; wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } function extract(options) { return JSON.parse(decodeURIComponent(options.encodedData)); } module.exports = { push, extract, }; [代码] 在页面中使用: [代码]const router = require('../../router/index.js'); Page({ onLoad(options) { router.push({ name: 'uc', data: { isActive: true, }, }); }, }); [代码] navigateTo or switchTab 页面跳转除了navigateTo之外还有switchTab,我们是不是可以把这个差异抹掉?答案是肯定的,如果我们在配置routes的时候就已经指定是普通页面还是tab页面,那么程序完全可以切换到对应的跳转方式。 我们修改一下[代码]router/routes.js[代码],假设home是一个tab页面: [代码]module.exports = { // 主页 home: { type: 'tab', path: '/pages/index/index', }, uc: { path: '/pages/a/index', }, }; [代码] 然后修改[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; if (route.type === 'tab') { wx.switchTab({ url: `${route.path}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${route.path}?encodedData=${dataStr}`, }); } [代码] 搞定,这样我们一个[代码]router.push[代码]就能自动切换两种跳转方式了,而且之后一旦页面类型有变动,我们也只需要修改[代码]route[代码]的定义就可以了。 直接寻址 alias用着很不错,但是有一点挺麻烦得就是每新建一个页面都要写一个alias,即使没有别名的需要,我们是不是可以处理一下,如果在alias没命中,那就直接把name转化成url?这也是阔以的。 [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : name; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 在页面中使用: [代码]Page({ onLoad(options) { router.push({ name: 'pages/user_center/a/index', data: { isActive: true, }, }); }, }); [代码] 注意,为了方便维护,我们规定了每个页面都必须存放在一个特定的文件夹,一个文件夹的当前路径下只能存在一个index页面,比如[代码]pages/index[代码]下面会存放[代码]pages/index/index.js[代码]、[代码]pages/index/index.wxml[代码]、[代码]pages/index/index.wxss[代码]、[代码]pages/index/index.json[代码],这时候你就不能继续在这个文件夹根路径存放另外一个页面,而必须是新建一个文件夹来存放,比如[代码]pages/index/pageB/index.js[代码]、[代码]pages/index/pageB/index.wxml[代码]、[代码]pages/index/pageB/index.wxss[代码]、[代码]pages/index/pageB/index.json[代码]。 这样是能实现功能,但是这个name怎么看都跟alias风格差太多,我们试着定义一套转化规则,让直接寻址的name与alias风格统一一些,[代码]pages[代码]和[代码]index[代码]其实我们可以省略掉,[代码]/[代码]我们可以用[代码].[代码]来替换,那么原来的name就变成了[代码]user_center.a[代码]: [代码]Page({ onLoad(options) { router.push({ name: 'user_center.a', data: { isActive: true, }, }); }, }); [代码] 我们再来改进[代码]router/index.js[代码]中[代码]push[代码]的实现: [代码]function push({ name, data }) { const dataStr = encodeURIComponent(JSON.stringify(data)); const route = routes[name]; const url = route ? route.path : `pages/${name.replace(/\./g, '/')}/index`; if (route.type === 'tab') { wx.switchTab({ url: `${url}`, // 注意tab页面是不支持传参的 }); return; } wx.navigateTo({ url: `${url}?encodedData=${dataStr}`, }); } [代码] 这样一来,由于支持直接寻址,跳转home和uc还可以写成这样: [代码]router.push({ name: 'index', // => /pages/index/index }); router.push({ name: 'user_center', // => /pages/user_center/index }); [代码] 这样一来,除了一些tab页面以及特定的路由需要写alias之外,我们也不需要新增一个页面就写一条alias这么麻烦了。 其他 除了上面介绍的navigateTo和switchTab外,其实还有[代码]wx.redirectTo[代码]、[代码]wx.navigateBack[代码]以及[代码]wx.reLaunch[代码]等,我们也可以做一层封装,过程雷同,所以我们就不再一个个介绍,这里贴一下最终简化后的api以及原生api的映射关系: [代码]router.push => wx.navigateTo router.replace => wx.redirectTo router.pop => wx.navigateBack router.relaunch => wx.reLaunch [代码] 最终实现已经在发布在github上,感兴趣的朋友可以移步了解:mp-router。
2019-04-26 - 爬虫vs反爬虫
爬虫介绍 爬虫简单介绍就是一个获取数据的途径。有时我们需要进行数据分析等操作,都会将别人网站中现成的数据放入我们自己本地数据库内,这时候,我们可以使用爬虫来实现。 网站的重要资料、信息财产被轻易窃取,是不能随便泄漏的。我们就应该使用反爬虫技术。本文将依次先将常见的反爬虫技术,与对应的爬虫技巧。 爬虫原理 一般我们访问网络资源都是通过uri。我们要获取的信息,一般有两种常见形式。json或html。html一般是后端服务器渲染后返回的,json是服务器直接返回给前端,然后前端自己在将数据渲染到页面上。如果是json类型的话,可以直接请求这个uri,或者等待前端渲染完毕再从html获取。获取到html信息后,通过dom操作,即可获得对应内容。爬取手机app的内容时,需要使用抓包工具:Fiddler,Charles,Wireshark。 最简单的爬虫,用shell就可以实现。复杂的爬虫,甚至需要用到机器学习分析。 注意:大部分情况爬虫是没有法律问题的,只有网站明确声明了禁止使用网络爬虫和转载商业化时(付费知识)时,爬虫才会触犯法律 为了更快的创建一只爬虫,请先安装Postman,Chrome等软件,用于发送请求,并可以查看详细请求和响应头部和内容 如果有反爬虫需要,请一定要记录服务器请求日志。分析日志才能找出潜在爬虫,即使没有及时对应的解决的方案,也可以暂时将其拉入黑名单。 爬虫流程 [图片] 整体流程的关键在于将网上的数据通过机器方式自动获得。没有反爬虫机制的 uri,还可以使用分布式爬虫,开启多线程,快速爬取信息。遇到了反爬虫机制,一般来说用上对应的反反爬虫方法就行了。大部分网站仅仅是对游客有访问限制,如果不想注册账号,基本都可以使用代理方式解决。也可以在登录之后复制登录的完整请求头解决。但是对于某些数据会有验证码拦截。这时我们就要将网站使用的验证码分类并在网上找到对应的解决方案。 查看请求 使用Chrom浏览器(也可以用别的,这里只用Chrome做例子)访问我们要爬取的页面。通过F12进入开发者工具,切换到network面板下(没有数据,请刷新再次请求) 找到要获取数据的URI [图片] 请求所携带的请求头都在这里,完整附带请求信息,可以充分模拟浏览器。 [图片] 直接通过Chrome将请求信息导出,在自己的终端下尝试请求。 由于Google页面携带的cookie内容较多,而且hk站点下编码不是常见的UTF8格式,会出现乱码,这里就以百度为例。 [代码]curl 'https://www.baidu.com/' -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:67.0) Gecko/20100101 Firefox/67.0' -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' -H 'Accept-Language: en-US,en;q=0.5' --compressed -H 'DNT: 1' -H 'Connection: keep-alive' -H 'Cookie: BAIDUID=606890F9A814F1194EEA6EC7D743CC84:FG=1; BIDUPSID=606890F9A814F1194EEA6EC7D743CC84; PSTM=1553581103; delPer=0; BD_HOME=0; H_PS_PSSID=26524_1461_21106_28722_28557_28697_28584_26350_28604_28606; BD_UPN=133252; BDORZ=B490B5EBF6F3CD402E515D22BCDA1598' -H 'Upgrade-Insecure-Requests: 1' -H 'Pragma: no-cache' -H 'Cache-Control: no-cache' [代码] 添加-o 将本次请求的response保存为文件。某些页面可能会返回乱码,是因为开启了gzip或者编码需要重新设置,部分网页编码非UTF8。 直接访问 这种网站一般数据都是完全公开的,可以算是0反爬虫。对于这种网站,直接请求即可。这种爬虫最好做,所以一般搜索引擎也最好搜索。很多网站即使内部有许多反爬虫机制,但是首页还是会为了SEO,一般不会反爬虫。 鉴别浏览器 通过浏览器进行网站访问时,都会携带user-agent信息。而在本地请求时,并不会携带这些信息。同时浏览器会保持一个session会话,而一般的request模块中,并不能携带session。我们可以通过session存放标记或者判断header部分,来鉴别爬虫。 爬虫方案 对于session的判断,使用带session的模块。比如python的requests或者使用headless。 对于header的判断,可以在浏览器的请求内复制完整的请求头。或者直接使用headless 判断token token一般不用于自己的主站。访问过很多网站,我只见过掘金在使用。token的目的不是反爬虫,而是防止用户的账号密码泄露。 先简单讲讲token步骤 客户端第一次登录 服务器确认登录并返回有期限的合法token 客服端进行请求时携带token 服务器判断token识别合法用户,然后在执行请求 爬虫方案 一般服务器都是使用sesssion的,但如果确实使用了token的。我们可以分析接口,获取token,并在请求时携带token即可。 限制ip访问频率 一般而言限制ip访问,不只是为了减少爬虫的数量,更是为了防止DDOS之类攻击。因为同一ip的大量访问,将会占用服务器的大量资源。如果触发的还是一个费时的操作,将会导致服务器来不及处理其他正常用户的请求。可以直接通过nginx自动让网站都有一个拦截ip频繁访问。 其实大部分网站还是会设置一定的阈值的,可能是1小时的访问次数,又或者每日,每个游客用户的访问次数。 爬虫方案 通过添加定时器:随机几秒延迟,不超过服务器的阈值,简单模拟人的访问频率。 使用代理服务器:大部分语言的请求,都提供了proxy的api。使用代理后,就可避开ip的限制了。所以我们反爬虫时尽力用非ip方式判断是不是用了proxy在爬取服务器 使用网站登录信息:部分网站是对游客有所限制,对于登录的用户会有更多访问次数。所以可以利用这点提高频率。 验证码 验证码一直是一个反人类的玩意。但是他设计的目的是为了反机器。验证码和破解验证码有着很长的战争历史。大部分后端语言都有快速部署数字验证码的组件。 爬虫方案 最简单的方破解法就是使用登录信息或者用代理更换ip,不过部分情况还是会遇到不可避免的验证码的。 使用第三方平台接入,手工在线解验证码。 使用机器学习配合headless,破解验证码 滑动验证码 [图片] 现在经常出现的滑动验证码,使用的是极验提供的验证码。通过滑动确实让用户的操作成本降低了。 爬虫方案 网上出现了不少的破解案例。这里推荐使用headless将通过代码能让鼠标执行操作验证码。 图片文字验证码 [图片] 爬虫方案 这种验证码比上面的要求更高,提高的用户的成本。使用的比较少,但是破解方法还是有的,就是使用OCR或者机器学习。 前端动态渲染 现代前端日益强大,许多事情在前端都可以完成。为了减轻服务器的压力。有些网站已经从传统的JSP,PHP渲染转为,Java,PHP提供接口,由前端自行渲染。 爬虫方案 这时候,我们可以再次在Network面板中查看获取信息的api接口。但是部分时候,数据会比较复杂,晦涩。甚至还有加密信息在返回的数据之内。其实我们也可能让数据直接在网页上自己渲染出来,就是通过headless等无头浏览器。实现 图片代替文字,字体映射 这种反爬方案比较高级,单一般用户体验也不会很高,成本也不低。遇到还是换个网站比较好。 headless无头浏览器 Node.js Python Puppeteer、phantomjs、Splash Selenium 更多headless 使用headless基本上算是终极解决方案了,用代码的方式去执行一个no GUI的浏览器。包括鼠标的移动,点击,拖动等。而且还能自动携带Session和Cookie信息,不过headless设计的初衷其实是前端自动化测试… [代码]const puppeteer = require('puppeteer'); async function getPage() { //创建实例 const browser = await puppeteer.launch(); //新建页面 const page = await browser.newPage(); await page.goto("https://juejin.im"); //等待1秒 await page.waitFor(1000); //截屏 await page.screenshot({path: `preview.png`}); //关闭实例 await browser.close(); } getPage(); [代码] 这段代码可以将SPA页面加载出来,查看效果可以使用api提供的screenshot截屏功能 [图片] 注意:使用headless需要加入渲染页面的性能,会导致爬虫性能极速下降(毕竟本来设计目的不是爬虫) 总结 其实为什么反爬虫没过多久都能被破解呢?其实主要原因是浏览器的用户信息的透明的,我们可以通过浏览器就可以看到开发的前端源代码。即使使用了各种技术,只要有前端,耐心的分析还是可以破解的。就算不行,我也可以通过headless确实以浏览器方式进行访问。即使是App也可以通过抓包,分析api,进行爬虫。 其实做反爬虫就好像是图灵测试,通过一系列的方法来辨别当前访问者是人还是机器。但是这个测试又不能使用户感到返感。
2019-03-26 - 实现小程序canvas拖拽功能
组件地址 https://github.com/jasondu/wx-comp-canvas-drag 实现效果 [图片] 如何实现 使用canvas 使用movable-view标签 由于movable-view无法实现旋转,所以选择使用canvas 需要解决的问题 如何将多个元素渲染到canvas上 如何知道手指在元素上、如果多个元素重叠如何知道哪个元素在最上层 如何实现拖拽元素 如何缩放、旋转、删除元素 看起来挺简单的嘛,就把上面这几个问题解决了,就可以实现功能了;接下来我们一一解决。 如何将多个元素渲染到canvas上 定义一个DragGraph类,传入元素的各种属性(坐标、尺寸…)实例化后推入一个渲染数组里,然后再循环这个数组调用实例中的渲染方法,这样就可以把多个元素渲染到canvas上了。 如何知道手指在元素上、如果多个元素重叠如何知道哪个元素在最上层 在DragGraph类中定义了判断点击位置的方法,我们在canvas上绑定touchstart事件,将手指的坐标传入上面的方法,我们就可以知道手指是点击到元素本身,还是删除图标或者变换大小的图标上了,这个方法具体怎么判断后面会讲解。 通过循环渲染数组判断是非点击到哪个元素到,如果点击中了多个元素,也就是多个元素重叠,那第一个元素就是最上层的元素啦。 ###如何实现拖拽元素 通过上面我们可以判断手指是否在元素上,当touchstart事件触发时我们记录当前的手指坐标,当touchmove事件触发时,我们也知道这时的坐标,两个坐标取差值,就可以得出元素位移的距离啦,修改这个元素实例的x和y,再重新循环渲染渲染数组就可以实现拖拽的功能。 如何缩放、旋转、删除元素 这一步相对比较难一点,我会通过示意图跟大家讲解。 我们先讲缩放和旋转 [图片] 通过touchstart和touchmove我们可以获得旋转前的旋转后的坐标,图中的线A为元素的中点和旋转前点的连线;线B为元素中点和旋转后点的连线;我们只需要求A和B两条线的夹角就可以知道元素旋转的角度。缩放尺寸为A和B两条线长度之差。 计算旋转角度的代码如下: [代码]const centerX = (this.x + this.w) / 2; // 中点坐标 const centerY = (this.y + this.h) / 2; // 中点坐标 const diffXBefore = px - centerX; // 旋转前坐标 const diffYBefore = py - centerY; // 旋转前坐标 const diffXAfter = x - centerX; // 旋转后坐标 const diffYAfter = y - centerY; // 旋转后坐标 const angleBefore = Math.atan2(diffYBefore, diffXBefore) / Math.PI * 180; const angleAfter = Math.atan2(diffYAfter, diffXAfter) / Math.PI * 180; // 旋转的角度 this.rotate = currentGraph.rotate + angleAfter - angleBefore; [代码] 计算缩放尺寸的代码如下: [代码]// 放大 或 缩小 this.x = currentGraph.x - (x - px); this.y = currentGraph.y - (x - px); [代码]
2019-02-20