注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

分享近期研究的 6 款开源API网关

随着API越来越广泛和规范化,对标准化、安全协议和可扩展性的需求呈指数级增长。随着对微服务的兴趣激增,这一点尤其如此,微服务依赖于API进行通信。API网关通过一个相对容易实现的解决方案来满足这些需求。 也许最重要的是,API网关充当用户和数据之间的中介。AP...
继续阅读 »

随着API越来越广泛和规范化,对标准化、安全协议和可扩展性的需求呈指数级增长。随着对微服务的兴趣激增,这一点尤其如此,微服务依赖于API进行通信。API网关通过一个相对容易实现的解决方案来满足这些需求。


也许最重要的是,API网关充当用户和数据之间的中介。API网关是针对不正确暴露的端点的基本故障保护,而这些端点是黑客最喜欢的目标。考虑到一个被破坏的API在某些情况下可能会产生惊人的灾难性后果,仅此一点就使得API网关值得探索。网关还添加了一个有用的抽象层,这有助于将来验证您的API,防止由于版本控制或后端更改而导致的中断和服务中断。


不幸的是,许多API网关都是专有的,而且价格不便宜!值得庆幸的是,已经有几个开源API网关来满足这一需求。我们已经回顾了六个著名的开源API网关,您可以自行测试,而无需向供应商作出大量承诺。


Kong Gateway (Open Source)


Kong Gateway(OSS)是一个受欢迎的开源API网关,因为它界面流畅、社区活跃、云原生架构和广泛的功能。它速度极快,重量轻。Kong还为许多流行的基于容器和云的环境提供了现成的部署,从Docker到Kubernetes再到AWS。这使您可以轻松地将Kong集成到现有的工作流程中,从而使学习曲线不那么陡峭。


Kong支持日志记录、身份验证、速率限制、故障检测等。更好的是,它有自己的CLI,因此您可以直接从命令行管理Kong并与之交互。您可以在各种发行版上安装开源社区Kong Gateway。基本上,Kong拥有API网关所需的一切。


Tyk Open-Source API Gateway


Tyk被称为“行业最佳API网关”。与我们列表中的其他API网关不同,Tyk确实是开源的,而不仅仅是开放核心或免费增值。它为开源解决方案提供了一系列令人印象深刻的特性和功能。和Kong一样,Tyk也是云原生的,有很多插件可用。Tyk甚至可以用于以REST和GraphQL格式发布自己的API。


Tyk对许多功能都有本机支持,包括各种形式的身份验证、配额、速率限制和版本控制。它甚至可以生成API文档。最令人印象深刻的是,Tyk提供了一个API开发者门户,允许您发布托管API,因此第三方可以注册您的API,甚至管理他们的API密钥。Tyk通过其开源API网关提供了如此多的功能,实在令人难以置信。


KrakenD Open-Source API Gateway


KrakenD的开源API网关是在Go中编写的,它有几个显著的特点,尤其是对微服务的优化。它的可移植性和无状态性是其他强大的卖点,因为它可以在任何地方运行,不需要数据库。由于KrakenDesigner,它比我们列表中的其他一些API网关更灵活、更易于接近,这是一个GUI,它可以让您直观地设计或管理API。您还可以通过简单地编辑JSON文件轻松地编辑API。


KrakenD包括速率限制、过滤、缓存和授权等基本功能,并且提供了比我们提到的其他API网关更多的功能。在不修改源代码的情况下,可以使用许多插件和中间件。它的效率也很高-据维护人员称,KrakenD的吞吐量优于Tyk和Kong的其他API网关。它甚至具有本地GraphQL支持。所有这些,KrakenD的网关非常值得一看。


Gravitee OpenSource API Management


Gravite.io是另一个API网关,它具有一系列令人印象深刻的功能,这次是用Java编写的。Gravitee有三个模块用于发布、监控和记录API:




  • API管理(APIM):APIM是一个开源模块,可以让您完全控制谁访问您的API以及何时何地。




  • 访问管理(AM):Gravite为身份和访问管理提供了一个本地开源授权解决方案。它基于OAuth 2.0/OpenID协议,具有集中的身份验证和授权服务。




  • 警报引擎(AE):警报引擎是一个用于监视API的模块,允许您自定义多渠道通知,以提醒您可疑活动。




Gravitee还具有API设计器Cockpit和命令行界面graviteio-cli。所有这些都使Gravitee成为最广泛的开源API网关之一。您可以在GitHub上查看Gravite.io OpenSource API管理,或直接下载AWS、Docker、Kubernetes、Red Hat,或在此处作为Zip文件。


Apinto Microservice Gateway


显然,Go是编写API网关的流行语言。Apinto API网关用Go编写,旨在管理微服务,并提供API管理所需的所有工具。它支持身份验证、API安全以及流控制。


Apinto支持HTTP转发、多租户管理、访问控制和API访问管理,非常适合微服务或具有多种类型用户的任何开发项目。Apinto还可以通过多功能的用户定义插件系统轻松地为特定用户定制。它还具有API健康检查和仪表板等独特功能。


Apinto Microservice针对性能进行了优化,具有动态路由和负载平衡功能。根据维护人员的说法,Apinto比Nginx或Kong快50%。


Apache APISIX API Gateway


我们将使用世界上最大的开源组织之一Apache软件基金会的一个开源API网关来完善我们的开源API网关列表。Apache APISIX API网关是另一个云原生API网关,具有您目前已经认识到的所有功能——负载平衡、身份验证、速率限制和API安全。然而,有一些特殊的功能,包括多协议支持和Kubernetes入口控制。


关于开源API网关的最后思考


不受限制、不受限制的API访问时代已经结束。随着API使用的广泛普及,有无数理由实现API网关以实现安全性,因为不正确暴露的API端点可能会造成严重损害。API网关可以帮助围绕API设置速率限制,以确保安全使用。而且,如果您向第三方供应商支付高昂的价格,开源选项可能会减少您的每月IT预算。


总之,API网关为您的API环境添加了一个重要的抽象层,这可能是它们最有用的功能。这样的抽象层是防止API端点和用户数据不当暴露的一些最佳方法。然而,几乎同样重要的是它为您的API增加了灵活性。


如果没有抽象层,即使对后端的微小更改也可能导致下游的严重破坏。添加API网关可以使敏捷框架受益,并有助于

作者:CV_coder
来源:juejin.cn/post/7241778027876401213
简化CI/CD管道。

收起阅读 »

图像识别,不必造轮子

闲来无事研究了百度图像识别 API,发现该功能还算强大,在此将其使用方法总结成教程,提供大家学习参考 首先预览下效果 从以上预览图中可看出,每张图片识别出5条数据,每条数据根据识别度从高往下排,每条数据包含物品名称、识别度、所属类目 准备工作 1、注册百度账...
继续阅读 »

闲来无事研究了百度图像识别 API,发现该功能还算强大,在此将其使用方法总结成教程,提供大家学习参考


首先预览下效果


图片


从以上预览图中可看出,每张图片识别出5条数据,每条数据根据识别度从高往下排,每条数据包含物品名称识别度所属类目


准备工作


1、注册百度账号


2、登录百度智能云控制台


3、在产品列表中找到 人工智能->图像识别


4、点击创建应用,如下图:


图片


图片


图片


已创建好的应用列表


代码部分


1、获取access_token值


注意:使用图像识别需用到access_token值,因此需先获取到,以便下面代码的使用


access_token获取的方法有多种,这里使用PHP获取,更多有关access_token获取的方法以及说明可查看官方文档:


ai.baidu.com/docs#/Auth/…


创建一个get_token.php文件,用来获取access_token值


PHP获取access_token代码示例:


<?php

//请求获取access_token值函数
function request_post($url = '', $param = '') {

if (empty($url) || empty($param)) {
return false;
}

$postUrl = $url;
$curlPost = $param;
$curl = curl_init();//初始化curl
curl_setopt($curl, CURLOPT_URL,$postUrl);//抓取指定网页
curl_setopt($curl, CURLOPT_HEADER, 0);//设置header
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);//要求结果为字符串且输出到屏幕上
curl_setopt($curl, CURLOPT_POST, 1);//post提交方式
curl_setopt($curl, CURLOPT_POSTFIELDS, $curlPost);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
$data = curl_exec($curl);//运行curl
curl_close($curl);

return $data;
}

$url = 'https://aip.baidubce.com/oauth/2.0/token'; //固定地址
$post_data['grant_type'] = 'client_credentials'; //固定参数
$post_data['client_id'] = '你的 Api Key'; //创建应用的API Key;
$post_data['client_secret'] = '你的 Secret Key'; //创建应用的Secret Key;
$o = "";
foreach ( $post_data as $k => $v )
{
$o.= "$k=" . urlencode( $v ). "&" ;
}
$post_data = substr($o,0,-1);

$res = request_post($url, $post_data);//调用获取access_token值函数

var_dump($res);

?>

返回的数据如下,红框内的就是我们所要的access_token值


图片


2、图片上传及识别


2.1、在项目的根目录下创建一个upload文件夹,用于存放上传的图片


2.2、创建一个index.html文件,用于上传图片及数据渲染


代码如下:


<!DOCTYPE html>  
<html>
<head>
<meta charset="utf-8"> 
<title>使用百度 API 实现图像识别</title> 
<style type="text/css">
  .spanstyle{
    display:inline-block;
    width:500px;
    height:500px;
    position: relative;
  }
</style>
<script src="https://cdn.bootcss.com/jquery/1.10.2/jquery.min.js"></script>

<script>

  function imageUpload(imgFile) {

    var uploadfile= imgFile.files[0]  //获取图片文件流

    var formData = new FormData();    //创建一个FormData对象

    formData.append('file',uploadfile);
    //将图片放入FormData对象对象中(由于图片属于文件格式,不能直接将文件流直接通过ajax传递到后台,需要放入FormData对象中。在传递)

    $("#loading").css("opacity",1);


     $.ajax({
          type: "POST",       //POST请求
          url: "upload.php",  //接收图片的地址(同目录下的php文件)
          data:formData,      //传递的数据
          dataType:"json",    //声明成功使用json数据类型回调

          //如果传递的是FormData数据类型,那么下来的三个参数是必须的,否则会报错
          cache:false,  //默认是true,但是一般不做缓存
          processData:false, //用于对data参数进行序列化处理,这里必须false;如果是true,就会将FormData转换为String类型
          contentType:false,  //一些文件上传http协议的关系,自行百度,如果上传的有文件,那么只能设置为false

         success: function(msg){  //请求成功后的回调函数


              console.log(msg.result)

              //预览上传的图片
              var filereader = new FileReader();
              filereader.onload = function (event) {
                  var srcpath = event.target.result;
                  $("#loading").css("opacity",0);
                  $("#PreviewImg").attr("src",srcpath);
                };
              filereader.readAsDataURL(uploadfile);


                //将后台返回的数据进行进一步处理
                var data=  '<li style="margin:2% 0"><span>物品名称:'+msg.result[0].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[0].score*100+'%'+';</span><span>所属类目:'+msg.result[0].root+';</span></li>'

                data=data+  '<li style="margin:2% 0"><span>物品名称:'+msg.result[1].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[1].score*100+'%'+';</span><span>所属类目:'+msg.result[1].root+';</span></li>'

                data=data+  '<li style="margin:2% 0"><span>物品名称:'+msg.result[2].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[2].score*100+'%'+';</span><span>所属类目:'+msg.result[2].root+';</span></li>'

                data=data+  '<li style="margin:2% 0"><span>物品名称:'+msg.result[3].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[3].score*100+'%'+';</span><span>所属类目:'+msg.result[3].root+';</span></li>'


                data=data+  '<li style="margin:2% 0"><span>物品名称:'+msg.result[4].keyword+';</span> <span style="padding: 0 2%">识别度:'+msg.result[4].score*100+'%'+';</span><span>所属类目:'+msg.result[4].root+';</span></li>'



                //将识别的数据在页面渲染出来
               $("#content").html(data);


        }
  });


   }



</script>
</head>
<body>

  <fieldset>
     <input type="file"  onchange="imageUpload(this)" >
     <legend>图片上传</legend>
  </fieldset>



<div style="margin-top:2%">
    <span class="spanstyle">
      <img id="PreviewImg" src="default.jpg" style="width:100%;max-height:100%"  >
      <img id="loading" style="width:100px;height:100px;top: 36%;left: 39%;position: absolute;opacity: 0;" src="loading.gif" >
    </span>


    <span class="spanstyle" style="vertical-align: top;border: 1px dashed #ccc;background-color: #4ea8ef;color: white;">
        <h4 style="padding-left:2%">识别结果:</h4>
        <ol style="padding-right: 20px;" id="content">

        </ol>
    </span>

</div>



</body>
</html>

2.3、创建一个upload.php文件,用于接收图片及调用图像识别API


备注:百度图像识别API接口有多种,这里使用的是【通用物体和场景识别高级版】 ;该接口支持识别10万个常见物体及场景,接口返回大类及细分类的名称结果,且支持获取图片识别结果对应的百科信息


该接口调用的方法也有多种,这里使用PHP来调用接口,更多有关通用物体和场景识别高级版调用的方法以及说明可查看官方文档:


ai.baidu.com/docs#/Image…


PHP请求代码示例:


<?php  

        //图像识别请求函数    
        function request_post($url ''$param ''){

            if (empty($url) || empty($param)) {
                return false;
            }

            $postUrl $url;
            $curlPost $param;
            // 初始化curl
            $curl curl_init();
            curl_setopt($curl, CURLOPT_URL, $postUrl);
            curl_setopt($curl, CURLOPT_HEADER, 0);
            // 要求结果为字符串且输出到屏幕上
            curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
            curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
            // post提交方式
            curl_setopt($curl, CURLOPT_POST, 1);
            curl_setopt($curl, CURLOPT_POSTFIELDS, $curlPost);
            // 运行curl
            $data curl_exec($curl);
            curl_close($curl);

            return $data;
        }

        $temp explode("."$_FILES["file"]["name"]);
        $extension end($temp);     // 获取图片文件后缀名


        $_FILES["file"]["name"]=time().'.'.$extension;//图片重命名(以时间戳来命名)

        //将图片文件存在项目根目录下的upload文件夹下
        move_uploaded_file($_FILES["file"]["tmp_name"], "upload/" . $_FILES["file"]["name"]);


        $token '调用鉴权接口获取的token';//将获取的access_token值放进去
        $url 'https://aip.baidubce.com/rest/2.0/image-classify/v2/advanced_general?access_token=' . $token;
        $img file_get_contents("upload/" . $_FILES["file"]["name"]);//本地文件路径(存入后的图片文件路径)
        $img base64_encode($img);//文件进行base64编码加密

        //请求所需要的参数
        $bodys array(
            'image' => $img,//Base64编码字符串
            'baike_num'=>5  //返回百科信息的结果数 5条
        );
        $res request_post($url$bodys);//调用请求函数

        echo $res;  //将识别的数据输出到前端


?>

结语补充


在实际开发过程中,获取access_token值并不是单独写成一个页面文件,而是写在项目系统的配置中;由于access_token值有效期为30天,可通过判断是否失效,来重新请求acc

作者:程序员Winn
来源:juejin.cn/post/7241874482574770235
ess_token值

收起阅读 »

什么是优雅的代码设计

今天我来解释一下什么样的代码才是优雅的代码设计。当然我们的代码根据实际的应用场景也分了很多维度,有偏向于底层系统的,有偏向于中间件的,也有偏向上层业务的,还有偏向于前端展示的。今天我主要来跟大家分析一下我对于业务代码的理解以及什么样才是我认为的优雅的业务代码设...
继续阅读 »

今天我来解释一下什么样的代码才是优雅的代码设计。当然我们的代码根据实际的应用场景也分了很多维度,有偏向于底层系统的,有偏向于中间件的,也有偏向上层业务的,还有偏向于前端展示的。今天我主要来跟大家分析一下我对于业务代码的理解以及什么样才是我认为的优雅的业务代码设计。


大家吐槽非常多的是,我们这边的业务代码会存在着大量的不断地持续的变化,导致我们的程序员对于业务代码设计得就比较随意。往往为了快速上线随意堆叠,不加深入思考,或者是怕影响到原来的流程,而不断在原来的代码上增加分支流程。


这种思想进一步使得代码腐化,使得大量的程序员更失去了“好代码”的标准。


那么如果代码优雅,那么要有哪些特征呢?或者说我们做哪些事情才会使得代码变得更加优雅呢?


结构化


结构化定义是指对某一概念或事物进行系统化、规范化的分析和定义,包括定义的范围、对象的属性、关系等方面,旨在准确地描述和定义所要表达的概念或事物。



我觉得首要的是代码,要一个骨架。就跟我们所说的思维结构是一样,我们对一个事物的判断,一般都是综合、立体和全面的,否则就会成为了盲人摸象,只见一斑。因此对于一个事物的判断,要综合、结构和全面。对于一段代码来说也是一样的标准,首先就是结构化。结构化是对一段代码最基本的要求,一个有良好结构的代码才可能称得上是好代码,如果只是想到哪里就写到哪里,一定成不了最优质的代码。


代码的结构化,能够让维护的人一眼就能看出主次结构、看出分层结构,能够快速掌握一段代码或者一段模块要完成的核心事情。


精简



代码跟我们抽象现实的物体一样,也要非常地精简。其实精简我觉得不仅在代码,在所有艺术品里面都是一样的,包括电影。电影虽然可能长达一个小时,两个小时,但你会发现优雅的电影它没有一帧是多余的,每出现的一个画面、一个细节,都是电影里要表达的某个情绪有关联。我们所说的文章也是一样,没有任何一个伏笔是多余的。代码也是一样,严格来说代码没有一个字符、函数、变量是多余的,每个代码都有它应该有的用处。就跟“奥卡姆剃刀”原理一样,每块代码都有它存在的价值包括注释。


但正如我们的创作一样,要完成一个功能,我们把代码写得复杂是简单的,但我们把它写得简单是非常难的。代码是思维结构的一种体现,而往往抽象能力是最为关键的,也是最难的。合适的抽象以及合理的抽象才能够让代码浓缩到最少的代码函数。


大部分情况来说,代码行数越少,则运行效率会越高。当然也不要成为极端的反面例子,不要一味追求极度少量的代码。代码的优雅一定是精要的,该有的有,不该有的一定是没有的。所以在完成一个业务逻辑的时候,一定要多问自己这个代码是不是必须有的,能不能以一种简要的方式来表达。


善用最佳实践


俗话说太阳底下没有新鲜事儿,一般来说,没有一个业务场景所需要用到的编码方式是需要你独创发明的。你所写的代码功能大概率都有人遇到过,因此对于大部分常用的编码模式,也都大家被抽象出来了一些最佳实践。那么最经典的就是23种设计模式,基本上可以涵盖90%以上的业务场景了。


以下是23种设计模式的部分简单介绍:

  1. 单例模式(Singleton Pattern):确保类只有一个实例,并提供全局访问点。
  2. 工厂模式(Factory Pattern):定义一个用于创建对象的接口,并让子类决定实例化哪个对象。
  3. 模板方法模式(Template Method Pattern):提供一种动态的创建对象的方法,通过使用不同的模板来创建对象。
  4. 装饰器模式(Decorator Pattern):将对象包装成另一个对象,从而改变原有对象的行为。
  5. 适配器模式(Adapter Pattern):将一个类的接口转换成客户希望的另一个接口,以使其能够与不同的对象交互。
  6. 外观模式(Facade Pattern):将对象的不同方面组合成一个单一的接口,从而使客户端只需访问该接口即可使用整个对象。

我们所说的设计模式就是一种对常用代码结构的一种抽象或者说套路。并不是说我们一定要用设计模式来实现功能,而是说我们要有一种最高效,最通常的方式去实现。这种方式带来了好处就是高效,而且别人理解起来也相对来说比较容易。


我们也不大推荐对于一些常见功能用一些花里胡哨的方式来实现,这样往往可能导致过度设计,但实际用处可能反而会带来其他问题。我觉得要用一些新型的代码,新型的思维方式应该是在一些比较新的场景里面去使用,去验证,而不应该在我们已有最佳实践的方式上去造额外的轮子。


这个就比如我们如果要设计一辆汽车,我们应该采用当前最新最成熟的发动机方案,而不应该从零开始自己再造一套新的发动机。但是如果这个发动机是在土星使用,要面对极端的环境,可能就需要基于当前的方案研制一套全新的发动机系统,但是大部分人是没有机会碰到土星这种业务环境的。所以通常情况下,还是不要在不需要创新的地方去创新。


除了善用最佳实践模式之快,我们还应该采用更高层的一些最佳实践框架的解决方案。比如我们在面对非常抽象,非常灵活变动的一些规则的管理上,我们可以使用大量的规则引擎工具。比如针对于流程式的业务模型上面,我们可以引入一些工作流的引擎。在需要RPC框架的时候,我们可以根据业务情况去调研使用HTTP还是DUBBO,可以集百家之所长。


持续重构



好代码往往不是一蹴而就的,而是需要我们持续打磨。有很多时候由于业务的变化以及我们思维的局限性,我们没有办法一次性就能够设计出最优的代码质量,往往需要我们后续持续的优化。所以除了初始化的设计以外,我们还应该在业务持续的发展过程中动态地去对代码进行重构。


但是往往程序员由于业务繁忙或者自身的懒惰,在业务代码上线正常运行后,就打死不愿意再动原来的代码。第一个是觉得跑得没有问题了何必去改,第二个就是改动了反而可能引起故障。这就是一种完全错误的思维,一来是给自己写不好的线上代码的一个借口,二来是没有让自己持续进步的机会。


代码重构的原则有很多,这里我就不再细讲。但是始终我觉得对线上第一个要敬畏,第二个也要花时间持续续治理。往往我们在很多时候初始化的架构是比较优雅的,是经过充分设计的,但是也是由于业务发展的迭代的原因,我们持续在存量代码上添加新功能。


有时候有一些不同的同学水平不一样,能力也不一样,所以导致后面写上的代码会非常地随意,导致整个系统就会变得越来越累赘,到了最后就不敢有新同学上去改,或者是稍微一改可能就引起未知的故障。


所以在这种情况下,如果还在追求优质的代码,就需要持续不断地重构。重构需要持续改善,并且最好每次借业务变更时,做小幅度的修改以降低风险。长此以往,整体的代码结构就得以大幅度的修改,真正达到集腋成裘的目的。下面是一些常见的重构原则:

  1. 单一职责原则:每个类或模块应该只负责一个单一的任务。这有助于降低代码的复杂度和维护成本。
  2. 开闭原则:软件实体(类、模块等)应该对扩展开放,对修改关闭。这样可以保证代码的灵活性和可维护性。
  3. 里氏替换原则:任何基类都可以被其子类替换。这可以减少代码的耦合度,提高代码的可扩展性。
  4. 接口隔离原则:不同的接口应该是相互独立的,它们只依赖于自己需要的实现,而不是其他接口。
  5. 依赖倒置原则:高层模块不应该依赖低层模块,而是依赖应用程序的功能。这可以降低代码的复杂度和耦合度。
  6. 高内聚低耦合原则:尽可能使模块内部的耦合度低,而模块之间的耦合度高。这可以提高代码的可维护性和可扩展性。
  7. 抽象工厂原则:使用抽象工厂来创建对象,这样可以减少代码的复杂度和耦合度。
  8. 单一视图原则:每个页面只应该有一个视图,这可以提高代码的可读性和可维护性。
  9. 依赖追踪原则:对代码中的所有依赖关系进行跟踪,并在必要时进行修复或重构。
  10. 测试驱动开发原则:在编写代码之前编写测试用例,并在开发过程中持续编写和运行测试用例,以确保代码的质量和稳定性。

综合


综上所述,代码要有结构化、可扩展、用最佳实践和持续重构。追求卓越的优质代码应该是每一位工程师的基本追求和基本要求,只有这样,才能不断地使得自己成为一名卓越的工程师。



作者:ali老蒋
来源:juejin.cn/post/7241115614102863928

收起阅读 »

初学后端,如何做好表结构设计?

前言 最近有不少前端和测试转Go的朋友在私信我:如何做好表结构设计? 大家关心的问题阳哥必须整理出来,希望对大家有帮助。 先说结论 这篇文章介绍了设计数据库表结构应该考虑的4个方面,还有优雅设计的6个原则,举了一个例子分享了我的设计思路,为了提高性能我们也要从...
继续阅读 »

前言


最近有不少前端和测试转Go的朋友在私信我:如何做好表结构设计?


大家关心的问题阳哥必须整理出来,希望对大家有帮助。


先说结论


这篇文章介绍了设计数据库表结构应该考虑的4个方面,还有优雅设计的6个原则,举了一个例子分享了我的设计思路,为了提高性能我们也要从多方面考虑缓存问题。


收获最大的还是和大家的交流讨论,总结一下:

  1. 首先,一定要先搞清楚业务需求。比如我的例子中,如果不需要灵活设置,完全可以写到配置文件中,并不需要单独设计外键。主表中直接保存各种筛选标签名称(注意维护的问题,要考虑到数据一致性)
  2. 数据库表结构设计一定考虑数据量和并发量,我的例子中如果数据量小,可以适当做冗余设计,降低业务复杂度。

4个方面


设计数据库表结构需要考虑到以下4个方面:

  1. 数据库范式:通常情况下,我们希望表的数据符合某种范式,这可以保证数据的完整性和一致性。例如,第一范式要求表的每个属性都是原子性的,第二范式要求每个非主键属性完全依赖于主键,第三范式要求每个非主键属性不依赖于其他非主键属性。
  2. 实体关系模型(ER模型):我们需要先根据实际情况画出实体关系模型,然后再将其转化为数据库表结构。实体关系模型通常包括实体、属性、关系等要素,我们需要将它们转化为表的形式。
  3. 数据库性能:我们需要考虑到数据库的性能问题,包括表的大小、索引的使用、查询语句的优化等。
  4. 数据库安全:我们需要考虑到数据库的安全问题,包括表的权限、用户角色的设置等。

设计原则


在设计数据库表结构时,可以参考以下几个优雅的设计原则:

  1. 简单明了:表结构应该简单明了,避免过度复杂化。
  2. 一致性:表结构应该保持一致性,例如命名规范、数据类型等。
  3. 规范化:尽可能将表规范化,避免数据冗余和不一致性。
  4. 性能:表结构应该考虑到性能问题,例如使用适当的索引、避免全表扫描等。
  5. 安全:表结构应该考虑到安全问题,例如合理设置权限、避免SQL注入等。
  6. 扩展性:表结构应该具有一定的扩展性,例如预留字段、可扩展的关系等。

最后,需要提醒的是,优雅的数据库表结构需要在实践中不断迭代和优化,不断满足实际需求和新的挑战。



下面举个示例让大家更好的理解如何设计表结构,如何引入内存,有哪些优化思路:



问题描述



如上图所示,红框中的视频筛选标签,应该怎么设计数据库表结构?除了前台筛选,还想支持在管理后台灵活配置这些筛选标签。


这是一个很好的应用场景,大家可以先自己想一下。不要着急看我的方案。


需求分析

  1. 可以根据红框的标签筛选视频
  2. 其中综合标签比较特殊,和类型、地区、年份、演员等不一样
  • 综合是根据业务逻辑取值,并不需要入库
  • 类型、地区、年份、演员等需要入库

3.设计表结构时要考虑到:

  • 方便获取标签信息,方便把标签信息缓存处理
  • 方便根据标签筛选视频,方便我们写后续的业务逻辑

设计思路

  1. 综合标签可以写到配置文件中(或者写在前端),这些信息不需要灵活配置,所以不需要保存到数据库中
  2. 类型、地区、年份、演员都设计单独的表
  3. 视频表中设计标签表的外键,方便视频列表筛选取值
  4. 标签信息写入缓存,提高接口响应速度
  5. 类型、地区、年份、演员表也要支持对数据排序,方便后期管理维护

表结构设计


视频表


字段注释
id视频主键id
type_id类型表外键id
area_id地区表外键id
year_id年份外键id
actor_id演员外键id

其他和视频直接相关的字段(比如名称)我就省略不写了


类型表


字段注释
id类型主键id
name类型名称
sort排序字段

地区表


字段注释
id类型主键id
name类型名称
sort排序字段

年份表


字段注释
id类型主键id
name类型名称
sort排序字段

原以为年份字段不需要排序,要么是年份正序排列,要么是年份倒序排列,所以不需要sort字段。


仔细看了看需求,还有“10年代”还是需要灵活配置的呀~


演员表


字段注释
id类型主键id
name类型名称
sort排序字段

表结构设计完了,别忘了缓存


缓存策略


首先这些不会频繁更新的筛选条件建议使用缓存:


  1. 比较常用的就是redis缓存
  2. 再进阶一点,如果你使用docker,可以把这些配置信息写入docker容器所在物理机的内存中,而不用请求其他节点的redis,进一步降低网络传输带来的耗时损耗
  3. 筛选条件这类配置信息,客户端和服务端可以约定一个更新缓存的机制,客户端直接缓存配置信息,进一步提高性能

列表数据自动缓存


目前很多框架都是支持自动缓存处理的,比如goframe和go-zero


goframe


可以使用ORM链式操作-查询缓存


示例代码:


package main

import (
"time"

"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)

func main() {
var (
db = g.DB()
ctx = gctx.New()
)

// 开启调试模式,以便于记录所有执行的SQL
db.SetDebug(true)

// 写入测试数据
_, err := g.Model("user").Ctx(ctx).Data(g.Map{
"name": "xxx",
"site": "https://xxx.org",
}).Insert()

// 执行2次查询并将查询结果缓存1小时,并可执行缓存名称(可选)
for i := 0; i < 2; i++ {
r, _ := g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: time.Hour,
Name: "vip-user",
Force: false,
}).Where("uid", 1).One()
g.Log().Debug(ctx, r.Map())
}

// 执行更新操作,并清理指定名称的查询缓存
_, err = g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: -1,
Name: "vip-user",
Force: false,
}).Data(gdb.Map{"name": "smith"}).Where("uid", 1).Update()
if err != nil {
g.Log().Fatal(ctx, err)
}

// 再次执行查询,启用查询缓存特性
r, _ := g.Model("user").Ctx(ctx).Cache(gdb.CacheOption{
Duration: time.Hour,
Name: "vip-user",
Force: false,
}).Where("uid", 1).One()
g.Log().Debug(ctx, r.Map())
}

go-zero


官方都做了详细的介绍,不作为本文的重点。


讨论


我的方案也在我的技术交流群里引起了大家的讨论,也和大家分享一下:


Q1 冗余设计和一致性问题



提问: 一个表里做了这么多外键,如果我要查各自的名称,势必要关联4张表,对于这种存在多外键关联的这种表,要不要做冗余呢(直接在主表里冗余各自的名称字段)?要是保证一致性的话,就势必会影响性能,如果做冗余的话,又无法保证一致性



回答:


你看文章的上下文应该知道,文章想解决的是视频列表筛选问题。


你提到的这个场景是在视频详情信息中,如果要展示这些外键的名称怎么设计更好。


我的建议是这样的:

  1. 根据需求可以做适当冗余,比如你的主表信息量不大,配置信息修改后同步修改冗余字段的成本并不高。
  2. 或者像我文章中写的不做冗余设计,但是会把外键信息缓存,业务查询从缓存中取值。
  3. 或者将视频详情的查询结果整体进行缓存

还是看具体需求,如果这些筛选信息不变化或者不需要手工管理,甚至不需要设计表,直接写死在代码的配置文件中也可以。进一步降低DB压力,提高性能。


Q2 why设计外键?



提问:为什么要设计外键关联?直接写到视频表中不就行了?这么设计的意义在哪里?



回答:

  1. 关键问题是想解决管理后台灵活配置
  2. 如果没有这个需求,我们可以直接把筛选条件以配置文件的方式写死在程序中,降低复杂度。
  3. 站在我的角度:这个功能的筛选条件变化并不会很大,所以很懂你的意思。也建议像我2.中的方案去做,去和产品经理拉扯喽~

总结


这篇文章介绍了设计数据库表结构应该考虑的4个方面,还有优雅设计的6个原则,举了一个例子分享了我的设计思路,为了提高性能我们也要从多方面考虑缓存问题。


收获最大的还是和大家的交流讨论,总结一下:

  1. 首先,一定要先搞清楚业务需求。比如我的例子中,如果不需要灵活设置,完全可以写到配置文件中,并不需要单独设计外键。主表中直接保存各种筛选标签名称(注意维护的问题,要考虑到数据一致性)
  2. 数据库表结构设计一定考虑数据量和并发量,我的例子中如果数据量小,可以适当做冗余设计,降低业务复杂度

作者:王中阳Go
来源:juejin.cn/post/7212828749128876092


收起阅读 »

Springboot如何优雅的进行数据校验

基于 Spring Boot ,如何“优雅”的进行数据校验呢? 引入依赖 首先只需要给项目添加上 spring-boot-starter-web 依赖就够了,它的子依赖包含了我们所需要的东西。 注意: Spring Boot 2.3 1 之后,spring-...
继续阅读 »

基于 Spring Boot ,如何“优雅”的进行数据校验呢?


引入依赖


首先只需要给项目添加上 spring-boot-starter-web 依赖就够了,它的子依赖包含了我们所需要的东西。


image.png


注意:
Spring Boot 2.3 1 之后,spring-boot-starter-validation 已经不包括在了 spring-boot-starter-web 中,需要我们手动加上!


<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

验证 Controller 的输入


一定一定不要忘记在类上加上 @ Validated 注解了,这个参数可以告诉 Spring 去校验方法参数。


验证请求体


验证请求体即使验证被 @RequestBody 注解标记的方法参数。


PersonController


我们在需要验证的参数上加上了@Valid注解,如果验证失败,它将抛出MethodArgumentNotValidException。默认情况下,Spring 会将此异常转换为 HTTP Status 400(错误请求)。


@RestController
@RequestMapping("/api/person")
@Validated
public class PersonController {

@PostMapping
public ResponseEntity<PersonRequest> save(@RequestBody @Valid PersonRequest personRequest) {
return ResponseEntity.ok().body(personRequest);
}
}

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PersonRequest {

@NotNull(message = "classId 不能为空")
private String classId;

@Size(max = 33)
@NotNull(message = "name 不能为空")
private String name;

@Pattern(regexp = "(^Man$|^Woman$|^UGM$)", message = "sex 值不在可选范围")
@NotNull(message = "sex 不能为空")
private String sex;

}

使用 Postman 验证


image.png


验证请求参数


验证请求参数(Path Variables 和 Request Parameters)即是验证被 @PathVariable 以及 @RequestParam 标记的方法参数。


PersonController


@RestController
@RequestMapping("/api/persons")
@Validated
public class PersonController {

@GetMapping("/{id}")
public ResponseEntity<Integer> getPersonByID(@Valid @PathVariable("id") @Max(value = 5, message = "超过 id 的范围了") Integer id) {
return ResponseEntity.ok().body(id);
}

@PutMapping
public ResponseEntity<String> getPersonByName(@Valid @RequestParam("name") @Size(max = 6, message = "超过 name 的范围了") String name) {
return ResponseEntity.ok().body(name);
}
}

使用 Postman 验证


image.png


image.png


嵌套校验


在一个校验A对象里另一个B对象里的参数


需要在B对象上加上@Valid注解


image.png


image.png


常用校验注解总结


JSR303 定义了 Bean Validation(校验)的标准 validation-api,并没有提供实现。Hibernate Validation是对这个规范/规范的实现 hibernate-validator,并且增加了 @Email、@Length、@Range 等注解。Spring Validation 底层依赖的就是Hibernate Validation。


JSR 提供的校验注解:



  • @Null 被注释的元素必须为 null

  • @NotNull 被注释的元素必须不为 null

  • @AssertTrue 被注释的元素必须为 true

  • @AssertFalse 被注释的元素必须为 false

  • @Min(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值

  • @Max(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值

  • @DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值

  • @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值

  • @Size(max=, min=) 被注释的元素的大小必须在指定的范围内

  • @Digits (integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内

  • @Past 被注释的元素必须是一个过去的日期

  • @Future 被注释的元素必须是一个将来的日期

  • @Pattern(regex=,flag=) 被注释的元素必须符合指定的正则表达式


Hibernate Validator 提供的校验注解



  • @NotBlank(message =) 验证字符串非 null,且长度必须大于 0

  • @Email 被注释的元素必须是电子邮箱地址

  • @Length(min=,max=) 被注释的字符串的大小必须在指定的范围内

  • @NotEmpty 被注释的字符串的必须非空

  • @Range(min=,max=,message=) 被注释的元素必须在合适的范围内


image.png


@JsonFormat与@DateTimeFormat注解的使用


@JsonFormat用于后端传给前端的时间格式转换,@DateTimeFormat用于前端传给后端的时间格式转换


JsonFormat


1、使用maven引入@JsonFormat所需要的jar包


        <dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.8.8</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.8.8</version>
</dependency>

<dependency>
<groupId>org.codehaus.jackson</groupId>
<artifactId>jackson-mapper-asl</artifactId>
<version>1.9.13</version>
</dependency>

2、在需要查询时间的数据库字段对应的实体类的属性上添加@JsonFormat


   @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateDate;

注: timezone:是时间设置为东八区,避免时间在转换中有误差,pattern:是时间转换格式


DataTimeFormat


1、添加依赖


       <dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.3</version>
</dependency>

2、我们在对应的接收前台数据的对象的属性上加@DateTimeFormat


@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime acquireDate;

3.这样我们就可以将前端获取的时间转换为一个符合自定义格式的时间格式存储到数据库了
全局异常统一处理:拦截并处理校验出错的返回数据
写一个全局异常处理类


@ControllerAdvice

public class GlobalExceptionHandler{
/**
* 处理参数校验异常
*/

@ExceptionHandler({MethodArgumentNotValidException.class})
@ResponseBody
public ErrorResponseData validateException(MethodArgumentNotValidException e) {
log.error("参数异常"+e.getBindingResult().getFieldError().getDefaultMessage(),e);
return new ErrorResponseData(10001,e.getBindingResult().getFieldError().getDefaultMessage());
}

/**
* 处理json转换异常(比如 @DateTimeFormat注解转换日期格式时)
*/

@ExceptionHandler({HttpMessageNotReadableException.class})
@ResponseBody
public ErrorResponseData jsonParseException(HttpMessageNotReadableException e) {
log.error("参数异常"+e.getLocalizedMessage(),e);
return new ErrorResponseData(10001,e.getCause().getMessage());
}

}

作者:Hypnosis
来源:juejin.cn/post/7241114001324228663
收起阅读 »

慢慢的喜欢上泛型 之前确实冷落了

前言 下图 CSDN 水印 为自身博客 什么泛型 通俗意义上来说泛型将接口的概念进一步延伸,”泛型”字面意思就是广泛的类型,类、接口和方法代码可以应用于非常广泛的类型,代码与它们能够操作的数据类型不再绑定在一起,同一套代码,可以用于多种数据类型,这样,不仅可...
继续阅读 »

前言


下图 CSDN 水印 为自身博客


什么泛型



通俗意义上来说泛型将接口的概念进一步延伸,”泛型”字面意思就是广泛的类型,类、接口和方法代码可以应用于非常广泛的类型,代码与它们能够操作的数据类型不再绑定在一起,同一套代码,可以用于多种数据类型,这样,不仅可以复用代码,降低耦合,同时,还可以提高代码的可读性和安全性。



泛型带来的好处



在没有泛型的情况的下,通过对类型 Object 的引用来实现参数的“任意化”,“任意化”带来的缺点是要做显式的强制类型转换,而这种转换是要求开发者对实际参数类型可以预知的情况下进行的。对于强制类型转换错误的情况,编译器可能不提示错误,在运行的时候才出现异常,这是本身就是一个安全隐患。
那么泛型的好处就是在编译的时候能够检查类型安全,并且所有的强制转换都是自动和隐式的



public class GlmapperGeneric<T> {
private T t;
public void set(T t) { this.t = t; }
public T get() { return t; }

public static void main(String[] args) {
// do nothing
}

/**
* 不指定类型
*/

public void noSpecifyType(){
GlmapperGeneric glmapperGeneric = new GlmapperGeneric();
glmapperGeneric.set("test");
// 需要强制类型转换
String test = (String) glmapperGeneric.get();
System.out.println(test);
}

/**
* 指定类型
*/

public void specifyType(){
GlmapperGeneric<String> glmapperGeneric = new GlmapperGeneric();
glmapperGeneric.set("test");
// 不需要强制类型转换
String test = glmapperGeneric.get();
System.out.println(test);
}
}



上面这段代码中的 specifyType 方法中 省去了强制转换,可以在编译时候检查类型安全,可以用在类,方法,接口上。



泛型中通配符



我们在定义泛型类,泛型方法,泛型接口的时候经常会碰见很多不同的通配符,比如 T,E,K,V 等等,这些通配符又都是什么意思呢?



常用的 T,E,K,V,?



本质上这些个都是通配符,没啥区别,只不过是编码时的一种约定俗成的东西。比如上述代码中的 T ,我们可以换成 A-Z 之间的任何一个 字母都可以,并不会影响程序的正常运行,但是如果换成其他的字母代替 T ,在可读性上可能会弱一些。通常情况下,T,E,K,V,?是这样约定的:




  • ?表示不确定的 java 类型

  • T (type) 表示具体的一个java类型

  • K V (key value) 分别代表java键值中的Key Value

  • E (element) 代表Element

  • < T > 等同于 < T extends Object>

  • < ? > 等同于 < ? extends Object>


?无界通配符



先从一个小例子看起:



// 范围较广
static int countLegs (List<? extends Animal > animals ) {
int retVal = 0;
for ( Animal animal : animals )
{
retVal += animal.countLegs();
}
return retVal;
}
// 范围定死
static int countLegs1 (List< Animal > animals ){
int retVal = 0;
for ( Animal animal : animals )
{
retVal += animal.countLegs();
}
return retVal;
}

public static void main(String[] args) {
List<Dog> dogs = new ArrayList<>();
// 不会报错
countLegs( dogs );
// 报错
countLegs1(dogs);
}



对于不确定或者不关心实际要操作的类型,可以使用无限制通配符(尖括号里一个问号,即 <?> ),表示可以持有任何类型。像 countLegs 方法中,限定了上届,但是不关心具体类型是什么,所以对于传入的 Animal 的所有子类都可以支持,并且不会报错。而 countLegs1 就不行。



上界通配符 < ? extends E>



上届:用 extends 关键字声明,表示参数化的类型可能是所指定的类型,或者是此类型的子类。
在类型参数中使用 extends 表示这个泛型中的参数必须是 E 或者 E 的子类,这样有两个好处:



1.如果传入的类型不是 E 或者 E 的子类,编译不成功
2. 泛型中可以使用 E 的方法,要不然还得强转成 E 才能使用


List<? extends Number> eList = null;
eList = new ArrayList<Integer>();
//语句1取出Number(或者Number子类)对象直接赋值给Number类型的变量是符合java规范的。
Number numObject = eList.get(0); //语句1,正确

//语句2取出Number(或者Number子类)对象直接赋值给Integer类型(Number子类)的变量是不符合java规范的。
Integer intObject = eList.get(0); //语句2,错误

//List<? extends Number>eList不能够确定实例化对象的具体类型,因此无法add具体对象至列表中,可能的实例化对象如下。
eList.add(new Integer(1)); //语句3,错误

下界通配符 < ? super E>



下界: 用 super 进行声明,表示参数化的类型可能是所指定的类型,或者是此类型的父类型,直至 Object



在类型参数中使用 super 表示这个泛型中的参数必须是 E 或者 E 的父类。


List<? super Integer> sList = null;
sList = new ArrayList<Number>();

//List<? super Integer> 无法确定sList中存放的对象的具体类型,因此sList.get获取的值存在不确定性
//,子类对象的引用无法赋值给兄弟类的引用,父类对象的引用无法赋值给子类的引用,因此语句错误
Number numObj = sList.get(0); //语句1,错误

//Type mismatch: cannot convert from capture#6-of ? super Integer to Integer
Integer intObj = sList.get(0); //语句2,错误
//子类对象的引用可以赋值给父类对象的引用,因此语句正确。
sList.add(new Integer(1)); //语句3,正确

1. 限定通配符总是包括自己
2. 上界类型通配符:add方法受限
3. 下界类型通配符:get方法受限
4. 如果你想从一个数据类型里获取数据,使用 ? extends 通配符
5. 如果你想把对象写入一个数据结构里,
6. 使用 ? super 通配符 如果你既想存,又想取,那就别用通配符
7. 不能同时声明泛型通配符上界和下界


?和 T 的区别


// 指定集合元素只能是T类型
List<T> list = new ArrayList<T>();
// 集合元素可以是任意类型的,这种是 没有意义的 一般是方法中只是为了说明用法
List<?> list = new Arraylist<?>();


?和 T 都表示不确定的类型,区别在于我们可以对 T 进行操作,但是对 ?不行,比如如下这种 :



// 可以
T t = operate();

// 不可以
?car = operate();


T 是一个 确定的 类型,通常用于泛型类和泛型方法的定义,?是一个 不确定 的类型,通常用于泛型方法的调用代码和形参,不能用于定义类和泛型方法。



区别1 通过 T 来 确保 泛型参数的一致性


   public <T extends Number> void test1(List<T> dest, List<T> src) {
System.out.println();
}

public static void main(String[] args) {
test test = new test();
// integer 是number 的子类 所以是正确的
List<Integer> list = new ArrayList<Integer>();
List<Integer> list1 = new ArrayList<Integer>();
test.test1(list,list1);
}

在这里插入图片描述



通配符是 不确定的,所以这个方法不能保证两个 List 具有相同的元素类型



public void
test(List<? extends Number> dest, List<? extends Number> src)

GlmapperGeneric<String> glmapperGeneric = new GlmapperGeneric<>();
List<String> dest = new ArrayList<>();
List<Number> src = new ArrayList<>();
glmapperGeneric.testNon(dest,src);
//上面的代码在编译器并不会报错,但是当进入到 testNon 方法内部操作时(比如赋值),对于 dest 和 src 而言,就还是需要进行类型转换

区别2:类型参数可以多重限定而通配符不行



使用 & 符号设定多重边界(Multi Bounds),指定泛型类型 T 必须是 MultiLimitInterfaceA 和 MultiLimitInterfaceB 的共有子类型,此时变量 t 就具有了所有限定的方法和属性。对于通配符来说,因为它不是一个确定的类型,所以不能进行多重限定



区别3:通配符可以使用超类限定而类型参数不行



类型参数 T 只具有 一种 类型限定方式



T extends A



但是通配符 ? 可以进行 两种限定



? extends A
? super A

Class和 Class<?>区别


Class<"T"> (默认没有双引号 系统会自动把T给我换成特殊字符才加的引号) 在实例化的时候,T 要替换成具体类。Class<?>它是个通配泛型,? 可以代表任何类型,所以主要用于声明时的限制情况


作者:进阶的派大星
来源:juejin.cn/post/7140472064577634341
收起阅读 »

都JDK17了,你还在用JDK8

Spring Boot 3.1.0-M1 已经发布一段时间了,不知道各位小伙伴是否关注了。随着Spring 6.0以及SpringBoot 3.0的发布,整个开发界也逐步进入到jdk17的时代。大有当年从jdk6 到jdk8升级过程,痛苦并快乐着。 为了不被时...
继续阅读 »

Spring Boot 3.1.0-M1 已经发布一段时间了,不知道各位小伙伴是否关注了。随着Spring 6.0以及SpringBoot 3.0的发布,整个开发界也逐步进入到jdk17的时代。大有当年从jdk6 到jdk8升级过程,痛苦并快乐着。


为了不被时代抛弃,开发者应追逐新的技术发展,拥抱变化,不要固步自封。


0x01 纵观发展




  • Pre-alpha(Dev)指在软件项目进行正式测试之前执行的所有活动




  • LTS(Long-Term Support)版本指的是长期支持版本




  • Alpha 软件发布生命周期的alpha阶段是软件测试的第一阶段




  • Beta阶段是紧随alpha阶段之后的软件开发阶段,以希腊字母第二个字母命名




  • Release candidate 发行候选版(RC),也被称为“银色版本”,是具备成为稳定产品的潜力的 beta 版本,除非出现重大错误,否则准备好发布




  • Stable release 稳定版又称为生产版本,是通过所有验证和测试阶段的最后一个发行候选版(RC)




  • Release 一旦发布,软件通常被称为“稳定版”




下面我们来看下JDK9~JDK17的发展:


版本发布时间版本类型支持时间新特性
JDK 92017年9月长期支持版(LTS)5年- 模块化系统(Jigsaw)
- JShell
- 接口的私有方法
- 改进的 try-with-resources
- 集合工厂方法
- 改进的 Stream API
JDK 102018年3月短期支持版(non-LTS)6个月- 局部变量类型推断
- G1 垃圾回收器并行全阶段
- 应用级别的 Java 类数据共享
JDK 112018年9月长期支持版(LTS)8年- HTTP 客户端 API
- ZGC 垃圾回收器
- 移除 Java EE 和 CORBA 模块
JDK 122019年3月短期支持版(non-LTS)6个月- switch 表达式
- JVM 原生 HTTP 客户端
- 微基准测试套件
JDK 132019年9月短期支持版(non-LTS)6个月- switch 表达式增强
- 文本块
- ZGC 垃圾回收器增强
JDK 142020年3月短期支持版(non-LTS)6个月- switch 表达式增强
- 记录类型
- Pattern Matching for instanceof
JDK 152020年9月短期支持版(non-LTS)6个月- 移除 Nashorn JavaScript 引擎
- ZGC 垃圾回收器增强
- 隐藏类和动态类文件
JDK 162021年3月短期支持版(non-LTS)6个月- 位操作符增强
- Records 类型的完整性
- Vector API
JDK 172021年9月长期支持版(LTS)8年- 垃圾回收器改进
- Sealed 类和接口
- kafka客户端更新
- 全新的安全存储机制

需要注意的是,LTS 版本的支持时间可能会受到 Oracle 官方政策变化的影响,因此表格中的支持时间仅供参考。


0x02 详细解读


JDK9 新特性


JDK 9 是 Java 平台的一个重大版本,于2017年9月发布。它引入了多项新特性,其中最重要的是模块化系统。以下是 JDK 9 新增内容的详细解释:



  1. 模块化系统(Jigsaw):


Jigsaw 是 JDK 9 引入的一个模块化系统,它将 JDK 拆分为约 90 个模块。这些模块相互独立,可以更好地管理依赖关系和可见性,从而提高了代码的可维护性和可重用性。模块化系统还提供了一些新的工具和命令,如 jmod 命令和 jlink 命令,用于构建和组装模块化应用程序。



  1. JShell:


JShell 是一个交互式的 Java 命令行工具,可以在命令行中执行 Java 代码片段。它可以非常方便地进行代码测试和调试,并且可以快速地查看和修改代码。JShell 还提供了一些有用的功能,如自动补全、实时反馈和历史记录等。



  1. 接口的私有方法:


JDK 9 允许在接口中定义 private 和 private static 方法。这些方法可以被接口中的其他方法调用,但不能被实现该接口的类使用。这样可以避免在接口中重复编写相同的代码,提高了代码的重用性和可读性。



  1. 改进的 try-with-resources:


在 JDK 9 中,可以在 try-with-resources 语句块中使用 final 或 effectively final 的变量。这样可以避免在 finally 语句块中手动关闭资源,提高了代码的可读性和可维护性。



  1. 集合工厂方法:


JDK 9 提供了一系列工厂方法,用于创建 List、Set 和 Map 等集合对象。这些方法可以使代码更加简洁和易读,而且还可以为集合对象指定初始容量和类型参数。



  1. 改进的 Stream API:


JDK 9 引入了一些新的 Stream API 方法,如 takeWhile、dropWhile 和 ofNullable 等。takeWhile 和 dropWhile 方法可以根据指定的条件从流中选择元素,而 ofNullable 方法可以创建一个包含一个非空元素或空元素的 Stream 对象。


除了以上几个新特性,JDK 9 还引入了一些其他的改进和优化,如改进的 Stack-Walking API、改进的 CompletableFuture API、Java 应用程序启动时优化(Application Class-Data Sharing)等等。这些新特性和改进都为 Java 应用程序的开发和运行提供了更好的支持。


JDK10 新特性


JDK10是JDK的一个短期支持版本,于2018年3月发布。它的主要特性如下:




  1. 局部变量类型推断:Java 10中引入了一种新的语法——var关键字,可以用于推断局部变量的类型,使代码更加简洁。例如,可以这样定义一个字符串类型的局部变量:var str = "hello"




  2. G1 垃圾回收器并行全阶段:JDK10中对G1垃圾回收器进行了改进,使其可以在并行全阶段进行垃圾回收,从而提高了GC效率。




  3. 应用级别的 Java 类数据共享:Java 10中引入了一项新的特性,即应用级别的 Java 类数据共享(AppCDS),可以在多个JVM进程之间共享Java类元数据,从而加速JVM的启动时间。




  4. 线程局部握手协议:Java 10中引入了线程局部握手协议(Thread-Local Handshakes),可以在不影响整个JVM性能的情况下,暂停所有线程执行特定的操作。




  5. 其他改进:Java 10还包含一些其他的改进,例如对Unicode 10.0的支持,对时间API的改进,以及对容器API的改进等等。




总的来说,JDK10主要关注于提高Java应用程序的性能和可维护性,通过引入一些新的特性和改进对JDK进行优化。


JDK11 新特性


JDK11是JDK的一个长期支持版本,于2018年9月发布。它的主要特性如下:




  1. HTTP 客户端 API:Java 11中引入了一个全新的HTTP客户端API,可以用于发送HTTP请求和接收HTTP响应,而不需要依赖第三方库。




  2. ZGC 垃圾回收器:Java 11中引入了ZGC垃圾回收器(Z Garbage Collector),它是一种可伸缩且低延迟的垃圾回收器,可以在数百GB的堆上运行,且最大停顿时间不超过10ms。




  3. 移除Java EE和CORBA模块:Java 11中移除了Java EE和CORBA模块,这些模块在Java 9中已被标记为“过时”,并在Java 11中被完全移除。




  4. Epsilon垃圾回收器:Java 11中引入了一种新的垃圾回收器,称为Epsilon垃圾回收器,它是一种无操作的垃圾回收器,可以在不进行垃圾回收的情况下运行应用程序,适用于测试和基准测试等场景。




  5. 其他改进:Java 11还包含一些其他的改进,例如对Lambda参数的本地变量类型推断,对字符串API的改进,以及对嵌套的访问控制的改进等等。




总的来说,JDK11主要关注于提高Java应用程序的性能和安全性,通过引入一些新的特性和改进对JDK进行优化。其中,HTTP客户端API和ZGC垃圾回收器是最值得关注的特性之一。


JDK12 新特性


JDK12是JDK的一个短期支持版本,于2019年3月发布。它的主要特性如下:




  1. Switch 表达式:Java 12中引入了一种新的Switch表达式,可以使用Lambda表达式风格来简化代码。此外,Switch表达式也支持返回值,从而可以更方便地进行流程控制。




  2. Microbenchmark Suite:Java 12中引入了一个Microbenchmark Suite,可以用于进行微基准测试,从而更好地评估Java程序的性能。




  3. JVM Constants API:Java 12中引入了JVM Constants API,可以用于在运行时获取常量池中的常量,从而更好地支持动态语言和元编程。




  4. Shenandoah 垃圾回收器:Java 12中引入了Shenandoah垃圾回收器,它是一种低暂停时间的垃圾回收器,可以在非常大的堆上运行,且最大暂停时间不超过几毫秒。




  5. 其他改进:Java 12还包含一些其他的改进,例如对Unicode 11.0的支持,对预览功能的改进,以及对集合API的改进等等。




总的来说,JDK12主要关注于提高Java应用程序的性能和可维护性,通过引入一些新的特性和改进对JDK进行优化。其中,Switch表达式和Shenandoah垃圾回收器是最值得关注的特性之一。


JDK13 新特性


JDK13是JDK的一个短期支持版本,于2019年9月发布。它的主要特性如下:




  1. Text Blocks:Java 13中引入了一种新的语法,称为Text Blocks,可以用于在代码中编写多行字符串,从而简化代码编写的复杂度。




  2. Switch 表达式增强:Java 13中对Switch表达式进行了增强,支持多个表达式和Lambda表达式。




  3. ZGC 并行处理引用操作:Java 13中对ZGC垃圾回收器进行了改进,支持并行处理引用操作,从而提高了GC效率。




  4. Reimplement the Legacy Socket API:Java 13中重新实现了Legacy Socket API,从而提高了网络编程的性能和可维护性。




  5. 其他改进:Java 13还包含一些其他的改进,例如对预览功能的改进,对嵌套访问控制的改进,以及对集合API的改进等等。




总的来说,JDK13主要关注于提高Java应用程序的性能和可维护性,通过引入一些新的特性和改进对JDK进行优化。其中,Text Blocks和Switch表达式增强是最值得关注的特性之一。


JDK14 新特性


JDK14是JDK的一个短期支持版本,于2020年3月发布。它的主要特性如下:




  1. Records:Java 14中引入了一种新的语法,称为Records,可以用于定义不可变的数据类,从而简化代码编写的复杂度。




  2. Switch 表达式增强:Java 14中对Switch表达式进行了增强,支持使用关键字 yield 返回值,从而可以更方便地进行流程控制。




  3. Text Blocks增强:Java 14中对Text Blocks进行了增强,支持在Text Blocks中嵌入表达式,从而可以更方便地生成动态字符串。




  4. Pattern Matching for instanceof:Java 14中引入了一种新的语法,称为Pattern Matching for instanceof,可以用于在判断对象类型时,同时对对象进行转换和赋值。




  5. 其他改进:Java 14还包含一些其他的改进,例如对垃圾回收器和JVM的改进,对预览功能的改进,以及对JFR的改进等等。




总的来说,JDK14主要关注于提高Java应用程序的可维护性和易用性,通过引入一些新的特性和改进对JDK进行优化。其中,Records和Pattern Matching for instanceof是最值得关注的特性之一。


JDK15 新特性


JDK15是JDK的一个短期支持版本,于2020年9月发布。它的主要特性如下:




  1. Sealed Classes:Java 15中引入了一种新的语法,称为Sealed Classes,可以用于限制某个类的子类的数量,从而提高代码的可维护性。




  2. Text Blocks增强:Java 15中对Text Blocks进行了增强,支持在Text Blocks中使用反斜杠和$符号来表示特殊字符,从而可以更方便地生成动态字符串。




  3. Hidden Classes:Java 15中引入了一种新的类,称为Hidden Classes,可以在运行时动态创建和卸载类,从而提高代码的灵活性和安全性。




  4. ZGC并发垃圾回收器增强:Java 15中对ZGC垃圾回收器进行了增强,支持在启动时指定内存大小,从而提高了GC效率。




  5. 其他改进:Java 15还包含一些其他的改进,例如对预览功能的改进,对JVM和垃圾回收器的改进,以及对集合API和I/O API的改进等等。




总的来说,JDK15主要关注于提高Java应用程序的可维护性和性能,通过引入一些新的特性和改进对JDK进行优化。其中,Sealed Classes和Hidden Classes是最值得关注的特性之一。


JDK16 新特性


JDK16是JDK的一个短期支持版本,于2021年3月发布。它的主要特性如下:




  1. Records增强:Java 16中对Records进行了增强,支持在Records中定义静态方法和构造方法,从而可以更方便地进行对象的创建和操作。




  2. Pattern Matching for instanceof增强:Java 16中对Pattern Matching for instanceof进行了增强,支持在判断对象类型时,同时对对象进行转换和赋值,并支持对switch语句进行模式匹配。




  3. Vector API:Java 16中引入了一种新的API,称为Vector API,可以用于进行SIMD(Single Instruction Multiple Data)向量计算,从而提高计算效率。




  4. JEP 388:Java 16中引入了一个新的JEP(JDK Enhancement Proposal),称为JEP 388,可以用于提高Java应用程序的性能和可维护性。




  5. 其他改进:Java 16还包含一些其他的改进,例如对垃圾回收器、JVM和JFR的改进,对预览功能的改进,以及对集合API和I/O API的改进等等。




总的来说,JDK16主要关注于提高Java应用程序的性能和可维护性,通过引入一些新的特性和改进对JDK进行优化。其中,Records增强和Pattern Matching for instanceof增强是最值得关注的特性之一。


JDK17 新特性


JDK17是JDK的一个长期支持版本,于2021年9月发布。它的主要特性如下:




  1. Sealed Classes增强:Java 17中对Sealed Classes进行了增强,支持在Sealed Classes中定义接口和枚举类型,从而提高代码的灵活性。




  2. Pattern Matching for switch增强:Java 17中对Pattern Matching for switch进行了增强,支持在switch语句中使用嵌套模式和or运算符,从而提高代码的可读性和灵活性。




  3. Foreign Function and Memory API:Java 17中引入了一种新的API,称为Foreign Function and Memory API,可以用于在Java中调用C和C++的函数和库,从而提高代码的可扩展性和互操作性。




  4. JEP 391:Java 17中引入了一个新的JEP(JDK Enhancement Proposal),称为JEP 391,可以用于提高Java应用程序的性能和可维护性。




  5. 其他改进:Java 17还包含一些其他的改进,例如对垃圾回收器、JVM和JFR的改进,对预览功能的改进,以及对集合API和I/O API的改进等等。




总的来说,JDK17主要关注于提高Java应用程序的灵活性、可扩展性和性能,通过引入一些新的特性和改进对JDK进行优化。其中,Sealed Classes增强和Foreign Function and Memory API是最值得关注的特性之一。


总结




  • JDK9:引入了模块化系统、JShell、私有接口方法、多版本兼容性等新特性




  • JDK10:引入了局部变量类型推断、垃圾回收器接口、并行全垃圾回收器等新特性




  • JDK11:引入了ZGC垃圾回收器、HTTP客户端API、VarHandles API等新特性




  • JDK12:引入了Switch表达式、新的字符串方法、HTTP/2客户端API等新特性




  • JDK13:引入了Text Blocks、Switch表达式增强、改进的ZGC性能等新特性




  • JDK14:引入了Records、Switch表达式增强、Pattern Matching for instanceof等新特性




  • JDK15:引入了Sealed Classes、Text Blocks增强、Hidden Classes等新特性




  • JDK16:引入了Records增强、Pattern Matching for instanceof增强、Vector API等新特性




  • JDK17:引入了Sealed Classes增强、Pattern Matching for switch增强、Foreign Function and Memory API等新特性




总的来说,JDK9到JDK17的更新涵盖了Java应用程序开发的各个方面,包括模块化、垃圾回收、性能优化、API增强等等,为Java开发者提供了更多的选择和工具,以提高代码的质量和效率


小记


Java作为一门长盛不衰的编程语言,未来的发展仍然有许多潜力和机会。




  • 云计算和大数据:随着云计算和大数据的发展,Java在这些领域的应用也越来越广泛。Java已经成为了许多云计算平台和大数据处理框架的首选语言之一。




  • 移动端和IoT:Java也逐渐开始在移动端和物联网领域崭露头角。Java的跨平台特性和安全性,使得它成为了许多移动应用和物联网设备的首选开发语言。




  • 前沿技术的应用:Java社区一直在积极探索和应用前沿技术,例如人工智能、机器学习、区块链等。Java在这些领域的应用和发展也将会是未来的趋势。




  • 开源社区的发展:Java开源社区的发展也将会对Java的未来产生重要影响。Java社区的开源项目和社区贡献者数量不断增加,将会为Java的发展提供更多的动力和资源。




  • 新的Java版本:Oracle已经宣布将在未来两年内发布两个新的Java版本,其中一个是短期支持版本,另一个是长期支持版本。这将会为Java开发者提供更多的新特性和改进,以满足不断变化的需求。




总的来说,Java作为一门优秀的编程语言,具有广泛的应用和发展前景。随着技术的不断创新和社区的不断发展,Java的未来将会更加光明。


更多内容:


image.png


作者:QIANGLU
来源:juejin.cn/post/7238795712569802809
收起阅读 »

对接了个三方支付,给俺气的呀

故事是这样的: 我们的商城需要在日本上线去赚小日子过得不错的日本人的钱,所以支付是首要的。就找了一家做日本本地支付的公司做对接,公司名字当然不能说,打我也不说。 第一天,很愉快,签了协议,给了开发文档。俺就准备开始撸代码了。 API文档 这开发文档,打开两秒钟...
继续阅读 »

故事是这样的:


我们的商城需要在日本上线去赚小日子过得不错的日本人的钱,所以支付是首要的。就找了一家做日本本地支付的公司做对接,公司名字当然不能说,打我也不说。


第一天,很愉快,签了协议,给了开发文档。俺就准备开始撸代码了。


API文档


这开发文档,打开两秒钟就自动挂掉了,我只能一次又一次的点击Reload


image.png


后来实在受不了了,我趁着那两三秒钟显示的时间,截图对照着看。结果就是所有的字段不能复制粘贴了,只能一个一个敲。


还是这个API文档,必输字段非必输字段乱的一塌糊涂,哪些是必输纯靠试,有的必输字段调试的时候有问题,对面直接甩过来一句,要么不传了吧。听得我懵懵的。


加密,验签


然后到了加密,验签。竟然能只给了node的demo,咱对接的大部分是后端程序员吧,没事,咱自己写。比较坑的是MD5加密完是大写的字母,这三方公司转小写了,也没有提示,折腾了一会,测了一会也就通了,还好还好。


场景支持


在之后就是支付的一个场景了,日本是没有微信支付宝这样的便捷支付的,要么信用卡,要么便利店支付



稍微说下便利店支付,就是说,客户下完单之后,会给到一个回执,是一串数字,我们且称之为支付码,他们便利店有一个类似于ATM机的柜员机,去这个机子上凭借这串支付码打印出来一个凭条,然后拿着这个凭条去找便利店的店员,现金支付



就是这个场景,就是这个数字,三方它就给客户显示一次,就一次,点击支付的时候显示一次。要是客户不截图,那么不好意思,您就重新下单吧。而且这个支付码我们拿不到,这个跳转了他们的页面,也不发个邮件告知。这明显没法用啊,我们的订单过期时间三天,客户这三天啥时候想去便利店支付都行,可是这只显示一次太扯了。

同样的请求再发一次显示的是支付进行中。这怎么玩,好说歹说他们排期开发了两周,把这个订单号重入解决了,就是说同一笔订单再次进入是可以看到那串支付码的。


测试环境不能测


最后,写完了,调通了,测一下支付,结果他们测试环境不支持日本的这个便利店支付测试,what? 测试环境不能测试?那我这么久在干什么,让我们上线上测,我的代码在测试环境还没搞完,让我上生产,最后上了,没测通,对方的问题,当天下午就把代码给回滚了。等着对方调试完继续测。


业务不完整


还有,不支持退款,作为一个支付公司,不支持退款,我们客户退款只能线下转账,闹呢。


以前对接三方的时候,遇到问题地想到的是我们是不是哪里做错了,再看看文档。对接这个公司,就跟他们公司的测试一样,找bug呢。


建议


这里是本文的重点,咱就是给点建议,作为一家提供服务的公司,不管是啥服务。



  1. 对外API文档应当可以正常显示,必输非必输定义正确,字段类型标注准确。

  2. 若是有验签的步骤,介绍步骤详细并配上各个语言的demo,并强调格式以及大小写。

  3. 牵扯到业务的,需要站在客户的角度思考,这个是否合情合理。

  4. 业务的完整性,有可能是尚未开发完全,但是得有备选的方案。


作者:奔跑的毛球
来源:juejin.cn/post/7127691522010529799
收起阅读 »

Go 开发短网址服务笔记

这篇文章是基于课程 Go 开发短地址服务 做的笔记 项目地址:github.com/astak16/sho… 错误处理 在处理业务逻辑时,如果出错误了,需要统一处理错误响应的格式,这样可以方便前端处理错误信息 所以需要定义一个 Error 接口,它包含了 er...
继续阅读 »

这篇文章是基于课程 Go 开发短地址服务 做的笔记


项目地址:github.com/astak16/sho…


错误处理


在处理业务逻辑时,如果出错误了,需要统一处理错误响应的格式,这样可以方便前端处理错误信息


所以需要定义一个 Error 接口,它包含了 error 接口,以及一个 Status() 方法,用来返回错误的状态码


type Error interface {
error
Status() int
}

这个接口用来判断错误类型,在 go 中可以通过 e.(type) 判断错误的类型


func respondWithError(w http.RespondWrite, err error) {
switch e.(type) {
case Error:
respondWithJSON(w, e.Status(), e.Error())
default:
respondWithJSON(w, http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
}
}

go 中 实现 Error 接口,只需要实现 Error()Status() 方法即可


func () Error() string {
return ""
}
func () Status() int {
return 0
}

这样定义的方法,只能返回固定的文本和状态码,如果想要返回动态内容,可以定义一个结构体


然后 ErrorStatus 方法接受 StatusError 类型


这样只要满足 StatusError 类型的结构体,就可以返回动态内容


所以上面的代码可以修改为:


type StatusError struct {
Code int
Err error
}
func (se StatusError) Error() string {
return se.Err.Error()
}
func (se StatusError) Status() int {
return se.Code
}

middlerware


RecoverHandler


中间件 RecoverHandler 作用是通过 defer 来捕获 panic,然后返回 500 状态码


func RecoverHandler(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Println("Recover from panic %+v", r)
http.Error(w, http.StatusText(500), 500)
}
}()
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}

LoggingHandler


LoggingHandler 作用是记录请求耗时


func (m Middleware) LoggingHandler(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
end := time.Now()
log.Printf("[%s] %q %v", r.Method, r.URL.Path, end.Sub(start))
}
return http.HandlerFunc(fn)
}

中间件使用


alicego 中的一个中间件库,可以通过 alice.New() 来添加中间件,具体使用如下:


m := alice.New(middleware.LoggingHandler, middleware.RecoverHandler)
mux.Router.HandleFunc("/api/v1/user", m.ThenFunc(controller)).Methods("POST")

生成短链接


redis 连接


func NewRedisCli(addr string, passwd string, db int) *RedisCli {
c := redis.NewClient(&redis.Options{
Addr: addr,
Password: passwd,
DB: db,
})

if _, err := c.Ping().Result(); err != nil {
panic(err)
}
return &RedisCli{Cli: c}
}

生成唯一 ID


redis 可以基于一个键名生成一个唯一的自增 ID,这个键名可以是任意的,这个方法是 Incr


代码如下:


err = r.Cli.Incr(URLIDKEY).Err()
if err != nil {
return "", err
}

id, err := r.Cli.Get(URLIDKEY).Int64()
if err != nil {
return "", err
}

fmt.Println(id) // 每次调用都会自增

存储和解析短链接


一个 ID 对应一个 url,也就是说当外面传入 id 时需要返回对应的 url


func Shorten() {
err := r.Cli.Set(fmt.Sprintf(ShortlinkKey, eid), url, time.Minute*time.Duration(exp)).Err()
if err != nil {
return "", err
}
}
func UnShorten() {
url, err := r.Cli.Get(fmt.Sprintf(ShortlinkKey, eid)).Result()
}

redis 注意事项


redis 返回的 error 有两种情况:



  1. redis.Nil 表示没有找到对应的值

  2. 其他错误,表示 redis 服务出错了


所以在使用 redis 时,需要判断返回的错误类型


if err == redis.Nil {
// 没有找到对应的值
} else if err != nil {
// redis 服务出错了
} else {
// 正确响应
}

测试


在测试用例中,如何发起一个请求,然后获取响应的数据呢?



  1. 构造请求


var jsonStr = []byte(`{"url":"https://www.baidu.com","expiration_in_minutes":60}`)
req, err := http.NewRequest("POST", "/api/shorten", bytes.NewBuffer(jsonStr))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")


  1. 捕获 http 响应


rw := httptest.NewRecorder()


  1. 模拟请求被处理


app.Router.ServeHTTP(rw, req)


  1. 解析响应


if rw.Code != http.ok {
t.Fatalf("Excepted status created, got %d", rw.Code)
}

resp := struct {
Shortlink string `json:"shortlink"`
}{}
if err := json.NewDecoder(rw.Body).Decode(&resp); err != nil {
t.Fatalf("should decode the response", err)
}

最终完整代码:


var jsonStr = []byte(`{"url":"https://www.baidu.com","expiration_in_minutes":60}`)
req, err := http.NewRequest("POST", "/api/shorten", bytes.NewBuffer(jsonStr))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")

rw := httptest.NewRecorder()
app.Router.ServeHTTP(rw, req)

if rw.Code != http.ok {
t.Fatalf("Excepted status created, got %d", rw.Code)
}
resp := struct {
Shortlink string `json:"shortlink"`
}{}

if err := json.NewDecoder(rw.Body).Decode(&resp); err != nil {
t.Fatalf("should decode the response")
}

代码


log.SetFlags(log.LstdFlags | log.Lshortfile)


作用是设置日志输出的标志


它们都是标志常量,用竖线 | 连接,这是位操作符,将他们合并为一个整数值,作为 log.SetFlags() 的参数



  • log.LstdFlags 是标准时间格式:2022-01-23 01:23:23

  • log.Lshortfile 是文件名和行号:main.go:23


当我们使用 log.Println 输出日志时,会自动带上时间、文件名、行号信息


recover 函数使用


recover 函数类似于其他语言的 try...catch,用来捕获 panic,做一些处理


使用方法:


func MyFunc() {
defer func() {
if r := recover(); r != nil {
// 处理 panic 情况
}
}
}

需要注意的是:



  1. recover 函数只能在 defer 中使用,如果在 defer 之外使用,会直接返回 nil

  2. recover 函数只有在 panic 之后调用才会生效,如果在 panic 之前调用,也会直接返回 nil

  3. recover 函数只能捕获当前 goroutinepanic,不能捕获其他 goroutinepanic


next.ServerHttp(w, r)


next.ServeHTTP(w, r),用于将 http 请求传递给下一个 handler


HandleFunc 和 Handle 区别


HandleFunc 接受一个普通类型的函数:


func myHandle(w http.ResponseWriter, r *http.Request) {}
http.HandleFunc("xxxx", myHandle)

Handle 接收一个实现 Handler 接口的函数:


func myHandler(w http.ResponseWriter, r *http.Request) {}
http.Handle("xxxx", http.HandlerFunc(myHandler))

他们的区别是:使用 Handle 需要自己进行包装,使用 HandleFunc 不需要


defer res.Body.Close()


为什么没有 res.Header.Close() 方法?


因为 header 不是资源,而 body 是资源,在 go 中,一般操作资源后,要及时关闭资源,所以 gobody 提供了 Close() 方法


res.Bodyio.ReadCloser 类型的接口,表示可以读取响应数据并关闭响应体的对象


w.Write()


代码在执行了 w.Writer(res) 后,还会继续往下执行,除非有显示的 returepanic 终止函数执行


func controller(w http.ResponseWriter, r *http.Request) {
if res, err := xxx; err != nil {
respondWithJSON(w, http.StatusOK, err)
}
// 这里如果有代码,会继续执行
}
func respondWithJSON(w http.ResponseWriter, code int, payload interface{}) {
res, _ json.Marshal(payload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(res)
}

需要注意的是,尽管执行了 w.Writer() 后,还会继续往下执行,但不会再对响应进行修改或写入任何内容了,因为 w.Write() 已经将响应写入到 http.ResponseWriter 中了


获取请求参数


路由 /api/info?shortlink=2


a.Router.Handle("/api/info", m.ThenFunc(a.getShortlinkInfo)).Methods("GET")

func getShortlinkInfo(w http.ResponseWriter, r *http.Request) {
vals := r.URL.Query()
s := vals.Get("shortlink")

fmt.Println(s) // 2
}

路由 /2


a.Router.Handle("/{shortlink:[a-zA-Z0-9]{1,11}}", m.ThenFunc(a.redirect)).Methods("GET")

func redirect(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
shortlink := vars["shortlink"]

fmt.Println(shortlink) // 2
}

获取请求体


json.NewDecoder(r.Body) 作用是将 http 请求的 body 内容解析为 json 格式


r.body 是一个 io.Reader 类型,它代表请求的原始数据


如果关联成功可以用 Decode() 方法来解析 json 数据


type User struct {
Name string `json:"name"`
Age int `json:"age"`
}

func controller(w http.ResponseWriter, r *http.Request){
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
fmt.Println(err)
}
fmt.Println(user)
}

new


用于创建一个新的零值对象,并返回该对象的指针


它接受一个类型作为参数,并返回一个指向该类型的指针


适用于任何可分配的类型,如基本类型、结构体、数组、切片、映射和接口等


// 创建一个新的 int 类型的零值对象,并返回指向它的指针
ptr := new(int) // 0

需要注意的是:new 只分配了内存,并初始化为零值,并不会对对象进行任何进一步的初始化。如果需要对对象进行自定义的初始化操作,可以使用结构体字面量或构造函数等方式


往期文章



  1. Go 项目ORM、测试、api文档搭建

  2. Go 实现统一加载资源的入口<
    作者:uccs
    来源:juejin.cn/post/7237702874880393274
    /a>

收起阅读 »

原型模式与享元模式

原型模式与享元模式 原型模式和享元模式,前者是在创建多个实例时,对创建过程的性能进行调优;后者是用减少创建实例的方式,来调优系统性能。这么看,你会不会觉得两个模式有点相互矛盾呢? 其实不然,它们的使用是分场景的。在有些场景下,我们需要重复创建多个实例,例如在循...
继续阅读 »

原型模式与享元模式


原型模式和享元模式,前者是在创建多个实例时,对创建过程的性能进行调优;后者是用减少创建实例的方式,来调优系统性能。这么看,你会不会觉得两个模式有点相互矛盾呢?


其实不然,它们的使用是分场景的。在有些场景下,我们需要重复创建多个实例,例如在循环体中赋值一个对象,此时我们就可以采用原型模式来优化对象的创建过程;而在有些场景下,我们则可以避免重复创建多个实例,在内存中共享对象就好了。


今天我们就来看看这两种模式的适用场景,看看如何使用它们来提升系统性能。


原型模式


原型模式是通过给出一个原型对象来指明所创建的对象的类型,然后使用自身实现的克隆接口来复制这个原型对象,该模式就是用这种方式来创建出更多同类型的对象。


使用这种方式创建新的对象的话,就无需再通过new实例化来创建对象了。这是因为Object类的clone方法是一个本地方法,它可以直接操作内存中的二进制流,所以性能相对new实例化来说,更佳。


实现原型模式


我们现在通过一个简单的例子来实现一个原型模式:


   //实现Cloneable 接口的原型抽象类Prototype
class Prototype implements Cloneable {
//重写clone方法
public Prototype clone(){
Prototype prototype = null;
try{
prototype = (Prototype)super.clone();
}catch(CloneNotSupportedException e){
e.printStackTrace();
}
return prototype;
}
}
//实现原型类
class ConcretePrototype extends Prototype{
public void show(){
System.out.println("原型模式实现类");
}
}

public class Client {
public static void main(String[] args){
ConcretePrototype cp = new ConcretePrototype();
for(int i=0; i< 10; i++){
ConcretePrototype clonecp = (ConcretePrototype)cp.clone();
clonecp.show();
}
}
}


要实现一个原型类,需要具备三个条件:



  • 实现Cloneable接口:Cloneable接口与序列化接口的作用类似,它只是告诉虚拟机可以安全地在实现了这个接口的类上使用clone方法。在JVM中,只有实现了Cloneable接口的类才可以被拷贝,否则会抛出CloneNotSupportedException异常。

  • 重写Object类中的clone方法:在Java中,所有类的父类都是Object类,而Object类中有一个clone方法,作用是返回对象的一个拷贝。

  • 在重写的clone方法中调用super.clone():默认情况下,类不具备复制对象的能力,需要调用super.clone()来实现。


从上面我们可以看出,原型模式的主要特征就是使用clone方法复制一个对象。通常,有些人会误以为 Object a=new Object();Object b=a; 这种形式就是一种对象复制的过程,然而这种复制只是对象引用的复制,也就是a和b对象指向了同一个内存地址,如果b修改了,a的值也就跟着被修改了。


我们可以通过一个简单的例子来看看普通的对象复制问题:


class Student {
private String name;

public String getName() {
return name;
}

public void setName(String name) {
this.name= name;
}

}
public class Test {

public static void main(String args[]) {
Student stu1 = new Student();
stu1.setName("test1");

Student stu2 = stu1;
stu2.setName("test2");

System.out.println("学生1:" + stu1.getName());
System.out.println("学生2:" + stu2.getName());
}
}


如果是复制对象,此时打印的日志应该为:


学生1:test1
学生2:test2


然而,实际上是:


学生1:test2
学生2:test2


通过clone方法复制的对象才是真正的对象复制,clone方法赋值的对象完全是一个独立的对象。刚刚讲过了,Object类的clone方法是一个本地方法,它直接操作内存中的二进制流,特别是复制大对象时,性能的差别非常明显。我们可以用 clone 方法再实现一遍以上例子。


//学生类实现Cloneable接口
class Student implements Cloneable{
private String name; //姓名

public String getName() {
return name;
}

public void setName(String name) {
this.name= name;
}
//重写clone方法
public Student clone() {
Student student = null;
try {
student = (Student) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return student;
}

}
public class Test {

public static void main(String args[]) {
Student stu1 = new Student(); //创建学生1
stu1.setName("test1");

Student stu2 = stu1.clone(); //通过克隆创建学生2
stu2.setName("test2");

System.out.println("学生1:" + stu1.getName());
System.out.println("学生2:" + stu2.getName());
}
}


运行结果:


学生1:test1
学生2:test2


深拷贝和浅拷贝


在调用super.clone()方法之后,首先会检查当前对象所属的类是否支持clone,也就是看该类是否实现了Cloneable接口。


如果支持,则创建当前对象所属类的一个新对象,并对该对象进行初始化,使得新对象的成员变量的值与当前对象的成员变量的值一模一样,但对于其它对象的引用以及List等类型的成员属性,则只能复制这些对象的引用了。所以简单调用super.clone()这种克隆对象方式,就是一种浅拷贝。


所以,当我们在使用clone()方法实现对象的克隆时,就需要注意浅拷贝带来的问题。我们再通过一个例子来看看浅拷贝。


//定义学生类
class Student implements Cloneable{
private String name; //学生姓名
private Teacher teacher; //定义老师类

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public Teacher getTeacher() {
return teacher;
}

public void setTeacher(Teacher teacher) {
this.teacher = teacher;
}
//重写克隆方法
public Student clone() {
Student student = null;
try {
student = (Student) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return student;
}

}

//定义老师类
class Teacher implements Cloneable{
private String name; //老师姓名

public String getName() {
return name;
}

public void setName(String name) {
this.name= name;
}

//重写克隆方法,对老师类进行克隆
public Teacher clone() {
Teacher teacher= null;
try {
teacher= (Teacher) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return student;
}

}
public class Test {

public static void main(String args[]) {
Teacher teacher = new Teacher (); //定义老师1
teacher.setName("刘老师");
Student stu1 = new Student(); //定义学生1
stu1.setName("test1");
stu1.setTeacher(teacher);

Student stu2 = stu1.clone(); //定义学生2
stu2.setName("test2");
stu2.getTeacher().setName("王老师");//修改老师
System.out.println("学生" + stu1.getName + "的老师是:" + stu1.getTeacher().getName);
System.out.println("学生" + stu1.getName + "的老师是:" + stu2.getTeacher().getName);
}
}


运行结果:


学生test1的老师是:王老师
学生test2的老师是:王老师


观察以上运行结果,我们可以发现:在我们给学生2修改老师的时候,学生1的老师也跟着被修改了。这就是浅拷贝带来的问题。


我们可以通过深拷贝来解决这种问题,其实深拷贝就是基于浅拷贝来递归实现具体的每个对象,代码如下:


   public Student clone() {
Student student = null;
try {
student = (Student) super.clone();
Teacher teacher = this.teacher.clone();//克隆teacher对象
student.setTeacher(teacher);
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return student;
}


适用场景


前面我详述了原型模式的实现原理,那到底什么时候我们要用它呢?


在一些重复创建对象的场景下,我们就可以使用原型模式来提高对象的创建性能。例如,我在开头提到的,循环体内创建对象时,我们就可以考虑用clone的方式来实现。


例如:


for(int i=0; i<list.size(); i++){
Student stu = new Student();
...
}


我们可以优化为:


Student stu = new Student();
for(int i=0; i<list.size(); i++){
Student stu1 = (Student)stu.clone();
...
}


除此之外,原型模式在开源框架中的应用也非常广泛。例如Spring中,@Service默认都是单例的。用了私有全局变量,若不想影响下次注入或每次上下文获取bean,就需要用到原型模式,我们可以通过以下注解来实现,@Scope(“prototype”)。


享元模式


享元模式是运用共享技术有效地最大限度地复用细粒度对象的一种模式。该模式中,以对象的信息状态划分,可以分为内部数据和外部数据。内部数据是对象可以共享出来的信息,这些信息不会随着系统的运行而改变;外部数据则是在不同运行时被标记了不同的值。


享元模式一般可以分为三个角色,分别为 Flyweight(抽象享元类)、ConcreteFlyweight(具体享元类)和 FlyweightFactory(享元工厂类)。抽象享元类通常是一个接口或抽象类,向外界提供享元对象的内部数据或外部数据;具体享元类是指具体实现内部数据共享的类;享元工厂类则是主要用于创建和管理享元对象的工厂类。


实现享元模式


我们还是通过一个简单的例子来实现一个享元模式:


//抽象享元类
interface Flyweight {
//对外状态对象
void operation(String name);
//对内对象
String getType();
}


//具体享元类
class ConcreteFlyweight implements Flyweight {
private String type;

public ConcreteFlyweight(String type) {
this.type = type;
}

@Override
public void operation(String name) {
System.out.printf("[类型(内在状态)] - [%s] - [名字(外在状态)] - [%s]\n", type, name);
}

@Override
public String getType() {
return type;
}
}


//享元工厂类
class FlyweightFactory {
private static final Map<String, Flyweight> FLYWEIGHT_MAP = new HashMap<>();//享元池,用来存储享元对象

public static Flyweight getFlyweight(String type) {
if (FLYWEIGHT_MAP.containsKey(type)) {//如果在享元池中存在对象,则直接获取
return FLYWEIGHT_MAP.get(type);
} else {//在响应池不存在,则新创建对象,并放入到享元池
ConcreteFlyweight flyweight = new ConcreteFlyweight(type);
FLYWEIGHT_MAP.put(type, flyweight);
return flyweight;
}
}
}


public class Client {

public static void main(String[] args) {
Flyweight fw0 = FlyweightFactory.getFlyweight("a");
Flyweight fw1 = FlyweightFactory.getFlyweight("b");
Flyweight fw2 = FlyweightFactory.getFlyweight("a");
Flyweight fw3 = FlyweightFactory.getFlyweight("b");
fw1.operation("abc");
System.out.printf("[结果(对象对比)] - [%s]\n", fw0 == fw2);
System.out.printf("[结果(内在状态)] - [%s]\n", fw1.getType());
}
}


输出结果:


[类型(内在状态)] - [b] - [名字(外在状态)] - [abc]
[结果(对象对比)] - [true]
[结果(内在状态)] - [b]


观察以上代码运行结果,我们可以发现:如果对象已经存在于享元池中,则不会再创建该对象了,而是共用享元池中内部数据一致的对象。这样就减少了对象的创建,同时也节省了同样内部数据的对象所占用的内存空间。


适用场景


享元模式在实际开发中的应用也非常广泛。例如Java的String字符串,在一些字符串常量中,会共享常量池中字符串对象,从而减少重复创建相同值对象,占用内存空间。代码如下:


 String s1 = "hello";
String s2 = "hello";
System.out.println(s1==s2);//true


还有,在日常开发中的应用。例如,池化技术中的线程池就是享元模式的一种实现;将商品存储在应用服务的缓存中,那么每当用户获取商品信息时,则不需要每次都从redis缓存或者数据库中获取商品信息,并在内存中重复创建商品信息了。


总结


原型模式和享元模式,在开源框架,和实际开发中,应用都十分广泛。


在不得已需要重复创建大量同一对象时,我们可以使用原型模式,通过clone方法复制对象,这种方式比用new和序列化创建对象的效率要高;在创建对象时,如果我们可以共用对象的内部数据,那么通过享元模式共享相同的内部数据的对象,就可以减少对象的创建,实现系统调优。


本文由mdnic

e多平台发布

收起阅读 »

Java字符串常量池和intern方法解析

Java字符串常量池和intern方法解析 这篇文章,来讨论一下Java中的字符串常量池以及Intern方法.这里我们主要讨论的是jdk1.7,jdk1.8版本的实现. 字符串常量池 在日常开发中,我们使用字符串非常的频繁,我们经常会写下类似如下的代码: S...
继续阅读 »

Java字符串常量池和intern方法解析


这篇文章,来讨论一下Java中的字符串常量池以及Intern方法.这里我们主要讨论的是jdk1.7,jdk1.8版本的实现.


字符串常量池


在日常开发中,我们使用字符串非常的频繁,我们经常会写下类似如下的代码:



  • String s = "abc";

  • String str = s + "def";


通常,我们一般不会这么写:String s = new String("jkl"),但其实这么写和上面的写法还是有很多区别的.


先思考一个问题,为什么要有字符串常量池这种概念?原因是字符串常量既然是不变的,那么完全就可以复用它,而不用再重新去浪费空间存储一个完全相同的字符串.字符串常量池是用于存放字符串常量的地方,在Java7和Java8中字符串常量池是堆的一部分.


假如我们有如下代码:


String s = "abc";
String s1 = s + "def";    
String s2 = new String("abc");
String s3 = new String("def");

那么从内存分配的角度上看,最终会有哪些字符串生成呢,首先我先给出一张图来代表最终的结论,然后再分析一下具体的原因:


image.png


现在来依次分析上面的代码的执行流程:




  1. 执行String s = "abc",此时遇到"abc"这个字符串常量,会在字符串常量池中完成分配,并且会将引用赋值给s,因此这条语句会在字符串常量池中分配一个"abc".(这里其实没有空格,是因为生成文章时出现了空格,下文中如果出现同样情况,请忽略空格)




  2. 执行String s1 = s + "def",其实这个语句看似简单,实则另有玄机,它其实最终编译而成的代码是这样的:String s1 = new StringBuilder("abc").append("def").toString().首先在这个语句中有两个字符串常量:"abc"和"def",所以在字符串常量池中应该放置"abc"和"def",但是上个步骤已经有"abc"了,所以只会放置"def".另外,new StringBuilder("abc")这个语句相当于在堆上分配了一个对象,如果是new出来的,是在堆上分配字符串,是无法共享字符串常量池里面的字符串的,也就是说分配到堆上的字符串都会有新的内存空间. 最后toString()也是在堆中分配对象(可以从源码中看到这个动作),最终相当于执行了new String("abcdef");所以总结起来,这条语句分析起来还是挺麻烦的,它分配了以下对象:



    • 在字符串常量池分配"abc",但本来就有一个"abc"了,所以不需要分配

    • 在字符串常量池中分配“def"

    • 在堆中分配了"abc"

    • 在堆中分配了"abcdef"




  3. 执行String s2 = new String("abc").首先有个字符串常量"abc",需要分配到字符串常量池,但是字符串常量池中已经有"abc"了,所以无需分配.因此new String("abc")最终在堆上分配了一个"abc".所以总结起来就是,在堆中分配了一个"abc"




  4. 执行String s3 = new String("def");.首先有个字符串常量"def",需要分配到字符串常量池,但是字符串常量池中已经有"def"了,所以无需分配.因此new String("def")最终在堆上分配了一个"def".所以总结起来就是,在堆中分配了一个"def"。




总结起来,全部语句执行后分配的对象如下:



  • 在堆中分配了两个"abc",一个"abcdef",一个"def"

  • 在字符串常量池中分配了一个"abc",一个"def"


也就是图中所表示的这些对象,如果明白了对象是如何分配的,我们就可以分析以下代码的结果:


String s = "abc";
String s1 = s + "def";    
String s2 = new String("abc");
String s3 = new String("def");
String s4 = "abcdef";
String s5 = "abc";
System.out.println(s == s2); //false 前者引用的对象在字符串常量池 后者在堆上
System.out.println(s == s5);; //true 都引用了字符串常量池中的"abc"
System.out.println(s1 == s4); //false 前者引用的对象在字符串常量池,后者在堆上

intern方法


在字符串对象中,有一个intern方法.在jdk1.7,jdk1.8中,它的定义是如果调用这个方法时,在字符串常量池中有对应的字符串,那么返回字符串常量池中的引用,否则返回调用时相应对象的引用,也就是说intern方法在jdk1.7,jdk1.8中只会复用某个字符串的引用,这个引用可以是对堆内存中字符串中的引用,也可能是对字符串常量池中字符串的引用.这里通过一个例子来说明,假如我们有下面这段代码:


String str = new String("abc");
String str2 = str.intern();
String str3 = new StringBuilder("abc").append("def").toString();
String str4 = str3.intern();
System.out.println(str == str2);
System.out.println(str3 == str4);  

那么str2和str以及str3和str4是否相等呢?如果理解了上面对字符串常量池的分析,那么我们可以明白在这段代码中,字符串在内存中是这么分配的:



  • 在堆中分配两个"abc",一个“abcdef"

  • 在字符串常量池中分配一个"def",一个"abc"



  1. 当执行String str2 = str.intern();时,会先从字符串常量池中寻找是否有对应的字符串,此时在字符串常量池中有一个"abc",那么str2就指向字符串常量池中的"abc",而str是new出来的,指向的是堆中的"abc",所以str不等于str2;



  1. 当执行String str4 = str3.intern();会先从字符串常量池中寻找"abcdef",此时字符串常量池中并没有"abcdef",因此str4会指向堆中的"abcdef",因此str3等于str4,我们会发现一个有意思的地方:如果将第三句改成String str3 = new StringBuilder("abcdef").toString();,也就是把append后面的字符串和前面的字符串做一个拼接,那么结果就会变成str3不等于str4.所以这两种写法的区别还是挺大的.


要注意的是,在jdk1.6中intern的定义是如果字符串常量池中没有对应的字符串,那么就在字符串常量池中创建一个字符串,然后返回字符串常量池中的引用,也就是说在jdk1.6中,intern方法返回的对象始终都是指向字符串常量池的.如果上面的代码在jdk1.6中运行,那么就会得到两个false,原因如下:



  1. 当执行String str2 = str.intern();时,会先从字符串常量池中寻找是否有对应的字符串,此时在字符串常量池中有一个"abc",那么str2就指向字符串常量池中的"abc",而str是new出来的,指向的是堆中的"abc",所以str不等于str2;



  1. 当执行String str4 = str3.intern();会先从字符串常量池中寻找"abcdef",此时字符串常量池中并没有"abcdef",因此执行intern方法会在字符串常量池中分配"abcdef",然后str4最终等于这个字符串的引用,因此str3不等于str4,因为上面的str3指向堆,而str4指向字符串常量池,所以两者一定不会相等.


深入理解JVM虚拟机一书中,就有类似的代码:


String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1 == str1.intern());
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2 == str2.intern());

在jdk1.6中,两个判断都为false.因为str1和str2都指向堆,而intern方法得出来的引用都指向字符串常量池,所以不会相等,和上面叙述的结论是一样的.在jdk1.7中,第一个是true,第二个是false.道理其实也和上述所讲的是一样的,对于第一个语句,最终会在堆上创建一个"计算机软件"的字符串,执行str1.intern()方法时,先在字符串常量池中寻找字符串,但没有找到,所以会直接引用堆上的这个"计算机软件",因此第一个语句会返回true,因为最终都是指向堆.而对于第三个语句,因为和第一个语句差不多,按理说最终比较也应该返回true.但实际上,str2.intern方法执行的时候,在字符串常量池中是可以找到"java"这个字符串的,这是因为在Java初始化环境去加载类的时候(执行main方法之前),已经有一个叫做"java"的字符串进入了字符串常量池,因此str2.intern方法返回的引用是指向字符串常量池的,所以最终判断的结果是false,因为一个指向堆,一个指向字符串常量池.


总结


从上面的分析看来,字符串常量池并不像是那种很简单的概念,要深刻理解字符串常量池,至少需要理解以下几点:



  • 理解字符串会在哪个内存区域存放

  • 理解遇到字符串常量会发生什么

  • 理解new String或者是new StringBuilder产生的对象会在哪里存放

  • 理解字符串拼接操作+最终编译出来的语句是什么样子的

  • 理解toString方法会发生什么


这几点都在本文章中覆盖了,相信理解了这几点之后一定对字符串常量池有一个更深刻的理解.其实这篇文章的编写原因是因为阅读深入理解JVM虚拟机这本书的例子时突然发现作者所说的和我所想的是不一样的,但是书上可能对这方面没有展开叙述,所以我去查了点资料,然后写了一些代码来验证,最终决定写一篇文章来记录一下自己的理解,在编写代码过程中,还发现了一个分析对象内存地址的类库,我放在参考资料中了.


参考资料


http://www.baeldung.com/java-object… 查看java对象内存地址


作者:JL8
来源:juejin.cn/post/7237827233351073849
收起阅读 »

iOS加固保护新思路

之前有写过【如何给iOS APP加固】,但是经过一段时间的思考,我找到了更具有实践性的代码,具体可以看下面。 技术简介 iOS加固保护是基于虚机源码保护技术,针对iOS平台推出的下一代加固产品。可以对iOS APP中的可执行文件进行深度混淆、加固,并使用独创的...
继续阅读 »

之前有写过【如何给iOS APP加固】,但是经过一段时间的思考,我找到了更具有实践性的代码,具体可以看下面。


技术简介


iOS加固保护是基于虚机源码保护技术,针对iOS平台推出的下一代加固产品。可以对iOS APP中的可执行文件进行深度混淆、加固,并使用独创的虚拟机技术对代码进行加密保护,使用任何工具都无法直接进行逆向、破解。对APP进行完整性保护,防止应用程序中的代码及资源文件被恶意篡改。


技术功能


目前iOS加固主要包含逻辑混淆、字符串加密、代码虚拟化、防调试、防篡改以及完整性保护这三大类功能。通过对下面的代码片段进行保护来展示各个功能的效果:


- (void) test {
if (_flag) {
test_string(@"Hello, World!",@"你好,世界!","Hello, World!");
} else {
dispatch_async(dispatch_get_mian_queue(), ^{
do_something( );
});
}
int i=0;
while (i++ < 100) {
sleep(1);
do_something( );
}
}

将代码编译后拖入IDA Pro中进行分析,可以得到这样的控制流图,只有6个代码块,且跳转逻辑简单,可以很容易地判断出if-else以及while的特征:


1.png


将其反编译为伪代码,代码逻辑及源代码中使用的字符串均清晰可见,与源代码结构基本一致,效果如下


void __cdecl -[ViewController test](ViewController *self, SEL a2)
{
signed int v2; // w19
__int64 v3; //x0

if ( self->_flag )
sub_100006534 ( CFSTR("Hello, World!"),CFSTR("你好,世界!"), "Hello, World!" );
else
dispatch_async ( &_dispatch_main_q, &off_10000C308 );
v2 = 100;
do
{
v3 = sleep( 1u );
sub_100006584( v3 );
--v2;
}
while ( v2 );
}

1 代码逻辑混淆


通过将原始代码的控制流进行切分、打乱、隐藏,或在函数中插入花指令来实现对代码的混淆,使代码逻辑复杂化但不影响原始代码逻辑。


对代码进行逻辑混淆保护后,该函数的控制流图会变得十分复杂,且函数中穿插了大量不会被执行到的无用代码块,以及相互间的逻辑跳转,逆向分析的难度大大增强:


2.png
若开启防反编译功能,则控制流图会被完全隐藏,只剩下一个代码块,且无法反编译出有效代码(如下图所示),这对于对抗逆向分析工具来说非常有效,包括但不限于(IDA Pro, Hopper Disassembler, Binary Ninja, GHIDRA等)


3.png


void __cdecl -[ViewController test](ViewController *self , SEL a2)
{
JUMPOUT (__CS__, sub_100005A94(6LL, a2));
}

2 字符串加密


把所有静态常量字符串(支持C/C++/OC/Swift字符串)进行加密,运行时解密,防止攻击者通过字符串进行静态分析,猜测代码逻辑。


对代码中的字符串进行加密之后,所有的字符串都被替换为加密的引用,任何反编译手段均无法看到明文的字符串。你好,世界!,Hello, World!等字符串原本可以被轻易的反编译出来,但保护之后已经看不到了:


void __cdecl -[ViewController test](ViewController *self, SEL a2)
{
__int64 v2; //x0
__int64 v3; //x0
signed int v4; // w19
__int64 v5; //x0

if ( self->_flag )
{
v2 = sub_100008288();
v3 = sub_10000082E8(v2);
sub_100008228(v3);
sub_100007FB0( &stru_100010368, &stru_10001038, &unk_100011344);
}
else
dispatch_async ( &_dispatch_main_q, &off_100010308 );
}
v4 = 100;
do
{
v5 = sleep( 1u );
sub_100008004( v5 );
--v4;
}
while ( v4 );
}

3 代码虚拟化


将原始代码编译为动态的DX-VM虚拟机指令,运行在DX虚拟机之上,无法被反编译回可读的源代码,任何工具均无法直接反编译虚拟机指令。


采用代码虚拟化保护后,对函数进行反编译将无法看到任何与原代码相似的内容,函数体中只有对虚拟机子系统的调用:


void __cdecl -[ViewController test](ViewController *self, SEL a2)
{
SEL v2; // x19
__int64 v3; // x21

v2 = a2;
v3 = sub_10000C1EC;
(( void (* )(void))sub_10000C1D8)();
sub_10000C1D8( v3, v2);
sub_10000C180( v3, 17LL);
}

4 防调试


防止通过调试手段分析应用逻辑,开启防调试功能后,App进程可以有效地阻止各类调试器的调试行为:


4.png


5 防篡改,完整性保护


防止应用程序中的代码及资源文件被恶意篡改,杜绝盗版或植入广告等二次打包行为。


结语


以上内容是不限制ios版本的。不过,对于App类型,仅支持.xcarchive格式,不支持.ipa格式。并且,有以下注意事项:



  • Build Setting 中 Enable Bitcode 设置为 YES

  • 使用 Archive 模式编译以确保Bitcode成功启用,否则编译出的文件将只包含bitcode-marker

  • 若无法开启Bitcode,可使用辅助工具进行处理,详见五、iOS加固辅助工具 -> 5.2 启用 bitcode

  • 压缩后体积在 2048M 以内


以上是根据顶象的加固产品操作指南出具的流程,如需要更详细的说明,可以自行前往用户中心~


作者:昀和
来源:juejin.cn/post/7236634496765509692
收起阅读 »

夯实基础:彻底搞懂零拷贝

零拷贝 零拷贝我相信大家都听说过,Netty 也用到了零拷贝来大幅提升网络吞吐量,但是大多数人对零拷贝中的原理和过程却很难讲清楚,接下来我会给大家详细讲解这方面的内容。 首先,我们看看,没有零拷贝的时候,应用程序是如何从服务器的磁盘读数据并通过网卡发送到网络的...
继续阅读 »

零拷贝


零拷贝我相信大家都听说过,Netty 也用到了零拷贝来大幅提升网络吞吐量,但是大多数人对零拷贝中的原理和过程却很难讲清楚,接下来我会给大家详细讲解这方面的内容。
首先,我们看看,没有零拷贝的时候,应用程序是如何从服务器的磁盘读数据并通过网卡发送到网络的。


无零拷贝时,数据的发送流程


非DMA非零拷贝 (1).png


大家可以通过上图看到,应用程序把磁盘数据发送到网络的过程中会发生4次用户态和内核态之间的切换,同时会有4次数据拷贝。过程如下:



  1. 应用进程向系统申请读磁盘的数据,这时候程序从用户态切换成内核态。

  2. 系统也就是 linux 系统得知要读数据会通知 DMA 模块要读数据,这时 DMA 从磁盘拉取数据写到系统内存中。

  3. 系统收到 DMA 拷贝的数据后把数据拷贝到应用内存中,同时把程序从内核态变为用户态。

  4. 应用内存拿到从应用内存拿到数据后,会把数据拷贝到系统的 Socket 缓存,然后程序从用户态切换为内核态。

  5. 系统再次调用 DMA 模块,DMA 模块把 Socket 缓存的数据拷贝到网卡,从而完成数据的发送,最后程序从内核态切换为用户态。


如何提升文件传输的效率?


我们程序的目的是把磁盘数据发送到网络中,所以数据在用户内存和系统内存直接的拷贝根本没有意义,同时与数据拷贝同时进行的用户态和内核态之间的切换也没有意义。而上述常规方法出现了4次用户态和内核态之间的切换,以及4次数据拷贝。我们优化的方向无非就是减少用户态和内核态之间的切换次数,以及减少数据拷贝的次数



为什么要在用户态和内核态之间做切换?


因为用户态的进程没有访问磁盘上数据的权限,也没有把数据从网卡发送到网络的权限。只有内核态也就是操作系统才有操作硬件的权限,所以需要系统向用户进程提供相应的接口函数来实现数据的读写。
这里涉及了两个系统接口调用分别是:


read(file, tmp_buf, len);


write(socket, tmp_buf, len);



于是,零拷贝技术应运而生,系统为我们上层应用提供的零拷贝方法方法有下列两种:



  • mmap + write

  • sendfile


MMAP + write


这个方法主要是用 MMAP 替换了 read。
对应的系统方法为:
buf = mmap(file,length)
write(socket,buf,length)
所谓的 MMAP, 其实就是系统内存某段空间和用户内存某段空间保持一致,也就是说应用程序能通过访问用户内存访问系统内存。所以,读取数据的时候,不用通过把系统内存的数据拷贝到用户内存中再读取,而是直接从用户内存读出,这样就减少了一次拷贝。
我们还是先看图:


零拷贝之mmap+write.png
给大家简述一下步骤:



  1. 应用进程通过接口调用系统接口 MMAP,并且进程从用户态切换为内核态。

  2. 系统收到 MMAP 的调用后用 DMA 把数据从磁盘拷贝到系统内存,这时是第1次数据拷贝。由于这段数据在系统内存和应用内存是共享的,数据自然就到了应用内存中,这时程序从内核态切换为用户态。

  3. 程序从应用内存得到数据后,会调用 write 系统接口,这时第2次拷贝开始,具体是把数据拷贝到 Socket 缓存,而且用户态切换为内核态。

  4. 系统通过 DMA 把数据从 Socket 缓存拷贝到网卡。

  5. 最后,进程从内核态切换为用户态。


这样做到收益是减少了一次拷贝但是用户态和内核态仍然是4次切换


sendfile


这个系统方法可以实现系统内部不同设备之间的拷贝。具体逻辑我们还是先上图:


零拷贝之sendfile.png
大家可以看到,使用 sendfile 主要的收益是避免了数据在应用内存和系统内存或socket缓存直接的拷贝,同时这样会避免用户态和内核态之间的切换。
基本原理分为下面几步:



  1. 应用进程调用系统接口 sendfile,进程从用户态切换完内核态。

  2. 系统接收到 sendfile 指令后,通过 DMA 从磁盘把数据拷贝到系统内存。

  3. 数据到了系统内存后,CPU 会把数据从系统内存拷贝到 socket 缓存中。

  4. 通过 DMA 拷贝到网卡中。

  5. 最后,进程从内核态切换为用户态。


但是,这还不是零拷贝,所谓的零拷贝不会在内存层面去拷贝数据,也就是系统内存拷贝到 socket 缓存,下面给大家介绍一下真正的零拷贝。


真正的零拷贝


真正的零拷贝是基于 sendfile,当网卡支持 SG-DMA 时,系统内存的数据可以直接拷贝到网卡。如果这样实现的话,执行流程就会更简单,如下图所示:


真正的零拷贝.png


基本原理分为下面几步:



  1. 应用进程调用系统接口 sendfile,进程从用户态切换完内核态。

  2. 系统接收到 sendfile 指令后,通过 DMA 从磁盘把数据拷贝到系统内存。

  3. 数据到了系统内存后,CPU 会把文件描述符和数据长度返回到 socket 缓存中(注意这里没有拷贝数据)。

  4. 通过 SG-DMA 把数据从系统内存拷贝到网卡中。

  5. 最后,进程从内核态切换为用户态。


零拷贝在用户态和内核态之间的切换是2次,拷贝是2次,大大减少了切换次数和拷贝次数,而且全程没有 CPU 参与数据的拷贝。


零拷贝的重要帮手 PageCache


大家知道,从缓存中读取数据的速度一定要比从磁盘中读取数据的速度要快的多。那么,有没有一种技术能让我们从内存中读取磁盘的数据呢?
PageCache 的目的就是让磁盘的数据缓存化到系统内存。那么,PageCache会选取哪些磁盘数据作为缓存呢?具体步骤是这样的:



  • 首先,当用户进程要求方法哪些磁盘数据时,DMA会把这部分磁盘数据缓存到系统内存里,那么问题又来了,磁盘空间明显比内存空间大的多,不可能不限制的把磁盘的数据拷贝到系统内存中,所以必须要有一个淘汰机制来把访问概率低的内存数据删除掉。

  • 那么,用什么方法淘汰内存的数据呢?答案是 LRU (Least Recently Used),最近最少使用。原理的认为最近很少使用的数据,以后使用到的概率也会很低。


那么,这样就够了吗?我们设想一个场景,我们在操作数据的时候有没有顺序读写的场景?比如说消息队列的顺序读取,我们消费了 id 为1的 message 后,也会消费 id 为2的 message。而消息队列的文件一般是顺序存储的,如果我们事先把 id 为2的 message 读出到系统内存中,那么就会大大加快用户进程读取数据的速度。


这样的功能在现代操作系统中叫预读,也就是说如果你读某个文件的了32 KB 的字节,虽然你这次读取的仅仅是 0 ~ 32 KB 的字节,但系统内核会自动把其后面的 32~64 KB 也读取到 PageCache,如果在 32~64 KB 淘汰出 PageCache 前,用户进程读取到它了,那么就会大大加快数据的读取速度,因为我们是直接在缓存上读出的数据,而不是从磁盘中读取。


作者:肖恩Sean
来源:juejin.cn/post/7236988255073370167
收起阅读 »

聊聊「短信」渠道的设计与实现

有多久,没有发过短信了? 一、背景简介 在常规的分布式架构下,「消息中心」的服务里通常会集成「短信」的渠道,作为信息触达的重要手段,其他常用的手段还包括:「某微」、「某钉」、「邮件」等方式; 对于《消息中心》的设计和实现来说,在前面已经详细的总结过,本文重点...
继续阅读 »

有多久,没有发过短信了?



一、背景简介


在常规的分布式架构下,「消息中心」的服务里通常会集成「短信」的渠道,作为信息触达的重要手段,其他常用的手段还包括:「某微」、「某钉」、「邮件」等方式;


对于《消息中心》的设计和实现来说,在前面已经详细的总结过,本文重点来聊聊消息中心的短信渠道的方式;



短信在实现的逻辑上,也遵循消息中心的基础设计,即消息生产之后,通过消息中心进行投递和消费,属于典型的生产消费模型;


二、渠道方对接


在大部分的系统中,短信功能的实现都依赖第三方的短信推送,之前总结过《三方对接》的经验,这里不再赘述;


但是与常规第三方对接不同的是,短信的渠道通常会对接多个,从而应对各种消息投递的场景,比如常见的「验证码」场景,「通知提醒」场景,「营销推广」场景;



这里需要考虑的核心因素有好几个,比如成本问题,短信平台的稳定性,时效性,触达率,并发能力,需要进行不同场景的综合考量;


验证码:该场景通常是用户和产品的关键交互环节,十分依赖短信的时效性和稳定性,如果出问题直接影响用户体验;


通知提醒:该场景同样与业务联系密切,但是相对来说对短信触达的时效性依赖并不高,只要在一定的时间范围内最终触达用户即可;


营销推广:该场景的数据量比较大,并且从实际效果来看,具有很大的不确定性,会对短信渠道的成本和并发能力重点考量;


三、短信渠道


1、流程设计


从整体上来看短信的实现流程,可以分为三段:「1」短信需求的业务场景,「2」消息中心的短信集成能力,「3」对接的第三方短信渠道;



需求场景:在产品体系中,需要用到短信的场景很多,不过最主要的还是对用户方的信息触达,比如身份验证,通知,营销等,其次则是对内的重要消息通知;


消息中心:提供消息发送的统一接口方法,不同业务场景下的消息提交到消息中心,进行统一维护管理,并根据消息的来源和去向,适配相应的推送逻辑,短信只是作为其中的一种方式;


渠道对接:根据具体的需求场景来定,如果只有验证码的对接需求,可以只集成一个渠道,或者从成本方面统筹考虑,对接多个第三方短信渠道,建议设计时考虑一定的可扩展;


2、核心逻辑


单从短信这种方式的管理来看,逻辑复杂度并不算很高,但是很依赖细节的处理,很多不注意的细微点都可能导致推送失败的情况;



实际在整个逻辑中,除了「验证码」功能有时效性依赖之外,其他场景的短信触达都可以选择「MQ队列」进行解耦,在消息中心的设计上,也具备很高的流程复用性,图中只是重点描述短信场景;


3、使用场景


3.1 验证码


对于「短信」功能中的「验证码」场景来说,个人感觉在常规的应用中是最复杂的,这可能会涉及到「账户」和相关「业务」的集成问题;


验证码获取


这个流程相对来说路径还比较简短,只要完成手机号的校验后,按照短信推送逻辑正常执行即可;



这里需要说明的是,为了确保系统的安全性,通常会设定验证码的时效性,并且只能使用一次,但是偶尔可能因为延时问题,引起用户多次申请验证码,基于缓存可以很好的管理这种场景的数据结构;


验证码消费


验证码的使用是非常简单的,现在很多产品在设计上,都弱化了登录和注册的概念,只要通过验证码机制,会默认的新建帐户和执行相关业务流程;



无论是何种业务场景下的「验证码」依赖,在处理流程时都要先校验其「验证码」的正确与否,才能判断流程是否向下执行,在部分敏感的场景中,还会限制验证码的错误次数,防止出现账户安全问题;


3.2 短信触达


无论是「通知提醒」还是「营销推广」,其本质上是追求信息的最终触达即可,大部分短信运营商都可以提供这种能力,只是系统内部的处理方式有很大差异;



在部分业务流程中,需要向用户投递短信消息,在营销推广的需求中,更多的是批量发送短信,部分需求其内部逻辑上,还可能存在一个转化率统计的问题,需要监控相关短信的交互状态;


四、模型设计


由于短信是集成在消息中心的服务中,其相关的数据结构模型都是复用消息管理的,具体细节描述,参考《消息中心》的内容即可,此处不赘述;



从技术角度来看的话,涉及经典的生产消费模型,第三方平台对接,任务和状态机管理等,消息中心作为分布式架构的基础服务,在设计上还要考虑一定的复用性。


五、参考源码


编程文档:
https://gitee.com/cicadasmile/butte-java-note

应用仓库:
https:/
/gitee.com/cicadasmile/butte-flyer-parent

作者:知了一笑
来源:juejin.cn/post/7237082256480649271
收起阅读 »

iOS H5页面秒加载预研

背景 原生架构+H5页面的组合是很常见的项目开发模式了,H5的优势是跨平台、开发快、迭代快、热更新,很多大厂的App大部分业务代码都是H5来实现的,众所周知H5页面的体验是比原生差的,特别是网络环境差的时候,如果首屏页面是H5的话,那酸爽遇见过的都懂 白屏警告...
继续阅读 »

背景


原生架构+H5页面的组合是很常见的项目开发模式了,H5的优势是跨平台、开发快、迭代快、热更新,很多大厂的App大部分业务代码都是H5来实现的,众所周知H5页面的体验是比原生差的,特别是网络环境差的时候,如果首屏页面是H5的话,那酸爽遇见过的都懂


白屏警告




白屏主要就是下载页面资源失败或者慢导致的,下面可以看下H5加载过程


H5加载过程


这部分内容其实网上已经很多了,如下


初始化 webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 js/css 资源 -> dom 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片


主要要优化的就是下载到渲染这段时间,最简单的方案就是把整个web包放在App本地,通过本地路径去加载,做到这一步,再配合上H5页面做加载中占位图,基本上就不会有白屏的情况了,而且速度也有了很明显的提升


可参考以下测试数据(网络地址加载和本地路径加载)


WiFi网络打开H5页面100次耗时:(代表网络优秀时)


本地加载平均执行耗时:0.28秒


网络加载平均执行耗时:0.58秒


4G/5G手机网络打开H5页面100次耗时:(代表网络良好时)


本地加载平均执行耗时:0.43秒


网络加载平均执行耗时:2.09秒


3G手机网络打开H5页面100次耗时:(代表网络一般或者较差时)


本地加载平均执行耗时:1.48秒


网络加载平均执行耗时:19.09秒


ok,恭喜你,H5离线秒加载功能优化完毕,这么简单?


IMG_4941.JPG


进入正题


通过上述实现本地加载后速度虽然是快了不少,但H5页面的数据显示还是需要走接口请求的,如果网络环境不好的话,也是会很慢的,这个问题怎么解决呢?


下面开始上绝活,这里引入一个概念 WKURLSchemeHandler
在iOS11及以上系统中,可以通过WKURLSchemeHandler自定义拦截请求,有什么用?简单说就是可以利用原生的数据缓存去加载H5页面,可以无视网络环境的影响,在拦截到H5页面的网络请求后先判断本地是否有缓存,有缓存的话可以直接拼接一个成功的返回,没有的话直接放开继续走网络请求,成功后再缓存数据,对H5来说也是无侵入无感知的。


首先自定义一个SchemeHandler类,遵守WKURLSchemeHandler协议,实现协议方法


@protocol WKURLSchemeHandler <NSObject>

/*! @abstract Notifies your app to start loading the data for a particular resource
represented by the URL scheme handler task.
@param webView The web view invoking the method.
@param urlSchemeTask The task that your app should start loading data for.
*/

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;

/*! @abstract Notifies your app to stop handling a URL scheme handler task.
@param webView The web view invoking the method.
@param urlSchemeTask The task that your app should stop handling.
@discussion After your app is told to stop loading data for a URL scheme handler task
it must not perform any callbacks for that task.
An exception will be thrown if any callbacks are made on the URL scheme handler task
after your app has been told to stop loading for it.
*/

- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;

@end

直接上代码


#import <Foundation/Foundation.h>
#import <WebKit/WebKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface YXWKURLSchemeHandler : NSObject<WKURLSchemeHandler>

@end

NS_ASSUME_NONNULL_END

#import "CMWKURLSchemeHandler.h"
#import "YXNetworkManager.h"
#import <SDWebImage/SDWebImageManager.h>
#import <SDWebImage/SDImageCache.h>

@implementation YXWKURLSchemeHandler

- (void) webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
NSURLRequest * request = urlSchemeTask.request;
NSString * urlStr = request.URL.absoluteString;
NSString * method = urlSchemeTask.request.HTTPMethod;
NSData * bodyData = urlSchemeTask.request.HTTPBody;
NSDictionary * bodyDict = nil;

if (bodyData) {
bodyDict = [NSJSONSerialization JSONObjectWithData:bodyData options:kNilOptions error:nil];
}

NSLog(@"拦截urlStr=%@", urlStr);
NSLog(@"拦截method=%@", method);
NSLog(@"拦截bodyData=%@", bodyData);

// 图片加载
if ([urlStr hasSuffix:@".jpg"] || [urlStr hasSuffix:@".png"] || [urlStr hasSuffix:@".gif"]) {
SDImageCache * imageCache = [SDImageCache sharedImageCache];
NSString * cacheKey = [[SDWebImageManager sharedManager] cacheKeyForURL:request.URL];
BOOL isExist = [imageCache diskImageDataExistsWithKey:cacheKey];
if (isExist) {
NSData * imgData = [[SDImageCache sharedImageCache] diskImageDataForKey:cacheKey];
[urlSchemeTask didReceiveResponse:[[NSURLResponse alloc] initWithURL:request.URL MIMEType:[self createMIMETypeForExtension:[urlStr pathExtension]] expectedContentLength:-1 textEncodingName:nil]];
[urlSchemeTask didReceiveData:imgData];
[urlSchemeTask didFinish];
return;
}
[[SDWebImageManager sharedManager] loadImageWithURL:request.URL options:SDWebImageRetryFailed progress:nil completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {}];
}

// 网络请求
NSData * cachedData = (NSData *)[YXNetworkCache httpCacheForURL:urlStr parameters:bodyDict];
if (cachedData) {
NSHTTPURLResponse * response = [self createHTTPURLResponseForRequest:urlSchemeTask.request];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:cachedData];
[urlSchemeTask didFinish];
} else {
NSURLSessionDataTask * dataTask = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
[urlSchemeTask didFailWithError:error];
} else {
[YXNetworkCache setHttpCache:data URL:urlStr parameters:bodyDict];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
}
}];
[dataTask resume];
}
} /* webView */

- (NSHTTPURLResponse *) createHTTPURLResponseForRequest:(NSURLRequest *)request {
// Determine the content type based on the request
NSString * contentType;

if ([request.URL.pathExtension isEqualToString:@"css"]) {
contentType = @"text/css";
} else if ([[request valueForHTTPHeaderField:@"Accept"] isEqualToString:@"application/javascript"]) {
contentType = @"application/javascript;charset=UTF-8";
} else {
contentType = @"text/html;charset=UTF-8"; // default content type
}

// Create the HTTP URL response with the dynamic content type
NSHTTPURLResponse * response = [[NSHTTPURLResponse alloc] initWithURL:request.URL statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:@{ @"Content-Type": contentType }];

return response;
}

- (NSString *) createMIMETypeForExtension:(NSString *)extension {
if (!extension || extension.length == 0) {
return @"";
}

NSDictionary * MIMEDict = @{
@"txt" : @"text/plain",
@"html" : @"text/html",
@"htm" : @"text/html",
@"css" : @"text/css",
@"js" : @"application/javascript",
@"json" : @"application/json",
@"xml" : @"application/xml",
@"swf" : @"application/x-shockwave-flash",
@"flv" : @"video/x-flv",
@"png" : @"image/png",
@"jpg" : @"image/jpeg",
@"jpeg" : @"image/jpeg",
@"gif" : @"image/gif",
@"bmp" : @"image/bmp",
@"ico" : @"image/vnd.microsoft.icon",
@"woff" : @"application/x-font-woff",
@"woff2": @"application/x-font-woff",
@"ttf" : @"application/x-font-ttf",
@"otf" : @"application/x-font-opentype"
};

NSString * MIMEType = MIMEDict[extension.lowercaseString];
if (!MIMEType) {
return @"";
}

return MIMEType;
} /* MIMETypeForExtension */

- (void) webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
NSLog(@"stop = %@", urlSchemeTask);
}

@end

重点来了,需要hook WKWebview 的 handlesURLScheme 方法来支持 http 和 https 请求的代理


直接上代码


#import "WKWebView+SchemeHandle.h"
#import <objc/runtime.h>

@implementation WKWebView (SchemeHandle)

+ (void) load {
static dispatch_once_t onceToken;

dispatch_once(&onceToken, ^{
Method originalMethod1 = class_getClassMethod(self, @selector(handlesURLScheme:));
Method swizzledMethod1 = class_getClassMethod(self, @selector(cmHandlesURLScheme:));
method_exchangeImplementations(originalMethod1, swizzledMethod1);
});
}

+ (BOOL) cmHandlesURLScheme:(NSString *)urlScheme {
if ([urlScheme isEqualToString:@"http"] || [urlScheme isEqualToString:@"https"]) {
return NO;
} else {
return [self handlesURLScheme:urlScheme];
}
}

@end

这里会有一个疑问了?为什么要这么用呢,这里主要是为了满足对H5端无侵入、无感知的要求
如果不hook http和https的话,就需要在H5端修改代码了,把scheme修改成自定义的customScheme,全部都要改,而且对安卓还不适用,所以别这么搞,信我!!!


老老实实hook,一步到位


如何使用


首先是WKWebView的初始化,直接上代码


- (WKWebView *) wkWebView {
if (!_wkWebView) {
WKWebViewConfiguration * config = [[WKWebViewConfiguration alloc] init];
// 允许跨域访问
[config setValue:@(true) forKey:@"allowUniversalAccessFromFileURLs"];

// 自定义HTTPS请求拦截
YXWKURLSchemeHandler * handler = [YXWKURLSchemeHandler new];
[config setURLSchemeHandler:handler forURLScheme:@"https"];

_wkWebView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, kScreenWidth, kScreenHeight) configuration:config];
_wkWebView.navigationDelegate = self;
}
return _wkWebView;
} /* wkWebView */

NSString * htmlPath = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html" inDirectory:@"H5"];
NSURL * fileURL = [NSURL fileURLWithPath:htmlPath];
NSURLRequest * request = [NSURLRequest requestWithURL:url];
[self.wkWebView loadRequest:request];

ok,到了这一步基本上就能看到效果了,H5页面的接口请求和图片加载都会拦截到,直接使用原生的缓存数据,忽略网络环境的影响实现秒加载了


可参考以下测试数据(自定义WKURLSchemeHandler拦截和不拦截)


3G手机网络打开H5页面100次耗时:(代表网络一般或者较差时)

Figure_1.png


拦截后加载平均执行耗时:0.24秒


不拦截加载平均执行耗时:1.09秒


可以发现通过自定义WKURLSchemeHandler拦截后,加载速度非常平稳根本不受网络的影响,不拦截的话虽然整体加载速度并不算太慢,但是随网络波动比较明显。


不足


这套方案也还是有一些缺点的,比如



  1. App打包的时候需要预嵌入web模块的数据,会导致App包的大小增加(需要尽量缩小web模块的包大小,特别是资源文件的优化)

  2. App需要设计一套web模块包的更新机制,同时需要设计一个web包上传发布平台,后续版本管理更新比之前直接上传服务器替换相对麻烦一些

  3. 需要更新时下载全量web模块包会略大,也可以考虑用BSDiff差分算法来做增量更新解决,但是会增加程序复杂度


总结


综上就是本次H5页面秒加载全部的预研过程了,总的来说,基本上可以满足秒加载的需求。没有完美的解决方案,只有合适的方案,有舍有得,根据公司项目情况来。


作者:风轻云淡的搬砖
来源:juejin.cn/post/7236281103221096505
收起阅读 »

详解越权漏洞

1.1. 漏洞原理 越权漏洞是指应用程序未对当前用户操作的身份权限进行严格校验,导致用户可以操作超出自己管理权限范围的功能,从而操作一些非该用户可以操作的行为。简单来说,就是攻击者可以做一些本来不该他们做的事情(增删改查)。 1.2. 漏洞分类 主要分为 水...
继续阅读 »

1.1. 漏洞原理


越权漏洞是指应用程序未对当前用户操作的身份权限进行严格校验,导致用户可以操作超出自己管理权限范围的功能,从而操作一些非该用户可以操作的行为。简单来说,就是攻击者可以做一些本来不该他们做的事情(增删改查)


IDOR


1.2. 漏洞分类


主要分为 水平越权垂直越权 两大类


1.2.1. 水平越权


发生在具有相同权限级别的用户之间。攻击者通过利用这些漏洞,访问其他用户拥有的资源或执行与其权限级别不符的操作。


1.2.2. 垂直越权


发生在具有多个权限级别的系统中。攻击者通过利用这些漏洞,从一个低权限级别跳转到一个更高的权限级别。例如,攻击者从普通用户身份成功跃迁为管理员。


1.3. 漏洞举例


1.3.1. 水平越权


假设一个在线论坛应用程序,每个用户都有一个唯一的用户ID,并且用户可以通过URL访问他们自己的帖子。应用程序的某个页面的URL结构如下:


https://example.com/forum/posts?userId=<用户ID>

应用程序使用userId参数来标识要显示的用户的帖子。假设Alice的用户ID为1,Bob的用户ID为2。


Alice可以通过以下URL访问她自己的帖子:


https://example.com/forum/posts?userId=1

现在,如果Bob意识到URL参数是可变的,他可能尝试修改URL参数来访问Alice的帖子。他将尝试将URL参数修改为Alice的用户ID(1):


https://example.com/forum/posts?userId=1

如果应用程序没有正确实施访问控制机制,没有验证用户的身份和权限,那么Bob将成功地通过URL参数访问到Alice的帖子。


1.3.2. 垂直越权


假设一个电子商务网站,有两种用户角色:普通用户和管理员。普通用户有限的权限,只能查看和购买商品,而管理员则拥有更高的权限,可以添加、编辑和删除商品。


在正常情况下,只有管理员可以访问和执行与商品管理相关的操作。然而,如果应用程序没有正确实施访问控制和权限验证,那么普通用户可能尝试利用垂直越权漏洞提升为管理员角色,并执行未经授权的操作。


例如,普通用户Alice可能意识到应用程序的URL结构如下:


https://example.com/admin/manage-products

她可能尝试手动修改URL,将自己的用户角色从普通用户更改为管理员,如下所示:


https://example.com/admin/manage-products?role=admin

如果应用程序没有进行足够的验证和授权检查,就会错误地将Alice的角色更改为管理员,从而使她能够访问和执行与商品管理相关的操作。


1.4. 漏洞危害


具体以实际越权的功能为主,大多危害如下:



  1. 数据泄露:攻击者可以通过越权访问敏感数据,如个人信息、财务数据或其他敏感业务数据。这可能导致违反隐私法规、信用卡信息泄露或个人身份盗用等问题。

  2. 权限提升:攻击者可能利用越权漏洞提升其权限级别,获得系统管理员或其他高权限用户的特权。这可能导致对整个系统的完全控制,并进行更广泛的恶意活动。


1.5. 修复建议



  1. 实施严格的访问控制:确保在应用程序的各个层面上实施适当的访问控制机制,包括身份验证、会话管理和授权策略。对用户进行适当的身份验证和授权,仅允许其执行其所需的操作。

  2. 验证用户输入:应该对所有用户输入进行严格的验证和过滤,以防止攻击者通过构造恶意输入来利用越权漏洞。特别是对于涉及访问控制的操作,必须仔细验证用户请求的合法性。

  3. 最小权限原则:在分配用户权限时,采用最小权限原则,即给予用户所需的最低权限级别,以限制潜在的越权行为。用户只应具备完成其任务所需的最小权限。

  4. 安全审计和监控:建立安全审计和监控机制,对系统中的访问活动进行监视和记录。这可以帮助检测和响应越权行为,并提供对事件的审计跟踪。


作者:初始安全
来源:juejin.cn/post/7235801811525664825
收起阅读 »

Java常用JVM参数实战

在Java应用程序的部署和调优过程中,合理配置JVM参数是提升性能和稳定性的关键之一。本文将介绍一些常用的JVM参数,并给出具体的使用例子和作用的分析。 内存管理相关参数 -Xmx和-Xms -Xmx参数用于设置JVM的最大堆内存大小,而-Xms参数用于设置J...
继续阅读 »

在Java应用程序的部署和调优过程中,合理配置JVM参数是提升性能和稳定性的关键之一。本文将介绍一些常用的JVM参数,并给出具体的使用例子和作用的分析。


内存管理相关参数


-Xmx和-Xms


-Xmx参数用于设置JVM的最大堆内存大小,而-Xms参数用于设置JVM的初始堆内存大小。这两个参数可以在启动时通过命令行进行配置,例如:


java -Xmx2g -Xms512m MyApp

上述示例将JVM的最大堆内存设置为2GB,初始堆内存设置为512MB。


作用分析:



  • 较大的最大堆内存可以增加应用程序的可用内存空间,提高性能。但也需要考虑服务器硬件资源的限制。

  • 合理设置初始堆内存大小可以减少JVM的自动扩容和收缩开销。


-XX:NewRatio和-XX:SurvivorRatio


-XX:NewRatio参数用于设置新生代与老年代的比例,默认值为2。而-XX:SurvivorRatio参数用于设置Eden区与Survivor区的比例,默认值为8。


例如,我们可以使用以下参数配置:


java -XX:NewRatio=3 -XX:SurvivorRatio=4 MyApp

作用分析:



  • 调整新生代与老年代的比例可以根据应用程序的特点来优化内存分配。

  • 调整Eden区与Survivor区的比例可以控制对象在新生代中的存活时间。


-XX:MaxMetaspaceSize


在Java 8及之后的版本中,-XX:MaxMetaspaceSize参数用于设置元空间(Metaspace)的最大大小。例如:


java -XX:MaxMetaspaceSize=512m MyApp

作用分析:



  • 元空间用于存储类的元数据信息,包括类的结构、方法、字段等。

  • 调整元空间的最大大小可以避免元空间溢出的问题,提高应用程序的稳定性。


-Xmn


-Xmn参数用于设置新生代的大小。以下是一个例子:


java -Xmn256m MyApp


  • -Xmn256m将新生代的大小设置为256MB。


作用分析:



  • 新生代主要存放新创建的对象,设置合适的大小可以提高垃圾回收的效率。


垃圾回收相关参数


-XX:+UseG1GC


-XX:+UseG1GC参数用于启用G1垃圾回收器。例如:


java -XX:+UseG1GC MyApp

作用分析:



  • G1垃圾回收器是Java 9及之后版本的默认垃圾回收器,具有更好的垃圾回收性能和可预测的暂停时间。

  • 使用G1垃圾回收器可以减少垃圾回收的停顿时间,提高应用程序的吞吐量。


-XX:ParallelGCThreads和-XX:ConcGCThreads


-XX:ParallelGCThreads参数用于设置并行垃圾回收器的线程数量,而-XX:ConcGCThreads参数用于设置并发垃圾回收器的线程数量。例如:


java -XX:ParallelGCThreads=4 -XX:ConcGCThreads=2 MyApp

作用分析:



  • 并行垃圾回收器通过使用多个线程来并行执行垃圾回收操作,提高回收效率。

  • 并发垃圾回收器在应用程序运行的同时执行垃圾回收操作,减少停顿时间。


-XX:+ExplicitGCInvokesConcurrent


-XX:+ExplicitGCInvokesConcurrent参数用于允许主动触发并发垃圾回收。例如:


java -XX:+ExplicitGCInvokesConcurrent MyApp

作用分析:



  • 默认情况下,当调用System.gc()方法时,JVM会使用串行垃圾回收器执行垃圾回收操作。使用该参数可以改为使用并发垃圾回收器执行垃圾回收操作,减少停顿时间。


性能监控和调优参数


-XX:+PrintGCDetails和-XX:+PrintGCDateStamps


-XX:+PrintGCDetails参数用于打印详细的垃圾回收信息,-XX:+PrintGCDateStamps参数用于打印垃圾回收发生的时间戳。例如:


java -XX:+PrintGCDetails -XX:+PrintGCDateStamps MyApp

作用分析:



  • 打印垃圾回收的详细信息可以帮助我们了解垃圾回收器的工作情况,检测潜在的性能问题。

  • 打印垃圾回收发生的时间戳可以帮助我们分析应用程序的垃圾回收模式和频率。


-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath


-XX:+HeapDumpOnOutOfMemoryError参数用于在发生内存溢出错误时生成堆转储文件,-XX:HeapDumpPath参数用于指定堆转储文件的路径。例如:


java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump/file MyApp

作用分析:



  • 在发生内存溢出错误时生成堆转储文件可以帮助我们分析应用程序的内存使用情况,定位内存泄漏和性能瓶颈。


-XX:ThreadStackSize


-XX:ThreadStackSize参数用于设置线程栈的大小。以下是一个例子:


java -XX:ThreadStackSize=256k MyApp

作用分析:



  • 线程栈用于存储线程执行时的方法调用和局部变量等信息。

  • 通过调整线程栈的大小,可以控制应用程序中线程的数量和资源消耗。


-XX:MaxDirectMemorySize


-XX:MaxDirectMemorySize参数用于设置直接内存的最大大小。以下是一个例子:


java -XX:MaxDirectMemorySize=1g MyApp

作用分析:



  • 直接内存是Java堆外的内存,由ByteBuffer等类使用。

  • 合理设置直接内存的最大大小可以避免直接内存溢出的问题,提高应用程序的稳定性。


其他参数


除了上述介绍的常用JVM参数,还有一些其他参数可以根据具体需求进行配置,如:



  • -XX:+DisableExplicitGC:禁止主动调用System.gc()方法。

  • -XX:+UseCompressedOops:启用指针压缩以减小对象引用的内存占用。

  • -XX:OnOutOfMemoryError:在发生OutOfMemoryError时执行特定的命令或脚本。


这些参数可以根据应用程序的特点和需求进行调优和配置,以提升应用程序的性能和稳定性。


总结


本文介绍了一些常用的JVM参数,并给出了具体的使用例子和作用分析。合理配置这些参数可以优化内存管理、垃圾回收、性能监控等方面,提升Java应用程序的性能和稳定性。


在实际应用中,建议根据应用程序的需求和性能特点,综合考虑不同参数的使用。同时,使用工具进行性能监控和分析,以找出潜在的问题和瓶颈,并根据实际情况进行调优。



我是蚂蚁背大象,文章对你有帮助给项目点个❤关注我GitHub:mxsm,文章有不正确的地方请您斧正,创建ISSUE提交PR~谢谢!


作者:蚂蚁背大象
来源:juejin.cn/post/7235435351049781304

收起阅读 »

从前后端的角度分析options预检请求

本文分享自华为云社区《从前后端的角度分析options预检请求——打破前后端联调的理解障碍》,作者: 砖业洋__ 。 options预检请求是干嘛的?options请求一定会在post请求之前发送吗?前端或者后端开发需要手动干预这个预检请求吗?不用文档定义堆砌...
继续阅读 »

本文分享自华为云社区《从前后端的角度分析options预检请求——打破前后端联调的理解障碍》,作者: 砖业洋__ 。


options预检请求是干嘛的?options请求一定会在post请求之前发送吗?前端或者后端开发需要手动干预这个预检请求吗?不用文档定义堆砌名词,从前后端角度单独分析,大白话带你了解!


从前端的角度看options——post请求之前一定会有options请求?信口雌黄!


你是否经常看到这种跨域请求错误?


image.png


这是因为服务器不允许跨域请求,这里会深入讲一讲OPTIONS请求。


只有在满足一定条件的跨域请求中,浏览器才会发送OPTIONS请求(预检请求)。这些请求被称为“非简单请求”。反之,如果一个跨域请求被认为是“简单请求”,那么浏览器将不会发送OPTIONS请求。


简单请求需要满足以下条件:



  1. 只使用以下HTTP方法之一:GETHEADPOST

  2. 只使用以下HTTP头部:AcceptAccept-LanguageContent-LanguageContent-Type

  3. Content-Type的值仅限于:application/x-www-form-urlencodedmultipart/form-datatext/plain


如果一个跨域请求不满足以上所有条件,那么它被认为是非简单请求。对于非简单请求,浏览器会在实际请求(例如PUTDELETEPATCH或具有自定义头部和其他Content-TypePOST请求)之前发送OPTIONS请求(预检请求)。


举个例子吧,口嗨半天是看不懂的,让我们看看 POST请求在什么情况下不发送OPTIONS请求


提示:当一个跨域POST请求满足简单请求条件时,浏览器不会发送OPTIONS请求(预检请求)。以下是一个满足简单请求条件的POST请求示例:


// 使用Fetch API发送跨域POST请求
fetch("https://example.com/api/data", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "key1=value1&key2=value2"
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error("Error:", error));

在这个示例中,我们使用Fetch API发送了一个跨域POST请求。请求满足以下简单请求条件:



  1. 使用POST方法。

  2. 使用的HTTP头部仅包括Content-Type

  3. Content-Type的值为"application/x-www-form-urlencoded",属于允许的三种类型之一(application/x-www-form-urlencoded、multipart/form-data或text/plain)。


因为这个请求满足了简单请求条件,所以浏览器不会发送OPTIONS请求(预检请求)。


我们再看看什么情况下POST请求之前会发送OPTIONS请求,同样用代码说明,进行对比


提示:在跨域请求中,如果POST请求不满足简单请求条件,浏览器会在实际POST请求之前发送OPTIONS请求(预检请求)。


// 使用Fetch API发送跨域POST请求
fetch("https://example.com/api/data", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Custom-Header": "custom-value"
},
body: JSON.stringify({
key1: "value1",
key2: "value2"
})
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error("Error:", error));

在这个示例中,我们使用Fetch API发送了一个跨域POST请求。请求不满足简单请求条件,因为:



  1. 使用了非允许范围内的Content-Type值("application/json" 不属于 application/x-www-form-urlencodedmultipart/form-datatext/plain)。

  2. 使用了一个自定义HTTP头部 “X-Custom-Header”,这不在允许的头部列表中。


因为这个请求不满足简单请求条件,所以在实际POST请求之前,浏览器会发送OPTIONS请求(预检请求)。


你可以按F12直接在Console输入查看Network,尽管这个网址不存在,但是不影响观察OPTIONS请求,对比一下我这两个例子。


总结:当进行非简单跨域POST请求时,浏览器会在实际POST请求之前发送OPTIONS预检请求,询问服务器是否允许跨域POST请求。如果服务器不允许跨域请求,浏览器控制台会显示跨域错误提示。如果服务器允许跨域请求,那么浏览器会继续发送实际的POST请求。而对于满足简单请求条件的跨域POST请求,浏览器不会发送OPTIONS预检请求。


后端可以通过设置Access-Control-Max-Age来控制OPTIONS请求的发送频率。OPTIONS请求没有响应数据(response data),这是因为OPTIONS请求的目的是为了获取服务器对于跨域请求的配置信息(如允许的请求方法、允许的请求头部等),而不是为了获取实际的业务数据,OPTIONS请求不会命中后端某个接口。因此,当服务器返回OPTIONS响应时,响应中主要包含跨域配置信息,而不会包含实际的业务数据


本地调试一下,前端发送POST请求,后端在POST方法里面打断点调试时,也不会阻碍OPTIONS请求的返回


image.png


2.从后端的角度看options——post请求之前一定会有options请求?胡说八道!


在配置跨域时,服务器需要处理OPTIONS请求,以便在响应头中返回跨域配置信息。这个过程通常是由服务器的跨域中间件(Node.jsExpress框架的cors中间件、PythonFlask框架的flask_cors扩展)或过滤器(JavaSpringBoot框架的跨域过滤器)自动完成的,而无需开发人员手动处理。


以下是使用Spring Boot的一个跨域过滤器,供参考


import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsConfig {

public CorsConfig() {
}

@Bean
public CorsFilter corsFilter()
{
// 1. 添加cors配置信息
CorsConfiguration config = new CorsConfiguration();
// Response Headers里面的Access-Control-Allow-Origin: http://localhost:8080
config.addAllowedOrigin("http://localhost:8080");
// 其实不建议使用*,允许所有跨域
config.addAllowedOrigin("*");

// 设置是否发送cookie信息,在前端也可以设置axios.defaults.withCredentials = true;表示发送Cookie,
// 跨域请求要想带上cookie,必须要请求属性withCredentials=true,这是浏览器的同源策略导致的问题:不允许JS访问跨域的Cookie
/**
* withCredentials前后端都要设置,后端是setAllowCredentials来设置
* 如果后端设置为false而前端设置为true,前端带cookie就会报错
* 如果后端为true,前端为false,那么后端拿不到前端的cookie,cookie数组为null
* 前后端都设置withCredentials为true,表示允许前端传递cookie到后端。
* 前后端都为false,前端不会传递cookie到服务端,后端也不接受cookie
*/

// Response Headers里面的Access-Control-Allow-Credentials: true
config.setAllowCredentials(true);

// 设置允许请求的方式,比如get、post、put、delete,*表示全部
// Response Headers里面的Access-Control-Allow-Methods属性
config.addAllowedMethod("*");

// 设置允许的header
// Response Headers里面的Access-Control-Allow-Headers属性,这里是Access-Control-Allow-Headers: content-type, headeruserid, headerusertoken
config.addAllowedHeader("*");
// Response Headers里面的Access-Control-Max-Age:3600
// 表示下回同一个接口post请求,在3600s之内不会发送options请求,不管post请求成功还是失败,3600s之内不会再发送options请求
// 如果不设置这个,那么每次post请求之前必定有options请求
config.setMaxAge(3600L);
// 2. 为url添加映射路径
UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();
// /**表示该config适用于所有路由
corsSource.registerCorsConfiguration("/**", config);

// 3. 返回重新定义好的corsSource
return new CorsFilter(corsSource);
}
}


这里setMaxAge方法来设置预检请求(OPTIONS请求)的有效期,当浏览器第一次发送非简单的跨域POST请求时,它会先发送一个OPTIONS请求。如果服务器允许跨域,并且设置了Access-Control-Max-Age头(设置了setMaxAge方法),那么浏览器会缓存这个预检请求的结果。在Access-Control-Max-Age头指定的时间范围内,浏览器不会再次发送OPTIONS请求,而是直接发送实际的POST请求,不管POST请求成功还是失败,在设置的时间范围内,同一个接口请求是绝对不会再次发送OPTIONS请求的。


后端需要注意的是,我这里设置允许请求的方法是config.addAllowedMethod("*")*表示允许所有HTTP请求方法。如果未设置,则默认只允许“GET”和“HEAD”。你可以设置的HTTPMethodGET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE


经过我的测试,OPTIONS无需手动设置,因为单纯只设置OPTIONS也无效。如果你设置了允许POST,代码为config.addAllowedMethod(HttpMethod.POST); 那么其实已经默认允许了OPTIONS,如果你只允许了GET,尝试发送POST请求就会报错。


举个例子,这里只允许了GET请求,当我们尝试发送一个POST非简单请求,预检请求返回403,服务器拒绝了OPTIONS类型的请求,因为你只允许了GET,未配置允许OPTIONS请求,那么浏览器将收到一个403 Forbidden响应,表示服务器拒绝了该OPTIONS请求,POST请求的状态显示CORS error



Spring Boot中,配置允许某个请求方法(如POSTPUTDELETE)时,OPTIONS请求通常会被自动允许。这意味着在大多数情况下,后端开发人员不需要特意考虑OPTIONS请求。这种自动允许OPTIONS请求的行为取决于使用的跨域处理库或配置,最好还是显式地允许OPTIONS请求。


点击关注,第一时间了解华为云新鲜技术~


作者:华为云开发者联盟
来源:juejin.cn/post/7233587643724234811
收起阅读 »

学了设计模式,我重构了原来写的垃圾代码

前言 最近笔者学习了一些设计模式,都记录在我的专栏 前端要掌握的设计模式 中,感兴趣的掘友可以移步看看。本着 学东西不能停留在用眼睛看,要动手实践 的理念,笔者今天带来的是一篇关于代码逻辑重构的文章,将学到的东西充分运用到实际的项目中。 重构代码的背景 要重构...
继续阅读 »

前言


最近笔者学习了一些设计模式,都记录在我的专栏 前端要掌握的设计模式 中,感兴趣的掘友可以移步看看。本着 学东西不能停留在用眼睛看,要动手实践 的理念,笔者今天带来的是一篇关于代码逻辑重构的文章,将学到的东西充分运用到实际的项目中。


重构代码的背景


要重构的代码是之前笔者的一篇文章——我是怎么开发一个Babel插件来实现项目需求的?,大概的逻辑就是实现 JS 代码的一些转换需求:



  1. 去掉箭头函数的第一个参数(如果是ctx(ctx, argu1) => {}转换为(argu1) => {}

  2. 函数调用加上this.: sss(ctx) 转换为 this.sss()

  3. ctx.get('one').$xxx() 转换为 this.$xxxOne()

  4. const crud = ctx.get('two'); crud.$xxx();转换为this.$xxxTwo()


  5. /**
    * 处理批量的按钮显示隐藏
    * ctx.setVisible('code1,code2,code3', true)
    * 转化为
    * this.$refs.code1.setVisible(true)
    * this.$refs.code2.setVisible(true)
    * this.$refs.code3.setVisible(true)
    */


  6. 函数调用把部分 API 第一参数为ctx的变为arguments

  7. 函数调用去掉第一个参数(如果是ctx

  8. 函数声明去掉第一个参数(如果是ctx

  9. 普通函数 转为 () => {}

  10. 标识符ctx 转为 this

  11. ctx.data 转为 this

  12. xp.message(options) 转换为 this.$message(options)

  13. const obj = { get() {} } 转化为 const obj = { get: () => {} }


具体的实现可参考之前的文章,本文主要分享一下重构的实现。


重构前


所有的逻辑全写在一个 JS 文件中:
image.png
还有一段逻辑很长:
image.png


为啥要重构?


虽然主体部分被我折叠起来了,依然可以看到上面截图的代码存在很多问题,而且主体内容只会比这更糟:



  1. 难以维护,虽然写了注释,但是让别人来改根本看不明白,改不动,甚至不想看

  2. 如果新增了转换需求,不得不来改上面的代码,违反开放封闭原则。因为你无法保证你改动的代码不会造成原有逻辑的 bug。

  3. 代码没有章法,乱的一批,里面还有一堆 if/elsetry/catch

  4. 如果某个转换逻辑,我不想启用,按照现有的只能把对应的代码注释,依然是具有破坏性的


基于以上的这些问题,我决定重构一下。


重构后


先来看下重构后的样子:
image.png
统一将代码放到一个新的文件夹code-transform下:



  • transfuncs文件夹用来放具体的转换逻辑

  • util.js中有几个工具函数

  • trans_config.js用于配置transfuncs中转换逻辑是否生效

  • index.js 导出访问者对象 visitor(可以理解为我们根据配置动态组装一个 visitor 出来)


transfuncs下面的文件格式


如下图所示,该文件夹下的每个 JS 文件都默认导出一个函数,是真正的转换逻辑。
image.png
文件名命名规则:js ast树中节点的type_执行转换的大概内容


其余三个文件内容概览


image.png
其中笔者主要说明一下index.js


import config from './trans_config'

const visitor = {}
/**
* 导出获取访问者对象的函数
*/

export function generateVisitorByConfig() {
if (Object.keys(visitor).length !== 0) {
return visitor
}
// 过滤掉 trans_config.js 中不启用的转换规则
const transKeys = Object.keys(config).filter(key => config[key])
// 导入 ./transfuncs 下的转换规则
const context = require.context('./transfuncs', false, /\.js$/)
const types = new Set()
// 统计我们定义的转换函数,是哪些 ast 节点执行转换逻辑
// 别忘了文件名命名规则:js ast树中节点的type_执行转换的大概内容
// 注意去重,因为我们可能在同一种节点类型,会执行多种转换规则。
// 比如 transfuncs 下有多个 CallExpression_ 开头的文件。
context.keys().forEach(path => {
const fileName = path.substring(path.lastIndexOf('/') + 1).replace('.js', '')
const type = fileName.split('_')[0]
types.add(type)
})

const arrTypes = [...types]
// 到此 arrTypes 可能是这样的:
// ['CallExpression', 'FunctionDeclaration', 'MemberExpression', ...]
// 接着遍历每种节点 type

arrTypes.forEach(type => {
const typeFuncs = context.keys()
// 在 transfuncs 文件夹下找出以 对应 type 开头
// 并且 trans_config 中启用了的的文件
.filter(path => path.includes(type) && transKeys.find(key => path.includes(key)))
// 得到文件导出的 function
.map(path => context(path).default)
// 如果 typeFuncs.length > 0,就给 visitor 设置该节点执行的转换逻辑
typeFuncs.length > 0 && (visitor[type] = path => {
typeFuncs.forEach(func => func(path, attribute))
})
})
// 导出 visitor
return visitor
}

最后调用:


import { generateVisitorByConfig } from '../code-transform'

const transed = babel.transform(code, {
presets: ['es2016'],
sourceType: 'module',
plugins: [
{
visitor: generateVisitorByConfig()
}
]
}).code

有些掘友可能对babel的代码转换能力、babel插件不是很了解, 看完可能还处于懵的状态,对此建议各位先去我的上一篇我是怎么开发一个Babel插件来实现项目需求的? 大致看下逻辑,或者阅读一下Babel插件手册,看完之后自然就通了。


总结


到此呢,该部分的代码重构就完成了,能够明显看出:



  1. 文件变多了,但是每个文件做的事情更专一了

  2. 可以很轻松启用、禁用转换规则了,trans_config中配置一下即可,再也不用注释代码了

  3. 可以很轻松的新增转换逻辑,你只需要关注你在哪个节点处理你的逻辑,注意下文件名即可,你甚至不需要关心引入文件,因为会自动引入。

  4. 更容易维护了,就算离职了你的同事也能改的动你的代码,不会骂人了

  5. 逻辑更清晰了

  6. 对个人来说,代码组织能力提升了😃


👊🏼感谢观看!如果对你有帮助,别忘了 点赞 ➕ 评论 + 收藏 哦!


作者:Lvzl
来源:juejin.cn/post/7224205585125556284
收起阅读 »

如何避免旧代码成包袱?5步教你接手别人的系统

👉腾小云导读 老系统的代码,是每一个程序员都不想去触碰的领域,秉着能跑就行的原则,任由其自生自灭。本期就给大家讲讲,接手一套故障频发的复杂老系统需要从哪些地方着手。内容包括:代码串讲、监控建设和告警治理、代码缺陷修复、研发流程建设。在细节上,结合腾讯研发生态,...
继续阅读 »

👉腾小云导读


老系统的代码,是每一个程序员都不想去触碰的领域,秉着能跑就行的原则,任由其自生自灭。本期就给大家讲讲,接手一套故障频发的复杂老系统需要从哪些地方着手。内容包括:代码串讲、监控建设和告警治理、代码缺陷修复、研发流程建设。在细节上,结合腾讯研发生态,介绍有哪些工具可以使用,也介绍一些告警治理、代码 bug 修复的经验、研发流程建设等。欢迎阅读。


👉看目录,点收藏


1 项目背景


2 服务监控


2.1 平台自带监控


2.2 业务定制监控


3 串讲文档


3.1 串讲文档是什么


3.2 为什么需要串讲文档


3.3 怎么输出串讲文档


4 代码质量


4.1 业务逻辑 bug


4.2 防御编程


4.3 Go-python 内存泄露问题


4.4 正确使用外部库


4.5 避免无限重试导致雪崩


4.6 真实初始化


4.7 资源隔离


4.8 数据库压力优化


4.9 互斥资源管理


5 警告治理


5.1 全链路超时时间合理设置


5.2 基于业务分 set 进行资源隔离


5.3 高耗时计算使用线程池


6 研发流程


7 优化效果总结


7.1 健全的 CICD


7.2 更完备的可观测性


7.3 代码 bug 修复


7.4 服务被调成功率优化


7.5 外部存储使用优化


7.6 CPU 用量下降


7.7 代码质量提升


7.8 其他优化效果


01、项目背景


内容架构为 QB 搜索提供内容接入、计算、分发的全套服务。经过多年的快速迭代,内容架构包括 93 个服务,光接入主链路就涉及 7 个服务,支持多种接口类型。其分支定制策略多且散,数据流向混杂,且有众多 bug。


项目组接手这套架构的早期,每天收到大量的业务故障反馈以及服务自身告警。即便投入小组一半的人力做运维支持,依旧忙得焦头烂额。无法根治系统稳定性缺陷的同时,项目组还需要继续承接新业务,而新业务又继续暴露系统缺陷,陷入不断恶化的负循环,漏洞百出。没有人有信心去承诺系统稳定性,团队口碑、开发者的信心都处于崩溃的边缘。


在此严峻的形势下,项目组全员投入启动稳定性治理专项,让团队进入系统稳定的正循环


02、服务监控


监控可以帮助我们掌握服务运行时状态,发现、感知服务异常情况。通过看到问题 - 定位问题 - 修复问题来更快的熟悉模块架构和代码实现细节。下面分两部分介绍,如何利用监控达成稳定性优化。


2.1 平台自带监控


若服务的部署、发布、运行托管于公共平台,则这些平台可能会提供容器资源使用情况的监控,包括:CPU 利用率监控、内存使用率监控、磁盘使用率监控等服务通常应该关注的核心指标。我们的服务部署在123 平台(司内平台),有如下常用的监控。


平台自带监控:


监控类型解析
服务运行监控123 平台的 tRPC 自定义监控:event_alarm 可监控服务异常退出。
节点服务资源使用监控内存使用率监控:检查是否存在内存泄露,会不会不定期 OOM。
磁盘使用率监控:检查日志是否打印的过多,日志滚动配置是否合理。
CPU 使用率监控
如存在定期/不定期的 CPU 毛刺,则检查对应时段请求/定期任务是否实现不合理。如CPU 配额高而使用率低,则检查是配置不合理导致资源浪费,还是服务实现不佳导致 CPU 打不上去。

外部资源平台监控:


数据库连接数监控:检查服务使用 DB 是否全是长连接,使用完没有及时 disconnect 。
数据库慢查询监控:SQL 命令是否不合理,DB 表是否索引设置不合理。数据库 CPU 监控:检查服务是否全部连的 DB 主机,对于只读的场景可选择用只读账号优先读备机,降低 DB 压力。其他诸如腾讯云 Redis 等外部资源也有相关的慢查询监控,亦是对应检查。

2.2 业务定制监控


平台自带的监控让我们掌控服务基本运行状态。我们还需在业务代码中增加业务自定义监控,以掌控业务层面的运转情况。


下面介绍常见的监控完善方法,以让各位对于业务运行状态尽在掌握之中。


2.2.1 在主/被调监控中增加业务错误码


一般来说,后台服务如果无法正常完成业务逻辑,会将错误码和错误详情写入到业务层的回包结构体中,然后在框架层返回成功。


这种实现导致我们无法从平台自带的主/被调监控中直观看出有多少请求是没有正常结果的。一个服务可能看似运行平稳,基于框架层判断的被调成功率 100%,但其中却有大量没有正常返回结果的请求,在我们的监控之外。


此时如果我们想要对这些业务错误做监控,需要上报这些维度:请求来源(主调服务、主调容器、主调 IP、主调 SET)、被调容器、错误码、错误数,这和被调监控有极大重合,存在资源浪费,并且主、被调服务都有开发成本。


若服务框架支持,我们应该尽可能使用框架层状态包来返回业务自定义的错误码。以tRPC框架为例服务的<被调监控 - 返回码>中,会上报框架级错误码和业务错误码:框架错误码是纯数字,业务错误码是<业务协议_业务码>。如此一来,就可以在主/被调服务的平台自带监控中看到业务维度的返回码监控。


2.2.2 在主/被调监控中注入业务标识


有时候一个接口会承担多个功能,用请求参数区分执行逻辑A / 逻辑B。在监控中,我们看到这个接口失败率高,需要进一步下钻是 A 失败高还是 B 失败高、A/B 分别占了多少请求量等等。


对这种诉求,我们可以在业务层上报一个带有各种主调属性的多维监控以及接口参数维度用于下钻分析。但这种方式会造成自定义监控和被调监控字段的重叠浪费。


更好的做法是:将业务维度注入到框架的被调监控中,复用被调监控中的主调服务、节点、SET、被调接口、IP、容器等信息。


2.2.3 单维属性上报升级成多维属性上报


单维属性监控指的是上报单个字符串(维度)、一个浮点数值以及这个浮点数值对应的统计策略。多维监控指的是上报多个字符串(维度)、多个浮点数值以及每个浮点数值对应的统计策略。


下面以错误信息上报场景为例,说明单维监控的缺点以及如何切换为多维上报。


作为后台服务,处理逻辑环节中我们需要监控上报各类关键错误,以便及时感知错误、介入处理。内容架构负责多个不同业务的接入处理,每个业务具有全局唯一的资源标识,下面称为 ResID。当错误出现时,做异常上报时,我们需要上报 “哪个业务”,出现了“什么错误”。


使用单维上报时,我们只能将二者拼接在一起,上报 string (ResID + "." + ErrorMsg)。对不同的 ResID 和不同的 ErrorMsg,监控图是铺平展开的,只能逐个查看,无法对 ResID 下钻查看每类错误分别出现了多少次或者对 ErrorMsg 下钻,查看此错误分别在哪些 ID 上出现。


除了查看上的不便,多维属性监控配置告警也比单维监控便利。对于单维监控,我们需要对每一个可能出现的组合配置条件:维度值 = XXX,错误数 > N 则告警。一旦出现了新的组合,比如新接入了一个业务,我们就需要再去加告警配置,十分麻烦且难以维护。


而对于多维监控表,我们只需设置一条告警配置:对 ResID && 错误维度 下钻,错误数 > N 则告警。新增 ResID 或新增错误类型时,均会自动覆盖在此条告警配置内,维护成本低,配置简单。


03、串讲文档


监控可以帮助我们了解服务运行的表现,想要“深度清理”服务潜在问题,我们还需要对项目做代码级接手。在稳定性治理专项中,我们要求每个核心模块都产出一份串讲文档,而后交叉学习,使得开发者不单熟悉自己负责的模块,也对完整系统链路各模块功能有大概的理解,避免窥豹一斑。


3.1 串讲文档是什么


代码串讲指的是接手同学在阅读并理解模块代码后,系统的向他人介绍对该模块的掌握情况。代码串讲文档则是贯穿串讲过程中的分享材料,需要对架构设计、代码实现、部署、监控、理想化思考等方面进行详细介绍,既帮助其他同学更好的理解整个模块,也便于评估接手同学对项目的理解程度。


代码串讲文档通常包括以下内容:模块主要功能,上下游关系、整体架构、子模块的详细介绍、模块研发和上线流程、模块的关键指标等等。在写串讲文档的时候也通常需要思考很多问题:这个功能为什么需要有?设计思路是这样的?技术上如何实现?最后是怎么应用的?


3.2 为什么需要串讲文档


原因
解析
确保代码走读的质量串讲文档涵盖了模块最重要的几个部分,要求开发人员编写串讲文档的这些章节,可保障他在走读此模块时高度关注到这些部分,并总结输出成文档,确保了代码学习的深度和质量。
强化理解编写串讲文档的过程,也是对模块各方面的提炼总结。在编写的过程中,开发人员可能会发现走读中未想到的问题。通过编写代码串讲文档,开发人员可以更好地理解整个系统的结构和实现,加强对代码和系统的理解,为后续接手、维护该系统提供了帮助。
团队知识沉淀和积累串讲文档是团队内部进行知识分享和沟通的载体,也是团队知识不断沉淀积累的过程,可以作为后续新人加入团队时了解系统的第一手材料,也可以作为其他同学后续多次翻阅、了解该系统的材料。

3.3 怎么输出串讲文档


增代码串讲文档的时候,需要从2个方面进行考虑——读者角度和作者角度。


作者角度: 需要阐述作者对系统和代码的理解和把握,同时也需要思考各项细节:这个功能为什么需要有、设计思路是怎样的、技术上如何实现、最后是怎么应用的等等。


读者角度: 需要考虑目标受众是哪些,尽可能地把读者当成技术小白,思考读者需要了解什么信息,如何才能更好地理解代码的实现和作用。


通常,代码串讲文档可以包含以下几个部分:


文档构成
信息
模块主要功能首先需要明确模块的主要功能,并在后续的串讲中更加准确地介绍代码的实现。
上下游关系在介绍每个模块时,需要明确它与其他模块之间的上下游关系,即模块之间的调用关系,这有助于了解模块之间的依赖关系,从而更好地理解整个系统的结构和实现。
名词解释对一些关键词、专业术语和缩写词等专业术语进行解释,不同团队使用的术语和缩写可能不同,名词解释可以减少听众的阅读难度,提高沟通效率。
整体架构介绍整个系统的设计思路、实现方案和架构选择的原因以及优缺点。
子模块的详细解读在介绍每个子模块时,需要对其进行详细的解读,包括该模块的具体功能、实现方式、代码实现细节等方面。
模块研发和上线流程介绍模块的研发和上线流程,包括需求分析、设计、开发、测试、上线等环节。这有助于了解模块的开发过程和上线流程,以及研发团队的工作流程。
模块的关键指标介绍模块的关键指标,包括不限于业务指标、服务监控、成本指标等。
模块当前存在问题及可能的解决思路主要用于分析该模块目前存在的问题,并提出可能的解决思路和优化方案。
理想化的思考对该模块或整个系统的未来发展和优化方向的思考和展望,可以对模块的长期目标、技术选型、创新探索、稳定性建设等方面进行思考。
中长线工作安排结合系统现状和理想化思考,做出可行的中长线工作计划。
串讲中的问题这部分用于记录串讲中的问题及解答,通常是Q&A的形式,串讲中的问题也可能是系统设计相关的问题,后续将作为todo项加入到工作安排中。

04、代码质量


代码质量很大程度上决定服务的稳定性。在对代码中业务逻辑 bug 进行修复的同时,我们也对服务的启动、数据库压力及互斥资源管理做优化。


4.1 业务逻辑bug


4.1.1 内存泄漏


如下图代码所示,使用 malloc 分配内存,但没有 free,导致内存泄露。该代码为 C 语言风格,现代 C++ 使用智能指针可以避免该问题。


图片


4.1.2 空指针访问成员变量


如下图所示的代码,如果一个非虚成员函数没有使用成员变量,因编译期的静态绑定,空指针也可以成功调用该成员函数。但如果该成员函数使用了成员变量,那么空指针调用该函数时则会 core。该类问题在接入系统仓库中比较普遍,建议所有指针都要进行合理的初始化。


图片


4.2 防御编程


4.2.1 输入防御


如下图所示,如果发生了错误且没有提前返回,request 将引发 panic。针对输入,在没有约定的情况下,建议加上常见的空指针判断及异常判断。


图片


4.2.2 数组长度防御-1


如下图所示,当 url 长度超过 512 时,将会被截断,导致产出错误的url。建议针对字符串数组的长度进行合理的初始化,亦或者使用string来替代字符数组。


图片


4.2.3 数组长度防御-2


如下图所示,老代码不判断数组长度,直接取值。当出现异常数据时,该段代码则会core。建议在每次取值时,基于上下文做防御判断。


图片


4.2.4 野指针问题


下图中的ts指针指向内容和 create_time 一致。当 create_time 被 free 之后,ts 指针就变成了野指针。


该代码为 C 语言风格代码,很容易出现内存方面的问题。建议修改为现代 C++风格。


图片


下图中,临时变量存储的是 queue 中的值的引用。当 queue pop 后,此值会被析构;而变量引用的存储空间也随之释放,访问此临时变量可能出现未定义的行为。


图片


4.2.5 全局资源写防护


同时读写全局共有资源,尤其生成唯一 id,要考虑并发的安全性。


这里直接通过查询 DB 获取最大的 res_id,转成 int 后加一,作为新增资源的唯一 id。如果并发度超过 1,很可能会出现 id 重复,影响后续操作逻辑。


图片


4.2.6 lua 添加 json 解析防御


如下图所示的 lua 脚本中,使用 cjson 将字符串转换 json_object。当 data_obj 不是合法的 json 格式字符串时,decode 接口会返回 nil。修复前的脚本未防御返回值为空的情况,一旦传入非法字符串,lua 脚本就会引发 coredump。


图片


4.3 Go-python 内存泄露问题


如下图 85-86 行代码所示,使用 Go-python 调用 python 脚本,将 Go 的 string 转为PyString,此时 kv 为 PyObject。部分 PyObject 需要在函数结束时调用 DecRef,减少引用计数,以确保资源释放,否则会造成内存泄露。


判定依据是直接看 python sdk 的源码注释,如果有 New Reference , 那么必须在使用完毕时释放内存,Borrowed Reference 不需要手动释放。


图片


4.4 正确使用外部库


4.4.1 Kafka Message 结束字符


当生产者为 Go 服务时,写入 kafka 的消息字符串不会带有结束字符 '\0'。当生产者为 C++ 服务时,写入 kafka 的消息字符串会带有结束字符 '\0'。


如下图 481 行代码所示,C++中使用 librdkafka 获取消费数据时,需传入消息长度,而不是依赖程序自行寻找 '\0' 结束符。


图片


4.5 避免无限重试导致雪崩


如下图所示代码所示,失败之后立马重试。当出现问题时,不断立即重试,会导致雪崩。给重试加上 sleep,减缓下游雪崩的速度,留下缓冲的时间。


图片


4.6 真实初始化


如果每次服务启动都存在一定的成功率抖动,需要检查服务注册前的初始化流程,看看是不是存在异步初始化,导致未完成初始化即提供对外服务。如下图 48 行所示注释,原代码中初始化代码为异步函数,服务对外提供服务时可能还没有完成初始化。


图片


4.7 资源隔离


时延/成功率要求不同的服务接口,建议使用不同的处理线程。如下图中几个 service,之前均共用同一个处理线程池。其中 secure_review_service 处理耗时长,流量不稳定,其他接口流量大且时延敏感,线上出现过 secure_review_service 瞬时流量波峰,将处理线程全部占住且队列积压,导致其他 service 超时失败。


图片


4.8 数据库压力优化


4.8.1 分批拉取


当某个表数据很多或单次传输量很大,拉取节点也很多的时候,数据库的压力就会很大。这个时候,可以使用分批读取。下图 308-343 行代码,修改了 sql 语句,从一批拉取改为分批拉取。


图片


4.8.2 读备机


如果业务场景为只读不写数据,且对一致性要求不高,可以将读配置修改为从备机读取。mysql 集群一般只会有单个主机、多个备机,备机压力较小。如下图 44 行代码所示,使用readuser,主机压力得到改善。


图片


4.8.3 控制长链接个数


需要使用 mysql 长链接的业务,需要合理配置长链接个数,尤其是服务节点数很多的情况下。连接数过多会导致 mysql 实例内存使用量大,甚至 OOM;此外 mysql 的连接数是刚性限制,超过阈值后,客户端无法正常建立 mysql 连接,程序逻辑可能无法正常运转。


4.8.4 建好索引


使用 mysql 做大表检索时,应该建立与查询条件对应的索引。本次优化中,我们根据 DB 慢查询统计,找到有大表未建查询适用的索引,导致 db 负载高,查询速度慢。


4.8.5 实例拆分


非分布式数据库 (如 mariaDB) 存储空间是有物理上限的,需要预估好数据量,数据量过多及时进行合理的拆库。


4.8.6 分布式数据库负载均衡


分布式数据库一般应用在海量数据场景,使用中需要留意节点间负载均衡,否则可能出现单机瓶颈,拖垮整个集群性能。如下图是内容架构使用到的 hbase,图中倒数两列分别为请求数和region 数,从截图可看出集群的 region 分布较均衡,但部分节点请求量是其他节点几倍。造成图中请求不均衡的原因是集群中有一张表,有废弃数据占用大量 region,导致使用中的 region 在节点间分布不均,由此导致请求不均。解决方法是清理废弃数据,合并空数据 region。


图片


4.9 互斥资源管理


4.9.1 避免连接占用


接入系统服务的 mysql 连接全部使用了连接池管理。每一个连接在使用完之后,需要及时释放,否则该连接就会被占住,最终连接池无资源可用。下图所示的 117 行连接释放为无效代码,因为提前 return。有趣的是,这个 bug 和另外一个 bug 组合起来,解决了没有连接可用的问题:当没有连接可用时,获取的连接则会为空。该服务不会对连接判空,导致服务 core 重启,连接池重新初始化,又有可用的连接了。针对互斥的资源,要进行及时释放。


图片


4.9.2 使用 RAII 释放资源


下图所示的 225 行代码,该任务为互斥资源,只能由一个节点获得该任务并执行该任务。GetAllValueText 执行完该任务之后,应该释放该任务。然而在 240 行提前 return 时,没有及时释放该资源。


优化后,我们使用 ScopedDeferred 确保函数执行完成,退出之前一定会执行资源释放。


图片


05、告警治理


告警轰炸是接手服务初期常见的问题。除了前述的代码质量优化,我们还解决下述几类告警:


全链路超时配置不合理。下游服务的超时时间,大于上游调用它的超时时间,导致多个服务超时告警轰炸、中间链路服务无效等待等。
业务未隔离。某个业务流量突增引起全链路队列阻塞,影响到其他业务。请求阻塞。请求线程中的处理耗时过长,导致请求队列拥堵,请求消息得不到及时处理。

5.1 全链路超时时间合理设置


未经治理的长链路服务,因为超时设置不合理导致的异常现象:




  • 超时告警轰炸: A 调用 B,B 调用 C,当 C 异常时,C 有超时告警,B 也有超时告警。在稳定性治理分析过程中,C 是错误根源,因而 B 的超时告警没有价值,当链路较长时,会因某一个底层服务的错误,导致海量的告警轰炸。




  • 服务无效等待: A 调用 B,B 调用 C,当 A->B 超时的时候,B 还在等 C 的回包,此时 B 的等待是无价值的。




这两种现象是因为超时时间配置不合理导致的,对此我们制定了“超时不扩散原则”,某个服务的超时不应该通过框架扩散传递到它的间接调用者,此原则要求某个服务调用下游的超时必须小于上游调用它的超时时间。


5.2 基于业务分 set 进行资源隔离


针对某个业务的流量突增影响其他业务的问题,我们可将重点业务基于 set 做隔离部署。确保特殊业务只运行于独立节点组上,当他流量暴涨时,不干扰其他业务平稳运行,降低损失范围。


5.3 高耗时计算使用线程池


如下图红色部分 372 行所示,在请求响应线程中进行长耗时的处理,占住请求响应线程,导致请求队列阻塞,后续请求得不到及时处理。如下图绿色部分 368 行所示,我们将耗时处理逻辑转到线程池中异步处理,从而不占住请求响应线程。


图片


06、研发流程


在研发流程上,我们沿用司内其他技术产品积累的 CICD 建设经验,包括以下措施:


研发方式具体流程
建设统一镜像统一开发镜像,任意服务都可以在统一镜像下开发编译
配置工蜂仓库保护 master 分支,代码经过评审才可合入
规范分支和 tag 命名,便于后续信息追溯
规范化 commit 信息,确保信息可读,有条理,同样便于后续信息追溯
建设蓝盾流水线MR 流水线提交 MR 时执行的流水线,涵盖代码静态检查、单元测试、接口测试,确保合入 master 的代码质量达到基本水平
提交构建流水线:MR 合入后执行的流水线,同样涵盖代码检查,单元测试,接口测试,确保 master 代码随时可发布
XAC 发布流水线:发布上线的流水线,执行固化的灰度->全量流程,避免人工误操作
落实代码评审机制确保代码合入时,经过了至少一位同事的检查和评审,保障代码质量、安全

07、优化效果总结


7.1 健全的CICD


7.1.1 代码合入


在稳定性专项优化前,内容架构的服务没有合理的代码合入机制,导致主干代码出现违背编码规范、安全漏洞、圈复杂度高、bug等代码问题。


优化后,我们使用统一的蓝盾流水线模板给所有服务加上流水线,以保证上述问题能被自动化工具、人工代码评审拦截。


7.1.2 服务发布


在稳定性优化前,内容架构服务发布均为人工操作,没有 checklist 机制、审批机制、自动回滚机制,有很大安全隐患。


优化后,我们使用统一的蓝盾流水线模板给所有服务加上 XAC 流水线,实现了提示发布人在发布前自检查、double_check 后审批通过、线上出问题时一键回滚。


7.2 更完备的可观测性


7.2.1 多维度监控


在稳定性优化前,内容架构服务的监控覆盖不全,自定义监控均使用一维的属性监控,这导致多维拼接的监控项泛滥、无法自由组合多个维度,给监控查看带来不便。


优化后,我们用更少的监控项覆盖更多的监控场景。


7.2.2 业务监控和负责人制度


在稳定性优化前,内容架构服务几乎没有业务维度监控。优化后,我们加上了重要模块的多个业务维度监控,譬如:数据断流、消息组件消息挤压等,同时还建立值班 owner、服务 owner 制度,确保有告警、有跟进。


7.2.3 trace 完善与断流排查文档建设


在稳定性优化前,虽然已有上报鹰眼 trace 日志,但上报不完整、上报有误、缺乏排查手册等问题,导致对数据处理全流程的跟踪调查非常困难。


优化后,修正并补全了 trace 日志,建设配套排查文档,case 处理从不可调查变成可高效率调查。


7.3代码bug修复


7.3.1 内存泄露修复


在稳定性优化前,我们观察到有3个服务存在内存泄露,例如代码质量章节中描述内存泄露问题。


图片


7.3.2 coredump 修复 & 功能 bug 修复


在稳定性优化前,历史代码中存在诸多 bug 与可能导致 coredump 的隐患代码。


我们在稳定性优化时解决了如下 coredump 与 bug:


  • JSON 解析前未严格检查,导致 coredump 。
  • 服务还未初始化完成即接流,导致服务重启时被调成功率猛跌。
  • 服务初始化时没有同步加载配置,导致服务启动后缺失配置而调用失败。
  • Kafka 消费完立刻 Commit,导致服务重启时,消息未实际处理完,消息可能丢失/
  • 日志参数类型错误,导致启动日志疯狂报错写满磁盘。

7.4 服务被调成功率优化


在稳定性优化前,部分内容架构服务的被调成功率不及 99.5% ,且个别服务存在严重的毛刺问题。优化后,我们确保了服务运行稳定,调用成功率保持在 99.9%以上。


7.5 外部存储使用优化


7.5.1 MDB 性能优化


在稳定性优化前,内容架构各服务对MDB的使用存在以下问题:低效/全表SQL查询、所有服务都读主库、数据库连接未释放等问题。造成MDB主库的CPU负载过高、慢查询过多等问题。优化后,主库CPU使用率、慢查询数都有大幅下降。


图片


7.5.2 HBase 性能优化


在稳定性优化前,内容架构服务使用的 HBase 存在单节点拖垮集群性能的问题。


优化后,对废弃数据进行了清理,合并了空数据 region,使得 HBase 调用的 P99 耗时有所下降。


图片


7.6 CPU 用量下降


在稳定性优化前,内容架构服务使用的线程模型是老旧的 spp 协程。spp 协程在在高吞吐低延迟场景下性能有所不足,且未来 trpc 会废弃 spp。


我们将重要服务的线程模型升级成了 Fiber 协程,带来更高性能和稳定性的同时,服务CPU利用率下降了 15%。


7.7 代码质量提升


在稳定性优化前,内容架构服务存在很多不规范代码。例如不规范命名、魔术数字和常量、超长函数等。每个服务都存在几十个代码扫描问题,最大圈复杂度逼近 100。


优化后,我们对不规范代码进行了清扫,修复规范问题,优化代码命名,拆分超长函数。并在统一清理后,通过 MR 流水线拦截,保护后续合入不会引入新的规范类问题。


7.8 其他优化效果


经过本轮治理,我们的服务告警量大幅度下降。以系统中的核心处理服务为例,告警数量从159条/天降低到了0条/天。业务 case 数从 22 年 12 月接手以来的 18 个/月下降到 4 个/月。值班投入从最初的 4+ 人力,降低到 0.8 人力。


我们项目组在完成稳定性接手之后,下一步将对全系统做理想化重构,进一步提升迭代效率、运维效率。希望这些经验也对你接管/优化旧系统有帮助。如果觉得内容有用,欢迎分享。

作者:腾讯云开发者
来源:juejin.cn/post/7233564835044524092
收起阅读 »

你管这破玩意叫缓存穿透?还是缓存击穿?

大家好,我是哪吒。 一、缓存预热 Redis缓存预热是指在服务器启动或应用程序启动之前,将一些数据先存储到Redis中,以提高Redis的性能和数据一致性。这可以减少服务器在启动或应用程序启动时的数据传输量和延迟,从而提高应用程序的性能和可靠性。 1、缓存预热...
继续阅读 »

大家好,我是哪吒。


一、缓存预热


Redis缓存预热是指在服务器启动或应用程序启动之前,将一些数据先存储到Redis中,以提高Redis的性能和数据一致性。这可以减少服务器在启动或应用程序启动时的数据传输量和延迟,从而提高应用程序的性能和可靠性。


1、缓存预热常见步骤


(1)数据准备


在应用程序启动或服务器启动之前,准备一些数据,这些数据可以是静态数据、缓存数据或其他需要预热的数据。


(2)数据存储


将数据存储到Redis中,可以使用Redis的列表(List)数据类型或集合(Set)数据类型。


(3)数据预热


在服务器启动或应用程序启动之前,将数据存储到Redis中。可以使用Redis的客户端工具或命令行工具来执行此操作。


(4)数据清洗


在服务器启动或应用程序启动之后,可能会对存储在Redis中的数据进行清洗和处理。例如,可以删除过期的数据、修改错误的数据等。


需要注意的是,Redis缓存预热可能会增加服务器的开销,因此应该在必要时进行。同时,为了减少预热的次数,可以考虑使用Redis的其他数据类型,如哈希表(Hash)或有序集合(Sorted Set)。此外,为了提高数据一致性和性能,可以使用Redis的持久化功能,将数据存储到Redis中,并在服务器重启后自动恢复数据。


2、代码实现


@Component
@Slf4j
public class BloomFilterInit
{
@Resource
private RedisTemplate redisTemplate;

//初始化白名单数据
@PostConstruct
public void init() {
//1 白名单客户加载到布隆过滤器
String key = "customer:1";
//2 计算hashValue,由于存在计算出来负数的可能,我们取绝对值
int hashValue = Math.abs(key.hashCode());
//3 通过hashValue和2的32次方后取余,获得对应的下标坑位
long index = (long)(hashValue % Math.pow(2,32));
log.info(key+" 对应的坑位index:{}",index);
//4 设置redis里面的bitmap对应类型白名单:whitelistCustomer的坑位,将该值设置为1
redisTemplate.opsForValue().setBit("whitelistCustomer",index,true);

}
}

二、缓存雪崩


Redis缓存雪崩是指在缓存系统中,由于某些原因,缓存的数据突然大量地被删除或修改,导致缓存系统的性能下降,甚至无法正常工作。


1、什么情况会发生缓存雪崩?


(1)误删除


由于误操作或故障,缓存系统可能会误删除一些正常的数据。这种情况通常会在数据库中发生。


(2)误修改


由于误操作或故障,缓存系统可能会误修改一些正常的数据。这种情况通常会在数据库中发生。


(3)负载波动


缓存系统通常会承受一定的负载波动,例如,在高峰期间,数据量可能会大幅增加,从而导致缓存系统的性能下降。


(4)数据变化频繁


如果缓存系统中的数据变化频繁,例如,每秒钟都会有大量的数据插入或删除,那么缓存系统可能会因为响应过慢而导致雪崩。


2、Redis缓存集群实现高可用


(1)主从 + 哨兵


(2)Redis集群


(3)开启Redis持久化机制aof/rdb,尽快恢复缓存集群。


3、如何避免Redis缓存雪崩?


(1)数据备份


定期备份数据,以防止误删除或误修改。


(2)数据同步


定期同步数据,以防止数据不一致。


(3)负载均衡


使用负载均衡器将请求分配到多个Redis实例上,以减轻单个实例的负载。


(4)数据优化


优化数据库结构,减少数据变化频繁的情况。


(5)监控与告警


监控Redis实例的性能指标,及时发现缓存系统的异常,并发出告警。


三、缓存穿透


Redis缓存穿透是指在Redis缓存系统中,由于某些原因,缓存的数据无法被正常访问或处理,导致缓存失去了它的作用。


1、什么情况会发生缓存穿透?


(1)数据量过大


当缓存中存储的数据量过大时,缓存的数据量可能会超过Redis的数据存储限制,从而导致缓存失去了它的作用。


(2)数据更新频繁


当缓存中存储的数据更新频繁时,缓存的数据可能会出现异步的变化,导致缓存无法被正常访问。


(3)数据过期


当缓存中存储的数据过期时,缓存的数据可能会失去它的作用,因为Redis会在一定时间后自动将过期的数据删除。


(4)数据权限限制


当缓存中存储的数据受到权限限制时,只有拥有足够权限的用户才能访问和处理这些数据,从而导致缓存失去了它的作用。


(5)Redis性能瓶颈


当Redis服务器的性能达到极限时,Redis缓存可能会因为响应过慢而导致穿透。


2、如何避免Redis缓存穿透?


(1)设置合理的缓存大小


根据实际需求设置合理的缓存大小,以避免缓存穿透。


(2)优化数据结构


根据实际需求优化数据结构,以减少数据的大小和更新频率。


(3)设置合理的过期时间


设置合理的过期时间,以避免缓存失去它的作用。


(4)增加Redis的并发处理能力


通过增加Redis的并发处理能力,以提高缓存的处理能力和响应速度。


(5)优化Redis服务器的硬件和软件配置


通过优化Redis服务器的硬件和软件配置,以提高Redis的性能和处理能力。


Redis缓存穿透


四、通过空对象缓存解决缓存穿透


如果发生了缓存穿透,可以针对要查询的数据,在Redis中插入一条数据,添加一个约定好的默认值,比如defaultNull。


比如你想通过某个id查询某某订单,Redis中没有,MySQL中也没有,此时,就可以在Redis中插入一条,存为defaultNull,下次再查询就有了,因为是提前约定好的,前端也明白是啥意思,一切OK,岁月静好。


这种方式只能解决key相同的情况,如果key都不同,则完蛋。


五、Google布隆过滤器Guava解决缓存穿透



1、引入pom


<!--guava Google 开源的 Guava 中自带的布隆过滤器-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>

2、创建布隆过滤器


BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 100);

(3)布隆过滤器中添加元素


bloomFilter.mightContain(1)

(4)判断布隆过滤器中是否存在


bloomFilter.mightContain(1)

3、fpp误判率


@Service
@Slf4j
public class GuavaBloomFilterService {
public static final int SIZE = 1000000;

//误判率
public static double fpp = 0.01;

//创建guava布隆过滤器
private static BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), SIZE, fpp);

public void guavaBloomFilter() {
for (int i = 1; i <= SIZE; i++) {
bloomFilter.put(i);
}
ArrayList<Integer> list = new ArrayList<>(10000);

for (int i = SIZE + 1; i <= SIZE + (10000); i++) {
if (bloomFilter.mightContain(i)) {
log.info("被误判了:{}", i);
list.add(i);
}
}
log.info("误判总数量:{}", list.size());
}
}

六、Redis缓存击穿


Redis缓存击穿是指在Redis缓存系统中,由于某些原因,缓存的数据无法被正常访问或处理,导致缓存失去了它的作用。


1、什么情况会发生缓存击穿?


根本原因:热点Key失效


(1)数据量过大


当缓存中存储的数据量过大时,缓存的数据量可能会超过Redis的数据存储限制,从而导致缓存失去了它的作用。


(2)数据更新频繁


当缓存中存储的数据更新频繁时,缓存的数据可能会出现异步的变化,导致缓存无法被正常访问。


(3)数据过期


当缓存中存储的数据过期时,缓存的数据可能会失去它的作用,因为Redis会在一定时间后自动将过期的数据删除。


(4)数据权限限制


当缓存中存储的数据受到权限限制时,只有拥有足够权限的用户才能访问和处理这些数据,从而导致缓存失去了它的作用。


(5)Redis性能瓶颈


当Redis服务器的性能达到极限时,Redis缓存可能会因为响应过慢而导致击穿。


2、如何避免Redis缓存击穿?


(1)设置合理的缓存大小


根据实际需求设置合理的缓存大小,以避免缓存穿透。


(2)优化数据结构


根据实际需求优化数据结构,以减少数据的大小和更新频率。


(3)设置合理的过期时间


设置合理的过期时间,以避免缓存失去它的作用。


(4)增加Redis的并发处理能力


通过增加Redis的并发处理能力,以提高缓存的处理能力和响应速度。


(5)优化Redis服务器的硬件和软件配置


通过优化Redis服务器的硬件和软件配置,以提高Redis的性能和处理能力。


七、Redis缓存击穿解决方案


1、互斥更新


通过双检加锁机制。


2、差异失效时间



先更新从缓存B,再更新主缓存A,而且让从缓存B的缓存失效时间长于A,保证A失效时,B还在。


作者:哪吒编程
来源:juejin.cn/post/7233052510553636901
收起阅读 »

一次查找分子级Bug的经历,过程太酸爽了

作者:李亚飞 Debugging is like trying to find a needle in a haystack, except the needle is also made of hay. Debug调试就像是在大片的干草堆中找针一样,只不...
继续阅读 »

作者:李亚飞





Debugging is like trying to find a needle in a haystack, except the needle is also made of hay.


Debug调试就像是在大片的干草堆中找针一样,只不过针也是由干草制成的。



在软件开发的世界里,偶尔会出现一些非常隐蔽的 Bug,这时候工程师们像探险家一样,需要深入代码的丛林,寻找隐藏在其中的“幽灵宝藏”。前段时间,我和我的团队也踏上了这样一段刺激、有趣的探险之旅。


最近繁忙的工作告一段落,我总算轻松下来了,想趁这个机会,跟大家分享我们的这次“旅途”。



01 引子


我是 ShowMeBug 的 CEO 李亚飞,是一个古老的 Ruby 工程师。由于 2019 年招聘工程师的噩梦经历,我立志打造一个真实模拟工作场景的 IDE,用来终结八股文、算法横行的技术招聘时代。


这个云上的 IDE 引擎,我称之为轻协同 IDE 引擎——因为它不是为了繁杂重度的工作场景准备的,而是适应于大部分人的习惯、能快速上手熟悉、加载速度快、能协同(面试用)、低延迟感,让用户感受非常友好


图片


多环境启动与切换


为了达成秒级启动环境的性能要求,我们设计了一套精巧的分布式文件系统架构,其核心是一个可以瞬间复制大量小文件的写时复制 (COW) 技术。IO 吞吐能达到几万人同时在线,性能绝对是它的一大优势。


我们对此信心满满,然而没想到,很快就翻车了。


02 探险启程


2023 年 1 月,北方已经白雪皑皑,而深圳却仍难以感受到冬天的寒意。


我和我的团队在几次打开文件树的某个文件时,会显得有点慢——当时没有人在意,按照常规思路,“网速”背了这个锅。事后我们复盘才发现,这个看似微不足道的小问题,其实正是我们开始这次探险之旅的起点。


1 月底,南方的寒意缓缓侵入。这时候我们的轻协同 IDE 引擎已经开始陆续支持了 Vue2、Vue3、React、Django、Rails 等框架环境,一开始表现都很棒,加载和启动速度都很快。但是,跑了一段时间,我们开始察觉,线上环境就出现个别环境(Rails 环境)启动要 20-30s 才能完成


虽然其他环境仍然保持了极快的加载和启动速度,但敏锐的第六感告诉我,不行,这一定有什么猫腻,如果不立即行动,势必会对用户体验带来很不好的影响。于是,我开始安排团队排查眼前这个不起眼的问题,我们的探险之旅正式开始。


03 初露希望


湿冷的冬季,夜已深,我和我们的团队依旧坐在电脑前苦苦探索,瑟瑟发抖。


探险之旅的第一站,就是老大难的问题:定位Bug。目前只有某一个环境启动很慢,其他的环境都表现不错。大家想了很多办法都没有想明白为什么,甚至怀疑这个环境的模板是不是有问题——但把代码放在本地启动,最多就2秒。


哎,太诡异了。我们在这里卡了至少一周时间,不断追踪代码,分析日志文件,尝试各种方案,都没有弄清楚一个正常的程序启动为什么会慢。我们一度陷入了疲惫和焦虑的情绪中。



Debug 是种信仰,只有坚信自己能找到 Bug,才有可能找到 Bug。



软件开发界一直有一个低级 Bug 定律:所有诡异的问题都来自一个低级原因。在这“山重水复疑无路”之际,我们决定重新审视我们的探险路径:为什么只有 Rails 更慢,其他并不慢?会不会只是一个非常微小的原因而导致?


这时候,恰好有一个架构师朋友来访,向我们建议,可以用 perf 火焰图分析看看 Rails 的启动过程。


图片


perf火焰图实例


当我们用 perf 来分析时,惊讶地发现:原来 Rails 的启动要加载更多的文件! 紧接着,我们又重新用了一个文件读写监控的工具:fatrace,通过它,我们看到 Rails 每次启动需要读写至少 5000 个文件,但其他框架并不需要。


这才让我们突然意识到,会不会是文件系统读写速度不及预期,导致了启动变慢。


04 Bug现身


为了搞清楚是不是文件系统读写速度的问题,我急需一个测试 IO 抖动的脚本。我们初步估算一下,写好这个脚本需要好几个小时的时间。


夜已深,研发同学都陆续下班了。时间紧迫!我想起了火爆全球的 ChatGPT,心想,不如让它写一个试试。


图片


测试 IO 抖动的脚本


Cool,几乎不需要改动就能用,把代码扔在服务器开跑,一测,果然发现问题:每一次文件读写都需要 10-20ms 才能完成 。实际上,一个优秀的磁盘 IO 读写时延应该在亚毫级,但这里至少慢了 50 倍。

Bingo,如同“幽灵宝藏”一般的分子级 Bug 逐渐显现,问题的根因已经确认:过慢的磁盘 IO 读写引发了一系列操作变慢,进而导致启动时间变得非常慢


更庆幸的是,它还让我们发现了偶尔打开文件树变慢的根本原因,这也是整个系统并发能力下降的罪魁祸首


05 迷雾追因


看到这里,大家可能会问,这套分布式文件系统莫非一直这么慢,你们为什么在之前没有发现?


非也,早在项目开始的时候,这里的时延是比较良好的,大家没有特别注意这个 IOPS 性能指标,直到我们后面才留意到,系统运行超过一个月时,IO 读写时延很容易就进入到卡顿的状态,表现就是文件系统所在主机 CPU 忽高忽低,重启就会临时恢复。


此时,探险之旅还没结束。毕竟,这个“幽灵宝藏”周围依旧笼罩着一层迷雾。


我们继续用 fatrace(监控谁在读写哪个 IO)监控线上各个候选人答题目录的 IO读写情况,好家伙,我们发现了一个意外的情况:几乎每一秒都有一次全量的文件 stats 操作 (这是一个检测文件是否有属性变化的 IO 操作)!


也就是说,比如有 1000 个候选人正在各自的 IDE 中编码,每个候选人平均有 300 个文件,就会出现每秒 30 万的 IO 操作数!


我们赶紧去查资料,根据研究数据显示,一个普通的 SSD 盘的 IOPS 最高也就到 2-3 万 。于是,我们重新测试了自己分布式文件系统的 IOPS 能力,结果发现也是 2-3 万 。


那这肯定远远达不到我们理想中的能力级别。


这时,问题更加明确:某种未知的原因导致了大量的 IOPS 的需求,引发了 IO 读写时延变长,慢了大约几十倍


06 接近尾声


我和我的团队继续深究下去,问题已经变得非常明确了:


原来,早在去年 12 月,我们上线一个监听文件增删的变化来通知各端刷新的功能。


最开始我们采用事件监听 (fswatch event),因为跨了主机,所以存在 1-2s 的延迟。研发同学将其改为轮询实现的方案,进而引发了每秒扫描目录的 stats 行为。


当在百人以下访问时,IOPS 没有破万,还足够应对。但一旦访问量上千,便会引发 IO 变慢,进而导致系统出现各种异常:间歇导致某些关键接口 QPS 变低,进而引发系统抖动


随着“幽灵宝藏”显露真身,这次分子级 Bug 的探险之旅也已经接近尾声。团队大
呼:这过程实在太酸爽了!


07 技术无止境


每一个程序员在成长路上,都需要与 Bug 作充足的对抗,要么你勇于探索,深入代码的丛林,快速定位,挖到越来越丰富的“宝藏”,然后尽情汲取到顶级的知识,最终成为高手;或者被它打趴下, 花费大量时间都找不到问题的根源,成为芸芸众生中的一人。


当然,程序员的世界中,不单单是 Debug。


当我毕业 5 年之后,开始意识到技术的真正价值是解决真正的社会问题。前文中我提到,由于我发现技术招聘真是一个极其痛苦的事:特别花面试官的时间,却又无法有效分析出候选人的技术能力,所以创立 ShowMeBug 来解决这个问题:用模拟实战的编程环境,解决科学评估人才的难度


这个轻协同 IDE 技术从零开发,支持协同文件树、完全自定义的文件编辑器、协同的控制台 (Console) 与终端 (Shell),甚至直接支持 Ctrl+P 的文件树搜索,不仅易于使用,又强大有力。


但是这还不够。要知道,追求技术精进是我们技术人的毕生追求。对于这个轻协同IDE,我们追求三个零:零配置、零启动、零延迟。其中,零启动就是本文所追求的极限:以最快的速度启动环境和切换环境


因此,探险之旅结束后,我们进一步改进了此文件系统,设定 raid 的多磁盘冗余,采用高性能 SSD,同时重新制定了新磁盘架构参数,优化相关代码,最终大幅提升了分布式文件系统的稳定性与并发能力。


截止本文结尾,我们启动环境的平均速度为 1.3 秒,切换环境速度进入到亚秒级,仅需要 780ms。目前在全球范围的技术能力评估赛道 (TSA) 中,具备 1-2 年的领先性


08 后记


正当我打算结束本文时,我们内部的产品吐槽群信息闪烁,点开一看:嚯,我们又发现了新 Bug。


立夏已至,我们的探险之旅又即将开始。


作者:ShowMeBug技术团队
来源:juejin.cn/post/7231429790615240764
收起阅读 »

编程中最难的就是命名?这几招教你快速上手

作者:陈立(勤仁) 你可不能像给狗狗取名字那样给类、方法、变量命名。仅仅因为它很可爱或者听上去不错。 在写代码的时候,你要经常想着,那个最终维护你代码的人可能将是一个有暴力倾向的疯子,并且他还知道你住在哪里。 01 为什么命名很重要? 在项目中,从项目的创建到...
继续阅读 »

作者:陈立(勤仁)


你可不能像给狗狗取名字那样给类、方法、变量命名。仅仅因为它很可爱或者听上去不错。


在写代码的时候,你要经常想着,那个最终维护你代码的人可能将是一个有暴力倾向的疯子,并且他还知道你住在哪里。


01 为什么命名很重要?


在项目中,从项目的创建到方法的实现,每一步都以命名为起点,我们需要给变量、方法、参数、类命名,这些名字出现在代码的每个角落,随处可见,混乱或错误的命名不仅让我们对代码难以理解,更糟糕的是,会误导我们的思维,导致对代码的理解完全错误。如果整个项目始终贯穿着好的命名,就能给阅读者一个神清气爽的开始,也能给阅读者一个好的指引。


要知道,代码的阅读次数远远多于编写的次数。请确保你所取的名字更侧重于阅读方便而不是编写方便。


02 为什么很难正确命名?


有人称编程中最难的事情就是命名。我同样深以为然,中国有句古话叫做万事开头难。抛开环境搭建,真正到了编码阶段第一件事就是命名,而最常见的一种情况,就是毫无目的、仅凭个人的喜好的去决定了一个名字。但因为没有想清楚目标和具体实施步骤,所以进行过程中往往会面临无数次的小重构甚至是推倒重来。


1、缺乏意愿


害怕在选择名字上花时间,对做好命名的意愿不足,随心所欲,甚至无视团队对命名的基本规范,觉得编译器能编译通过,代码能正常运行就成。


其实对发现的命名问题进行重构和推倒重来并不可怕,最可怕的是当下程序员不具备发现问题后肯回过头来纠偏的意愿。这终将演变成为一场灾难。


2、缺乏思考


没想清楚被命名的事物是什么,事物应该承担什么职责,是否会对其他人造成误解。


新手程序员总会花很多时间学习一门编程语言、代码语法、技术和工具。他们觉得如果掌握了这些东西,就能成为一个好程序员。然而事实并不是这样,事实上,编程不仅仅关乎掌握技能和工具,更重要的是在特定范畴内解决问题的能力,还有和其他程序员合作的能力。因此,能在代码中准确的表达自己的想法就变得异常重要,代码中最直观的表达方式是命名,其次是注释。


3、缺乏技巧


选一个好的名字真很难,你可能得有较高的描述能力和共同的文化背景。并且知晓一些常见且应该避免的命名问题。


如果最终还是没法找到合适的名字,还请添加准确的注释辅助他人理解,等想到合适的名字后再进行替换,不过往往能够通过注释(母语)描述清楚的事物,命名应该问题不大,问题大的是连注释都无法准确表达,那说明可能当前类、函数、变量承担的职责太多太杂。


03 如何正确的命名?


这里不讨论具体语言的命名规则,原因是不同编程语言命名规则各不相同,甚至不同团队间相同语言的命名规则也有出入。这里主要从提高可读性出发,结合我所在的客户端团队日常开发情况,以Java作为演示语言,给一些关于命名的建议。


1、名副其实


无论是变量、方法、或者类,在看到他名称的时候应该以及答复了所有的大问题,它应该告诉你,它为什么会存在,他做什么事,应该怎么做。如果在看到名称时,还需要去查找注释来确认自己的理解,那就不算名副其实。而且在发现有更好的命名时,记得果断替换。


Case1:到底怎样算End?

代码示例:


public interface OnRequestListener {
/**
* 请求结束 只有成功点才认为是真正的结束
* @param ...
*/

void onRequestEnd(....);
/**
* 请求开始
* @param ...
*/

void onRequestStart(...);
}

大脑活动:


onRequestEnd是请求的什么阶段?请求成功和失败任一情况都算 “end”吗?喔,原来注释有写:“只有成功点才认为是真正的结束”。


修改建议:


// 省略注释
public interface OnRequestListener {
void onStart(....);
void onSuccess(....);
void onFailure(...);
}

2、避免误导


在每种语言中都有内置的标识符,他们都有特定的含义,如果和他们没有关联就不要在命名中加上他们。


2.1 避免使用令人误解的名字


Case1:命错名的集合

代码示例:


private List<SectionModel> dataSet;

大脑活动:


“dataSet” 在最初一定是为了元素去重选择了Set类型,肯定后来某一个历史时刻发现有bug被偷偷改成了List类型,但是变量名没变。


代码跟读:


跟踪提交记录,呃,在18年被刚定义时就是 List<***> dataSet;


修改建议:


private List<SectionModel> dataList;
或者
private List<SectionModel> sections;

Case2:不是View的View类

代码示例:


/** 作者+日期 */
public class RItemOverlayView {
}

/** 作者+日期 */
public class NRItemOverlayView {
}

大脑活动:


“N”是啥意思?类名只有一个N的字母差别,难道是新旧的差别,新的和旧的有什么区别呢?


类名以View结尾,嗯,应该是一个视图,可是,视图为啥不用继承视图基类的?


代码跟读:


喔,N确实代表“New”的意思,NRItemOverlayView被首页推荐使用,RItemOverlayView被购后推荐使用。


这个类主要核心工作是构建浮层视图(职责并不单一),而类本身并不是一个真正的视图;


修改建议:


// 放在首页推荐场景的包下
public class ItemOverlayViewCreator {
}

// 放在购后推荐场景的包下
public class ItemOverlayViewCreator {
}

Case3:整形变量为啥要用is开头

代码示例:


private int isFirstEnter = 0;

大脑活动:


为什么“is”开头的变量却声明成整形?到底是要计数还是判断真假呢?


代码跟读:


isFirstEnter < 1 做第一次进入的逻辑


修改建议:


private boolean isFirstEnter = true;

Case4:开关作用反掉啦

代码示例:


....
if (InfoFlowOrangeConfig.getBooleanValue(POST_DELAYED_HIDE_COVER_VIEW_ENABLE, true)) {
hideCoverImageView();
} else {
delayedHideCoverImageView();
}

大脑活动:


为什么开关名为“delay....”为“true”的时候,走的不是delay逻辑,那开关要怎么发?容我多看几遍,是不是最近没休息好所以看岔了。


代码跟读:


反复看了几遍,确实是开关命名和实际操作完全相反,开关名意为“延迟隐藏封面视图”,执行的却是“立即隐藏封面视图”。


修改建议:


....
if (InfoFlowOrangeConfig.getBooleanValue(IMMEDIATELY_HIDE_COVER_VIEW_ENABLE, true)) {

hideCoverImageView();
} else {
delayedHideCoverImageView();
}

3、做有意义的区分


如果单纯只是为了区分两个名称不能一样,就使用就使用诸如数字,字母来做区分的话,那似乎是毫无意义的区分。


3.1 避免在名字中使用数字


case1: 来自包名的暴击

问题示例:


以下是首页客户端的工程目录节选,数字化的包名:recommend、recommend2、recommend3、recommend4


image.png


大脑活动:


2、3、4难道是因为首页历史包袱太沉重,推荐迭代的版本实在太多导致Old、New单词不够用所以用数字来代替新旧4个历史阶段的版本吗?


代码跟读:



  • recommend:推荐的公共工具和模块;

  • recommend2:收藏夹场景的推荐实现;

  • recommend3:首页场景的推荐实现;

  • recommend4:购后场景的推荐实现;


修改建议:


这里暂时只讨论如何把数字替换成有意义的命名


image.png


3.2 避免使用具有相似含义的名字


case1:同一个类下的“刷新7剑客”

代码示例:


image.png


大脑活动:


为什么一个Adapter类对外有七个刷新数据的接口?


"refreshData()" 和 “speedRefreshData()” 是什么区别?“mainRefreshData()” + "refreshDeltaData()" =“mainRefreshDeltaData()” ?


是一个拆分组合的关系吗?我应该在何总场景下如何正确的使用refresh,我在哪,我在做什么?


代码跟读:


大部分refresh代码线上并不会被调用。阅读和调试下来,实际还在生效的方法只有一个:“gatewayRefreshData()”。


修改建议:实际上这已经不是一个单纯优化命名可以解决的问题,无论叫的多具体,面对7个刷新接口都会懵圈。期望在方法声明期间,作者多体量后来的阅读者和维护者,及时的调整代码。


后来者可以从实际出发去假存真,做减法干掉其它无用的6个刷新方法保留一个刷新接口。


case2:4个数据源定义,该用谁呢

代码示例:


声明1:


public interface IR4UDataSource { 
....
}

声明2:


public interface RecommendIDataSource {
....
}

声明3:


public interface IRecommendDataResource {
....
}

声明4:


public class RecmdDataSource {
....
}

大脑活动:


4个推荐数据源,其中有3个是接口声明,为什么接口定义了不能多态,不能复用接口的声明?这三代的抽象好像有一丢丢失败。


代码跟读:


homepage 包下的 IR4UDataSource,和非常古老的首页曾经爱过,线上实际不会使用;


Recommend2 包下的“RecommendIDataSource” 属于收藏夹,但也属于古老版本,收藏夹不在使用;


Recommend3 包下的“IRecommendDataResource” 确实是首页场景推荐使用,但也是曾经的旧爱;


原来当今的真命天子是Recommend3包下的“RecmdDataSource”,一个使用俏皮缩写未继承接口的实体类,看来是已经放弃伪装。


修改建议:


......


3.3 避免使用具有不同含义但却有相似名字的变量


case1 : 大家都是view,到底谁是谁

代码示例:


public void showOverlay(@NonNull View view ...) {
...
View rootView = getRootView(view);
DxOverlayViewWidget dView = createDxOverlayViewWidget();
dView.showOverLayer(view.getContext(), (ViewGroup)rootView, cardData, itemData);


...
}

代码跟读:


代码中存在3个以view结尾的局部变量,rootView、view 、 dView,其中 view 和 dView 之间只有一个字母的差异,方法如果长一点,view 和 dView 使用频率在高一点,掺杂着rootView会让人抓狂。另外dView也并不是一个view,实际是个DXViewWidget。


修改建议:


public void showOverlay(@NonNull View hostView ...) {
...
ViewGroup parentView = getParentView(hostView);
DxOverlayViewWidget dxOverlayViewWidget = createDxOverlayViewWidget();
dxOverlayViewWidget.showOverLayer(hostView.getContext(), parentView, ...);
...
}

4.使用读的出来的名称


使用读的出来的名称,而不是自造词,这会给你无论是记忆,还是讨论需要说明是哪个方法时,都能带来便利。可以使用达成共识的缩写,避免造成阅读障碍。


4.1 避免使用令人费解的缩写


Case1:接口定义中的俏皮缩写

代码示例:


/**
* Created by *** on 16/8/6.
*/
public interface IR4UDataSource {
....
}

大脑活动:


R4U是什么?R4和Recommend4这个目录有什么关系,难道是购后推荐的数据源定义吗?那U又代表什么?


代码跟读:


原来R4U是Recommend For You的俏皮写法


修改建议:


public interface IRecommendForYouDataSource {
....
}

Case2:成员变量命名的缩写

代码示例:


....
// 标题指示器(indicators)
private LinearLayout mTabLL;
private TabLayout mTabLayout;
....

大脑活动:


“mTabLL”是什么呢?有注释!难道mTabLL是指示器视图?“LL“”也不像是indicators的缩写,喔,LL是LinearLayout的首字母缩写。嗯,使用LinearLayout自定义做成指示器有点厉害!诶,不对,好像TabLayout更像是个选项卡式指示器的样子。


代码跟读:


原来“mTabLL” 下面声明的 “mTabLayout”才是指示器视图,“mTabLL”只是指示器视图的父视图。还好“mTabLayout”没有缩写成“mTabL”,导致和“mTabLL”傻傻分不清,作者已然是手下留情了。


修改建议:


....
private LinearLayout mTabLayoutParentView;
private TabLayout mTabLayout;
....

Case3:局部变量命名的缩写

代码示例:


....
for (PageParams.GroupBuckets ss:params.groupBucketIds.values()) {

if (ss != null) {
bucketIds.removeAll(ss.bucketIdsAll);
Collections.addAll(bucketIds, ss.currentBucketIds);
}
}
....

大脑活动:


"ss"是什么鬼,是不是写错了,GroupBuckets首字母缩写是“gb”,PageParams和GroupBuckets 的首字母缩写是“pg”


这难道是,PageParams 和 GroupBuckets 的尾字母缩写,在一个圈复杂度为18的方法中看到尾字母缩写“ss”?啊!好难受。


修改建议:


for (PageParams.GroupBuckets groupBuckets :params.groupBucketIds.values()) {
if (groupBuckets != null) {
....
}
}

5、使用可搜索的名称


若变量或常量可能在代码中多处使用,则应赋其以便于搜索的名称。


5.1 给魔法值赐名


Case1:数字魔法值没法搜索也看不懂

代码示例:


public static void updateImmersiveStatusBar(Context context) {
....
if (TextUtils.equals(isFestivalOn, "1")) {
if (TextUtils.equals(navStyle, "0") || TextUtils.equals(navStyle, "1")) {
....
} else if (TextUtils.equals(navStyle, "2")) {
....
}
}
....
}

大脑活动:


对于TextUtils.equals(isFestivalOn, "1") ,我还能猜测一下这里的“1” 代表开关为开的意思。


那TextUtils.equals(navStyle, "0"/"1"/"2") 中的“0”,“1”,“2” 我该如何知道代表什么意思?


老板,请不要再问我为什么需求吞吐率不高,做需求慢了,可能是因为我的想象力不够。


修改建议:


实际上,协议约定时就不应该以 “0”,“1”,“2” 这类无意义的数字做区分声明。


public static final String FESTIVAL_ON = "1";
public static final String NAV_STYLE_FESTIVAL = "0";
public static final String NAV_STYLE_SKIN = "1";
public static final String NAV_STYLE_DARK = "2";

public static void updateImmersiveStatusBar(Context context) {
....
if (TextUtils.equals(isFestivalOn, FESTIVAL_ON)) {
if (TextUtils.equals(navStyle, NAV_STYLE_FESTIVAL)
|| TextUtils.equals(navStyle, NAV_STYLE_SKIN)) {
....
} else if (TextUtils.equals(navStyle, NAV_STYLE_DARK)) {
....
}
}
....
}

5.2 避免在名字中拼错单词


Case1:接口拼错单词,实现类也被迫保持队形

代码示例:


public interface xxx {
....
void destory();
}

image.png


修改建议:


public interface xxx {
....
void destroy();
}

6、类的命名


应该总是名词在最后面,名词决定了这个类代表什么,前面的部分都是用于修饰这个名词;比如,假如现在你有一个服务,然后又是一 个关于订单的服务,那就可以命名为OrderService,这样命名就是告诉我们这是一个服务,然后是一个订单服务;再比如 CancelOrderCommand,看到这个我们就知道这是一个Command,即命令,然后是什么命令呢?就是一个取消订单的命令,CancelOrder表示取消订单。


类的命名可以参考前面讲述过的规则。实际上往往了解一个类更多需要通过查看类的方法定义,而仅仅通过类名无法知晓类是如何工作的。关于类的更多内容,会在后续章节详细展开。


7、方法的命名


可以用一个较强的动词带目标的形式。一个方法往往是对某一目标进行操作,名字应该反映出这个操作过程是干什么的,而对某一目标进行操作则意味着我们应该使用动宾词组。比如:addOrder()。当方法有返回值的时候,方法应该用它所返回的值命名,比如currentPenColor()。


《代码大全》:变量名称的最佳长度是 9 到 15 个字母,方法往往比变量要复杂,因而其名字也要长些。有学者认为恰当的长度是 20 到 35 个字母。但是,一般来说 15 到 20 个字母可能更现实一些,不过有些名称可能有时要比它长。


7.1 避免对方法使用无意义或者模棱两可的动词


避免无意义或者模棱两可的动词 。有些动词很灵活,可以有任何意义,比如 HandleCalculation(),processInput()等方法并没有告诉你它是作什么的。这些名字最多告诉你,它们正在进行一些与计算或输入等有关的处理。


所用的动词意义模糊是由于方法本身要做的工作太模糊。方法存在着功能不清的缺陷,其名字模糊只不过是个标志而已。如果是这种情况,最好的解决办法是重新构造这个方法,弄清它们的功能,从而使它们有一个清楚的、精确描述其功能的名字。


Case1: 名不副实的process

代码示例:


/**
* 处理主图的数据
*
* @return 如果有浮层数据就返回true,没有就返回false
*/

private boolean processMainPic() {
....
boolean hasMainPicFloat = false;
....
return hasMainPicFloat;
}

// 调用处
boolean hasMainPicFloat = processMainPic();

大脑活动:


1、方法名的字面意思是处理主图(暂不纠结缩写Pic了),但是是如何处理主图的呢?


2、返回值是bool类型,是表示处理成功或失败吗?


3、查看注释解释,当前方法是在处理主图的数据,返回为是否存在浮层数据,为什么一个处理主图数据的方法检查的是浮层数据呢?


看完发现,这个方法原来是拿主图数据检查其中是否存在浮层数据,名不副实呀。


修改建议:


额外说明:既然工程默认“Float”是浮层,这里不做额外修改,但实际上不合理,毕竟Float在Java中表示浮点型数据类型,会引起误解。


/**
* 是否有浮层数据
*
* @return 如果有浮层数据就返回true,没有就返回false
*/

private boolean hasFloatData($MainPictureData) {
....
boolean hasFloatData = false;
....
return hasFloatData;
}

// 调用处
boolean hasFloatData = hasFloatData(mainPictureData);

Case2: 我该如何正确使用这个方法

代码示例:


// 10多处调用
... = GatewayUtils.processTime(System.currentTimeMillis());

public class GatewayUtils {
....
// 这个方法没有注释
public static long processTime(long time) {
return time + (SDKUtils.getTimeOffset() * 1000L);
}
....
}

大脑活动:


好多地方调用工具类的processTime,processTime到底是在处理些什么呢?


如果入参传入的不是 System.currentTimeMillis() 而是 SystemClock.uptimeMillis() 或者随意传入一个long值,方法的返回值会是什么呢?


修改建议:


public static long currentNetworkTime() {
return System.currentTimeMillis() + (SDKUtils.getTimeOffset() * 1000L);
}

7.2 避免返回和方法名定义不一致的类型


Case1: 私有方法就可以乱定义吗?

码示例:


// 唯一调用处
final IPageProvider pageProvider = checkActivityAvaliable();
if (pageProvider == null) {
....
return;
}

// 函数声明
private IPageProvider checkActivityAvaliable() {
IPageProvider pageProvider = pageProviderWeakReference.get();
if (pageProvider == null) {
PopFactory.destroyPopCenter(pageName);
return null;
}
return pageProvider;
}

大脑活动:


check方法如果有返回值的话不应该是bool类型吗?


“Avaliable”拼错了诶,正确的单词拼写是:“Available”。


“IPageProvider” 和 “ActivityAvaliable” 是什么关系,为什么校验可用的Activity返回的是“IPageProvider”。


代码跟读:


原来方法里面偷偷做了一个销毁“PopCenter”的动作。把获取“PageProvider”和销毁“PopCenter”两件事情放在了一起。确实没看懂方法名和方法所做任何一件事情有什么关系。


修改建议:


干掉checkActivityAvaliable()方法。(这里不展开讨论高质量的函数相关内容)


final IPageProvider pageProvider = pageProviderWeakReference.get();
if (pageProvider == null) {
PopFactory.destroyPopCenter(pageName);
....
return;
}

04 养成良好的命名习惯一些建议


1.对自己严格自律,自己写代码时要有一种希望把每个名称都命名好的强烈意识和严格的自律意识;


2.要努力分析和思考当前被你命名的事物或逻辑的本质;这点非常关键,思考不深入,就会导致最后对这个事物的命名错误,因为你还没想清楚被你命名的事物是个什么东西;


3.你的任何一个属性的名字都要和其实际所代表的含义一致;你的任何一个方法所做的事情都要和该方法的名字的含义一致;


4.要让你的程序的每个相似的地方的命名风格总是一致的。不要一会儿大写,一会儿小写;一会儿全称一会儿简写;一会儿帕斯卡(Pascal)命名法,一会儿骆驼(Camel)命名法或匈牙利命名法;


作者:阿里云云原生
来源:juejin.cn/post/7225524569506005053
收起阅读 »

使用双token实现无感刷新,前后端详细代码

前言 近期写的一个项目使用双token实现无感刷新。最后做了一些总结,本文详细介绍了实现流程,前后端详细代码。前端使用了Vue3+Vite,主要是axios封装,服务端使用了koa2做了一个简单的服务器模拟。 一、token 登录鉴权 jwt:JSON Web...
继续阅读 »

微信图片_2022090618343531.jpg


前言


近期写的一个项目使用双token实现无感刷新。最后做了一些总结,本文详细介绍了实现流程,前后端详细代码。前端使用了Vue3+Vite,主要是axios封装,服务端使用了koa2做了一个简单的服务器模拟。


一、token 登录鉴权


jwt:JSON Web Token。是一种认证协议,一般用来校验请求的身份信息和身份权限。
由三部分组成:Header、Hayload、Signature


header:也就是头部信息,是描述这个 token 的基本信息,json 格式


{
"alg": "HS256", // 表示签名的算法,默认是 HMAC SHA256(写成 HS256)
"type": "JWT" // 表示Token的类型,JWT 令牌统一写为JWT
}

payload:载荷,也是一个 JSON 对象,用来存放实际需要传递的数据。不建议存放敏感信息,比如密码。


{
"iss": "a.com", // 签发人
"exp": "1d", // expiration time 过期时间
"sub": "test", // 主题
"aud": "", // 受众
"nbf": "", // Not Before 生效时间
"iat": "", // Issued At 签发时间
"jti": "", // JWT ID 编号
// 可以定义私有字段
"name": "",
"admin": ""
}

Signature 签名 是对前两部分的签名,防止数据被篡改。
需要指定一个密钥。这个密钥只有服务器才知道,不能泄露。使用 Header 里面指定的签名算法,按照公式产生签名。


算出签名后,把 Header、Payload、Signature 三个部分拼成的一个字符串,每个部分之间用 . 分隔。这样就生成了一个 token


二、何为双 token



  • accessToken:用户获取数据权限

  • refreshToken:用来获取新的accessToken


双 token 验证机制,其中 accessToken 过期时间较短,refreshToken 过期时间较长。当 accessToken 过期后,使用 refreshToken 去请求新的 token。


双 token 验证流程



  1. 用户登录向服务端发送账号密码,登录失败返回客户端重新登录。登录成功服务端生成 accessToken 和 refreshToken,返回生成的 token 给客户端。

  2. 在请求拦截器中,请求头中携带 accessToken 请求数据,服务端验证 accessToken 是否过期。token 有效继续请求数据,token 失效返回失效信息到客户端。

  3. 客户端收到服务端发送的请求信息,在二次封装的 axios 的响应拦截器中判断是否有 accessToken 失效的信息,没有返回响应的数据。有失效的信息,就携带 refreshToken 请求新的 accessToken。

  4. 服务端验证 refreshToken 是否有效。有效,重新生成 token, 返回新的 token 和提示信息到客户端,无效,返回无效信息给客户端。

  5. 客户端响应拦截器判断响应信息是否有 refreshToken 有效无效。无效,退出当前登录。有效,重新存储新的 token,继续请求上一次请求的数据。


注意事项



  1. 短token失效,服务端拒绝请求,返回token失效信息,前端请求到新的短token如何再次请求数据,达到无感刷新的效果。

  2. 服务端白名单,成功登录前是还没有请求到token的,那么如果服务端拦截请求,就无法登录。定制白名单,让登录无需进行token验证。


三、服务端代码


1. 搭建koa2服务器


全局安装koa脚手架


npm install koa-generator -g

创建服务端 直接koa2+项目名


koa2 server

cd server 进入到项目安装jwt


npm i jsonwebtoken

为了方便直接在服务端使用koa-cors 跨域


npm i koa-cors

在app.js中引入应用cors


const cors=require('koa-cors')
...
app.use(cors())

2. 双token


新建utils/token.js


const jwt=require('jsonwebtoken')

const secret='2023F_Ycb/wp_sd' // 密钥
/*
expiresIn:5 过期时间,时间单位是秒
也可以这么写 expiresIn:1d 代表一天
1h 代表一小时
*/

// 本次是为了测试,所以设置时间 短token5秒 长token15秒
const accessTokenTime=5
const refreshTokenTime=15

// 生成accessToken
const accessToken=(payload={})=>{ // payload 携带用户信息
return jwt.sign(payload,secret,{expireIn:accessTokenTime})
}
//生成refreshToken
const refreshToken=(payload={})=>{
return jwt.sign(payload,secret,{expireIn:refreshTokenTime})
}

module.exports={
secret,
setAccessToken,
setRefreshToken
}

3. 路由


直接使用脚手架创建的项目已经在app.js使用了路由中间件
在router/index.js 创建接口


const router = require('koa-router')()
const jwt = require('jsonwebtoken')
const { accesstoken, refreshtoken, secret }=require('../utils/token')

/*登录接口*/
router.get('/login',()=>{
let code,msg,data=null
code=2000
msg='登录成功,获取到token'
data={
accessToken:accessToken(),
refreshToken:refreshToken()
}
ctx.body={
code,
msg,
data
}
})

/*用于测试的获取数据接口*/
router.get('/getTestData',(ctx)=>{
let code,msg,data=null
code=2000
msg='获取数据成功'
ctx.body={
code,
msg,
data
}
})

/*验证长token是否有效,刷新短token
这里要注意,在刷新短token的时候回也返回新的长token,延续长token,
这样活跃用户在持续操作过程中不会被迫退出登录。长时间无操作的非活
跃用户长token过期重新登录
*/

router.get('/refresh',(ctx)=>{
let code,msg,data=null
//获取请求头中携带的长token
let r_tk=ctx.request.headers['pass']
//解析token 参数 token 密钥 回调函数返回信息
jwt.verify(r_tk,secret,(error)=>{
if(error){
code=4006,
msg='长token无效,请重新登录'
} else{
code=2000,
msg='长token有效,返回新的token'
data={
accessToken:accessToken(),
refreshToken:refreshToken()
}
}
})
})


4. 应用中间件


utils/auth.js


const { secret } = require('./token')
const jwt = require('jsonwebtoken')

/*白名单,登录、刷新短token不受限制,也就不用token验证*/
const whiteList=['/login','/refresh']
const isWhiteList=(url,whiteList)=>{
return whiteList.find(item => item === url) ? true : false
}

/*中间件
验证短token是否有效
*/

const cuth = async (ctx,next)=>{
let code, msg, data = null
let url = ctx.path
if(isWhiteList(url,whiteList)){
// 执行下一步
return await next()
} else {
// 获取请求头携带的短token
const a_tk=ctx.request.headers['authorization']
if(!a_tk){
code=4003
msg='accessToken无效,无权限'
ctx.body={
code,
msg,
data
}
} else{
// 解析token
await jwt.verify(a_tk,secret.(error)=>{
if(error)=>{
code=4003
msg='accessToken无效,无权限'
ctx.body={
code,
msg,
datta
}
} else {
// token有效
return await next()
}
})
}
}
}
module.exports=auth

在app.js中引入应用中间件


const auth=requier(./utils/auth)
···
app.use(auth)

其实如果只是做一个简单的双token验证,很多中间件是没必要的,比如解析静态资源。不过为了节省时间,方便就直接使用了koa2脚手架。


最终目录结构:


双token服务端目录结构.png


四、前端代码


1. Vue3+Vite框架


前端使用了Vue3+Vite的框架,看个人使用习惯。


npm init vite@latest client_side

安装axios


npm i axios

2. 定义使用到的常量


config/constants.js


export const ACCESS_TOKEN = 'a_tk' // 短token字段
export const REFRESH_TOKEN = 'r_tk' // 短token字段
export const AUTH = 'Authorization' // header头部 携带短token
export const PASS = 'pass' // header头部 携带长token

3. 存储、调用过期请求


关键点:把携带过期token的请求,利用Promise存在数组中,保持pending状态,也就是不调用resolve()。当获取到新的token,再重新请求。
utils/refresh.js


export {REFRESH_TOKEN,PASS} from '../config/constants.js'
import { getRefreshToken, removeRefreshToken, setAccessToken, setRefreshToken} from '../config/storage'

let subsequent=[]
let flag=false // 设置开关,保证一次只能请求一次短token,防止客户多此操作,多次请求

/*把过期请求添加在数组中*/
export const addRequest = (request) => {
subscribes.push(request)
}

/*调用过期请求*/
export const retryRequest = () => {
console.log('重新请求上次中断的数据');
subscribes.forEach(request => request())
subscribes = []
}

/*短token过期,携带token去重新请求token*/
export const refreshToken=()=>{
if(!flag){
flag = true;
let r_tk = getRefershToken() // 获取长token
if(r_tk){
server.get('/refresh',Object.assign({},{
headers:{[PASS]=r_tk}
})).then((res)=>{
//长token失效,退出登录
if(res.code===4006){
flag = false
removeRefershToken(REFRESH_TOKEN)
} else if(res.code===2000){
// 存储新的token
setAccessToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
flag = false
// 重新请求数据
retryRequest()
}
})
}
}
}

4. 封装axios


utlis/server.js


import axios from "axios";
import * as storage from "../config/storage"
import * as constants from '../config/constants'
import { addRequest, refreshToken } from "./refresh";

const server = axios.create({
baseURL: 'http://localhost:3004', // 你的服务器
timeout: 1000 * 10,
headers: {
"Content-type": "application/json"
}
})

/*请求拦截器*/
server.interceptors.request.use(config => {
// 获取短token,携带到请求头,服务端校验
let aToken = storage.getAccessToken(constants.ACCESS_TOKEN)
config.headers[constants.AUTH] = aToken
return config
})

/*响应拦截器*/
server.interceptors.response.use(
async response => {
// 获取到配置和后端响应的数据
let { config, data } = response
console.log('响应提示信息:', data.msg);
return new Promise((resolve, reject) => {
// 短token失效
if (data.code === 4003) {
// 移除失效的短token
storage.removeAccessToken(constants.ACCESS_TOKEN)
// 把过期请求存储起来,用于请求到新的短token,再次请求,达到无感刷新
addRequest(() => resolve(server(config)))
// 携带长token去请求新的token
refreshToken()
} else {
// 有效返回相应的数据
resolve(data)
}

})

},
error => {
return Promise.reject(error)
}
)

5. 复用封装


import * as constants from "./constants"

// 存储短token
export const setAccessToken = (token) => localStorage.setItem(constanst.ACCESS_TOKEN, token)
// 存储长token
export const setRefershToken = (token) => localStorage.setItem(constants.REFRESH_TOKEN, token)
// 获取短token
export const getAccessToken = () => localStorage.getItem(constants.ACCESS_TOKEN)
// 获取长token
export const getRefershToken = () => localStorage.getItem(constants.REFRESH_TOKEN)
// 删除短token
export const removeAccessToken = () => localStorage.removeItem(constants.ACCESS_TOKEN)
// 删除长token
export const removeRefershToken = () => localStorage.removeItem(constants.REFRESH_TOKEN)

6. 接口封装


apis/index.js


import server from "../utils/server";
/*登录*/
export const login = () => {
return server({
url: '/login',
method: 'get'
})
}
/*请求数据*/
export const getData = () => {
return server({
url: '/getList',
method: 'get'
})
}

项目运行


双token前端.png


最后的最后,运行项目,查看效果
后端设置的短token5秒,长token10秒。登录请求到token后,请求数据可以正常请求,五秒后再次请求,短token失效,这时长token有效,请求到新的token,refresh接口只调用了一次。长token也过期后,就需要重新登录啦。
效果.png


写在最后


这就是一整套的前后端使用双token机制实现无感刷新。token能做到的还有很多,比如权限管理、同一账号异地登录。本文只是浅显的应用了一下。


下次空余时间写写大文件切片上传,写文不易,大家多多点赞。感谢各位看官老爷。


作者:夜琛白
来源:juejin.cn/post/7224764099187736634
收起阅读 »

如何按百分比将功能灰度放量

当我们发布新功能时,需要尽可能降低因新功能发布所导致的线上风险,通常会采取灰度放量的方式将新功能逐步发布给用户。在具体实施灰度放量时,我们可以根据业务需求选择相应的放量规则,常见如按白名单放量(如仅 QA 可见)、按特定人群属性放量(如仅某个城市的用户可见)亦...
继续阅读 »

当我们发布新功能时,需要尽可能降低因新功能发布所导致的线上风险,通常会采取灰度放量的方式将新功能逐步发布给用户。在具体实施灰度放量时,我们可以根据业务需求选择相应的放量规则,常见如按白名单放量(如仅 QA 可见)、按特定人群属性放量(如仅某个城市的用户可见)亦或是按用户百分比放量。


当我们选择将功能以用户百分比放量时,如下图所示,会先将功发布给10% 内部用户,此时即便出现问题影响也相对可控,如观察没有问题后逐步扩大需要放量的用户百分比,实现从少量到全量平滑过渡的上线。



那么在 FeatureProbe 上要如何实现百分比放量?


下面将通过一个实际的例子介绍如何通过 FeatureProbe 实现按百分比放量发布一个新功能。


步骤一:创建一个特性开关



接着,配置开关百分比信息。以收藏功能百分比发布为例,设置 10%  的用户可用收藏功能,而另外 90% 的用户无法使用收藏功能。



步骤二:将 SDK 接入应用程序


接下来,将 FeatureProbe SDK 接入应用程序。FeatureProbe 提供完整清晰的接入引导,只需按照步骤即可快速完成 SDK 接入。


1、选择所使用的 SDK



2、按步骤设置应用程序



3、测试应用程序 SDK接入情况



步骤三:按百分比放量发布开关


开关信息配置和 SDK 接入都完成后,点击发布按钮并确认发布。这将会将收藏功能发布给用户,但只有10%的用户可以使用收藏功能。



如果希望逐步扩大灰度范围,可以在开关规则中配置百分比比例。



大部分情况下,我们希望在一个功能的灰度放量过程中,某个特定用户一旦进入了灰度放量组,在灰度比例不减少的情况下,总是进入灰度组。不希望用户因为刷新页面、重新打开APP、请求被分配到另一个服务端实例等原因,一会看到新功能,一会看不到新功能,从而感到迷惑。要达到用户稳定进入灰度组,只需要在上述代码第三步创建 User 时指定stableRollout 即可,具体使用详情见:docs.featureprobe.io/zh-CN/tutor…


总结


灰度按百分比放量是一种软件开发中常用的功能发布方法,它可以帮助提高软件可靠性,提高用户体验,在实施时也需要注意几个方面:


1、确定放量目标:首先需要确定放量的目标,例如增加多少百分比的数据量。这个目标需要根据实际情况进行制定,例如需要考虑数据量的大小、计算资源的限制等因素。


2、确定放量规则:你需要确定在放量过程中,哪些功能会被启用,哪些功能会被禁用。你可以根据开发进度、测试结果和市场需求等因素来确定放量规则。


3、监控放量过程:在实施放量操作时,需要监控放量过程,以确保放量结果的稳定性和可靠性。如果出现异常情况,需要及时采取措施进行调整。


若要了解有关FeatureProbe 灰度发布的更多信息,请查看其官方文档中的教程。该教程可以提供关于如何进行灰度发布的详细说明。文档中还包括其他相关主题的信息,例如如何进行服务降级和指标分析等。请访问以下链接以查看该文档:docs.featureprobe.io/zh-CN/tutor…


关于我们


FeatureProbe 是国内首家功能开关管理开源平台,它包含了灰度放量、AB实验、实时配置变更等针对『功能粒度』的一系列管理操作,完全开源,可以放心直接使用。


当前 FeatureProbe 作为一个功能开关管理平台已经使用 Apache 2.0 License 协议完全开源,你可以在 GitHub 和 Gitee 上访问源码,你也可以在上面给提 issue 和 feature 等。


GitHub: github.com/FeatureProb…


Gitee: gitee.com/featureprob…


体验环境: featureprobe.io/


作者:FeatureProbe
来源:juejin.cn/post/7224045063424049208
收起阅读 »

ElasticSearch数据存储与搜索基本原理

1.缘起: 为啥想学习es,主要是在工作中会用到,但是因为不了解原理,所以用起来畏手畏脚的,就想了解下es是怎么存储数据,以及es是怎么搜索数据的,我们平时应该如 何使用es,以及使用时候需要注意的方面。 es:github.com/elastic/ela… ...
继续阅读 »

1.缘起:


为啥想学习es,主要是在工作中会用到,但是因为不了解原理,所以用起来畏手畏脚的,就想了解下es是怎么存储数据,以及es是怎么搜索数据的,我们平时应该如 何使用es,以及使用时候需要注意的方面。
es:github.com/elastic/ela…
lucene:github.com/apache/luce…


2.es的一些基础概念


es是一个基于lucence的分布式的搜索引擎,它使用java编写,并提供了一套RESTful api,是一款流行的企业级搜索引擎


2.1 es的特点



  1. 横向可扩展性: 作为大型分布式集群, 很容易就能扩展新的服务器到ES集群中; 也可运行在单机上作为轻量级搜索引擎使用.

  2. 更丰富的功能: 与传统关系型数据库相比, ES提供了全文检索、同义词处理、相关度排名、复杂数据分析、海量数据的近实时处理等功能.

  3. 分片机制提供更好地分布性: 同一个索引被分为多个分片(Shard), 利用分而治之的思想提升处理效率.

  4. 高可用: 提供副本(Replica)机制, 一个分片可以设置多个副本, 即使在某些服务器宕机后, 集群仍能正常工作.

  5. 开箱即用: 提供简单易用的API, 服务的搭建、部署和使用都很容易操作.


2.2 es的重要概念


1.cluster(集群)
可以通过为多个节点配置同一个集群名来创建集群,通过elasticsearch.yml文件配置。
2.node(节点)
运行了单个es实例的主机被成为节点,一个集群里会包含一个或者多个节点。可以用来存储数据,搜索数据,操作数据。有三个主节点,三个数据节点。


3.shard(分片)
一个索引会分成多个分片,并存储在不同的节点中。每个shard都是一个最小工作单元,承载部分数据,对应一个lucene实例,具有完整的建立索引和处理请求的能力。shard分为primary shard和replica shard,其中replica shard 负责容错,以及承担读请求负载。一个document只会存在一个primary shard及其replica shard中,而不会存在于多个primary shard中。shards:5*2,表示有五个primary shard 以及五个replica shard。一旦创建完成,其primary shard的数量将不可更改。


4.index(索引)
一堆类型数据结构相同的document的集合。类似于数据库中的表
5.document( 文档)
es中的最小数据单元。比如一条纠纷单的数据,存在es中就是一个document。但是存储格式为json。类似于数据库中的一行数据。
6.type
类型,一个索引会存在一个或者多个 type,一个type下的document有相同的field。7.x后废弃
7.field
一个field就是一个数据字段
8.term
field的内容在经过analyze后,会被分词为term,是数据中最小的存储单位
9.数据库和es概念类比Elasticsearch 关系型数据库


3.es是如何存储数据的


3.1 es写入数据过程


在这里插入图片描述



  1. 用户发送的请求会随机打到某一node,此时这个node为coordinate node

  2. coordinate node通过路由策略找到对应的主分片 shard = hash(routing) % number_of_primary_shards,其中routing为docId,如果docId不存在,es会生成一个id来实现路由。主分片也会把请求转发到副本分片,实现数据备份。索引的primary shards在索引创建后不可更改也是因为路由策略,举个例子,对于同一个docId,原本存在primary shard 0 中,但是primary shard num修改后,就被路由到primary shard 3,这样就出现数据查不到的情况了。

  3. 主分片&副本分片会构建索引以及将索引落盘


3.2 es近实时特性的原理



  1. 写请求将数据写入buffer中,此时数据是不能被搜索到的,此时会同时将写操作记录在translog中,translog的落盘如果配置成同步,此时就会落盘,如果配置成异步,会在配置间隔时间进行落盘。

  2. 默认1s一次的refresh操作,es会将buffer里的数据存入os cache中,并把buffer中的数据转化成segment,此时document便可以被搜索到了。每次refresh都会生成一个segment,es会定期进行segment合并。refresh数据到os cache后,buffer会被清空。

  3. 每隔30min或者segment达到512M 后,会把os cache中的segment写入到磁盘中,这个过程叫做flush。此时会生成一个commit point文件,用来唯一标识该segment。执行flush操作时,会把buffer和os cache里的数据清空,此时translog也会被落盘。原有的translog会被删除,会在内存中创建一个新的translog。


3.3 segment的数据结构以及索引的原理


segment是lucene的概念,也是实现搜索的关键。名称 扩展名 简要描述
Term Dictionary .tim term词典,存储term信息
Term Index .tip 指向term词典的索引
Frequencies .doc 包含了有term的频率的文档列表
Positions .pos 存储了每个出现在索引中的term的位置信息
Payloads .pay 存储了额外的每一个位置的元数据信息如一个字符偏移和用户的负载
Field Index .fdx 包含field data的指针
Field Data .fdt 存储docs里的field表1 segment文件


其中和倒排索引相关的是.tim、.tip、.doc、.pos、.pay文件,而和正向索引相关的是.fdx、.fdt文件。下面先讲下倒排索引


3.3.1 lucene的倒排索引结构


倒排索引,其实从字面意义上很容易理解错,但是看英文就会好理解一些,inverted index,反向的索引。为什么称之为反向的索引,那应该有正向索引,正向索引指的是文档id和文档内容的映射关系,mysql的主键索引就是一个正向索引,而倒排索引,就是把这种对应关系颠倒,指的是,索引词(关键词)和文档之间的对应关系,即通过一个关键词,可以得到包含这个关键词的所有文档的文档id。
在这里插入图片描述



  1. 通过对查询语句的解析,得到需要查找的term,找到该term对应的.tip文件和.tim文件。lucene会默认为每一个term都创建对应的索引

  2. term index主要由FSTIndex和indexStartFP组成,FST(Finite State Transducer)有限状态转移机。我觉得可以理解为一个词语前缀索引。通过对前缀索引的搜索,就可以缩小搜索的范围,提高搜索的效率。

  3. indexStartFPn里存的是FSTIndexn的地址, 为啥要存indexStartFPn,是因为每个FSTIndexn的大小不一样,为了节省存储空间,密集存储FSTIndexn,但是这样就没办法快速查找FSTIndexn。因此使用空间换时间,存下每个FSTIndexn的起始地址,而indexStartFP的大小都一样,这样就可以通过indexStartFPn进行二分查找了。

  4. 通过term的前缀匹配定位到该term可能存在的block,此时就需要到.tim文件里去查找。可以看到.tim文件我们比较关注的是三部分。一是suffix;二是TermStats;三是TermsMetaData。其中suffix里存放的就是该term的后缀长度和suffix的内容。TermStats里包含的是该Term在文档中的频率以及所有 Term的频率,这部分是为了计算相关性。

  5. 第三部分TermsMetaData里存放的是该Term在.doc、.pos、.pay中的地址。.doc文件中存放的是docId信息,包括这个term所在的docId、频率等信息。.pos文件里包含该term在每个文档里的位置。通过.tim文件里存放的这些地址,就可以去对应的文件里得出该term所在的文档id、位置、频率这些重要的信息了。


3.3.2 lucene的正向索引结构


通过倒排索引拿到的docId后,如何去拿到文档的其他字段信息,这时候就需要用到正向索引了
在这里插入图片描述


● fdt文件里每个chunk里包含压缩后的doc信息。fdx文件里存放的是每组chunk的起始地址
● fdx文件较小,直接加载到内存
● 通过fdx文件拿到对应的docid所在chunk的地址,再加载doc的数据信息。
● 通过正向索引拿到doc的数据类似于mysql里的回表操作


3.3.3 和mysql索引的对比


在这里插入图片描述


● 默认设置情况下 lucene会为doc里的每个term都创建对应的倒排、正向索引,而mysql只会为你指定的列创建索引,因此对于复杂查询场景,使用es来查询更合适,mysql无法构建索引覆盖所有的查询情况
● mysql的索引是存放在磁盘里的,检索时需要分页加载到内存再检索;lucene的.tip文件很小,可以直接放入内存,在检索时候,通过term index来快速定位到term可能存在的block。相当于给索引(词典表.tim文件)又建立了一层索引,查询效率更高


4.es是如何搜索数据的


在这里插入图片描述



  1. 用户发送的搜索请求会随机达到任意一个node上,该node即为coordinate node。

  2. coordinate node会将请求转发到该索引对应的所有的primary shard或者replica shard中

  3. 每个shard处理query请求,通过lucene的搜索能力,将搜索结果返回给coordinate node

  4. coordinate node将所有的结果处理后,根据排序要求取topk,再返回给client。


每一个分片中发生的搜索过程
在这里插入图片描述



  1. 对用户的请求语句进行词法、语法分析,生成查询语法树,把文本请求转换为Lucene理解的请求对象

  2. 按照查询语法树,搜索索引获取最终匹配的文档id集合

  3. 对查询结果进行相关性排序,获取Top N的文档id集合,获取文档原始数据后返回用户
    5.一些关于es的使用思考

  4. 对于复杂的查询场景,es查询优于mysql,对于实时性要求高,或需要实现事务操作的场景,需要使用mysql。

  5. 由于es是通过查询所有分片,合并后再给出最终查询结果,所以也和mysql一样,需要注意深分页的问题,不过这块es已经做了限制,默认只返回前1w条的查询结果

  6. 索引过大也会导致查询慢,可以从上面讲的索引结构看出,虽然term index是可以加载到内存的,但是最终的term dict也是存在磁盘里的,对于具体term的查询需要花费很多时间。此时可以考虑重建索引,使用更多的分片来存储/查询数据。

  7. 尽可能地使用filter来代替query,query需要对查询结果进行相关性排序,而filter则不需要。


作者:chenyuxi
来源:juejin.cn/post/7222440107214274597
收起阅读 »

我调用第三方接口遇到的13个坑

前言 在实际工作中,我们经常需要在项目中调用第三方API接口,获取数据,或者上报数据,进行数据交换和通信。 那么,调用第三方API接口会遇到哪些问题?如何解决这些问题呢? 这篇文章就跟大家一起聊聊第三方API接口的话题,希望对你会有所帮助。 1 域名访问不到...
继续阅读 »

前言


在实际工作中,我们经常需要在项目中调用第三方API接口,获取数据,或者上报数据,进行数据交换和通信。


那么,调用第三方API接口会遇到哪些问题?如何解决这些问题呢?


这篇文章就跟大家一起聊聊第三方API接口的话题,希望对你会有所帮助。


图片


1 域名访问不到


一般我们在第一次对接第三方平台的API接口时,可能会先通过浏览器或者postman调用一下,该接口是否可以访问。


有些人可能觉得多次一举。


其实不然。


有可能你调用第三方平台的API接口时,他们的接口真的挂了,他们还不知道。


还有一种最重要的情况,就是你的工作网络,是否可以访问这个外网的接口。


有些公司为了安全考虑,对内网的开发环境,是设置了防火墙的,或者有一些其他的限制,有些ip白名单,只能访问一些指定的外网接口。


如果你发现你访问的域名,在开发环境访问不通,就要到运维同学给你添加ip白名单了。


2 签名错误


很多第三方API接口为了防止别人篡改数据,通常会增加数字签名(sign)的验证。


sign = md5(多个参数拼接 + 密钥)


在刚开始对接第三方平台接口时,会遇到参数错误,签名错误等问题。


其中参数错误比较好解决,重点是签名错误这个问题。


签名是由一些算法生成的。


比如:将参数名和参数值用冒号拼接,如果有多个参数,则按首字母排序,然后再将多个参数一起拼接。然后加盐(即:密钥),再通过md5,生成一个签名。


如果有多个参数,你是按首字母倒序的,则最后生成的签名会出问题。


如果你开发环境的密钥,用的生产环境的,也可能会导致生产的签名出现问题。


如果第三方平台要求最后3次md5生成签名,而你只用了1次,也可能会导致生产的签名出现问题。


因此,接口签名在接口联调时是比较麻烦的事情。


如果第三方平台有提供sdk生成签名是最好的,如果没有,就只能根据他们文档手写签名算法了。


3 签名过期


通过上面一步,我们将签名调通了,可以正常访问第三方平台获取数据了。


但你可能会发现,同一个请求,15分钟之后,再获取数据,却返回失败了。


第三方平台在设计接口时,在签名中增加了时间戳校验,同一个请求在15分钟之内,允许返回数据。如果超过了15分钟,则直接返回失败。


这种设计是为了安全考虑。


防止有人利用工具进行暴力破解,不停伪造签名,不停调用接口校验,如果一直穷举下去的话,总有一天可以校验通过的。


sign = md5(多个参数拼接 + 密钥 + 时间戳)


因此,有必要增加时间戳的校验。


如果出现这种情况,不要慌,重新发起一次新的请求即可。


4 接口突然没返回数据


如果你调用第三方平台的某个API接口查询数据,刚开始一直都有数据返回。


但突然某一天没返回数据了。


但是该API接口能够正常响应。


不要感到意外,有可能是第三方平台将数据删除了。


我对接完第三方平台的API接口后,部署到了测试环境,发现他们接口竟然没有返回数据,原因是他们有一天将测试环境的数据删完了。


因此,在部署测试环境之前,要先跟对方沟通,要用哪些数据测试,不能删除。


5 token失效


有些平台的API接口在请求之前,先要调用另外一个API接口获取token,然后再header中携带该token信息才能访问其他的业务API接口。


在获取token的API接口中,我们需要传入账号、密码和密钥等信息。每个接口对接方,这些信息都不一样。


我们在请求其他的API接口之前,每次都实时调用一次获取token的接口获取token?还是请求一次token,将其缓存到redis中,后面直接从redis获取数据呢?


很显然我们更倾向于后者,因为如果每次请求其他的API接口之前,都实时调用一次获取token的接口获取token,这样每次都会请求两次接口,性能上会有一些影响。


如果将请求的token,保存到redis,又会出现另外一个问题:token失效的问题。


我们调用第三方平台获取token的接口获取到的token,一般都有个有效期,比如:1天,1个月等。


在有效期内,该API接口能够正常访问。如果超过了token的有效期,则该API接口不允许访问。


好办,我们把redis的失效时间设置成跟token的有效期一样不就OK了?


想法是不错,但是有问题。


你咋保证,你们系统的服务器时间,跟第三方平台的服务器时间一模一样?


我之前遇到过某大厂,提供了获取token接口,在30天内发起请求,每次都返回相同的token值。如果超过了30天,则返回一个新的。


有可能出现这种情况,你们系统的服务器时间要快一些,第三方平台的时间要慢一些。结果到了30天,你们系统调用第三方平台的获取token接口获取到了token还是老的token,更新到redis中了。


过一段时间,token失效了,你们系统还是用老的token访问第三方平台的其他API接口,一直都返回失败。但获取新的token却要等30天,这个时间太漫长了。


为了解决这个问题,需要捕获token失效的异常。如果在调用其他的API接口是发现token失效了,马上请求一次获取token接口,将新的token立刻更新到redis中。


这样基本可以解决token失效问题,也能尽可能保证访问其他接口的稳定性和性能。


6 接口超时


系统上线之后,调用第三方API接口,最容易出现的问题,应该是接口超时问题了。


系统到外部系统之间,有一条很复杂的链路,中间有很多环节出现问题,都可能影响API接口的相应时间。


作为API接口的调用方,面对第三方API接口超时问题,除了给他们反馈问题,优化接口性能之外,我们更有效的方式,可能是增加接口调用的失败重试机制


例如:


int retryCount=0;
do {
   try {
      doPost();
      break;
   } catch(Exception e) {
     log.warn("接口调用失败")
     retryCount++;
   }
where (retryCount <= 3)

如果接口调用失败,则程序会立刻自动重试3次


如果重试之后成功了,则该API接口调用成功


如果重试3次之后还是失败,则该API接口调用失败


7 接口返回500


调用第三方API接口,偶尔因为参数的不同,可能会出现500的问题。


比如:有些API接口对于参数校验不到位,少部分必填字段,没有校验不能为空。


刚好系统的有些请求,通过某个参数去调用该API接口时,没有传入那个参数,对方可能会出现NPE问题。而该接口的返回code,很可能是500。


还有一种情况,就是该API接口的内部bug,传入不同的参数,走了不同的条件分支逻辑,在走某个分支时,接口逻辑出现异常,可能会导致接口返回500。


这种情况做接口重试也没用,只能联系第三方API接口提供者,反馈相关问题,让他们排查具体原因。


他们可能会通过修复bug,或者修复数据,来解决这个问题。


8 接口返回404


如果你在系统日志中发现调用的第三方API接口返回了404,这就非常坑了。


如果第三方的API接口没有上线,很可能是他们把接口名改了,没有及时通知你。


这种情况,可以锤他们了。


还有一种情况是,如果第三方的API接口已经上线了,刚开始接口是能正常调用的。


第三方也没有改过接口地址。


后来,突然有一天发现调用第三方的API接口还是出现了404问题。


这种情况很可能是他们网关出问题了,最新的配置没有生效,或者改了网关配置导致的问题。


总之一个字:坑。


9 接口返回少数据了


之前我调过一个第三方的API接口分页查询数据,接入非常顺利,但后来上线之后,发现他们的接口少数据了。


一查原因发现是该分页查询接口,返回的总页数不对,比实际情况少了。


有些小伙伴可能会好奇,这么诡异的问题我是怎么发现?


之前调用第三方API接口分页查询分类数据,保存到我们的第三方分类表中。


突然有一天,产品反馈说,第三方有个分类在分类树中找不到。


我确认之后,发现竟然是真的没有。


从调用第三方API接口的响应日志中,也没有查到该分类的数据。


这个API接口是分页查询接口,目前已经分了十几页查询数据,但还是没有查到我们想要的分类。


之前的做法是先调用一次API接口查询第一页的数据,同时查出总页数。然后再根据总页数循环调用,查询其他页的数据。


我当时猜测,可能是他们接口返回的总页数有问题。


于是,可以将接口调用逻辑改成这样的:



  • 从第一页开始,后面每调用一次API接口查数据,页数就加1。然后判断接口返回的数据是否小于pageSize,

  • 如果不小于,则进行下一次调用。

  • 如果小于,则说明已经是最后一页了,可以停止后续调用了。


验证之后发现这样果然可以获取那个分类的数据,只能说明第三方的分页查询接口返回的总页数比实际情况小了。


10 偷偷改参数了


我之前调用过某平台的API接口获取指标的状态,之前根据双方约定的状态有:正常禁用 两种。


然后将状态更新到我们的指标表中。


后来,双方系统上线运行了好几个月。


突然有一天,用户反馈说某一条数据明明删除了,为什么在页面上还是可以查到。


此时,我查我们这边的指标表,发现状态是正常的。


然后查看调用该平台的API接口日志,发现返回的该指标的状态是:下架


what?


这是什么状态?


跟该平台的开发人员沟通后,发现他们改了状态的枚举,增加了:上架、下架等多个值,而且没有通知我们。


这就坑了。


我们这边的代码中判断,如果状态非禁用状态,都认为是正常状态。


而下架状态,自动被判断为正常状态。


经过跟对方沟通后,他们确认下架状态,是非正常状态,不应该显示指标。他们改了数据,临时解决了该指标的问题。


后来,他们按接口文档又改回了之前的状态枚举值。


11 接口时好时坏


不知道你在调用第三方接口时,有没有遇到过接口时好时坏的情况。


5分钟前,该接口还能正常返回数据。


5分钟后,该接口返回503不可用。


又过了几分钟,该接口又能正常返回数据了。


这种情况大概率是第三方平台在重启服务,在重启的过程中,可能会出现服务暂时不可用的情况。


还有另外一种情况:第三方接口部署了多个服务节点,有一部分服务节点挂了。也会导致请求第三方接口时,返回值时好时坏的情况。


此外还有一种情况:网关的配置没有及时更新,没有把已经下线的服务剔除掉。


这样用户请求经过网关时,网关转发到了已经下线的服务,导致服务不可用。网关转发请求到正常的服务,该服务能够正常返回。


如果遇到该问题,要尽快将问题反馈给第三方平台,然后增加接口失败重试机制。


12 文档和接口逻辑不一致


之前还遇到一个第三方平台提供的API查询接口,接口文档中明确写明了有个dr字段表示删除状态


有了这个字段,我们在同步第三方平台的分类数据时,就能够知道有哪些数据是被删除的,后面可以及时调整我们这边的数据,将相关的数据也做删除处理。


后来发现有些分类,他们那边已经删除了,但是我们这边却没删除。


这是啥情况呢?


代码逻辑很简单,我review了一下代码,也没有bug,为什么会出现这种情况呢?


追查日志之后发现,调用第三方平台获取分类接口时,对方并没有把已删除的分类数据返回给我们。


也就是说接口文档中的那个dr字段没有什么用,接口文档和接口逻辑不一致。


这个问题估计好多小伙伴都遇到过。


如果要解决这个问题,主要的方案有两种:



  1. 第三方平台按文档修改接口逻辑,返回删除状态。

  2. 我们系统在调用分类查询接口之后,根据分类code判断,如果数据库中有些分类的code不在接口返回值中,则删除这些分类。


13 欠费了


我们调用过百度的票据识别接口,可以自动识别发票信息,获取发票编号和金额等信息。


之前是另外一个同事对接的接口,后来他离职了。


发票识别功能上线,使用了很长一段时间,一直都没有出问题。


后来,某一天,生产环境用户反馈发票识别不了了。


我查询了相关服务的日志,没有发现异常,这就奇怪了。


打开代码仔细看了一下,发现那位同事的代码中调用第三方的API接口,接收响应数据时,直接转换成了对象,没有打印当时返回的字符串。


莫非,接口返回值有问题?


后来,我增加了日志,打印出了该接口真正的返回内容值。


原因一下查到了,原来是欠费了。


如果出现该了异常,百度的API接口返回的数据结构,用之前那位同事的实体有些参数没法获取到。


这是一个不小的坑。


我们在接收第三方API接口返回数据时,尽可能先用字符串接收返回值,然后将字符串转换成相应实体类,一定要将该返回值在日志中打印出来,方便后面定位问题。


不要直接用实体对象接收返回值,有些API接口,如果出现不同的异常,返回的数据结构差异比较大。


有些异常结果可能是他们网关系统直接返回的,有些异常是他们业务系统返回的。


其实,我们之前还遇到过其他坑,比如:调用分类树查询接口,但第三方返回的数据有重复的id,我们这边该如何处理这种异常数据呢?


我们在job中循环调用第三方API接口获取数据,如果其中某一次调用失败了,是try/catch捕获异常呢?继续执行后面的调用,还是直接终止当前的程序?如果try/catch如何保证数据一致性?终止当前程序,该如何处理后续的流程?


最后说一句(求关注,别白嫖我)


如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。


作者:苏三说技术
来源:juejin.cn/post/7222577873793368123
收起阅读 »

我遇到的一个难题,早在1966年就已经有解决方案了...

1. 起因 这一切还得从前段时间接到的一个需求讲起。 业务方需要离线计算一批A附近5公里内的B,并统计聚合B的相关指标,进行展示。 嗯,听起来很合理。🤔 2. 问题 虽然在进行前期评估时,就已经预料到计算量会很大(当时的计算方案十分简陋)。 但在实际运行过程中...
继续阅读 »

1. 起因


这一切还得从前段时间接到的一个需求讲起。

业务方需要离线计算一批A附近5公里内的B,并统计聚合B的相关指标,进行展示。


嗯,听起来很合理。🤔


2. 问题


虽然在进行前期评估时,就已经预料到计算量会很大(当时的计算方案十分简陋)。

但在实际运行过程中,还是发现附近5km的逻辑,计算效率过于低下(按照城市编码将数据拆分为3个任务进行并行计算,但平均耗时还是会达到7-10个小时,无法接受)😦


3. 一些尝试


3.1 第一版:


最开始的计算逻辑很粗暴:把每个城市内的A和B进行full join,然后根据经纬度逐个计算距离,排除掉超出距离限制的集合。肉眼可见的低效。。。


c27f53441c4b15687e02c45821dbd306.gif


3.2 第二版:


由于全量计算十分耗时,并且大部分B的坐标也不会经常变更,因此开始考虑使用增量计算进行优化,减少重复计算。

但在实际任务运行过程中发现,大量耗时用在了历史数据和新增数据的合并写入,并没有有效的效率提升。


322fe630-a978-43d8-9284-78b0865067d3.jpg


3.3 第三版:


这个时候,已经没有什么优化的头绪了。只是一次偶然的搜索,让我发现了一个全新的实现逻辑。(没错,面向google编程)


ad5f2626-5584-4ca1-8809-567474172f11.jpg


一个周五的晚上,脑袋里思索着通过经纬度计算距离的逻辑,突然一个想法出现:既然经纬度可以进行距离计算,是否意味着经纬度的数字也是和距离有着一定的转换关系,比如经度小数点后1位,代表距离xx公里?


带着这个疑问,我发现了这两篇文章。


image.png


其中 高效的多维空间点索引算法 — Geohash 和 Google S2介绍的案例,与我的需求十分相似。(大神的文章值得好好阅读)


里面提到的geohash算法,则是1966年就有人提出的一种将经纬度坐标点进行一维化编码的解决思路。而后续的google的s2、uber的h3,均是在此设计理念的基础上优化而来的。


这种算法的本质就是对地球的每一块区域都进行编码(精度可调),也就是一个编码代表着一段经纬度范围内的区域。


那么接下来问题就简单了,找到合适的编码方案以及精度参数,测试验证即可。


具体的方案选择就不重复了。可以参考这个帖子:geohash、google s2、uber h3三种格网系统比较


我这边最终选择的是h3(h3-pyspark)。


4. 最终解决


第一步:将A的经纬度按照需要的精度进行编码,再获取该编码附近x公里的区域编码集合。
image.png


第二步:将B的经纬度按照同样的精度进行编码。


第三步:将两个数据集inner join,即可获得符合要求的集合。


是的,就是这么简单。(摊手)


5ae26b5d-d753-4900-9c61-213e400f87cd.png


5. 总结


通过这次的问题解决,学习到了这类场景的通用解决方案,受益匪浅。


6. 参考文章


高效的多维空间点索引算法 — Geohash 和 Google S2

彩云天气地理查询优化(2): 行政区划查询

geohash算法

geohash、google s2、uber h3三种格网系统比较

h3-pyspark

Uber H3使用


作者:一匹二维马
来源:juejin.cn/post/7213209438714527800
收起阅读 »

为什么说过早优化是万恶之源?

  Donald Knuth(高德纳)是一位计算机科学界的著名学者和计算机程序设计的先驱之一。他被誉为计算机科学的“圣经”《计算机程序设计艺术》的作者,提出了著名的“大O符号”来描述算法的时间复杂度和空间复杂度,开发了TeX系统用于排版科技文献,获得过图灵奖、...
继续阅读 »

  Donald Knuth(高德纳)是一位计算机科学界的著名学者和计算机程序设计的先驱之一。他被誉为计算机科学的“圣经”《计算机程序设计艺术》的作者,提出了著名的“大O符号”来描述算法的时间复杂度和空间复杂度,开发了TeX系统用于排版科技文献,获得过图灵奖、冯·诺伊曼奖、美国国家科学奖章等多项荣誉。今天要说的就是他所提出的一条软件设计重要原则 Premature optimization is the root of all evil 过早优化是万恶之源
在这里插入图片描述

为什么说“过早优化是万恶之源”? 我认为过早优化代码会让人陷入到错误的目标中去,从而忽视掉了最重要的目标。举个很简单的例子,你需要快速构建一个产品来抢占用户,你当下最重要的目标是让这个产品快速上线,而不是把这个产品打造的好用(在中国互联网下,这样的事数不胜数),如果你只关注到后者体验、性能问题而忽视了速度,在当下高度竞争的市场之下,你根本毫无机会。


  当然上面这个例子是从感性的层面说的,对很多程序猿来说也可能涉及不到产品层面的内容。我们从软件设计的层面,理性的来说,过早优化可能会导致以下的一些问题:




  1. 增加代码的复杂性:过度优化可能会导致代码的复杂性增加,从而降低代码的可读性和可维护性。如果代码过于复杂,可能会导致开发人员难以理解和维护代码,从而增加开发成本和时间。




  2. 耗费开发时间和资源:过度优化可能会导致开发人员花费大量时间和资源在代码的性能优化上,而忽略了其他重要的开发任务。这可能会导致项目进度延误和开发成本增加。




  3. 降低代码的可移植性:过度优化可能会导致代码的可移植性降低。如果代码过于依赖于特定的硬件或操作系统,可能会导致代码无法在其他环境中运行。




  4. 降低代码的可扩展性:过度优化可能会降低代码的可扩展性。如果代码过于依赖于特定的算法或数据结构,可能会导致代码无法适应未来的需求变化。




过早优化的典型案例


  在软件工程史上由于过度关注软件性能导致项目最终失败的案例比比皆是,比如我下面要说的一些项目,在软件工程史上都是非常知名的项目(当然可能有些新生代程序员已经不知道了)。




  1. IBM OS/360操作系统:在20世纪60年代,IBM公司开发了OS/360操作系统,这是当时最大的软件工程项目之一。在开发过程中,IBM公司过于关注代码的性能问题,导致代码的复杂性增加,开发时间延误,最终导致项目的失败。我知晓这个项目还是在我最近在阅读的一本软件工程经典书籍《人月神话》中,也推荐大家阅读下,这个项目虽然最终失败了,但也给整个软件工程领域留下了宝贵的经验。




  2. Netscape Navigator浏览器:在20世纪90年代,Netscape公司开发了Navigator浏览器,这是当时最流行的浏览器之一。在开发过程中,Netscape公司过于关注代码的性能问题,导致代码的复杂性增加,开发时间延误,最终导致浏览器市场份额严重下降。




  3. Windows Vista操作系统:在21世纪初,微软公司开发了Windows Vista操作系统,这是当时最大的软件工程项目之一。在开发过程中,微软公司过于关注代码的性能问题,导致代码的复杂性增加,开发时间延误,最终导致操作系统的用户体验不佳,市场反响不佳。话说这个操作系统我还用过呢,用户界面还是很漂亮的,很多UI设计也被沿用到了Window7中。




如何识别过早优化


  在软件开发过程中,如何判断是否过早优化呢?这里有一些概括性的判断标准,可以简单参考下:




  1. 是否存在性能问题:如果代码还没有性能问题,那么过早优化就是不必要的。因此,在进行优化之前,应该先测试代码的性能,确定是否存在性能问题。




  2. 是否优化了未来可能发生的问题:如果优化的是未来可能发生的问题,而不是当前存在的问题,那么就可能是过早优化。在进行优化之前,应该优先考虑当前存在的问题,而不是未来可能发生的问题。




  3. 是否牺牲了代码的可读性和可维护性:如果优化代码会导致代码的复杂性增加,降低代码的可读性和可维护性,那么就可能是过早优化。在进行优化之前,应该优先考虑代码的可读性、可维护性和可扩展性。




  4. 是否浪费了大量的开发时间和资源:如果优化代码会浪费大量的开发时间和资源,而不是提高代码的性能和效率,那么就可能是过早优化。在进行优化之前,应该评估优化的成本和收益,确定是否值得进行优化。




  判断是否过早优化需要根据具体情况进行评估。在进行优化之前,应该先测试代码的性能,确定是否存在性能问题。同时,也应该优先考虑代码的可读性、可维护性和可扩展性,避免过度优化。


总结


  作为一名在IT领域摸爬滚打多年的工程师,我深有体会地认识到过早优化是软件开发中的一大陷阱。在软件开发的初期,我们可能会过于关注代码的性能问题,而忽略了代码的可读性、可维护性和可扩展性。这种做法可能会导致代码的复杂性增加,降低代码的可读性和可维护性,甚至可能会浪费大量的开发时间和资源。


  在软件开发过程中,我们应该避免过早优化,而是优先考虑代码的可读性、可维护性和可扩展性。当需要进行性能优化时,应该在代码的基础上进行优化,通过分析性能瓶颈、优化算法和数据结构等方法来提高代码的性能和效率。同时,我们也应该意识到,性能优化并不是软件开发的唯一目标,我们还应该注重代码的可读性、可维护性和可扩展性,以便保证代码的质量和可靠性


作者:xindoo
来源:juejin.cn/post/7217305951552634935
收起阅读 »

SpringBoot 项目使用 Sa-Token 完成登录认证

一、设计思路 对于一些登录之后才能访问的接口(例如:查询我的账号资料),我们通常的做法是增加一层接口校验: 如果校验通过,则:正常返回数据。 如果校验未通过,则:抛出异常,告知其需要先进行登录。 那么,判断会话是否登录的依据是什么?我们先来简单分析一下登录...
继续阅读 »

一、设计思路


对于一些登录之后才能访问的接口(例如:查询我的账号资料),我们通常的做法是增加一层接口校验:



  • 如果校验通过,则:正常返回数据。

  • 如果校验未通过,则:抛出异常,告知其需要先进行登录。


那么,判断会话是否登录的依据是什么?我们先来简单分析一下登录访问流程:



  1. 用户提交 name + password 参数,调用登录接口。

  2. 登录成功,返回这个用户的 Token 会话凭证。

  3. 用户后续的每次请求,都携带上这个 Token。

  4. 服务器根据 Token 判断此会话是否登录成功。


所谓登录认证,指的就是服务器校验账号密码,为用户颁发 Token 会话凭证的过程,这个 Token 也是我们后续判断会话是否登录的关键所在。


动态图演示:


登录认证


接下来,我们将介绍在 SpringBoot 中如何使用 Sa-Token 完成登录认证操作。



Sa-Token 是一个 java 权限认证框架,主要解决登录认证、权限认证、单点登录、OAuth2、微服务网关鉴权 等一系列权限相关问题。
Gitee 开源地址:gitee.com/dromara/sa-…



首先在项目中引入 Sa-Token 依赖:


<!-- Sa-Token 权限认证 -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot-starter</artifactId>
<version>1.34.0</version>
</dependency>

注:如果你使用的是 SpringBoot 3.x,只需要将 sa-token-spring-boot-starter 修改为 sa-token-spring-boot3-starter 即可。


二、登录与注销


根据以上思路,我们需要一个会话登录的函数:


// 会话登录:参数填写要登录的账号id,建议的数据类型:long | int | String, 不可以传入复杂类型,如:User、Admin 等等
StpUtil.login(Object id);

只此一句代码,便可以使会话登录成功,实际上,Sa-Token 在背后做了大量的工作,包括但不限于:



  1. 检查此账号是否之前已有登录

  2. 为账号生成 Token 凭证与 Session 会话

  3. 通知全局侦听器,xx 账号登录成功

  4. Token 注入到请求上下文

  5. 等等其它工作……


你暂时不需要完整的了解整个登录过程,你只需要记住关键一点:Sa-Token 为这个账号创建了一个Token凭证,且通过 Cookie 上下文返回给了前端


所以一般情况下,我们的登录接口代码,会大致类似如下:


// 会话登录接口 
@RequestMapping("doLogin")
public SaResult doLogin(String name, String pwd) {
// 第一步:比对前端提交的账号名称、密码
if("zhang".equals(name) && "123456".equals(pwd)) {
// 第二步:根据账号id,进行登录
StpUtil.login(10001);
return SaResult.ok("登录成功");
}
return SaResult.error("登录失败");
}

如果你对以上代码阅读没有压力,你可能会注意到略显奇怪的一点:此处仅仅做了会话登录,但并没有主动向前端返回 Token 信息。
是因为不需要吗?严格来讲是需要的,只不过 StpUtil.login(id) 方法利用了 Cookie 自动注入的特性,省略了你手写返回 Token 的代码。


如果你对 Cookie 功能还不太了解,也不用担心,我们会在之后的 [ 前后端分离 ] 章节中详细的阐述 Cookie 功能,现在你只需要了解最基本的两点:



  • Cookie 可以从后端控制往浏览器中写入 Token 值。

  • Cookie 会在前端每次发起请求时自动提交 Token 值。


因此,在 Cookie 功能的加持下,我们可以仅靠 StpUtil.login(id) 一句代码就完成登录认证。


除了登录方法,我们还需要:


// 当前会话注销登录
StpUtil.logout();

// 获取当前会话是否已经登录,返回true=已登录,false=未登录
StpUtil.isLogin();

// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin();

异常 NotLoginException 代表当前会话暂未登录,可能的原因有很多:
前端没有提交 Token、前端提交的 Token 是无效的、前端提交的 Token 已经过期 …… 等等。


Sa-Token 未登录场景值参照表:


场景值对应常量含义说明
-1NotLoginException.NOT_TOKEN未能从请求中读取到 Token
-2NotLoginException.INVALID_TOKEN已读取到 Token,但是 Token无效
-3NotLoginException.TOKEN_TIMEOUT已读取到 Token,但是 Token已经过期
-4NotLoginException.BE_REPLACED已读取到 Token,但是 Token 已被顶下线
-5NotLoginException.KICK_OUT已读取到 Token,但是 Token 已被踢下线

那么,如何获取场景值呢?废话少说直接上代码:


// 全局异常拦截(拦截项目中的NotLoginException异常)
@ExceptionHandler(NotLoginException.class)
public SaResult handlerNotLoginException(NotLoginException nle)
throws Exception {

// 打印堆栈,以供调试
nle.printStackTrace();

// 判断场景值,定制化异常信息
String message = "";
if(nle.getType().equals(NotLoginException.NOT_TOKEN)) {
message = "未提供token";
}
else if(nle.getType().equals(NotLoginException.INVALID_TOKEN)) {
message = "token无效";
}
else if(nle.getType().equals(NotLoginException.TOKEN_TIMEOUT)) {
message = "token已过期";
}
else if(nle.getType().equals(NotLoginException.BE_REPLACED)) {
message = "token已被顶下线";
}
else if(nle.getType().equals(NotLoginException.KICK_OUT)) {
message = "token已被踢下线";
}
else {
message = "当前会话未登录";
}

// 返回给前端
return SaResult.error(message);
}



注意:以上代码并非处理逻辑的最佳方式,只为以最简单的代码演示出场景值的获取与应用,大家可以根据自己的项目需求来定制化处理

三、会话查询


// 获取当前会话账号id, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.getLoginId();

// 类似查询API还有:
StpUtil.getLoginIdAsString(); // 获取当前会话账号id, 并转化为`String`类型
StpUtil.getLoginIdAsInt(); // 获取当前会话账号id, 并转化为`int`类型
StpUtil.getLoginIdAsLong(); // 获取当前会话账号id, 并转化为`long`类型

// ---------- 指定未登录情形下返回的默认值 ----------

// 获取当前会话账号id, 如果未登录,则返回null
StpUtil.getLoginIdDefaultNull();

// 获取当前会话账号id, 如果未登录,则返回默认值 (`defaultValue`可以为任意类型)
StpUtil.getLoginId(T defaultValue);

四、Token 查询


// 获取当前会话的token值
StpUtil.getTokenValue();

// 获取当前`StpLogic`的token名称
StpUtil.getTokenName();

// 获取指定token对应的账号id,如果未登录,则返回 null
StpUtil.getLoginIdByToken(String tokenValue);

// 获取当前会话剩余有效期(单位:s,返回-1代表永久有效)
StpUtil.getTokenTimeout();

// 获取当前会话的token信息参数
StpUtil.getTokenInfo();

TokenInfo 是 Token 信息 Model,用来描述一个 Token 的常用参数:


{
"tokenName": "satoken", // token名称
"tokenValue": "e67b99f1-3d7a-4a8d-bb2f-e888a0805633", // token值
"isLogin": true, // 此token是否已经登录
"loginId": "10001", // 此token对应的LoginId,未登录时为null
"loginType": "login", // 账号类型标识
"tokenTimeout": 2591977, // token剩余有效期 (单位: 秒)
"sessionTimeout": 2591977, // User-Session剩余有效时间 (单位: 秒)
"tokenSessionTimeout": -2, // Token-Session剩余有效时间 (单位: 秒) (-2表示系统中不存在这个缓存)
"tokenActivityTimeout": -1, // token剩余无操作有效时间 (单位: 秒)
"loginDevice": "default-device" // 登录设备类型
}

五、来个小测试,加深一下理解


新建 LoginAuthController,复制以下代码


package com.pj.cases.use;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import cn.dev33.satoken.stp.SaTokenInfo;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;

/**
* Sa-Token 登录认证示例
*
* @author kong
* @since 2022-10-13
*/

@RestController
@RequestMapping("/acc/")
public class LoginAuthController {

// 会话登录接口 ---- http://localhost:8081/acc/doLogin?name=zhang&pwd=123456
@RequestMapping("doLogin")
public SaResult doLogin(String name, String pwd) {

// 第一步:比对前端提交的 账号名称 & 密码 是否正确,比对成功后开始登录
// 此处仅作模拟示例,真实项目需要从数据库中查询数据进行比对
if("zhang".equals(name) && "123456".equals(pwd)) {

// 第二步:根据账号id,进行登录
// 此处填入的参数应该保持用户表唯一,比如用户id,不可以直接填入整个 User 对象
StpUtil.login(10001);

// SaResult 是 Sa-Token 中对返回结果的简单封装,下面的示例将不再赘述
return SaResult.ok("登录成功");
}

return SaResult.error("登录失败");
}

// 查询当前登录状态 ---- http://localhost:8081/acc/isLogin
@RequestMapping("isLogin")
public SaResult isLogin() {
// StpUtil.isLogin() 查询当前客户端是否登录,返回 true 或 false
boolean isLogin = StpUtil.isLogin();
return SaResult.ok("当前客户端是否登录:" + isLogin);
}

// 校验当前登录状态 ---- http://localhost:8081/acc/checkLogin
@RequestMapping("checkLogin")
public SaResult checkLogin() {
// 检验当前会话是否已经登录, 如果未登录,则抛出异常:`NotLoginException`
StpUtil.checkLogin();

// 抛出异常后,代码将走入全局异常处理(GlobalException.java),如果没有抛出异常,则代表通过了登录校验,返回下面信息
return SaResult.ok("校验登录成功,这行字符串是只有登录后才会返回的信息");
}

// 获取当前登录的账号是谁 ---- http://localhost:8081/acc/getLoginId
@RequestMapping("getLoginId")
public SaResult getLoginId() {
// 需要注意的是,StpUtil.getLoginId() 自带登录校验效果
// 也就是说如果在未登录的情况下调用这句代码,框架就会抛出 `NotLoginException` 异常,效果和 StpUtil.checkLogin() 是一样的
Object userId = StpUtil.getLoginId();
System.out.println("当前登录的账号id是:" + userId);

// 如果不希望 StpUtil.getLoginId() 触发登录校验效果,可以填入一个默认值
// 如果会话未登录,则返回这个默认值,如果会话已登录,将正常返回登录的账号id
Object userId2 = StpUtil.getLoginId(0);
System.out.println("当前登录的账号id是:" + userId2);

// 或者使其在未登录的时候返回 null
Object userId3 = StpUtil.getLoginIdDefaultNull();
System.out.println("当前登录的账号id是:" + userId3);

// 类型转换:
// StpUtil.getLoginId() 返回的是 Object 类型,你可以使用以下方法指定其返回的类型
int userId4 = StpUtil.getLoginIdAsInt(); // 将返回值转换为 int 类型
long userId5 = StpUtil.getLoginIdAsLong(); // 将返回值转换为 long 类型
String userId6 = StpUtil.getLoginIdAsString(); // 将返回值转换为 String 类型

// 疑问:数据基本类型不是有八个吗,为什么只封装以上三种类型的转换?
// 因为大多数项目都是拿 int、long 或 String 声明 UserId 的类型的,实在没见过哪个项目用 double、float、boolean 之类来声明 UserId
System.out.println("当前登录的账号id是:" + userId4 + " --- " + userId5 + " --- " + userId6);

// 返回给前端
return SaResult.ok("当前客户端登录的账号id是:" + userId);
}

// 查询 Token 信息 ---- http://localhost:8081/acc/tokenInfo
@RequestMapping("tokenInfo")
public SaResult tokenInfo() {
// TokenName 是 Token 名称的意思,此值也决定了前端提交 Token 时应该使用的参数名称
String tokenName = StpUtil.getTokenName();
System.out.println("前端提交 Token 时应该使用的参数名称:" + tokenName);

// 使用 StpUtil.getTokenValue() 获取前端提交的 Token 值
// 框架默认前端可以从以下三个途径中提交 Token:
// Cookie (浏览器自动提交)
// Header头 (代码手动提交)
// Query 参数 (代码手动提交) 例如: /user/getInfo?satoken=xxxx-xxxx-xxxx-xxxx
// 读取顺序为: Query 参数 --> Header头 -- > Cookie
// 以上三个地方都读取不到 Token 信息的话,则视为前端没有提交 Token
String tokenValue = StpUtil.getTokenValue();
System.out.println("前端提交的Token值为:" + tokenValue);

// TokenInfo 包含了此 Token 的大多数信息
SaTokenInfo info = StpUtil.getTokenInfo();
System.out.println("Token 名称:" + info.getTokenName());
System.out.println("Token 值:" + info.getTokenValue());
System.out.println("当前是否登录:" + info.getIsLogin());
System.out.println("当前登录的账号id:" + info.getLoginId());
System.out.println("当前登录账号的类型:" + info.getLoginType());
System.out.println("当前登录客户端的设备类型:" + info.getLoginDevice());
System.out.println("当前 Token 的剩余有效期:" + info.getTokenTimeout()); // 单位:秒,-1代表永久有效,-2代表值不存在
System.out.println("当前 Token 的剩余临时有效期:" + info.getTokenActivityTimeout()); // 单位:秒,-1代表永久有效,-2代表值不存在
System.out.println("当前 User-Session 的剩余有效期" + info.getSessionTimeout()); // 单位:秒,-1代表永久有效,-2代表值不存在
System.out.println("当前 Token-Session 的剩余有效期" + info.getTokenSessionTimeout()); // 单位:秒,-1代表永久有效,-2代表值不存在

// 返回给前端
return SaResult.data(StpUtil.getTokenInfo());
}

// 会话注销 ---- http://localhost:8081/acc/logout
@RequestMapping("logout")
public SaResult logout() {
// 退出登录会清除三个地方的数据:
// 1、Redis中保存的 Token 信息
// 2、当前请求上下文中保存的 Token 信息
// 3、Cookie 中保存的 Token 信息(如果未使用Cookie模式则不会清除)
StpUtil.logout();

// StpUtil.logout() 在未登录时也是可以调用成功的,
// 也就是说,无论客户端有没有登录,执行完 StpUtil.logout() 后,都会处于未登录状态
System.out.println("当前是否处于登录状态:" + StpUtil.isLogin());

// 返回给前端
return SaResult.ok("退出登录成功");
}

}

代码注释已针对每一步操作做出详细解释,大家可根据可参照注释中的访问链接进行逐步测试。


本示例代码已上传至 Gitee,可参考:
Sa-Token 登录认证示例




参考资料



作者:省长
来源:juejin.cn/post/7215971680349569061
收起阅读 »

往往排查很久的问题,最后发现都非常简单。。。

之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。 大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommi...
继续阅读 »

之前线上发生了一个很诡异的异常,网上各种搜索、排查,都没有找到问题,给大家分享一下。


大概在 2 月份的时候,我们的某个应用整合了中间件的 kafka 客户端,发布到灰度和蓝节点进行观察,然后就发现线上某个 Topic 发生了大量的RetriableCommitException,并且集中在灰度机器上。


E20:21:59.770 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR [Consumer clientId=xx-xx.4-0, groupId=xx-xx-consumer_[gray]] Offset commit with offsets {xx-xx-xx-callback-1=OffsetAndMetadata{offset=181894918, leaderEpoch=4, metadata=''}, xx-xx-xx-callback-0=OffsetAndMetadata{offset=181909228, leaderEpoch=5, metadata=''}} failed org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.TimeoutException: Failed to send request after 30000 ms.


排查


检查了这个 Topic 的流量流入、流出情况,发现并不是很高,至少和 QA 环境的压测流量对比,连零头都没有达到。


但是从发生异常的这个 Topic 的历史流量来看的话,发生问题的那几个时间点的流量又确实比平时高出了很多。



同时我们检查 Broker 集群的负载情况,发现那几个时间点的 CPU 负载也比平时也高出很多(也只是比平时高,整体并不算高)。



对Broker集群的日志排查,也没发现什么特殊的地方。


然后我们对这个应用在QA上进行了模拟,尝试复现,遗憾的是,尽管我们在QA上把生产流量放大到很多倍并尝试了多次,问题还是没能出现。


此时,我们把问题归于当时的网络环境,这个结论在当时其实是站不住脚的,如果那个时刻网络环境发生了抖动的话,其它应用为什么没有这类异常?


可能其它的服务实例网络情况是好的,只是发生问题的这个灰实例网络发生了问题。


那问题又来了,为什么这个实例的其它 Topic 没有报出异常,偏偏问题只出现在这个 Topic 呢?。。。。。。。。。


至此,陷入了僵局,无从下手的感觉。


从这个客户端的开发、测试到压测,如果有 bug 的话,不可能躲过前面那么多环节,偏偏爆发在了生产环境。


没办法了,我们再次进行了一次灰度发布,如果过了一夜没有事情发生,我们就把问题划分到环境问题,如果再次出现问题的话,那就只能把问题划分到我们实现的 Kafka 客户端的问题了。


果不其然,发布后的第二天凌晨1点多,又出现了大量的 RetriableCommitFailedException,只是这次换了个 Topic,并且异常的原因又多出了其它Caused by 。


org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.DisconnectException
...
...
E16:23:31.640 RuntimeException  org.apache.kafka.clients.consumer.RetriableCommitFailedException  ERROR 
...
...
org.apache.kafka.clients.consumer.RetriableCommitFailedException: Offset commit failed with a retriable exception. You should retry committing the latest consumed offsets.
Caused by: org.apache.kafka.common.errors.TimeoutException: The request timed out.

分析


这次出现的异常与之前异常的不同之处在于:



  1. 1. Topic 变了

  2. 2. 异常Cause变了


而与之前异常又有相同之处:



  1. 1. 只发生在灰度消费者组

  2. 2. 都是RetriableCommitFailedException


RetriableCommitFailedException 意思很明确了,可以重试提交的异常,网上搜了一圈后仅发现StackOverFlow上有一问题描述和我们的现象相似度很高,遗憾的是没人回复这个问题:StackOverFlow。


我们看下 RetriableCommitFailedException 这个异常和产生这个异常的调用层级关系。



除了产生异常的具体 Cause 不同,剩下的都是让我们再 retry,You should retry Commiting the lastest consumed offsets。



从调用层级上来看,我们可以得到几个关键的信息,commit 、 async。


再结合异常发生的实例,我们可以得到有用关键信息: 灰度、commit 、async。


在灰度消息的实现上,我们确实存在着管理位移和手动提交的实现。



看代码的第 62 行,如果当前批次消息经过 filter 的过滤后一条消息都不符合当前实例消费,那么我们就把当前批次进行手动异步提交位移。结合我们在生产的实际情况,在灰度实例上我们确实会把所有的消息都过滤掉,并异步提交位移。


为什么我们封装的客户端提交就会报大量的报错,而使用 spring-kafka 的没有呢?


我们看下Spring对提交位移这块的核心实现逻辑。



可以同步,也可以异步提交,具体那种提交方式就要看 this.containerProperties.isSyncCommits() 这个属性的配置了,然而我们一般也不会去配置这个东西,大部分都是在使用默认配置。



人家默认使用的是同步提交方式,而我们使用的是异步方式。


同步提交和异步提交有什么区别么?


先看下同步提交的实现:



只要遇到了不是不可恢复的异常外,在 timer 参数过期时间范围内重试到成功(这个方法的描述感觉不是很严谨的样子)。



我们在看下异步提交方式的核心实现:



我们不要被第 645 行的 RequestFuture future = sendOffsetCommitRequest(offsets) 所迷惑,它其实并不是发送位移提交的请求,它内部只是把当前请求包装好,放到 private final UnsentRequests unsent = new UnsentRequests(); 这个属性中,同时唤醒真正的发送线程来发送的。



这里不是重点,重点是如果我们的异步提交发生了异常,它只是简单的使用 RetriableCommitFailedException 给我们包装了一层。


重试呢?为什么异步发送产生了可重试异常它不给我们自动重试?


如果我们对多个异步提交进行重试的话,很大可能会导致位移覆盖,从而引发重复消费的问题。


正好,我们遇到的所有异常都是 RetriableCommitException 类型的,也就是说,我们把灰度位移提交的方式修改成同步可重试的提交方式,就可以解决我们遇到的问题了。


作者:艾小仙
来源:juejin.cn/post/7214398563023274021
收起阅读 »

多端登录如何实现踢人下线

1:项目背景 一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。 或者你登录了PC端,这时候你登陆了AP...
继续阅读 »

1:项目背景


一个项目往往会有小程序,APP,PC等多端访问,比如淘宝,京东等。这时候就会有一些踢人下线的需求,比如你在一台电脑登录了PC端,这时候你再另外一台电脑也登录PC端,这时候之前在另外一台电脑上就会被强制下线。


或者你登录了PC端,这时候你登陆了APP或者小程序,这时候PC端的账号也会被强制下线


2:项目只有PC端


假设我们现在的项目只有PC端,没有小程序或者APP,那么这时候就是很简单了,用户的sessin(也就是所谓的Token)一般都是存储在redis中,session中包括用户ID等一些信息,当然还有一个最重要的就是登录的ip地址。


image.png


1:用户在登录的时候,从redis中获取用户session,如果没有就可以直接登录了


2:用户在另外一台电脑登录,从redis中获取到用户session,这时候用户session是有的,说明用户之前已经登录过了


3:这时候从用户session中获取IP,判断二者的ip是不是相同,如果不同,这时候就要发送一个通知给客户端,让另外一台设备登录的账号强制下线即可


3:项目有PC端和APP端和小程序端


当你的应用有PC端和APP端的时候,我们用户的session如果还是只存一个ip地址,那明显就是不够的,因为很多情况下,我们PC端和APP端是可以同时登录的,比如淘宝,京东等都是,也就是所谓的双端登录


这时候就会有多种情况


单端登录:PC端,APP端,小程序只能有一端登录
双端登录:允许其中二个端登录
三端登录:三个端都可以同时登录

对于三端可以同时登录就很简单,但是现在有个限制,就是app端只能登录一次,不能同时登录,也就是我一个手机登录了APP,另外一个手机登录的话,之前登录的APP端就要强制下线


所以我们的用户session存储的格式如下


{
userId:用户的id
clientType:PC端,小程序端,APP端
imei:就是设备的唯一编号(对于PC端这个值就是ip地址,其余的就是手机设备的一个唯一编号)
}


单端登录


首先我们要知道,用户登录不同的设备那么用户session是不一样的。对于单端登录,那么我们可以拿到用户的所有的session,然后根据clientType和imei号来强制将其它端的用户session删除掉,然后通知客户端强制下线


双端登录


同样拿到所有用户的session,然后根据自己的业务需求来判定哪一端需要强制下线,比如我们现在已经登录了PC端和APP端,这时候登录小程序,现在要让APP端的强制下线。


这时候登录之后获取用户所有的session,这时候会有二个用户session,首先拿到clientType = APP的session,然后来通知客户端这个端需要强制下线。


如果这时候我登录了PC端和一个APP端,这时候我用另外一台手机登录APP端,那么之前那台手机上登录的APP端就要被强制下线,这个时候仅通过clientType是不行的,因为我二个手机登录的clientType都是APP端。所以这时候就要根据imei号来判断了。因为不同的手机imei号是不一样的。


这时候我拿到用户所有的session



PC端的session
sessionA{
userId: 1,
clientType: PC,
imei: "123"
}

APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "12345"
}

这时候我从另外一台手机登录的时候,生成的session应该是这样的


 APP端的session
sessionA{
userId: 1,
clientType: APP,
imei: "1234567"
}

我发现同一个clientType的session已经有了,这时候我要判断imei号是否一样,imei一样说明是同一台设备,不同说明不是同一台设备,我们只需要把对应设备的账号强制下线即可了


总结


不管是单端登录,双端登录还是多端登录,我们都是根据用户session来判断。只要根据clientType和imei号来就可以满足我们大部分的踢人下线需求了。


作者:我是小趴菜
来源:juejin.cn/post/7213598216884486204
收起阅读 »

用了这两款插件,同事再也不说我代码写的烂了

大家好,我是风筝同事:你的代码写的不行啊,不够规范啊。我:我写的代码怎么可能不规范,不要胡说。于是同事打开我的 IDEA ,安装了一个插件,然后执行了一下,规范不规范,看报告吧。这可怎么是好,这玩意竟然给我挑出来这么多问题,到底靠谱不。同事潇洒的走掉了,只留下...
继续阅读 »

大家好,我是风筝

同事:你的代码写的不行啊,不够规范啊。

我:我写的代码怎么可能不规范,不要胡说。

于是同事打开我的 IDEA ,安装了一个插件,然后执行了一下,规范不规范,看报告吧。

这可怎么是好,这玩意竟然给我挑出来这么多问题,到底靠谱不。

同事潇洒的走掉了,只留下我在座位上盯着屏幕惊慌失措。我仔细的查看了这个报告的每一项,越看越觉得这插件指出的问题有道理,果然是我大意了,竟然还给我挑出一个 bug 来。

这是什么插件,review 代码无敌了。

这个插件就是 SonarLint,官网的 Slogan 是 clean code begins in your IDE with {SonarLint}

作为一个程序员,我们当然希望自己写的代码无懈可击了,但是由于种种原因,有一些问题甚至bug都无法避免,尤其是刚接触开发不久的同学,也有很多有着多年开发经验的程序员同样会有一些不好的代码习惯。

代码质量和代码规范首先肯定是靠程序员自身的水平和素养决定的,但是提高水平的是需要方法的,方法就有很多了,比如参考大厂的规范和代码、比如有大佬带着,剩下的就靠平时的一点点积累了,而一些好用的插件能够时时刻刻提醒我们什么是好的代码规范,什么是好的代码。

SonarLint 就是这样一款好用的插件,它可以实时帮我们 review代码,甚至可以发现代码中潜在的问题并提供解决方案。

SonarLint 使用静态代码分析技术来检测代码中的常见错误和漏洞。例如,它可以检测空指针引用、类型转换错误、重复代码和逻辑错误等。这些都是常见的问题,但是有时候很难发现。使用 SonarLint 插件,可以在编写代码的同时发现这些问题,并及时纠正它们,这有助于避免这些问题影响应用程序的稳定性。

比如下面这段代码没有结束循环的条件设置,SonarLint 就给出提示了,有强迫症的能受的了这红下划线在这儿?

SonarLint 插件可以帮助我提高代码的可读性。代码应该易于阅读和理解,这有助于其他开发人员更轻松地维护和修改代码。 SonarLint 插件可以检测代码中的代码坏味道,例如不必要的注释、过长的函数和变量名不具有描述性等等。通过使用 SonarLint 插件,可以更好地了解如何编写清晰、简洁和易于理解的代码。

例如下面这个名称为 hello_world的静态 final变量,SonarLint 给出了两项建议。

  1. 因为变量没有被使用过,建议移除;
  2. 静态不可变变量名称不符合规范;

SonarLint 插件可以帮助我遵循最佳实践和标准。编写符合标准和最佳实践的代码可以确保应用程序的质量和可靠性。 SonarLint 插件可以检测代码中的违反规则的地方,例如不安全的类型转换、未使用的变量和方法、不正确的异常处理等等。通过使用 SonarLint 插件,可以学习如何编写符合最佳实践和标准的代码,并使代码更加健壮和可靠。

例如下面的异常抛出方式,直接抛出了 Exception,然后 SonarLint 建议不要使用 Exception,而是自定义一个异常,自定义的异常可能让人直观的看出这个异常是干什么的,而不是 Exception基本类型导出传递。

安装 SonarLint

可以直接打开 IDEA 设置 -> Plugins,在 MarketPlace中搜索SonarLint,直接安装就可以。

还可以直接在官网下载,打开页面https://www.sonarsource.com/products/sonarlint/,在页面中可以看到多种语言、多种开发工具的下载图标,点击下方的 EXPLORE即可到下载页面去下载了。虽然我们只是在 IDEA 中使用,但是它不管支持 Java 、不只支持 IDEA ,还支持 Python、PHP等众多语言,以及 Visual Studio 、VS Code 等众多 IDE。

在 IDEA 中使用

SonarLint 插件安装好之后,默认就开启了实时分析的功能,就跟智能提示的功能一样,随着你噼里啪啦的敲键盘,SonarLint插件就默默的进行分析,一旦发现问题就会以红框、红波浪线、黄波浪线的方式提示。

当然你也可以在某一文件中点击右键,也可在项目根目录点击右键,在弹出菜单中点击Analyze with SonarLint,对当前文件或整个项目进行分析。

分析结束后,会生成分析报告。

左侧是对各个文件的分析结果,右侧是对这个问题的建议和修改示例。

SonarLint 对问题分成了三种类型

类型说明
Bug代码中的 bug,影响程序运行
Vulnerability漏洞,可能被作为攻击入口
Code smell代码意味,可能影响代码可维护性

问题按照严重程度分为5类

严重性说明
BLOCKER已经影响程序正常运行了,不改不行
CRITICAL可能会影响程序运行,可能威胁程序安全,一般也是不改不行
MAJOR代码质量问题,但是比较严重
MINOR同样是代码质量问题,但是严重程度较低
INFO一些友好的建议

SonarQube

SonarLint 是在 IDE 层面进行分析的插件,另外还可以使用 SonarQube功能,它以一个 web 的形式展现,可以为整个开发团队的项目提供一个web可视化的效果。并且可以和 CI\CD 等部署工具集成,在发版前提供代码分析。

SonarQube是一个 Java 项目,你可以在官网下载项目本地启动,也可以以 docker 的方式启动。之后可以在 IDEA 中配置全局 SonarQube配置。

也可以在 SonarQube web 中单独配置一个项目,创建好项目后,直接将 mvn 命令在待分析的项目中执行,即可生成对应项目的分析报告,然后在 SonarQube web 中查看。

5

对于绝大多数开发者和开发团队来说,SonarQube 其实是没有必要的,只要我们每个人都解决了 IDE 中 SonarLint 给出的建议,当然最终的代码质量就是符合标准的。

阿里 Java 规约插件

每一个开发团队都有团队内部的代码规范,比如变量命名、注释格式、以及各种类库的使用方式等等。阿里一直在更新 Java 版的阿里巴巴开发者手册,有什么泰山版、终极版,想必各位都听过吧,里面的规约如果开发者都能遵守,那别人恐怕再没办法 diss 你的代码不规范了。

对应这个开发手册的语言层面的规范,阿里也出了一款 IDEA 插件,叫做 Alibaba Java Coding Guidelines,可以在插件商店直接下载。

比如前面说的那个 hello_world变量名,插件直接提示「修正为以下划线分隔的大写模式」。

再比如一些注释上的提示,不建议使用行尾注释。

image-20230314165107639

还有,比如对线程池的使用,有根据规范建议的内容,建议自己定义核心线程数和最大线程数等参数,不建议使用 Excutors工具类。

有了这俩插件,看谁还能说我代码写的不规范了。

作者:古时的风筝
来源:juejin.cn/post/7211151196328804408
收起阅读 »

保姆级JAVA对接ChatGPT教程,实现自己的AI对话助手

1.前言 大家好,我是王老狮,近期OpenAI开放了chatGPT的最新gpt-3.5-turbo模型,据介绍该模型是和当前官网使用的相同的模型,如果你还没体验过ChatGPT,那么今天就教大家如何打破网络壁垒,打造一个属于自己的智能助手把。本文包括API K...
继续阅读 »

1.前言


大家好,我是王老狮,近期OpenAI开放了chatGPT的最新gpt-3.5-turbo模型,据介绍该模型是和当前官网使用的相同的模型,如果你还没体验过ChatGPT,那么今天就教大家如何打破网络壁垒,打造一个属于自己的智能助手把。本文包括API Key的申请以及网络代理的搭建,那么事不宜迟,我们现在开始。


2.对接流程


2.1.API-Key的获取


首先第一步要获取OpenAI接口的API Key,该Key是你用来调用接口的token,主要用于接口鉴权。获取该key首先要注册OpenAi的账号,具体可以见我的另外一篇文章,ChatGPT保姆级注册教程



  1. 打开platform.openai.com/网站,点击view API Key,


image.png



  1. 点击创建key


image.png



  1. 弹窗显示生成的key,记得把key复制,不然等会就找不到这个key了,只能重新创建。


image.png


将API Key保存好以备用


2.2.API用量的查看


这里可以查看API的使用情况,新账号注册默认有5美元的试用额度,之前都是18美元,API成本降了之后试用额度也狠狠地砍了一刀啊,哈哈。


image.png


2.3.核心代码实现


2.3.1.pom依赖


<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0modelVersion>
<groupId>com.webtapgroupId>
<artifactId>webtapartifactId>
<version>0.0.1version>
<packaging>jarpackaging>

<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.1.2.RELEASEversion>
parent>

<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
<dependency>
<groupId>nz.net.ultraq.thymeleafgroupId>
<artifactId>thymeleaf-layout-dialectartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-jpaartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-mailartifactId>
dependency>

<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-lang3artifactId>
<version>3.4version>
dependency>
<dependency>
<groupId>commons-codecgroupId>
<artifactId>commons-codecartifactId>
dependency>
<dependency>
<groupId>org.jsoupgroupId>
<artifactId>jsoupartifactId>
<version>1.9.2version>
dependency>

<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
<version>1.2.56version>
dependency>
<dependency>
<groupId>net.sourceforge.nekohtmlgroupId>
<artifactId>nekohtmlartifactId>
<version>1.9.22version>
dependency>
<dependency>
<groupId>com.github.pagehelpergroupId>
<artifactId>pagehelper-spring-boot-starterartifactId>
<version>1.4.1version>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpasyncclientartifactId>
<version>4.0.2version>
dependency>
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpcore-nioartifactId>
<version>4.3.2version>
dependency>

<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpclientartifactId>
<version>4.3.5version>
<exclusions>
<exclusion>
<artifactId>commons-codecartifactId>
<groupId>commons-codecgroupId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>commons-httpclientgroupId>
<artifactId>commons-httpclientartifactId>
<version>3.1version>
<exclusions>
<exclusion>
<artifactId>commons-codecartifactId>
<groupId>commons-codecgroupId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>1.3.1version>
dependency>
<dependency>
<groupId>com.github.ulisesbocchiogroupId>
<artifactId>jasypt-spring-boot-starterartifactId>
<version>2.0.0version>
dependency>

dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>

project>

2.3.2.实体类ChatMessage.java


用于存放发送的消息信息,注解使用了lombok,如果没有使用lombok可以自动生成构造方法以及get和set方法


@Data
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
//消息角色
String role;
//消息内容
String content;
}

2.3.3.实体类ChatCompletionRequest.java


用于发送的请求的参数实体类,参数释义如下:


model:选择使用的模型,如gpt-3.5-turbo


messages :发送的消息列表


temperature :温度,参数从0-2,越低表示越精准,越高表示越广发,回答的内容重复率越低


n :回复条数,一次对话回复的条数


stream :是否流式处理,就像ChatGPT一样的处理方式,会增量的发送信息。


max_tokens :生成的答案允许的最大token数


user :对话用户


@Data
@Builder
public class ChatCompletionRequest {

String model;

List<ChatMessage> messages;

Double temperature;

Integer n;

Boolean stream;

List<String> stop;

Integer max_tokens;

String user;
}

2.3.4.实体类ExecuteRet .java


用于接收请求返回的信息以及执行结果



/**
* 调用返回
*/

public class ExecuteRet {

/**
* 操作是否成功
*/

private final boolean success;

/**
* 返回的内容
*/

private final String respStr;

/**
* 请求的地址
*/

private final HttpMethod method;

/**
* statusCode
*/

private final int statusCode;

public ExecuteRet(booleansuccess, StringrespStr, HttpMethodmethod, intstatusCode) {
this.success =success;
this.respStr =respStr;
this.method =method;
this.statusCode =statusCode;
}

@Override
public String toString()
{
return String.format("[success:%s,respStr:%s,statusCode:%s]", success, respStr, statusCode);
}

/**
*@returnthe isSuccess
*/

public boolean isSuccess() {
return success;
}

/**
*@returnthe !isSuccess
*/

public boolean isNotSuccess() {
return !success;
}

/**
*@returnthe respStr
*/

public String getRespStr() {
return respStr;
}

/**
*@returnthe statusCode
*/

public int getStatusCode() {
return statusCode;
}

/**
*@returnthe method
*/

public HttpMethod getMethod() {
return method;
}
}

2.3.5.实体类ChatCompletionChoice .java


用于接收ChatGPT返回的数据


@Data
public class ChatCompletionChoice {

Integer index;

ChatMessage message;

String finishReason;
}

2.3.6.接口调用核心类OpenAiApi .java


使用httpclient用于进行api接口的调用,支持post和get方法请求。


url为配置文件open.ai.url的值,表示调用api的地址:https://api.openai.com/ ,token为获取的api-key。
执行post或者get方法时增加头部信息headers.put("Authorization", "Bearer " + token); 用于通过接口鉴权。



@Slf4j
@Component
public class OpenAiApi {

@Value("${open.ai.url}")
private String url;
@Value("${open.ai.token}")
private String token;

private static final MultiThreadedHttpConnectionManagerCONNECTION_MANAGER= new MultiThreadedHttpConnectionManager();

static {
// 默认单个host最大链接数
CONNECTION_MANAGER.getParams().setDefaultMaxConnectionsPerHost(
Integer.valueOf(20));
// 最大总连接数,默认20
CONNECTION_MANAGER.getParams()
.setMaxTotalConnections(20);
// 连接超时时间
CONNECTION_MANAGER.getParams()
.setConnectionTimeout(60000);
// 读取超时时间
CONNECTION_MANAGER.getParams().setSoTimeout(60000);
}

public ExecuteRet get(Stringpath, Map headers) {
GetMethod method = new GetMethod(url +path);
if (headers== null) {
headers = new HashMap<>();
}
headers.put("Authorization", "Bearer " + token);
for (Map.Entry h : headers.entrySet()) {
method.setRequestHeader(h.getKey(), h.getValue());
}
return execute(method);
}

public ExecuteRet post(Stringpath, Stringjson, Map headers) {
try {
PostMethod method = new PostMethod(url +path);
//log.info("POST Url is {} ", url + path);
// 输出传入参数
log.info(String.format("POST JSON HttpMethod's Params = %s",json));
StringRequestEntity entity = new StringRequestEntity(json, "application/json", "UTF-8");
method.setRequestEntity(entity);
if (headers== null) {
headers = new HashMap<>();
}
headers.put("Authorization", "Bearer " + token);
for (Map.Entry h : headers.entrySet()) {
method.setRequestHeader(h.getKey(), h.getValue());
}
return execute(method);
} catch (UnsupportedEncodingExceptionex) {
log.error(ex.getMessage(),ex);
}
return new ExecuteRet(false, "", null, -1);
}

public ExecuteRet execute(HttpMethodmethod) {
HttpClient client = new HttpClient(CONNECTION_MANAGER);
int statusCode = -1;
String respStr = null;
boolean isSuccess = false;
try {
client.getParams().setParameter(HttpMethodParams.HTTP_CONTENT_CHARSET, "UTF8");
statusCode = client.executeMethod(method);
method.getRequestHeaders();

// log.info("执行结果statusCode = " + statusCode);
InputStreamReader inputStreamReader = new InputStreamReader(method.getResponseBodyAsStream(), "UTF-8");
BufferedReader reader = new BufferedReader(inputStreamReader);
StringBuilder stringBuffer = new StringBuilder(100);
String str;
while ((str = reader.readLine()) != null) {
log.debug("逐行读取String = " + str);
stringBuffer.append(str.trim());
}
respStr = stringBuffer.toString();
if (respStr != null) {
log.info(String.format("执行结果String = %s, Length = %d", respStr, respStr.length()));
}
inputStreamReader.close();
reader.close();
// 返回200,接口调用成功
isSuccess = (statusCode == HttpStatus.SC_OK);
} catch (IOExceptionex) {
} finally {
method.releaseConnection();
}
return new ExecuteRet(isSuccess, respStr,method, statusCode);
}

}

2.3.7.定义接口常量类PathConstant.class


用于维护支持的api接口列表


public class PathConstant {
public static class MODEL {
//获取模型列表
public static String MODEL_LIST = "/v1/models";
}

public static class COMPLETIONS {
public static String CREATE_COMPLETION = "/v1/completions";
//创建对话
public static String CREATE_CHAT_COMPLETION = "/v1/chat/completions";

}
}

2.3.8.接口调用调试单元测试类OpenAiApplicationTests.class


核心代码都已经准备完毕,接下来写个单元测试测试下接口调用情况。



@SpringBootTest
@RunWith(SpringRunner.class)
public class OpenAiApplicationTests {

@Autowired
private OpenAiApi openAiApi;
@Test
public void createChatCompletion2() {
Scanner in = new Scanner(System.in);
String input = in.next();
ChatMessage systemMessage = new ChatMessage('user', input);
messages.add(systemMessage);
ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder()
.model("gpt-3.5-turbo-0301")
.messages(messages)
.user("testing")
.max_tokens(500)
.temperature(1.0)
.build();
ExecuteRet executeRet = openAiApi.post(PathConstant.COMPLETIONS.CREATE_CHAT_COMPLETION, JSONObject.toJSONString(chatCompletionRequest),
null);
JSONObject result = JSONObject.parseObject(executeRet.getRespStr());
List choices = result.getJSONArray("choices").toJavaList(ChatCompletionChoice.class);
System.out.println(choices.get(0).getMessage().getContent());
ChatMessage context = new ChatMessage(choices.get(0).getMessage().getRole(), choices.get(0).getMessage().getContent());
System.out.println(context.getContent());
}

}


  • 使用Scanner 用于控制台输入信息,如果单元测试时控制台不能输入,那么进入IDEA的安装目录,修改以下文件。增加最后一行增加-Deditable.java.test.console=true即可。


image.png
image.png




  • 创建ChatMessage对象,用于存放参数,role有user,system,assistant,一般接口返回的响应为assistant角色,我们一般使用user就好。




  • 定义请求参数ChatCompletionRequest,这里我们使用3.1日发布的最新模型gpt-3.5-turbo-0301。具体都有哪些模型大家可以调用v1/model接口查看支持的模型。




  • 之后调用openAiApi.post进行接口的请求,并将请求结果转为JSON对象。取其中的choices字段转为ChatCompletionChoice对象,该对象是存放api返回的具体信息。


    接口返回信息格式如下:


    {
    "id": "chatcmpl-6rNPw1hqm5xMVMsyf6PXClRHtNQAI",
    "object": "chat.completion",
    "created": 1678179420,
    "model": "gpt-3.5-turbo-0301",
    "usage": {
    "prompt_tokens": 16,
    "completion_tokens": 339,
    "total_tokens": 355
    },
    "choices": [{
    "message": {
    "role": "assistant",
    "content": "\n\nI. 介绍数字孪生的概念和背景\n A. 数字孪生的定义和意义\n B. 数字孪生的发展历程\n C. 数字孪生在现代工业的应用\n\nII. 数字孪生的构建方法\n A. 数字孪生的数据采集和处理\n B. 数字孪生的建模和仿真\n C. 数字孪生的验证和测试\n\nIII. 数字孪生的应用领域和案例分析\n A. 制造业领域中的数字孪生应用\n B. 建筑和城市领域中的数字孪生应用\n C. 医疗和健康领域中的数字孪生应用\n\nIV. 数字孪生的挑战和发展趋势\n A. 数字孪生的技术挑战\n B. 数字孪生的实践难点\n C. 数字孪生的未来发展趋势\n\nV. 结论和展望\n A. 总结数字孪生的意义和价值\n B. 展望数字孪生的未来发展趋势和研究方向"
    },
    "finish_reason": "stop",
    "index": 0
    }]
    }



  • 输出对应的信息。




2.3.9.结果演示


image.png


2.4.连续对话实现


2.4.1连续对话的功能实现


基本接口调通之后,发现一次会话之后,没有返回完,输入继续又重新发起了新的会话。那么那么我们该如何实现联系上下文呢?其实只要做一些简单地改动,将每次对话的信息都保存到一个消息列表中,这样问答就支持上下文了,代码如下:


List messages = new ArrayList<>();
@Test
public void createChatCompletion() {
Scanner in = new Scanner(System.in);
String input = in.next();
while (!"exit".equals(input)) {
ChatMessage systemMessage = new ChatMessage(ChatMessageRole.USER.value(), input);
messages.add(systemMessage);
ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder()
.model("gpt-3.5-turbo-0301")
.messages(messages)
.user("testing")
.max_tokens(500)
.temperature(1.0)
.build();
ExecuteRet executeRet = openAiApi.post(PathConstant.COMPLETIONS.CREATE_CHAT_COMPLETION, JSONObject.toJSONString(chatCompletionRequest),
null);
JSONObject result = JSONObject.parseObject(executeRet.getRespStr());
List choices = result.getJSONArray("choices").toJavaList(ChatCompletionChoice.class);
System.out.println(choices.get(0).getMessage().getContent());
ChatMessage context = new ChatMessage(choices.get(0).getMessage().getRole(), choices.get(0).getMessage().getContent());
messages.add(context);
in = new Scanner(System.in);
input = in.next();
}
}

因为OpenAi的/v1/chat/completions接口消息参数是个list,这个是用来保存我们的上下文的,因此我们只要将每次对话的内容用list进行保存即可。


2.4.2结果如下:


image.png


image.png


4.常见问题


4.1.OpenAi接口调用不通


因为https://api.openai.com/地址也被限制了,但是接口没有对地区做校验,因此可以自己搭建一个香港代理,也可以走科学上网。


我采用的是香港代理的模式,一劳永逸,具体代理配置流程如下:



  1. 购买一台香港的虚拟机,反正以后都会用得到,作为开发者建议搞一个。搞活动的时候新人很便宜,基本3年的才200块钱。

  2. 访问nginx.org/download/ng… 下载最新版nginx

  3. 部署nginx并修改/nginx/config/nginx.conf文件,配置接口代理路径如下


server {
listen 19999;
server_name ai;

ssl_certificate /usr/local/nginx/ssl/server.crt;
ssl_certificate_key /usr/local/nginx/ssl/server.key;

ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;

ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;

#charset koi8-r;

location /v1/ {
proxy_pass ;
}
}


  1. 启动nginx

  2. 将接口访问地址改为nginx的机器出口IP+端口即可


如果代理配置大家还不了解,可以留下评论我单独出一期教程。


4.2.接口返回401


检查请求方法是否增加token字段以及key是否正确


5.总结


至此JAVA对OpenAI对接就已经完成了,并且也支持连续对话,大家可以在此基础上不断地完善和桥接到web服务,定制自己的ChatGPT助手了。我自己也搭建了个平台,不断地在完善中,具体可见下图,后续会开源出来,想要体验的可以私信我获取地址和账号哈


image.png



作者:王老狮
来源:juejin.cn/post/7208907027841171512
收起阅读 »

删库跑路后的现场还原

数据库是公司重要资产,在此类重要资产平台上,尤其是重要操作,应该保持敬畏心。 数据库被删了?可怎么证明是某某某删了数据库?或者根本都不知道谁删除了数据库,又没抓现行,该怎么办? 正文 第一步 证据先行,有录屏有真相 删库动作的录制回放 录制回放让团队能清楚...
继续阅读 »

数据库是公司重要资产,在此类重要资产平台上,尤其是重要操作,应该保持敬畏心。



数据库被删了?可怎么证明是某某某删了数据库?或者根本都不知道谁删除了数据库,又没抓现行,该怎么办?



正文


第一步 证据先行,有录屏有真相


删库动作的录制回放


录制回放让团队能清楚了解和学习用户路径和行为,其中对于关键页面诸如删除等高价值的动作,可以开启录制回放功能,比如下图,就是某一用户某一行为的屏幕录制情况。
tutieshi_640x360_15s.gif


删库成功的页面截图


针对录制回放的内容,可以看到用户点击删除按钮这一高风险行为。


image.png


第二步 录屏背后是详细的用户访问数据


rum中查看用户会话


在用户使用产品的那一刻,用户体验就开始了。用户体验数据洞见很多,加购物车、下单、视频播放等高价值按钮背后的性能等相关数据和业务息息相关:比如下图展示了成功删除数据库的提示弹窗。


image.png


发现用户登录并浏览数据库平台的详细信息


每一次用户会话中,记录着用户的来源、访问时长,以及用户行为,这里面就包含对页面的加载(切换)和按钮点击。下图便是一个用户登录数据库管理平台后,0-20分钟以内的用户旅程
image.png


发现用户点击删除库的按钮的详细信息


链接或者按钮背后隐藏着逻辑和用户动机,充分利用能转化良好化学反应。反之,在用户旅程中,也能看到用户点击删除数据库的按钮的行为,如下图所示:
image.png


点击按钮成功触发删除数据库的接口请求


为了明白请求或行为在系统中的'前世今生',链路追踪已经成了必备,在下图中,用户行为触发的请求的完整上下文就被“追踪”到了:
image.png


后台处理接口请求


在产品使用流畅度中,丝滑不一定是卖点,但“慢”肯定是用户卡点,通过全链路链路追踪综合分析,可以得到请求耗时占比,进一步定位卡在哪里(前端、后端、网络),详情见下图:
image.png


第三步 成功删库的链路详情


前后端加上数据库形成可视化闭环,构成的业务链路,能够高效定位业务情况,下图能完整看出一次删库的效率:
image.png


第四步 自动关联删库日志


全链路追踪能锦上添花的要数自动关联日志的功能了,下图能清晰看到链路所产生的日志:
image.png


以上我们便通过用户删库的录屏用户行为链路信息、操作日志等,还原了删库现场。当然,其中涉及了很多技术内容,下面整理了其中一些常见问题


相关技术点的FAQ :


1. 如何针对关键步骤开启录制回放功能


删除按钮 为例 ,用户点击删除按钮后 可以开启 录制回放功能


  function deleteDB(){
showConfirm(deleteDB).then((yes,no)=>{
if(yes)=>[ datafluxRum.startSessionReplayRecording();]
})

}

2. 录制回放是否涉及密码等用户私密信息


出于数据安全考虑,任何情况下,以下元素都会被屏蔽:



  • password、email 和 tel 类型的输入

  • 具有 autocomplete 属性的元素,例如信用卡号、到期日期和安全代码


3 . 如何将 用户行为后端 进行关联


前后端关联通过http请求头的traceID进行关联,开启rumapm简单设置即可实现关联。
rum中仅仅需要在启动时注明后端地址。以本文的后台管理系统为例,需要在启动rum时开启allowTracingOrigin这个字段,配置见下图


image.png


可以参照如下代码


 window.DATAFLUX_RUM &&
window.DATAFLUX_RUM.init({
applicationId: "node_mongo_admin_express",
datakitOrigin: "http://mongodb_admin:9529", // 协议(包括://),域名(或IP地址)[和端口号]
env: "production",
service:"node_mongo_admin_express",
version: "1.0.0",
trackInteractions: true,
allowedTracingOrigins: ["http://mongodb_admin:1234"], // 非必填,允许注入trace采集器所需header头部的所有请求列表。可以是请求的origin,也可以是是正则
sessionSampleRate: 100,
sessionReplaySampleRate: 100,
defaultPrivacyLevel: 'allow',
});
window.DATAFLUX_RUM && window.DATAFLUX_RUM.startSessionReplayRecording()

4. 如何自动将采集的日志链路信息进行关联


需要将traceID注入日志,进行切分,就可以实现链路日志的关联。本文仅用一行进行了关联,代码见下图。


image.png


5. 如何从后端下钻到数据库


仅需要接入追踪工具即可实现下图全链路追踪,本文后端使用node的express框架,链路追踪展示图如下:


image.png


其中服务调用拓扑关系如下,也就是web端访问后端(node技术栈)的,后端调用数据库(mongo


image.png


6. 后端支持java吗?


支持javapythongo以及.net等,接入的学习成本是有的,整体对于开发而言,接入配置问题不大。


7. 前端的技术架构或技术栈有兼容性吗?


目前不论是mpa还是spa,不论是ssr、还是csr,亦或是vuereactjQuery等,都支持,但针对不同架构,需要选择接入的场景。


8. 还支持哪些场景?


支持的场景很多,比如:



  • 线上告警的故障定位

  • 开发、测试环境的bug调试

  • 用户行为的追踪与回放

  • 性能瓶颈的查找与性能提升


9.有关请求耗时占比,能更详细的举个例子吗?


我们以后端为例,看到db_create这个接口:


image.png


这些数据是如何统计得出的呢?感兴趣的同学可以查看下图:
image.png


其中每个部分的计算原理如下:


Queueing(队列)耗时 = Duration - First Byte - Download  
First Byte(首包)耗时 = responseStart - domainLookupStart
Download(下载)耗时 = responseEnd - responseStart


更深入的技术内容,我将在今后的文章继续为大家整理。


综上所述


可观测性切入点很多,聪明的团队会观测;可观测性是研发质量的试金石,是企业城墙的基石,用好可观测性,能更多的了解系统,扩宽业务。



本文由观测云高级产品技术专家刘刚和交付工程师 苏桐桐共同撰写,其中所有截图及数据,均来自模拟数据,此外也欢迎一起探讨技术和业务。



参考词汇



  • adminMongo:mongo数据库管理平台

  • rum: 真实用户体验

  • apm: 应用性能管理

  • metrics:指标

  • logs:日志

  • trace:链路


作者:Yestodorrow
来源:juejin.cn/post/7207787191622893624
收起阅读 »

Spring Boot+微信小程序_保存微信登录者的个人信息

1. 前言 微信小程序开发平台,提供有一类 API,可以让开发者获取到微信登录用户的个人数据。这类 API 统称为开放接口。 Tip:微信小程序开发平台,会把微信登录用户的个人信息分为明文数据和敏感数据。 明文数据也称为公开数据,开发者可以直接获取到,如登录...
继续阅读 »

1. 前言


微信小程序开发平台,提供有一类 API,可以让开发者获取到微信登录用户的个人数据。这类 API 统称为开放接口



Tip:微信小程序开发平台,会把微信登录用户的个人信息分为明文数据敏感数据


明文数据也称为公开数据,开发者可以直接获取到,如登录者的昵称、头像……


敏感数据如电话号码、唯一标识符……等数据,只有高级认证开发者和经过登录者授权后才能解密获取到。



这一类 API较多,且 API之间功能有重叠之处,相互之间的区别较微小。有的适用于低版本,有的适用于高版本。


为了避免在使用时出现选择混乱,本文将通过具体应用案例介绍几个常用 API的使用。


2. 开放接口


开放接口是对一类 API的统称,开发者可以通过调用这类接口得到微信登录用户的授权或获取登录者的个人数据
开放接口又分成几个子类 API



  • 登录接口: 包括 wx.pluginLogin(Object args)wx.login(Object object)wx.checkSession(Object object) 几 个 API

  • 账号信息: 包括Object wx.getAccountInfoSync()此接口用来获取开发者的账号信息。

  • 用户信息: 包括 wx.getUserProfile(Object object)wx.getUserInfo(Object object)UserInfo。使用频率非常高的接口,常用于小程序中获取登录者个人公开数据。

  • 授权接口:wx.authorizeForMiniProgram(Object object)wx.authorize(Object object)


除上述列出的子类接口,还有收货地址、生物认证……等诸多子类 API,有兴趣者可以自行了解。


2.1 登录接口


登录接口中有 3API,对于开发者来说,使用频率较高的是 login接口,此环节将重点介绍此接口。



非本文特别关注的接口,会简略带过。



wx.pluginLogin(Object args):此接口只能在插件中可以调用,调用此接口获得插件用户的标志凭证code,插件可使用此凭证换取用于识别用户的唯一标识 OpenpId


用户不同、宿主小程序不同或插件不同的情况下,该标识均不相同,即当且仅当同一个用户在同一个宿主小程序中使用同一个插件时,OpenpId 才会相同。


对于一般开发者,此 接口用的不是很多,具体使用细节在此处也不做过多复述。



什么是 OpenId?


当微信用户登录公众号或小程序时,微信平台为每一个微信登录者分配的一个唯一标识符号。



2.1.1 wx.login(Object object)


功能描述:




  • 开发者使用此接口可以获取到微信登录者登录凭证(code)



    登录凭证具有临时性,也就是每次调用时都会不一样,所以code 只能使用一次。





  • 开发者可以通过临时code,再向微信接口服务器索取登录者的唯一标识符 OpenId、微信开发平台账号的唯一标识 UnionID(需要当前小程序已绑定到微信开放平台帐号)、以及会话密钥 session_key




那么,获取到的openIdsession_key对于开发者而言,有什么实质性的意义?




  • 根据 OpenId的唯一性特点,可以在微信用户第一次登录时,把OpenID保存在数据库或缓存中,在后续登录时,只需要检查用户的 OpenId是否存在于数据库或缓存中,便能实现自动登录功能。




  • session_key 也称会话密钥,用来解密微信登录者的敏感数据。



    后文将详细介绍。





如何获取OpenId


现通过一个简单案例,实现微信小程序端与开发者服务器之间的数据交互。以此了解开发者服务器如何通过微信小程序传递过来的用户临时 code换取到登录者的更多信息。


实现之前,先通过一个简易演示图了解其过程。


wx01.png


简单描述整个请求过程:



  • 微信用户打开微信小程序后,开发者在微信小程序中通过调用wx.login接口获取到临时登录凭证 code

  • 在微信小程序中调用 wx.request 接口向开发者服务器发送 http 请求,需要把登录凭证 code一并发送过去。

  • 开发者服务器使用发送过来的 code 以及开发者凭证信息向微信接口服务器索取微信登录者的 openIdsession_key


简而言之,就是 3 者(微信小程序、开发者服务器、微信接口服务器)之间的一个击鼓传花游戏。


开发流程:


第一步:项目结构分析


完整的系统由 2 个部分组成:




  • 微信小程序端 APP



    如对微信小程序开发不是很了解,请先阅读官方提供的相关文档。





  • 服务器端应用程序。



    本文的服务器端应用程序基于 Spring Boot开发平台。





本项目结构是标准的前后端分离模式,微信小程序是前端应用,服务器端应用程序为后台应用。


第二步:新建微信小程序(前端应用)


打开微信开发工具,新建一个名为 guokeai 的小程序项目 ,项目会初始化一个index 页面。在 index.js中编写如下代码。


//index.js
const app = getApp()
const httpRequest = require("../../utils/request.js")

Page({
data: {
isHasUserInfo: null,
userInfo: null
},
//启动时
onLoad: function () {
let this_ = this
/***
* 检查微信用户是否已经登录到后台服务器
* 已经登录的标志,数据库中存在 OPENID
*/

let code = null
//调用 login 接口
wx.login({
success: (res) => {
//得到登录用户的临时 code
code = res.code
//向开发者服务器发送请求
let api = "wx/getLoginCertificate"
let config = {
url: api,
method: "GET",
data: {
code: code
}
}
let promise = httpRequest.wxRequest(config)
promise.then(res => {
let isHas = null
// 有没有完整的微信登录者信息
isHas = res.data == 0 ? false : true
app.globalData.isHasUserInfo = isHas
this_.setData({
isHasUserInfo: isHas
})
}).catch(res => {
console.log("fail", res)
});
}
})
}
})

代码解释:



  • 一般会在微信小程序启动时,也就是在页面onload 函数中调用 wx.login接口,检查用户是否登录过。

  • http://127.0.0.1:8080/wx/getLoginCertificate开发者服务器提供的对外处理微信用户信息的接口。

  • 最后只是简单地输出开发者服务器端返回的数据。

  • httpRequest.wxRequest(config)是自定义的封装wx.request接口的请求组件。


function wxRequest(config) {
//返回的数据类型
let dataType = config.dataType == null ? "json" : config.dataType;
let responseType = config.responseType == null ? "text" : config.responseType;
//服务器基地址
let serverUrl = "http://127.0.0.1:8080/"
//超时
let timeout = config.timeout == null ? 50000 : config.timeout;
//目标地址,基地址+接口
let url = serverUrl + config.url;
//数据提交方式
let method = config.method == null ? "GET" : config.method;
//提交数据
let data = config.data == null ? null : config.data
//头信息
let header = {
// 默认值
'content-type': 'application/json',
'x-requested-with': 'XMLHttpRequest'
}
let sessionId = wx.getStorageSync('sessionId')
if (sessionId) {
header["cookie"] = sessionId
}
return new Promise(function (resolve, reject) {
wx.request({
url: url,
data: data,
//返回的数据类型(json)
dataType: dataType,
enableCache: false,
enableHttp2: false,
enableQuic: false,
method: method,
header: header,
responseType: responseType,
timeout: timeout,
success: (res) => {
console.log("requestData", res)
if (res.cookies != null && res.cookies.length != 0)
wx.setStorageSync('sessionId', res.cookies[0])
resolve(res)
},
fail: (res) => {
console.log("requestException", res)
reject(res)
}
})
})
}

第三步:创建开发者服务器程序(后台应用)


本文使用 spring boot快速搭建后台应用程序。在项目的 pom.xml文件中除了必要的依赖包外,还需要添加以下 的依赖包。


<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.73</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.13</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>



  • fastjson阿里云提供的开源 JSON解析框架。



    微信小程序开发者服务器构建的项目结构,是标准的前后端分离模式。


    请求与响应时,数据交互常使用JSON格式。这时使用 fastjson 作为json解析器,当然,也可以选择其它的类似解析器。





  • httpclient 是一个http请求组件。




  • mysql-connector-java 本文案例使用 MySQL数据库,需要加载相应的驱动包。




  • mybatis-plus-boot-startermybatis-plus 依赖包。




在后台应用中编写处理器(响应)组件:


@RestController
@RequestMapping("/wx")
public class WxAction {
@Autowired
private IWxService wxService;
/***
* 获取到微信用户的 OPENID
*/

@GetMapping("/getLoginCertificate")
public String getLoginCertificate(@RequestParam("code") String code) throws Exception {
WxUserInfo wxInfo = this.wxService.getLoginCertificate(code);
//用户不存在,或者用户的信息不全
return wxInfo==null || wxInfo.getNickName()==null?"0":"1";
}

代码解释:



  • IWxService是处理器依赖的业务组件,提供有 getLoginCertificate()方法用来实现通过code微信接口服务器换取微信登录者的 openIdsession_key


编写业务组件:


@Service
public class WxService implements IWxService {
@Override
public WxUserInfo getLoginCertificate(String code) throws Exception {
//请求地址
String requestUrl = WxUtil.getWxServerUrl(code);
// 发送请求
String response = HttpClientUtils.getRequest(requestUrl);
//格式化JSON数据
WxUserInfo wxUserInfo = JSONObject.parseObject(response, WxUserInfo.class);
//检查数据库中是否存在 OPENID
WxUserInfo wxUserInfo_ = this.wxUserMapper.selectById(wxUserInfo.getOpenId());
if (wxUserInfo_ == null) {
//数据库中没有用户的 OPENID,添加到数据库中
this.wxUserMapper.insert(wxUserInfo);
} else {
if (!wxUserInfo.getSessionKey().equals(wxUserInfo_.getSessionKey())) {
//如果数据库保存的session_key和最新的session_key 不相同,则更新
wxUserInfo_.setSessionKey(wxUserInfo.getSessionKey());
this.wxUserMapper.updateById(wxUserInfo_);
}
}
return wxUserInfo_;
}
}

代码解释:




  • WxUtil 是自定义的一个工具组件,用来构建请求微信接口服务器url


    https://api.weixin.qq.com/sns/jscode2session微信接口服务器对外提供的接口,请求此接口时,需要提供 4 个请求数据。


    appid:小程序 appId。


    secret:小程序 appSecret。


    js_code:获取到的微信登录者的临时 code


    grant_type:授权类型,此处只需填写 authorization_code




public class WxUtil {
private final static String APP_ID = "微信小程序开发者申请的 appid";
private final static String APP_SECRET = "微信小程序开发者申请的 APP_SECRET";
//
private final static String WX_LOGIN_SERVER_URL = "https://api.weixin.qq.com/sns/jscode2session?appid={0}&secret={1}&js_code={2}&grant_type=authorization_code";
public static String getWxServerUrl(String code) throws IOException {
String url = MessageFormat.format(WX_LOGIN_SERVER_URL, new String[]{APP_ID, APP_SECRET, code});
return url;
}
}


  • HttpClientUtils也是一个自定义组件,用来向指定的服务器发送 http请求。


public class HttpClientUtils {
/**
* GET请求
*/

public static String getRequest(String url) throws Exception {
//HttpClient对象
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = null;
try {
HttpGet httpGet = new HttpGet(url);
response = httpClient.execute(httpGet);
//响应体
HttpEntity entity = response.getEntity();
if (entity != null) {
//格式化响应体
return EntityUtils.toString(entity);
}
} catch (ClientProtocolException e) {
throw e;
} catch (IOException e) {
throw e;
} finally {
response.close();
httpClient.close();
}
return null;
}
}


  • WxUserInfo 是自定义的数据封装类。微信接口服务器返回的数据是以JSON格式组装的,这里需要格式成对象数据,便于在 java中处理。本文使用 MyBatisPlus操作数据库,此类也对应数据库中的gk_wx_user表。


@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("gk_wx_user")
public class WxUserInfo {
//OPEN_id
@TableId(type = IdType.ASSIGN_ID, value = "open_id")
private String openId;
//会话密钥
@TableField(value = "session_key")
private String sessionKey;
//头像路径
@TableField("avatar_url")
private String avatarUrl;
//城市
private String city;
//国家
private String country;
//性别
private String gender;
//语言
private String language;
//昵称
@TableField("nick_name")
private String nickName;
//备注名或真实名
@TableField("real_name")
private String realName;
//省份
private String province;
//学生ID
@TableField("stu_id")
private Integer stuId;
}

MyBatis 数据库映射组件:


@Repository
public interface WxUserMapper extends BaseMapper<WxUserInfo> {

}

第四步:测试。


先启动后台应用程序,再启动微信小程序,可以在数据库表中查看到如下信息。


数据库.png


微信用户的openidsession_key已经保存到后台的数据库表中。


2.1.2 wx.checkSession(Object object)


官方文档中,有一段对 session_key的生命周期的描述。



  • session_key的生命周期有不确定性,可以使用 wx.login接口刷新 session_key。为了避免频繁调用 wx.login 接口,可以通过调用 wx.checkSession(Object object)接口判断session_key是否已经过期。

  • 当开发者在实现自定义登录态时,可以考虑以 session_key 有效期作为自身登录态有效期,也可以实现自定义的时效性策略。


wx.checkSession 的功能,可以使用此接口判断session_key是否过期。



  • 调用成功说明当前 session_key 未过期。

  • 调用失败说明 session_key 已过期。


2.2 用户信息接口


wx.login接口仅能获取到微信登录者的有限数据,如果想要获取到登录者的更多个人信息,可以使用用户信息接口中的相关API



  • wx.getUserProfile(Object object)。获取用户信息,页面产生点击事件(例如 buttonbindtap 的回调中)后才可调用,每次请求都会弹出授权窗口,用户同意后返回 userInfo

  • wx.getUserInfo(Object object) 。和 wx.getUserProfile的功能一样,在基础库 2.10 的后续版本中,其功能已经被削弱。

  • UserInfo是用户信息封装类。


getUserProfile是从 基础库2.10.4版本开始支持的接口,该接口用来替换 wx.getUserInfo,意味着官方不建议再使用getUserInfo接口获取用户的个人信息。


下图是官方提供的 2 个接口的功能对比图。


接口调整.png


为了避免频繁弹窗,可以在第一次获取到用户信息后保存在数据库中以备以后所用。为了获取到用户的敏感数据,在后台要通过getUserProfile接口所获取的数据进行解密操作。


2.2.2 wx.getUserProfile


下面通过具体代码讲解如何保存微信登录者的个人数据。先了解一下整个数据获取的流程,这里直接截取官方提供的一张流程图。


解密码.jpg


获取微信登录者的个人信息,需要经过 2 个步骤。


签名效验:



  • 通过调用wx.getUserProfile接口获取数据时,接口会同时返回 rawDatasignature,其中 signature = sha1( rawData + session_key )

  • 开发者将 signaturerawData 发送到开发者服务器进行校验。服务器利用用户对应的 session_key 使用相同的算法计算出签名 signature2 ,比对signaturesignature2 即可校验数据的完整性。


解密加密数据:



  • 对称解密使用的算法为 AES-128-CBC,数据采用PKCS#7填充。

  • 对称解密的目标密文为 Base64_Decode(encryptedData)

  • 对称解密秘钥 aeskey = Base64_Decode(session_key), aeskey16字节。

  • 对称解密算法初始向量 为Base64_Decode(iv),其中iv由数据接口返回。


具体编写实现。


**第一步:**在微信小程序端编码。


index.wxml页面中添加一个按钮,并注册bindtap事件。


<view>
<button bindtap="getUserProfile">获取用户数据</button>
</view>

index.js中添加一个名为getUserProfile的事件回调函数。为了避免不必要的弹窗,只有当后台没有获取到个人数据时,才调用wx.getUserProfile接口。


getUserProfile: function (e) {
let this_ = this
if (!this.data.isHasUserInfo) {
//如果服务器端没有保存完整的微信登录者信息
wx.getUserProfile({
desc: '需要完善您的资料!',
success: (res) => {
this_.setData({
//小程序中用来显示个人信息
userInfo: res.userInfo,
isHasUserInfo: true
})
//再次登录,因为 session_key 有生命中周期
wx.login({
success(res_) {
//保存到服务器端
let config = {
url: "wx/wxLogin",
method: "GET",
data: {
code: res_.code,
//明文数据
rawData: res.rawData,
//加密数据
encryptedData: res.encryptedData,
iv: res.iv,
//数字签名
signature: res.signature
}
}
let promise = httpRequest.wxRequest(config)
promise.then(res => {
//返回
console.log("wxLogin", res)
}).catch(res => {
console.log("fail", res)
});
}
})
}
})
}
}

服务器端代码:


pom.xml文件中添加如下依赖包,用来解密数据。


<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk16</artifactId>
<version>1.46</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.15</version>
</dependency>

在处理器类WxAction中添加wxLogin响应方法。


@RestController
@RequestMapping("/wx")
public class WxAction {
@Autowired
private IWxService wxService;
/***
*
* @param code
* @param rawData
* @param encryptedData
* @param iv
* @param signature
* @return
* @throws Exception
*/

@GetMapping("/wxLogin")
public WxUserInfo wxLogin(@RequestParam("code") String code, @RequestParam("rawData") String rawData,
@RequestParam("encryptedData") String encryptedData, @RequestParam("iv") String iv,
@RequestParam("signature") String signature)
throws Exception {
WxUserInfo wxInfo = this.wxService.getWxUserInfo(code, rawData, encryptedData, iv, signature);
return wxInfo;
}
}

业务代码:


小程序中传递过来的数据是经过base64编码以及加密的数据,需要使用 Base64解码字符串,再使用解密算法解密数据。先提供一个解密方法。


public String decrypt(String session_key, String iv, String encryptData) {

String decryptString = "";
//解码经过 base64 编码的字符串
byte[] sessionKeyByte = Base64.getDecoder().decode(session_key);
byte[] ivByte = Base64.getDecoder().decode(iv);
byte[] encryptDataByte = Base64.getDecoder().decode(encryptData);

try {
Security.addProvider(new BouncyCastleProvider());
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
//得到密钥
Key key = new SecretKeySpec(sessionKeyByte, "AES");
//AES 加密算法
AlgorithmParameters algorithmParameters = AlgorithmParameters.getInstance("AES");
algorithmParameters.init(new IvParameterSpec(ivByte));
cipher.init(Cipher.DECRYPT_MODE, key, algorithmParameters);
byte[] bytes = cipher.doFinal(encryptDataByte);
decryptString = new String(bytes);
} catch (Exception e) {
e.printStackTrace();
}
return decryptString;
}

具体获取数据的业务实现:


@Override
public WxUserInfo getWxUserInfo(@NotNull String code, @NotNull String rawData, @NotNull String encryptedData, @NotNull String iv, @NotNull String signature) throws Exception {
//会话密钥
WxUserInfo wxUserInfo = this.getLoginCertificate(code);
String signature2 = DigestUtils.sha1Hex(rawData + wxUserInfo.getSessionKey());
if (!signature.equals(signature2)) {
throw new Exception("数字签名验证失败");
}
//数字签名验证成功,解密
String infos = this.decrypt(wxUserInfo.getSessionKey(), iv, encryptedData);
//反序列化 JSON 数据
WxUserInfo wxUserInfo_ = JSONObject.parseObject(infos, WxUserInfo.class);
wxUserInfo_.setSessionKey(wxUserInfo.getSessionKey());
wxUserInfo_.setOpenId(wxUserInfo.getOpenId());
//更新数据库
this.wxUserMapper.updateById(wxUserInfo_);
return wxUserInfo_;
}

测试,启动微信小程序和后台应用,在小程序中触发按钮事件。


wx03.png


在弹出的对话框中,选择允许


wx04.png


查看后台数据库表中的数据。


wx05.png


能够获取到的微信登录者个人信息都保存到了数据库表中。至于怎么使用这些数据,可以根据自己的业务需要定制。


3.总结


微信开发平台,提供有诸多接口,可以帮助开发者获取到有用的数据。本文主要介绍 wx.loginwx.getProfile接口,因篇幅所限,不能对其它接口做详细介绍 ,有兴趣者可以查阅官方文档。


官方文档只会对接口功能做些介绍 ,如要灵活运用这些接口,还需要结合实际需要演练一下,如此方能有切身体会。


作者:一枚大果壳
来源:juejin.cn/post/7098216504302403591
收起阅读 »

序列化和反序列化

序列化隐秘的吭,你踩过了没? 序列化和反序列化 Java序列化的目的主要有2个: 网络传输 对象持久化 当2个相对独立的进程,需要进行跨进程服务调用时,就需要把被传输的Java对象编码为字节数组或者ByteBuffer对象。 接收方只需要把这些字节数...
继续阅读 »

序列化隐秘的吭,你踩过了没?


序列化和反序列化



Java序列化的目的主要有2个:




  • 网络传输

  • 对象持久化


image-20230301144505527


当2个相对独立的进程,需要进行跨进程服务调用时,就需要把被传输的Java对象编码为字节数组或者ByteBuffer对象


接收方只需要把这些字节数组或者Bytebuf对象重新解码成内存对象即可实现通信、调用的作用。


image-20230301145117301


那么在我们使用序列化的时候有哪些需要注意的,避免的坑呢?


成员变量不能以is开头



阿里的《Java开发手册》明文规定了:成员变量禁止使用类似 isXxxx 的命名方式,也不要有isXxx命名的方法



image-20230301150018030


image-20230301145401694


大概的意思就是:不要加is前缀,因为部分框架序列化的时候,会以为对应的字段名是isXxxx后面的Xxxx



  • 比如:isSucceed序列化成Succeed,前端读取isSucceed的时候就会发现没有这个字段,然后出错了。


u=4214115302,3196714167&fm=253&fmt=auto&app=120&f=JPEG


这里面的序列化框架其实就是fastjson,我们可以直接去看他的源码


fastjson源码分析:computeGetters



去找get前缀的方法,然后进行字符串切割找到get后面的



image-20230301161434898



去找is前缀的方法,然后进行字符串切割



image-20230301161413220



  • 这里还进行了驼峰命名的判断:ixXxx,第三个字符是否是大写等判断


所以isSucceed字段会被fastjson框架认为Succeed字段。


image.png


默认值



成员变量的默认值同样会带来坑



同样是阿里的《Java开发手册》里面也是规定了:POJO类、RPC方法必须使用包装类型


image.png


关于包装类型和基本类型的区别,如果还有不清楚的,赶紧去看,这是最基础的面试知识点..


POJO类必须使用包装类型



尽量让错误暴露在编译期,不要拖到运行期



基本类型具有初始值,比如:



  • Int:0

  • float:0.0f

  • boolean:false


一个统计点赞的接口里面的返回值包含一个表示点赞数变化的字段,当发生错误的时候,这个字段没有进行赋初始值,就会出现以下情况:



  • 基本类型:读默认值,0,表达的意思就是没有点赞数变化,程序上并不知道是服务器那边出了错。

  • 包装类型:读到了个null,程序上是知道服务器那边出错了,可以进行对应的显示,比如显示 - ,表示读取不到等操作。


u=3180711090,4079282331&fm=253&fmt=auto&app=138&f=JPEG


总的来说就是:如果字段设置为基础类型并且基础类型的默认值具有业务意义,那么就会出错,并且无法感知错误


RPC方法的返回值和参数必须使用包装类型



RPC调用常常具有超时导致调用失败的情况



如果用包装类型,那么在接收方,就能感知到,这次RPC调用是成功,还是失败。


包装数据类型的null值具有表示额外的信息功能



彦祖来都来了,点个赞👍再走吧,这对我来说真的非常重要



作者:Ashleejy
来源:juejin.cn/post/7205478140914843709
收起阅读 »

扯什么 try-catch 性能问题?

“yes,你看着这鬼代码,竟然在 for 循环里面搞了个 try-catch,不知道try-catch有性能损耗吗?”老陈煞有其事地指着屏幕里的代码: for (int i = 0; i < 5000; i++) { try { ...
继续阅读 »

“yes,你看着这鬼代码,竟然在 for 循环里面搞了个 try-catch,不知道try-catch有性能损耗吗?”老陈煞有其事地指着屏幕里的代码:


 for (int i = 0; i < 5000; i++) {
try {
dosth
} catch (Exception e) {
e.printStackTrace();
}
}

我探过头去看了眼代码,“那老陈你觉得该怎么改?”


“当然是把 try-catch 提到外面啊!”老陈脑子都不转一下,脱口而出。


“你是不是傻?且不说性能,这代码的目的明显是让循环内部单次调用出错不影响循环的运行,你其到外面业务逻辑不就变了吗!”


老陈挠了挠他的地中海,“好像也是啊!”



“回过头来,catch 整个 for 循环和在循环内部 catch,在不出错的情况下,其实性能差不多。” 我喝一口咖啡不经意地提到,准备在老陈前面秀一下。


“啥意思?”老陈有点懵地看着我,“try-catch是有性能损耗的,我可是看过网上资料的!”


果然,老陈上钩了,我二话不说直接打开 idea,一顿操作敲了以下代码:


public class TryCatchTest {

@Benchmark
public void tryfor(Blackhole blackhole) {
try {
for (int i = 0; i < 5000; i++) {
blackhole.consume(i);
}
} catch (Exception e) {
e.printStackTrace();
}
}

@Benchmark
public void fortry(Blackhole blackhole) {
for (int i = 0; i < 5000; i++) {
try {
blackhole.consume(i);
} catch (Exception e) {
e.printStackTrace();
}
}
}

}

“BB 不如 show code,看到没,老陈,我把 try-catch 从 for 循环里面提出来跟在for循环里面做个对比跑一下,你猜猜两个差多少?”


“切,肯定 tryfor 性能好,想都不用想,不是的话我倒立洗头!”老陈信誓旦旦道。


我懒得跟他BB,直接开始了 benchmark,跑的结果如下:



可以看到,两者的性能(数字越大越好)其实差不多:



  • fortry: 86,261(100359-14098) ~ 114,457(100359+14098)

  • tryfor: 95,961(103216-7255) ~ 110,471(103216+7255)


我再调小(一般业务场景 for 循环次数都不会很多)下 for 循环的次数为 1000 ,结果也是差不多:



老陈一看傻了:“说好的性能影响呢?怎么没了?”


我直接一个javap,让老陈看看,其实两个实现在字节码层面没啥区别:



tryfor 的字节码



异常表记录的是 0 - 20 行,如果这些行里面的代码出现问题,直接跳到 23 行处理




fortry 的字节码



差别也就是异常表的范围小点,包的是 9-14 行,其它跟 tryfor 都差不多。



所以从字节码层面来看,没抛错两者的执行效率其实没啥差别。


“那为什么网上流传着try-catch会有性能问题的说法啊?”老陈觉得非常奇怪。


这个说法确实有,在《Effective Java》这本书里就提到了 try-catch 性能问题:



并且还有下面一段话:



正所谓听话不能听一半,以前读书时候最怕的就是一知半解,因为完全理解选择题能选对,完全不懂蒙可能蒙对,一知半解必定选到错误的选项!


《Effective Java》书中说的其实是不要用 try-catch 来代替正常的代码,书中的举例了正常的 for 循环肯定这样实现:



但有个卧龙偏偏不这样实现,要通过 try-catch 拐着弯来实现循环:



这操作我只能说有点逆天,这两个实现的对比就有性能损耗了


我们直接再跑下有try-catch 的代码和没 try-catch的 for 循环区别,代码如下:



结果如下:



+-差不多,直接看前面的分数对比,没有 ry-catch 的性能确实好些,这也和书中说的 try-catch 会影响 JVM 一些特定的优化说法吻合,但是具体没有说影响哪些优化,我猜测可能是指令重排之类的。


好了,我再总结下有关 try-catch 性能问题说法:



  1. try-catch 相比较没 try-catch,确实有一定的性能影响,但是旨在不推荐我们用 try-catch 来代替正常能不用 try-catch 的实现,而不是不让用 try-catch

  2. for循环内用 try-catch 和用 try-catch 包裹整个 for 循环性能差不多,但是其实两者本质上是业务处理方式的不同,跟性能扯不上关系,关键看你的业务流程处理。

  3. 虽然知道try-catch会有性能影响,但是业务上不需要避讳其使用,业务实现优先(只要不是书中举例的那种逆天代码就行),非特殊情况下性能都是其次,有意识地避免大范围的try-catch,只 catch 需要的部分即可(没把握全 catch 也行,代码安全执行第一)。


“好了,老陈你懂了没?”


“行啊yes,BB是一套一套的,走请你喝燕麦拿铁!” 老陈一把拉起我,我直接一个挣脱,“少来,我刚喝过咖啡,你那个倒立洗头,赶紧的!”我立马意识到老陈想岔开话题。


“洗洗洗,我们先喝个咖啡,晚上回去给你洗!”


晚上22点,老陈发来一张图片:



你别说,这头发至少比三毛多。


我是yes,我们下篇见~


作者:yes的练级攻略
来源:juejin.cn/post/7204121228016091197
收起阅读 »

Java 中为什么要设计 throws 关键词,是故意的还是不小心

我们平时在写代码的时候经常会遇到这样的一种情况 提示说没有处理xxx异常 然后解决办法可以在外面加上try-catch,就像这样 所以我之前经常这样处理 //重新抛出 RuntimeException public class ThrowsDemo { ...
继续阅读 »

我们平时在写代码的时候经常会遇到这样的一种情况


throws.png


提示说没有处理xxx异常


然后解决办法可以在外面加上try-catch,就像这样


trycatch.png


所以我之前经常这样处理


//重新抛出 RuntimeException
public class ThrowsDemo {

public void demo4throws() {
try {
new ThrowsSample().sample4throws();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

//打印日志
@Slf4j
public class ThrowsDemo {

public void demo4throws() {
try {
new ThrowsSample().sample4throws();
} catch (IOException e) {
log.error("sample4throws", e);
}
}
}

//继续往外抛,但是需要每个方法都添加 throws
public class ThrowsDemo {

public void demo4throws() throws IOException {
new ThrowsSample().sample4throws();
}
}

但是我一直不明白


这个方法为什么不直接帮我做


反而要让我很多余的加上一步


我处理和它处理有什么区别吗?


而且变的好不美观


本来缩进就多,现在加个try-catch更是火上浇油


public class ThrowsDemo {

public void demo4throws() {
try {
if (xxx) {
try {
if (yyy) {

} else {

}
} catch (Throwable e) {
}
} else {

}
} catch (IOException e) {

}
}
}

上面的代码,就算里面没有业务,看起来也已经比较乱了,分不清哪个括号和哪个括号是一对


还有就是对Lambda很不友好


lambda.png


没有办法直接用::来优化代码,所以就变成了下面这样


lambdatry.png


本来看起来很简单很舒服的Lambda,现在又变得又臭又长


为什么会强制 try-catch


为什么我们平时写的方法不需要强制try-catch,而很多jdk中的方法却要呢


那是因为那些方法在方法的定义上添加了throws关键字,并且后面跟的异常不是RuntimeException


一旦你显式的添加了这个关键字在方法上,同时后面跟的异常不是RuntimeException,那么使用这个方法的时候就必须要显示的处理


比如使用try-catch或者是给调用这个方法的方法也添加throws以及对应的异常


throws 是用来干什么的


那么为什么要给方法添加throws关键字呢?


给方法添加throws关键字是为了表明这个方法可能会抛出哪些异常


就像一个风险告知


这样你在看到这个方法的定义的时候就一目了然了:这个方法可能会出现什么异常


为什么 RuntimeException 不强制 try-catch


那为什么RuntimeException不强制try-catch呢?


因为很多的RuntimeException都是因为程序的BUG而产生的


比如我们调用Integer.parseInt("A")会抛出NumberFormatException


当我们的代码中出现了这个异常,那么我们就需要修复这个异常


当我们修复了这个异常之后,就不会再抛出这个异常了,所以try-catch就没有必要了


当然像下面这种代码除外


public boolean isInteger(String s) {
try {
Integer.parseInt(s);
return true;
} catch (NumberFormatException e) {
return false;
}
}

这是我们利用这个异常来达成我们的需求,是有意为之的


而另外一些异常是属于没办法用代码解决的异常,比如IOException


我们在进行网络请求的时候就有可能抛出这类异常


因为网络可能会出现不稳定的情况,而我们对这个情况是无法干预的


所以我们需要提前考虑各种突发情况


强制try-catch相当于间接的保证了程序的健壮性


毕竟我们平时写代码,如果IDE没有提示异常处理,我们完全不会认为这个方法会抛出异常


我的代码怎么可能有问题.gif


我的代码怎么可能有问题!


不可能绝对不可能.gif


看来Java之父完全预判到了程序员的脑回路


throws 和 throw 的区别


java中还有一个关键词throw,和throws只有一个s的差别


throw是用来主动抛出一个异常


public class ThrowsDemo {

public void demo4throws() throws RuntimeException {
throw new RuntimeException();
}
}

两者完全是不同的功能,大家不要弄错了


什么场景用 throws


我们可以发现我们平时写代码的时候其实很少使用throws


因为当我们在开发业务的时候,所有的分支都已经确定了


比如网络请求出现异常的时候,我们常用的方式可能是打印日志,或是进行重试,把异常往外抛等等


所以我们没有那么有必要去使用throws这个关键字来说明异常信息


但是当我们没有办法确定异常要怎么处理的时候呢?


比如我在GitHub上维护了一个功能库,本身没有什么业务属性,主要就是对于一些复杂的功能做了相应的封装,提供给自己或别人使用(如果有兴趣可以看看我的库,顺便给Star,嘿嘿


对我来说,当我的方法中出现异常时,我是不清楚调用这个方法的人是想要怎么处理的


可能有的想要重试,有的想要打印日志,那么我干脆就往外抛,让调用方法的人自己去考虑,自己去处理


所以简单来说,如果方法主要是给别人用的最好用throws把异常往外抛,反之就是可加可不加


结束


很多时候你的不理解只是因为你还不够了解


作者:不够优雅
来源:juejin.cn/post/7204594495996100664
收起阅读 »

Flutter动态化调研实践

一,前言 1,什么是动态化? 目前移动端应用的版本更新, 最常见的方式是定期发版,无论是安卓还是iOS,都需要提交新的安装包到应用市场进行审核。审核通过后,用户在应用市场进行App的下载更新。 而动态化, 就是不依赖更新程序安装包, 就能动态实时更新页面的技术...
继续阅读 »

一,前言


1,什么是动态化?


目前移动端应用的版本更新, 最常见的方式是定期发版,无论是安卓还是iOS,都需要提交新的安装包到应用市场进行审核。审核通过后,用户在应用市场进行App的下载更新。


而动态化, 就是不依赖更新程序安装包, 就能动态实时更新页面的技术。


2,动态化的必要性


为什么需要动态化技术呢? 因为上述定期发版更新应用的方式存在一些问题,比如:



  1. 审核周期长, 且可能审核不通过。 周期长导致发版本不够灵活, 紧急的业务需求不能及时上线。

  2. 线上出现急需修复的bug时,需要较长修复周期,影响用户体验。

  3. 安装包过大, 动辄几十兆几百兆的应用升级可能会让用户比较抗拒。

  4. 即使上线了,也无法达到全部用户升级, 服务端存在兼容多版本App的问题。


面对这些问题,如果能实现app增量、无感知更新,实现功能同步。无论是对公司还是用户都是非常重要的需求,能实现app动态化更新就显得非常重要,能很好的解决以上问题:



  1. 随时实现功能升级,不存在应用市场长时间审核和拒绝上线问题,达到业务需求快速上线的目的。

  2. 线上bug可以实时修复,提高用户体验。

  3. 可以减小发版功能包体积,只需要替换新增功能即可。

  4. 功能保持一致,类似网页一样,发版后用户同步更新,不存在旧版本兼容问题。


接下来,我们就来分析一下,目前业内主要的Flutter动态化更新方式。


二,动态化方案调研


在Flutter实践层面,简单来说分为三个流派:




  • 方案一:JavaScript是最好的语言(🤣碰瓷PHP)
    主要思路:利用Flutter做渲染,开发使用js,逻辑层通过v8/jscore解释运行。代表框架是腾讯的MXFlutter。这个框架是开源的,大写的👍。




  • 方案三:布局,逻辑,一把梭


    主要思路:与方案一最主要的区别是,逻辑层也是使用dart,增加了一层语法解析和运行时。有一个代表,美团的MTFlutter,然而没有开源动向,无从考察更多。




  • 方案二:DSL + JS


    主要思路:基于模板实现动态化,主要布局层采用Dart转DSL的方式,逻辑层使用JS。代表框架是58同城开源的Fair




MXFlutter


项目简介



MXFlutter 是一套基于 JavaScript 的 Flutter 框架,可以用极其类似 Dart 的开发方式,通过编写 JavaScript 代码,来开发 Flutter 应用,或者使用 mxjsbuilder 编译器,把现有Flutter 工程编译为JS,运行在 mxflutter 之上。



核心思想



核心思路是把 Flutter 的渲染逻辑中的三棵树中的第一棵,放到 JavaScript 中生成。用 JavaScript 完整实现了 Flutter 控件层封装,可以使用 JavaScript,用极其类似 Dart 的开发方式,开发Flutter应用,利用JavaScript版的轻量级Flutter Runtime,生成UI描述,传递给Dart层的UI引擎,UI引擎把UI描述生产真正的 Flutter 控件。



MxFlutter 目前已经停止维护,具体请看MXFlutter
MxFlutter通过JavaScript编写Dart,加载线上js文件,通过引擎在运行时转化并显示,从而达到动态化效果。 官方在0.7.0版本开始接入TypeScript,引入npm生态,优化了js开发的成本,向前端生态进一步靠拢。
很遗憾,在对比各大厂的方案时,发现MxFlutter的性价比极低,学习成本也高,而且又抛弃Dart生态。开发及维护成本都很高。


MTFlutter


项目简介



美团的MTFlutter团队flap项目采用的静态生产DSL方案,通过对Dart语言注解,保证平台一致性。实现了动态下发与解释的逻辑页面一体化的 Flutter 动态化方案。Flap 的出现让 Flutter 动态化和包大小这两个短板得到了一定程度的弥补,促进了 Flutter 生态的发展。



核心思想



通过静态生产 DSL+Runtime 解释运行的思路,实现了动态下发与解释的逻辑页面一体化的 Flutter 动态化方案,建设了一套 Flap 生态体系,涵盖了开发、发布、测试、运维各阶段。



布局和逻辑层都使用Dart, 增加了一层语法解析和运行时。然而没有开源动向,无从考察更多。


Fair


项目简介



Fair是为Flutter设计的动态化框架,通过Fair Compiler工具对原生Dart源文件的自动转化,使项目获得动态更新Widget Tree和State的能力。


创建Fair的目标是支持不发版(Android、iOS、Web)的情况下,通过业务bundle和JS下发实现更新,方式类似于React Native。与Flutter Fair集成后,您可以快速发布新的页面,而无需等待应用的下一个发布日期。Fair提供了标准的Widget,它可以被用作一个新的动态页面或作为现有Flutter页面的一部分,诸如运营位的排版/样式修改,整页面替换,局部替换等都可以使用。



核心思想



Fair是58自研的的动态化框架,通过Fair Compiler工具对原生Dart源文件的自动转化,使项目获得动态更新Widget Tree和State的能力。



pic_d3WbXUd1d1V9d1WcXU37U7U75aXdd17b


三,方案对比


经过上述三个方案的调研,我们来大概对比一下上述框架


方案开源方核心思想优点缺点
MXFlutter腾讯用js编写Dart,动态拉取js脚本目前相对最完整的Flutter使用JS开发方案采用js方式编写Dart,维护困难
MTFlutter美团布局,逻辑都使用Dart,增加语法解析和运行时支持布局动态化和逻辑动态化未开源
Fair58通过bundle和js实现热更新支持布局动态化和逻辑动态化开源社区活跃, 开发工具丰富部分语法不支持

可以看到, MXFlutter需要使用js写Dart, 官方已经停止更新,而这种方式我们不能接受, MTFlutter目前未开源,无从继续研究。 接下来着重看一下 Fair


四,Fair接入过程


1,添加依赖


推荐使用pub形式引入


# add Fair dependency
dependencies:
fair: 2.7.0

# add compiler dependency
dev_dependencies:
build_runner: ^2.0.0
fair_compiler: ^1.2.0

# switch "fair_version" according to the local Flutter SDK version
dependency_overrides:
fair_version: 3.0.0

Flutter版本切换


通过切换 flutter_version 版本进行版本兼容。例如,将本机切换为 flutter 2.0.6 后,Fair 需要同步切换


# switch to another stable flutter version
dependency_overrides:
fair_version: 2.0.6

2,使用 Fair


在App中接入Fair步骤如下:


将 FairApp 添加为需要动态化部分的顶级节点

常见做法是作为 App 的根节点,如果不是全局采用也可以作为子页面的根节点


void main() {
WidgetsFlutterBinding.ensureInitialized();

FairApp.runApplication(
_getApp(),
plugins: {
},
);
}

dynamic _getApp() => FairApp(
modules: {
},
delegate: {
},
child: MaterialApp(
home: FairWidget(
name: 'DynamicWidget',
path: 'assets/bundle/lib_src_page_dynamic_widget.fair.json',
data: {"fairProps": json.encode({})}),
),
);

添加动态组件

每一个动态组件由一个FairWidget表示。


FairWidget(
name: 'DynamicWidget',
path: 'assets/bundle/lib_src_page_dynamic_widget.fair.json',
data: {"fairProps": json.encode({})}),

根据不同场景诉求,FairWidget可以混合和使用



  1. 可以作为不同组件混合使用

  2. 一般作为一个全屏页面

  3. 支持嵌套使用,即可以局部嵌套在普通Widget下,也可以嵌套在另一个FairWidget下


五,Fair接入体验


1,fork,下载工程


将官方Github工程fork到自己仓库后, 下载工程。使用官方提供的 test_case/best_ui_templates工程体验fair的体验。


2, 执行 pub get


在 best_ui_templates工程中,执行 pub get命令获取依赖。


3,开发业务


接下来正式开始开发流程。 把一个页面改写为 用Fair 编写:



  1. 创建需要动态化的 componnet, 并添加 @FairPatch() 注解。添加上注解后,在Fair生成产物时,会把此Component build生成 FairWidget加载的产物。


image-20221116173528472


2, 执行 Fair工具链插件的命令生成产物, 如图:


<u>image-20221116173837910</u>


3, 最终生成的产物,拷贝到 assets/bundle目录下(配置config.json后,会自动拷贝)


<u><u>image-20221116182132104</u></u>


4, 看效果, 下图为使用 Fair 改造后的页面:



Screenshot_2022_1116_172859
Screenshot_2022_1116_192940

六,Fair优势


1,社区活跃度高


官方对Fair维护力度大,版本更新较快,问题修复及时,活跃的开发者社区氛围。


使得开发者在开发Fair过程中遇到的问题, 能够及时反馈给官方, 并能得到快速的帮助和解决。


2, 一份代码,灵活使用


Fair的区别于MTFlutter和MXFlutter这2种动态化方案,Fair能让同一份代码在Flutter原生和动态之间随意切换。在开发跟版本需求时,使用原生代码发布,以此持续保持Flutter的性能优势;而在热更新场景可以通过下发动态文件来达到动态更新Widget的目的。使用方式更加灵活。


3,配套开发工具丰富


Faircli配套工具链

官方为了让开发者快速上手,降低接入门槛, 解决在接入过程中的痛点。 Fair团队开发了Faircli配套工具链,主要包含三个部分:



  • 工程创建:快速搭建Fair载体工程及动态化工程。

  • 模板代码:提供页面及组件模板。

  • 本地热更新:线下开发使用,实现开发阶段快速预览Fair动态化功能。


在安装了工具链提供的dart命令行工具及AS插件后, 通过创建模板, 构建产物, 本地启服务,体验热更新功能,开发者可以轻松接入并体验Fair。


Fair语法检测插件

官方为了让开发者在Fair开发过程中,出现不正确或者不支持的语法问题。 开发了配套插件去提示用户使用Fair语法糖。


查看以下示例:


1,build方法下if的代码检测,及提示引导信息


44b58320-e608-420f-854f-799b5bf03cf5image


2,点击more action 或者 AS代码提示快捷键


41094a86-2aea-43e6-b7f0-69aef1c653c0image


3,根据提示点击替换


image.png


通过插件,在编写fair过程中,可以快速识别并解决不支持的语法问题。 提高开发Fair效率。


Fair Web代码编辑器

Fair其中一个方向是在线动态化平台,即在网页中编辑dart代码,在线预览Flutter效果和Fair动态化效果,并且发布Fair动态化产物。


通过在Fair Web代码编辑器,开发者可以在没有复杂的IDE配置的情况下,在网页端开发Fair并预览。 这无疑是降低了接入成本, 为开发者可以快速体验Fair提供了非常便捷的方式。


七,总结


通过近期对各大互联网公司在Flutter动态化方向上的探究方案。 发现这些方案都还没有达到成熟阶段,想在实际业务上落地, 还得看各团队后期的维护力度和开发投入程度。


MXFlutter使用js编写Dart的方式, 抛弃了原本Flutter的开发模式, 导致开发成本大,以及后续维护成本也大,官方已停止维护。


MTFlutter采用布局,逻辑都是使用Dart, 通过静态生产 DSL+Runtime 解释运行的思路,解决布局和逻辑的动态化,然而并没有开源计划,无从深入研究。


Fair通过Fair Compiler工具对原生Dart源文件的自动转化,使项目获得动态更新Widget Tree和State的能力。目前官方维护力度较大, 社区活跃,并且有比较全面的Fair生态工具。 期待 Fair 团队可以解决在开发Fair过程中一些体验问题,如语法支持不全等, 让Fair成为真正能够让开发者可以快速接入,能够达到和正常开发Flutter接近的体验。 为广大Flutter开发人员解决动态化的痛点。


支持我们


欢迎大家使用 Fair,也欢迎大家为我们点亮star

Github地址:github.com/wuba/fair

Fair官网:fair.58.com/


欢迎贡献


通过Issue提交问题,贡献代码请提交Pull Request,管理员将对代码进行审核。


作者:王猛猛
来源:juejin.cn/post/7174978087879671865
收起阅读 »

全网最优雅安卓控件可见性检测

引子 view.setOnClickListener { // 当控件被点击时触发的逻辑 } 正是因为 View 对控件点击采用了策略模式,才使得监听任何控件的点击事件变得易如反掌。 我有一个愿望。。。 如果 View 能有一个可见性监听该多好啊! view...
继续阅读 »

引子


view.setOnClickListener { // 当控件被点击时触发的逻辑 }

正是因为 View 对控件点击采用了策略模式,才使得监听任何控件的点击事件变得易如反掌。


我有一个愿望。。。


如果 View 能有一个可见性监听该多好啊!


view.setOnVisibilityChangeListener { isVisible: Boolean ->   }

系统并未提供这个方法。。。


但业务上有可见性监听的需要,比如曝光埋点。当某控件可见时,上报XXX。


数据分析同学经常抱怨曝光数据不准确,有的场景曝光多报了,有的场景曝光少报了。。。


开发同学看到曝光埋点也很头痛,不同场景的曝光检测有不同的方法,缺乏统一的可见性检测入口,存在一定重复开发。


本文就试图为单个控件以及列表项的可见性提供统一的检测入口。


控件的可见性受到诸多因素的影响,下面是影响控件可见性的十大因素:



  1. 手机电源开关

  2. Home 键

  3. 动态替换的 Fragment 遮挡了原有控件

  4. ScrollView, NestedScrollView 的滚动

  5. ViewPager, ViewPager2 的滚动

  6. RecyclerView 的滚动

  7. 被 Dialog 遮挡

  8. Activity 切换

  9. 同一 Activity 中 Fragment 的切换

  10. 手动调用 View.setVisibility(View.GONE)

  11. 被输入法遮盖


能否把这所有的情况都通过一个回调方法表达?目标是通过一个 View 的扩展方法完成上述所有情况的检测,并将可见性回调给上层,形如:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {}

若能实现就极大化简了上层可见性检测的复杂度,只需要如下代码就能实现任意控件的曝光上报埋点:


view.onVisibilityChange { view, isVisible ->
if(isVisible) { // 曝光埋点 }
else {}
}

控件全局可见性检测


可见性检测分为两步:



  1. 捕获时机:调用检测算法检测控件可见性的时机。

  2. 检测算法:描述如何检测控件是否对用户可见。


拿“手动调用 View.setVisibility(View.GONE)”举例,得先捕获 View Visibility 发生变化的时机,并在此刻检测控件的可见性。


下面是View.setVisibility()的源码:


// android.view.View.java
public void setVisibility(@Visibility int visibility) {
setFlags(visibility, VISIBILITY_MASK);
}

系统并未在该方法中提供类似于回调的接口,即一个 View 的实例无法通过回调的方式捕获到 visibility 变化的时机。


难道通过自定义 View,然后重写 setVisibility() 方法?


这个做法接入成本太高且不具备通用性。


除了“手动调用 View.setVisibility(View.GONE)”,剩下的影响可见性的因素大多都可找到对应回调。难道得在fun View.onVisibilityChange()中对每个因素逐个添加回调吗?


这样实现太过复杂了,而且也不具备通用性,假设有例外情况,fun View.onVisibilityChange()的实现就得修改。


上面列出的十种影响控件可见性的因素都是现象,不同的现象背后可能对应相同的本质。


经过深挖,上述现象的本质可被收敛为下面四个:



  1. 控件全局重绘

  2. 控件全局滚动

  3. 控件全局焦点变化

  4. 容器控件新增子控件


下面就针对这四个本质编程。


捕获全局重绘时机


系统提供了ViewTreeObserver


public final class ViewTreeObserver {
public void addOnGlobalLayoutListener(OnGlobalLayoutListener listener) {
checkIsAlive();
if (mOnGlobalLayoutListeners == null) {
mOnGlobalLayoutListeners = new CopyOnWriteArray();
}
mOnGlobalLayoutListeners.add(listener);
}
}

ViewTreeObserver 是一个全局的 View 树变更观察者,它提供了一系列全局的监听器,全局重绘即是其中OnGlobalLayoutListener


public interface OnGlobalLayoutListener {
public void onGlobalLayout();
}

当 View 树发生变化需要重绘的时候,就会触发该回调。


调用 View.setVisibility(View.GONE) 之所以能将控件隐藏,正是因为整个 View 树触发了一次重绘。(任何一次微小的重绘都是从 View 树的树根自顶向下的遍历并触发每一个控件的重绘,不需要重绘的控件会跳过,关于 Adroid 绘制机制的分析可以点击Android自定义控件 | View绘制原理(画多大?)


在可见性检测扩展方法中捕获第一个时机:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
}

其中viewTreeObserver是 View 的方法:


// android.view.View.java
public ViewTreeObserver getViewTreeObserver() {
if (mAttachInfo != null) {
return mAttachInfo.mTreeObserver;
}
if (mFloatingTreeObserver == null) {
mFloatingTreeObserver = new ViewTreeObserver(mContext);
}
return mFloatingTreeObserver;
}

getViewTreeObserver() 用于返回当前 View 所在 View 树的观察者。


全局重绘其实覆盖了上述的两个场景:



  1. 同一 Activity 中 Fragment 的切换

  2. 手动调用 View.setVisibility(View.GONE)

  3. 被输入法覆盖


这两个场景都会发生 View 树的重绘。


捕获全局滚动时机



  1. ScrollView, NestedScrollView 的滚动

  2. ViewPager, ViewPager2 的滚动

  3. RecyclerView 的滚动


上述三个时机的共同特点是“发生了滚动”。


每个可滚动的容器控件都提供了各自滚动的监听


// android.view.ScrollView.java
public interface OnScrollChangeListener {
void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY);
}

// androidx.viewpager2.widget.ViewPager2.java
public abstract static class OnPageChangeCallback {
public void onPageScrolled(int position, float positionOffset, @Px int positionOffsetPixels) {}
public void onPageSelected(int position) {}
public void onPageScrollStateChanged(@ScrollState int state) {}
}

// androidx.recyclerview.widget.RecyclerView.java
public abstract static class OnScrollListener {
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {}
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {}
}

难道要针对不同的滚动控件设置不同的滚动监听器?


这样可见性检测就和控件耦合了,不具有通用性,也愧对View.onVisibilityChange()这个名字。


还好又在ViewTreeObserver中找到了全局的滚动监听:


public final class ViewTreeObserver {
public void addOnScrollChangedListener(OnScrollChangedListener listener) {
checkIsAlive();

if (mOnScrollChangedListeners == null) {
mOnScrollChangedListeners = new CopyOnWriteArray();
}

mOnScrollChangedListeners.add(listener);
}
}

public interface OnScrollChangedListener {
public void onScrollChanged();
}

在可见性检测扩展方法中捕获第二个时机:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
viewTreeObserver.addOnScrollChangedListener {}
}

捕获全局焦点变化时机


下面这些 case 都是焦点发生了变化:



  1. 手机电源开关

  2. Home 键

  3. 被 Dialog 遮挡

  4. Activity 切换


同样借助于 ViewTreeObserver 可以捕获到焦点变化的时机。


到目前为止,全局可见性扩展方法中已经监听了三种时机,分别是全局重绘、全局滚动、全局焦点变化:


fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {
viewTreeObserver.addOnGlobalLayoutListener {}
viewTreeObserver.addOnScrollChangedListener {}
viewTreeObserver.addOnWindowFocusChangeListener {}
}

捕获新增子控件时机


最后一个 case 是最复杂的:动态替换的 Fragment 遮挡了原有控件。


该场景如下图所示:
1668323952343.gif


界面中有一个底边栏,其中包含各种 tab 标签,点击其中的标签会以 Fragment 的形式从底部弹出。此时,底边栏各 tab 从可见变为不可见,当点击返回时,又从不可见变为可见。


一开始的思路是“从被遮挡的 View 本身出发”,看看某个 View 被遮挡后,其本身的属性是否会发生变化?


View 内部以is开头的方法如下所示:


微信截图_20221113152433.png


我把其中名字看上去可能和被遮挡有关联的方法值全都打印出来了,然后触发 gif 中的场景,观察这些值在触发前后是否会发生变化。


几十个属性,一一比对,在看花眼之前,log 告诉我,被遮挡之后,这些都没有发生任何变化。。。。


绝望。。。但还不死心,继续寻找其他方法:


微信截图_20221113152922.png


我又找了 View 内部所有has开头的方法,也把其中看上去和被遮挡有关的方法全打印出来了。。。你猜结果怎么着?依然是徒劳。。。。


我开始质疑出发点是否正确。。。此时一声雷鸣劈醒了我。


视图只可能了解其自身以及其下层视图的情况,它无法得知它的平级甚至是父亲的绘制情况。而 gif 中的场景,即是在底边栏的同级有一个 Fragment 的容器。而且当视图被其他层级的控件遮挡时,整个绘制体系也不必通知那个被遮挡的视图,否则多低效啊(我yy的,若有大佬知道内情,欢迎留言指点一二。)


经过这层思考之后,我跳出了被遮挡的那个视图,转而去 Fragment 的容器哪里寻求解决方案。


Fragment 要被添加到 Activity 必须提供一个容器控件,容器控件提供了一个回调用于监听子控件被添加:


// android.view.ViewGroup.java
public void setOnHierarchyChangeListener(OnHierarchyChangeListener listener) {
mOnHierarchyChangeListener = listener;
}

public interface OnHierarchyChangeListener {
void onChildViewAdded(View parent, View child);
void onChildViewRemoved(View parent, View child);
}

为了监听 Fragment 被添加的这个瞬间,得为可见性检测扩展方法添加一个参数:


fun View.onVisibilityChange(
viewGroup:
ViewGroup? = null, // 容器
block: (
view: View, isVisible: Boolean) -> Unit
)
{ }

其中 viewGroup 表示 Fragment 的容器控件。


既然 Fragment 的添加也是往 View 树中插入子控件,那 View 树必定会重绘,可以在全局重绘回调中进行分类讨论,下面是伪代码:


fun View.onVisibilityChange(
viewGroup:
ViewGroup? = null,
block: (
view: View, isVisible: Boolean) -> Unit
)
{
var viewAdded = false
// View 树重绘时机
viewTreeObserver.addOnGlobalLayoutListener {
if(viewAdded){
// 检测新插入控件是否遮挡当前控件
}
else {
// 检测当前控件是否出现在屏幕中
}
}
// 监听子控件插入
viewGroup?.setOnHierarchyChangeListener(object : OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View?, child: View?) {
viewAdded = true
}
}
override fun onChildViewRemoved(parent: View?, child: View?) {
viewAdded = false
}
})
}

子控件的插入回调总是先于 View 树重绘回调。所以先在插入时置标志位viewAdded = true,以便在重绘回调中做分类讨论。(因为检测子控件遮挡和是否出现在屏幕中是两种不同的检测方案)


可见性检测算法


检测控件的可见性的算法是:“判断控件的矩形区域是否和屏幕有交集”


为此新增扩展属性:


val View.isInScreen: Boolean
get() = ViewCompat.isAttachedToWindow(this) && visibility == View.VISIBLE && getLocalVisibleRect(Rect())

val 类名.属性名: 属性类型这样的语法用于为类的实例添加一个扩展属性,它并不是真地给类新增了一个成员变量,而是在类的外部新增属性值的获取方法。


当前新增的属性是 val 类型的,即常量,所以只需要为其定义个 get() 方法来表达如何获取它的值。


View 是否在屏幕中由三个表达式共同决定。



  1. 先通过 ViewCompat.isAttachedToWindow(this) 判断控件是否依附于窗口。

  2. 再通过 visibility == View.VISIBLE 判断视图是否可见。

  3. 最后调用getLocalVisibleRect()判断它的矩形相对于屏幕是否可见:


// android.view.View.java
public final boolean getLocalVisibleRect(Rect r) {
final Point offset = mAttachInfo != null ? mAttachInfo.mPoint : new Point();
if (getGlobalVisibleRect(r, offset)) {
r.offset(-offset.x, -offset.y);
return true;
}
return false;
}

该方法会先获取控件相对于屏幕的矩形区域并存放在传入的 Rect 参数中,然后再将其偏移到控件坐标系。如果矩形区域为空,则返回 false 表示不在屏幕中,否则为 true。


刚才捕获的那一系列时机,有可能会被多次触发。为了只将可见性发生变化的事件回调给上层,得做一次过滤:


val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()

val checkVisibility = {
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 获取当前可见性
val isInScreen = this.isInScreen() && visibility == View.VISIBLE
// 无上一次可见性,表示第一次检测
if (lastVisibility == null) {
if (isInScreen) {
// 回调可见性回调给上层
block(this, true)
// 更新可见性
setTag(KEY_VISIBILITY, true)
}
}
// 当前可见性和上次不同
else if (lastVisibility != isInScreen) {
// 回调可见性给上层
block(this, isInScreen)
// 更新可见性
setTag(KEY_VISIBILITY, isInScreen)
}
}

过滤重复事件的方案是记录上一次可见性(记录在 View 的 tag 中),如果这一次可见性检测结果和上一次相同则不回调给上层。


将可见性检测定义为一个 lambda,这样就可以在捕获不同时机时复用。


以下是完整的可见性检测代码:


fun View.onVisibilityChange(
viewGroups:
List<ViewGroup> = emptyList()
, // 会被插入 Fragment 的容器集合
needScrollListener: Boolean = true,
block: (view: View, isVisible: Boolean) -> Unit
) {
val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()
val KEY_HAS_LISTENER = "KEY_HAS_LISTENER".hashCode()
// 若当前控件已监听可见性,则返回
if (getTag(KEY_HAS_LISTENER) == true) return

// 检测可见性
val checkVisibility = {
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 判断控件是否出现在屏幕中
val isInScreen = this.isInScreen
// 首次可见性变更
if (lastVisibility == null) {
if (isInScreen) {
block(this, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非首次可见性变更
else if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
}

// 全局重绘监听器
class LayoutListener : ViewTreeObserver.OnGlobalLayoutListener {
// 标记位用于区别是否是遮挡case
var addedView: View? = null
override fun onGlobalLayout() {
// 遮挡 case
if (addedView != null) {
// 插入视图矩形区域
val addedRect = Rect().also { addedView?.getGlobalVisibleRect(it) }
// 当前视图矩形区域
val rect = Rect().also { this@onVisibilityChange.getGlobalVisibleRect(it) }
// 如果插入视图矩形区域包含当前视图矩形区域,则视为当前控件不可见
if (addedRect.contains(rect)) {
block(this@onVisibilityChange, false)
setTag(KEY_VISIBILITY, false)
} else {
block(this@onVisibilityChange, true)
setTag(KEY_VISIBILITY, true)
}
}
// 非遮挡 case
else {
checkVisibility()
}
}
}

val layoutListener = LayoutListener()
// 编辑容器监听其插入视图时机
viewGroups.forEachIndexed { index, viewGroup ->
viewGroup.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View?, child: View?) {
// 当控件插入,则置标记位
layoutListener.addedView = child
}

override fun onChildViewRemoved(parent: View?, child: View?) {
// 当控件移除,则置标记位
layoutListener.addedView = null
}
})
}
viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
// 全局滚动监听器
var scrollListener:ViewTreeObserver.OnScrollChangedListener? = null
if (needScrollListener) {
scrollListener = ViewTreeObserver.OnScrollChangedListener { checkVisibility() }
viewTreeObserver.addOnScrollChangedListener(scrollListener)
}
// 全局焦点变化监听器
val focusChangeListener = ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
val isInScreen = this.isInScreen
if (hasFocus) {
if (lastVisibility != isInScreen) {
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
}
} else {
if (lastVisibility == true) {
block(this, false)
setTag(KEY_VISIBILITY, false)
}
}
}
viewTreeObserver.addOnWindowFocusChangeListener(focusChangeListener)
// 为避免内存泄漏,当视图被移出的同时反注册监听器
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
}

override fun onViewDetachedFromWindow(v: View?) {
v ?: return
// 有时候 View detach 后,还会执行全局重绘,为此退后反注册
post {
try {
v.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
} catch (_: java.lang.Exception) {
v.viewTreeObserver.removeGlobalOnLayoutListener(layoutListener)
}
v.viewTreeObserver.removeOnWindowFocusChangeListener(focusChangeListener)
if(scrollListener !=null) v.viewTreeObserver.removeOnScrollChangedListener(scrollListener)
viewGroups.forEach { it.setOnHierarchyChangeListener(null) }
}
removeOnAttachStateChangeListener(this)
}
})
// 标记已设置监听器
setTag(KEY_HAS_LISTENER, true)
}

该控件可见性检测方法,最大的用处在于检测 Fragment 的可见性。详细讲解可以点击 页面曝光难点分析及应对方案


Talk is cheap,show me the code


上述源码可以在这里找到。


推荐阅读


业务代码参数透传满天飞?(一)


业务代码参数透传满天飞?(二)


全网最优雅安卓控件可见性检测


全网最优雅安卓列表项可见性检测


页面曝光难点分析及应对方案


你的代码太啰嗦了 | 这么多对象名?


你的代码太啰嗦了 | 这么多方法调用?


作者:唐子玄
来源:juejin.cn/post/7165427955902971918
收起阅读 »

一个艰难就业的23年应届生的2022年

自我介绍 我的家乡是浙江-宁波-余姚,是一名就读于一所位于宁波-慈溪(学校:笑死,这就我一所大学,你直接报我名字得了)的双非独立学院的软件工程专业的23年应届生,7到10月有在南京实习,现在是孤身一人在杭州实习的社恐前端实习生,前端练习时长一年半,擅长唱、跳、...
继续阅读 »

自我介绍


我的家乡是浙江-宁波-余姚,是一名就读于一所位于宁波-慈溪(学校:笑死,这就我一所大学,你直接报我名字得了)的双非独立学院的软件工程专业的23年应届生,7到10月有在南京实习,现在是孤身一人在杭州实习的社恐前端实习生,前端练习时长一年半,擅长唱、跳、rap... 还只擅长Vue的渣渣前端程序猿,有兴趣可以关注我的公众号程序猿青空,23年开始我会时不时分享各种优秀文章、学习资源、学习课程,探索初期,还请多多关照。这篇文章会是我公众号的第一篇文章,主要对我这一年来的经历做一个简单的流水账总结,涉及到恋爱、租房、学习、工作等各方面内容,希望这份经验对你也能有所帮助。


学习


大二下半年的时候分流,自主报名到了我们学校的产业学院——企业和学校联合创办的培养应用型人才的学院。我文科相当薄弱,埋头考研会相当痛苦,也很清楚自己做不来官僚主义那一套,公职也不是适合我的职业(没错我对公职有偏见),很坚定就业这条路。因为还没有毕业,我的身份归根结底就是一个双非下流本科的一名大学生,为了避免自己毕业即失业,看当时产业学院的宣传也不错就去了。


事实上因为产业学院刚创办不久,而且并不是所有人来到这里都是为了就业的,也有可能是为了学分、助学金等其他方面的原因,课程设计、师资力量、同学质量等各方面都良莠不齐、鱼龙混杂。每门课程的期末大作业基本都是一个小项目,大三一年里两个期末都有为了大作业通宵的几天,再加上1500💰凑活过的生活费,死贵的电费和食堂伙食费,在这里学习和生活有时候还蛮辛苦的。好在我很清楚自己应该做什么,天赋不够,努力来凑,本来起跑线就低,更应该比别人卷一点。当然我也不是那种能够没日没夜卷的人(👀),关注了鱼皮,加入了他的知识星球,在星球天天学习健身(没错我还健身💪)打卡的flag没两个礼拜就立不住了,知识付费的事咱也没少干,就是说能一直坚持下来的着实不多,咱也明白咱就是个普通人,逆袭这种事确实还是很难做到的,我这人还是比较佛系的。


大三这一年我用一年的时间从零学前端,自认为还算是没有辜负自己,这一年时间的学习也还算有成果,虽然没法和卷王们争第一,也能跟在他们后面做个万年老二(😭呜呜呜)。下半年开始实习后更别说了,新的技术栈的学习基本就停滞了。实习前我还天真的以为能有更多的时间学习,正相反,比在学校学的更少,因为下班到家七八点,生活琐事会比在学校里多得多,而且我下班后还要花一个多钟头健身,再加上忙碌一天后更无心学习,只想躺平。


下半年做过的最卷的事也就参与了字节青训营,课题选择了前端监控平台,可惜的就是没能在青训营期间完成(😭呜呜呜,队友都摆烂了),当然也就没有结营证书。但我也不甘心就这样算罢,这个项目我就自己拉出来,作为我的毕业设计去完成它。解决实习期间学习效率低的最好办法就是在公司学习一些对公司业务有关或者优化公司项目的知识,名正言顺地摸鱼。我是Vue入门的,这一年里也一直守着Vue,来年第一季度目标就是学习React和Nest,开发一个自己的数据聚合的网站,能变现就最好了(😎欸嘿)。


生活&实习


大三下,也就是今年上半年,为了冲刺暑期实习,也就没去做兼职了,感叹本就艰难的生活的同时,殊不知这是为数不多还能自己自由掌控的日子了(😥我哭死)。其实我开始准备实习还是挺晚了,再加上期末没有太多时间,准备并不是太充分,没有太多自信心,投了几家大厂,不是没回应,就是笔试挂,就有点望而却步。


在我一个大佬同学的介绍下,面试了一家南京的小厂,过程很顺利,实习薪资给的也很可观,当时就没考虑那么多,就选择接受offer了(后来在杭州实习认识了几个小伙伴,才学了没几个月,暑假就面试进了独角兽企业,我那个时候确实应该再多投一投的)。刚开始的想法是第一次出门实习,有份经验就可以,在什么城市没关系,然而事实是工作上确实没什么关系,生活上关系可大了。7月13日第一次一个人拎上行李,义无反顾地去了南京,以为自己终于能够大展拳脚,再不济也能够在公司有所贡献,然而现实总是没那么理想。


上路


因为一个人前往外地工作,第一件事情便是租房,为了省点钱就托南京实习公司的一个同事看房子,因为他的房租到期也要找房子就顺便可以租在一起,有个照应。然而实际上因为是第一次出远门工作和生活,一切和自己的理想差距显然大了许多:因为不是自己实地看的房,而且也是第一次租房,虽然房租只有850💰,但是也可能因为是夏季大家都开空调,差不多50多💰一个礼拜的电费和其他乱七八糟的费用,一个月光租房子就差不多得1200💰,并不算贵,但是性价比极低;我的房间没地方晒衣服,只能晒在那个同事的房间的阳台,作为一个社恐患者,每次去都要做很多心理斗争(他会不会睡了,他会不会在忙....🙃);桌上只能堪堪放下我的显示器和笔记本,鼠标活动范围极小;床应该是睡过好几个租客了,明显的不舒服;吃的方面因为有点水土不服不能随便乱吃,同时也是为了省钱所以选择自己做饭,因此还得购置很多厨具调味品等等,一次性的开销💰不小;回学校的频率比我想象的高,因此来回车费也成为一大负担;当时租房合同是同事代签的,他签了一年,我那时候也不懂也没问,再加上当时换工作离开的比较急,没时间找转租,违约金直接血亏1700💰。


日常挤地铁


生活的种种问题都还能接受或者解决,然而工作方面,因为进入公司的时间段比较特殊再加上疫情影响,在南京实习的三个月里,我始终没有能够在技术上得到足够的提升,再加上与公司和领导的气场不合,使得我在公司整天如坐针毡,甚至有点无所事事(总之就是过的很不开心),虽然有不低的实习薪资,但是我始终没法在那里躺平。因此在中秋决定参与秋招,开始寻找第二份实习工作。


然而今年找工作并不简单,因为频繁发作的疫情,再加上互联网行业这些年的发展,行业的形势非常的严峻,各大公司都削减了HC(head count,人头数,就是最终录用的人数,肯定有小伙伴不懂这个词,我一开始就不懂🤏),作为一个民本23年应届生,在今年的秋招着实很难找到一份理想的工作。那段时间的想法就是尽快找到下一份工作(急急急急急急,我是急急国王),找到一份离家近、工资高、平台大至少满足两个的工作。从9月10日中秋就开始投出第一份简历,到10月19日确定来到杭州的一家四五百人的SaaS公司,这期间投出过几百份简历,得到的回应却寥寥无几,这是一段非常难忘的经历。


这一个月里每一天都在为找工作烦恼,一开始专注于线上面试,却始终的得不到理想工作的认可,持续的碰壁使得开始怀疑自己这些年的学习,自己的选择是不是错了,是不是自己能力确实没法满足他们的要求(被ktv了),后来也决定不放过线下面试的机会,顶着疫情在南京、杭州、家、学校几地频繁奔波,在杭州线下面试的那一天还是顶着自己身体上的各种不适(持续拉肚子,全身酸痛,萎靡不振),仍然要拿出饱满的精神去面对面试,好在当时就获得了面试官也是现在的leader的认可,简直就是久旱逢甘霖,虽然并不是直接发的offer,但是也是十分有信心。杭州比起南京的工作,实习薪资低了很多,但是因为线下面试,对于当时感受到的公司的氛围十分的心动,也就放弃了其他小公司更高薪资的offer,决定了自己的第二份实习工作。


又上路啦


换工作又是换城市,所以又需要租房搬家,购置各种必需品,又是一大笔开销,在还没进公司前始终在担忧自己先择了薪资更低的工作,到时候会不会付出了这么多,结果又远不如预期让自己更痛苦。不过在经过了一个月左右实习后,我在杭州的公司工作的感受让我相信自己的选择没有错。


10月23日我再一次拖着一大堆行李开始了迁徙,本来打算先简单看房子,先回家住几天再自驾,拖着行李回来看房子签合同,所以我把被子等一些大件的行李都寄回家了,但是这次进入杭州后就黄🐎了(之前几地来回跑黄都没黄一下),只能多看几套房子然后就签下来,好在当天就看到一个自己满意的,10几平,押一付一,一个月算上水电差不多也就1300💰,不至于睡大街,但是我没有被子,当时杭州刚开始降温,温度也就个位数,但是买被子太亏了,之后用不上,就买了床毛毯,多盖几件衣服,凑活过了两天(真的凑活,冷的雅痞)。


杭州租的房


11月1日正式入职,正式开启了在杭州的工作生活,有条不紊的入职手续,时长1周的实习生培训,认识了许多和我一起实习的小伙伴,刚进来还赶上公司的双十一活动,让我对未来的工作生活充满希望。


双十一零食自助


第一月开始接触了一些简单的业务,重新开始了健身,第二个月就参与开发了一个简单的项目,还封装了公共组件、开发了简单的提高开发效率的脚手架工具,我终于能够继续有条不紊运转了。


在南京实习的期间除了参加了字节青训营和准备面试而巩固基础外,专业上可以说是没有丝毫提升,不过生活经验确实收获满满,坚定了自己的目标,职业生涯规划更加清晰,为了达到目标去学会自律。这几个月的开销给自己和父母都增添了不小得负担,好在现在稳定下来勉强能够在杭州自给自足,生活重新步入正轨,比起在南京,杭州的生活更加得心应手。但是并不是说南京不好,南京是一个非常优雅的城市,这里有他躺在超市里超乖的猫猫,超治愈


超乖的猫猫


离开南京前我也花时间去好好游玩了两天(去了一些免费的博物馆,景点)。


忘记叫啥地了


比起杭州,我认为南京更适合生活,我只是去到了一个不适合我的公司和因为经验不足吃了不少亏才离开了这个城市。我很珍惜在杭州的这份工作,也非常享受现在忙碌充实的生活,我也希望自己的能力能够不断得到认可,继续探索自己的人生价值。


感情


呜呜呜,鼠鼠该死啊,鼠鼠长了个恋爱脑,但是好在现在穷的雅痞,我还社恐,可以心无旁骛地工作学习(搞💰)。出来实习没几个礼拜就跟在一起一年的女孩子分手了,其实在上半年因为我们对未来规划的分歧就吵过架,她想留在慈溪,而我更向往大城市(当然不止这一点原因啦),那个时候我就很清楚这段感情肯定没法坚持很久,下半年又异地,在各自的城市实习,天天吵架,自然而然就吵分了,累觉不爱。我深知自己不是啥好男人(男人没一个好东西),还没有资本,毕业前绝对要水泥封心(做杭州第一深情)。


其实我家离学校很近,但是从念大学开始还是很少回家了,在学校里没有什么感觉,直到独自出门在外工作才知道在家真好,爸爸妈妈真好(我是妈宝男,呜呜呜😭),看这篇文章的小伙伴不要再随便跟爸爸妈妈撒气了哦。家里的老人只剩下奶奶独自在乡下了,以后一定要多打电话。


展望


在未来的一年中,希望自己能够吸收已经犯过的错误的经验,保质保量地完成未来的各项工作,作为一名程序员最重要的最重要的就是自我驱动,持续学习,通过不断学习才能够在未来的工作中创造更多的价值,以下是我23年的一些计划


学习



  • 这个月先抓紧时间把自己的毕设解决,写复盘的分享博客,之后顺利毕业

  • 上半年学习React,Nest,开发一个数据聚合分享平台,同样做分享

  • 运营自己的博客和各平台账号,不说多少粉丝,能坚持不凉就行,争取每周一个博客

  • 每季至少阅读一本书,学习一个技术栈

  • 坚持自己的每日计划和每月复盘总结(包含年中和年终总结)


工作



  • 因为现在常态化了,不知道今年的就业形势会是什么样的,着实不想再像去年那样被支配了,所以还是希望得到自己满意的薪资的前提下在这里转正,但愿不要出什么幺蛾子吧

  • 继续卷进部门更深层业务,目标负责6个项目

  • 学习更多优化开发效率和质量的技术栈,明年就简单定个两个的目标吧,要求不高


生活



  • 我真的超级想买机车的,但是杭州主城区禁摩,所以先23年下半年花时间考个D照,看情况决定买个机车还是电驴

  • 3月份房租到期了,看房肯定又要放进日程了,看看到时候有没有合租的小伙伴吧,如果有人有兴趣到时候可以分享一下杭州租房经验

  • 健身肯定是要继续的,有一说一我肉体确实没啥天赋(也可能是吃得不够多),健身更多的是一种生活态度吧

  • 我是一个很不喜欢打电话的人,尤其是和长辈,感觉没话聊,但是老人家接到自己孩子的电话,知道孩子过得不错,真的会很开心。明年定个小目标,一个月给奶奶打一通电话。


2022年好像所有人都过的很艰难,或许所有人都想离开浪浪山,但是也不要忘记看看浪浪山的风景,让我们一起加油吧。最后再打个广告,关注公众号程序猿青空,免费领取191本计算机领域黑皮书电子书,更有集赞活动免费挑选精品课程(各个领域的都有),不定期分享各种优秀文章、学习资源、学习课程,能在未来(因为现在还没啥东西)享受更多福利。


作者:CyanSky
来源:juejin.cn/post/7189562801159929915
收起阅读 »

iOS 3年开发迷茫随心聊

iOS 3年开发迷茫随心聊 从毕业开始做iOS,到现在已经是第4个年头了。第一家公司,做了一年,项目没上线就倒闭了,导致找第二家公司的时候也没有一个项目能拿的出手。第二家公司误入一家游戏公司,每天工作就是将H5小游戏做成一个App,想办法上线,一年过去了,技术...
继续阅读 »

iOS 3年开发迷茫随心聊


从毕业开始做iOS,到现在已经是第4个年头了。第一家公司,做了一年,项目没上线就倒闭了,导致找第二家公司的时候也没有一个项目能拿的出手。第二家公司误入一家游戏公司,每天工作就是将H5小游戏做成一个App,想办法上线,一年过去了,技术其实也没什么长进,但是通过这个过程了解一些苹果上架的知识。也有了几个上线项目经验了。由于想找个正经互联网公司做App,也离职找工作。


找工作头一个月,发现面试面的问题都是与底层相关,一问三不知,在家埋头学了2个月底层相关知识(可以理解背题)。原理是懂一点了,但是没有在实际项目中运用,工资也上不太去。在面了20多家公司后,终于找到现在第三份工作。


由于有第二份工作的经历,在第三家公司上班的时候一直在学习,有意识的去面试的原理去解决一些开发中的问题,例如使用runtime解决一些问题,却发现runtime如果没有很强的理解,还是不要用在项目里,因为可能出现未知的风险。例如使用交换方法,全局做了修改,但是后期项目需求更改,保持全局修改的前提下,对其他情况要做不同处理。也没有太多需求会使用到原理的内容,性能也不需要优化。


小公司对技术不太感冒,能完成需求就行,虽然自己力所能及的去做一些规范,但觉得做的还是不够,也不清楚其他公司到底是如何做的。小公司个人感觉对员工做事的责任心更加看重。需求就是写页面,页面还原的好,做的快一点,bug少一点就行了。不是理想的一个团队有什么方案,规范,让开发更有效率。最大感触是还好没有成为一个油腻的开发~。


在现在的公司,做了几个项目,也没有大的bug。学会了Swift进行开发。也许也算是一种收获吧。但是公司不加薪,今年的目标是想学点东西换一份工作。


学了1个月RxSwift,感觉也快学不下去了,公司是不可能用了,网上也有人说这个架构太重了。语言是个问题,自己英语水平有限,学习速度太慢了。如果有看到的这篇文章的小伙伴,也可以给我点意见。


最近想学一点提高开发效率的技能。和面试相关的内容。如果有大神经历过我这个时期,还麻烦给点建议。建议退iOS坑的就不必留言了。个人虽然菜,但是如果还没有做到小公司天花板的话,目前不考虑退坑。


第一次发文章也不知道说啥,后面会更新一些学习

作者:MissSunRise
来源:juejin.cn/post/7071892765763698719
笔记啥的。感谢包容。

收起阅读 »

乱打日志的男孩运气怎么样我不知道,加班肯定很多!

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

前言


线上出现问题,你的第一反应是什么?


如果是我的话,第一时间想的应该是查日志:



  1. if…else 到底进入了哪个分支?

  2. 关键参数是不是有缺失?

  3. 入参是不是有问题,没做好校验放进去了?


良好的日志能帮我们快速定位到问题所在,坑你的东西往往最为无形,良好的日志就是要让这些玩意无所遁形!


日志级别


Java应用中,日志一般分为以下5个级别:



  • ERROR 错误信息

  • WARN 警告信息

  • INFO 一般信息

  • DEBUG 调试信息

  • TRACE 跟踪信息


1)ERROR


ERROR 级别的日志一般在 catch 块里面出现,用于记录影响当前线程正常运行的错误,出现 Exception 的地方就可以考虑打印 ERROR 日志,但不包括业务异常。


需要注意的是,如果你抛出了异常,就不要记录 ERROR 日志了,应该在最终的地方处理,下面这样做就是不对的:


try {
   int i = 1 / 0;
} catch (Exception e) {
   log.error("出错了,什么错我不知道,啊哈哈哈!", e);
   throw new CloudBaseException();
}

2)WARN


不应该出现,但是不会影响当前线程执行的情况可以考虑打印 WARN 级别的日志,这种情况有很多,比如:



  • 各种池(线程池、连接池、缓存池)的使用超过阈值,达到告警线

  • 记录业务异常

  • 出现了错误,但是设计了容错机制,因此程序能正常运行,但需要记录一下


3)INFO


使用最多的日志级别,使用范围很广,用来记录系统的运行信息,比如:



  • 重要模块中的逻辑步骤呈现

  • 客户端请求参数记录

  • 调用第三方时的参数和返回结构


4)DEBUG


Debug 日志用来记录自己想知道的所有信息,常常是某个功能模块运行的详细信息,已经中间的数据变化,以及性能信息。


Debug 信息在生产环境一般是关闭状态的,需要使用开关管理(比如 SpringBoot Admin 可以做到),一直开启会产生大量的 Debug,而 Debug 日志在程序正常运行时大部分时间都没什么用。


if (log.isDebugEnabled()) {
   log.debug("开始执行,开始时间:[{}],参数:[{}]", startTime, params);
   log.debug("通过计算,得到参数1:[{}],参数2:[{}]", param1, param2);
   log.debug("最后处理结果:[{}]", result);
}

5)TRACE


特别详细的系统运行完成信息,业务代码中一般不使用,除非有特殊的意义,不然一般用 DEBUG 代替,事实上,我编码到现在,也没有用过这个级别的日志。


使用正确的格式


如果你是这样打印日志的:


log.info("根据条件id:{}" + id + "查询用户信息");

不要这样做,会产生大量的字符串对象,占用空间的同时也会影响性能。


正确的做法是使用参数化信息的方式:


log.info("根据条件id:[{}],查询用户信息", id);

这样做除了能避免大量创建字符串之外,还能明确的把参数隔离出去,当你需要把参数复制出来的时候,只需要双击鼠标即可,而不是用鼠标慢慢对准再划拉一下。


这样打出来的日志,可读性强,对排查问题的帮助也很大!


小技巧


1)多线程


遇到多个线程一起执行的日志怎么打?


有些系统,涉及到并发执行,定时调度等等,就会出现多次执行的日志混在一起,出问题不好排查,我们可以把线程名打印进去,或者加一个标识用来表明这条日志属于哪一次执行:


if (log.isDebugEnabled()) {
   log.debug("执行ID=[{}],处理了ID=[{}]的消息,处理结果:[{}]", execId, id, result);
}

2)使用 SpringBoot Admin 灵活开关日志级别


image-20220727155526217


写在最后


一开始写代码的时候,没有规范日志的意识,不管哪里,都打个 INFO,打印出来的东西也没有思考过,有没有意义,其实让自己踩了不少坑,加了不少班,回过头,我想对学习时期的我说一句:”能让你加班的东西,都藏在各种细节里!写代码之前,先好好学习如何打日志!“


作者:你算哪块小蛋糕
来源:juejin.cn/post/7124958610123128839
收起阅读 »

Vue2 Diff 算法

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

Diff 算法


写在前面


因为之前看面试直播也经常问到 Diff 算法,然后作者本人用 Vue2 比较多,所以打算研究一下 Vue2 的 Diff 算法,其实很早就想学的,但是可能因为感觉 Diff 算法比较深奥,就一直拖着没学,但是最近在准备面试,就想着迟早都要学的,趁这个机会把 Diff 算法搞懂吧 🧐,作者就花了一天的时间研究了一下,可能没有完全理解 Diff 算法的精髓,请各位见谅。



💡 这个其实算作者的学习笔记,而且作者水平有限,改文章仅代表作者个人观点,如果有错误可以评论区指出来,会不断完善;同时本文很长,所以请读者们有耐心的看完,看完后作者相信你们会对 Diff 算法有更深的了解。本人觉得本文比目前网上讲解 Diff 算法的大部分文章要更好,因为本文从问题出发,教会大家如何思考,而不是直接从答案出发,就像读答案一样,这样感觉没什么意思,本文一步一步的引导大家去感受 Diff 算法的精妙,同时最后也做了一下小实验,让大家对 Diff 算法有更加直观的感受 🎉。



为什么要用 Diff 算法


虚拟 DOM


因为 Vue2 底层是用虚拟 DOM 来表示页面结构的,虚拟 DOM其实就是一个对象,如果想知道怎么生成的,其实大概流程就是:



  • 首先解析模板字符串,也就是 .vue 文件

  • 然后转换成 AST 语法树

  • 接着生成 render 函数

  • 最后调用 render 函数,就能生成虚拟 DOM


最小量更新


其实框架为了性能才使用的虚拟 DOM,因为 js 生成 DOM 然后展示页面是很消耗性能的,如果每一次的更新都把整个页面重新生成一遍那体验肯定不好,所以需要找到两个页面中变化的地方,然后只要把变化的地方用 js 更新 (可能是增加、删除或者更新) 一下就行了,也就是最小量更新。
那么怎么实现最小量更新呢?那么就要用 Diff 算法了,那么 Diff 算法对比的到底是什么呢?可能这是刚学 Diff 算法比较容易误解的地方,其实比对的是新旧虚拟 DOM,所以 Diff 算法就是找不同,找到两次虚拟 DOM 的不同之处,然后将不同反应到页面上,这就实现了最小量更新,如果只更新变化的地方那性能肯定更好。


页面更新流程


其实这个比较难解释,作者也就大致说一下,学了 Vue 的都知道这个框架的特点之一就有数据响应式,什么是响应式,也就是数据更新页面也更新,那么页面是怎么知道自己要更新了呢?其实这就是这个框架比较高明的地方了,大致流程如下:



  • 之前也说了会运行 render 函数,那么运行 render 函数的时候会被数据劫持,也就是进入 Object.definePropertyget,那么在这里收集依赖,那么是谁收集依赖呢?是每个组件,每个组件就是一个 Watcher,会记录这个组件内的所有变量 (也就是依赖),当然每个依赖 (Dep) 也会收集自己所在组件的 Watcher;

  • 然后当页面中的数据发生变化,那么就会出发 Object.definePropertyset,在这个函数里面数据就会通知每个 Watcher 更新页面,然后每个页面就会调用更新方法,这个方法就是 patch

  • 接着,就要找到两个页面之间的变化量,那么就要用到 Diff 算法了

  • 最后找到变化量后就可以进行更新页面了



其实是边找边更新的,为了让大家理解容易就将这两个部分分开了



Diff 算法简单介绍


面试问到 Diff 算法是什么,大家肯定会说两句,比如 头头、尾尾、尾头、头尾深度优先遍历(dfs)同层比较 类似这些话语,虽然能说一两句其实也是浅尝辄止。
其实作者看了 CSDN 上发的关于 Diff 算法的文章,就是阅读量很高的文章,作者觉得他也没讲明白,可能他自己没明白,或者自己明白了但是没讲清楚,那么作者会用自己的感悟和大家分享一下。


Diff 算法的前提


为了让大家能够了解清楚,这里先说明一下函数调用流程:



  • patch

  • patchVnode

  • updateChildren


Diff 算法的 前提 这个是很重要的,可能大家会问什么是前提?不就是之前说的那些比较嘛?说的没错但也不对,因为 Diff 算法到达之前说的 头头、尾尾、尾头、头尾 这一步的前提就是两次对比的节点是 相同的,这里的相同不是大家想的完全相同,只是符合某些条件就是相同了,为了简化说明,文章就只考虑一个标签只包含 key标签名(tag),那么之前说的相同就是 key 相同以及 tag 相同,为了证明作者的说法是有点正确的,那么这里也贴上源码:


// https://github.com/vuejs/vue/blob/main/src/core/vdom/patch.ts
// 36行
function sameVnode(a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory &&
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
(isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))
)
}

如果怕乱了,下面的可以省略不看也没事不影响整体了解,下面只是为了考虑所有情况才加的一个判断:
那么如果两个虚拟 DOM 不相同其实就不用继续比较了,而且如果相同也不用比较了,这里的相同是真的完全相同,也就是两个虚拟 DOM 的地址是一样的,那么也贴上源码:


function patchVnode(......) {
if (oldVnode === vnode) {
return
}
......
}

到目前为止大家可能会比较乱,现在总结一下:



  • patch 函数里比较的是新老虚拟 DOM 是否是 key 相同以及 tag 相同,如果不相同那么就直接替换,如果相同用 patchVnode


说了这么多,其实作者也就想表达一个观点,就是只有当两次比较的虚拟 DOM 是 相同的 才会考虑 Diff 算法,如果不符合那直接把原来的删除,替换新的 DOM 就行了。


patchVnode 函数


这个函数里的才是真正意义上的 Diff 算法,那么接下来会结合源码向大家介绍一下。



源码中核心代码在 patch.ts 的 638 行至 655 行。



其实,目前介绍 patchVnode 的都是直接对着源码来介绍的,但是大家可能不清楚为啥要这么分类,那么作者在这里就让大家明白为什么这么分类,首先在这里说一个结论:



  • 就是 text 属性和 children 属性不可能同时存在,这个需要大家看模板解析源码部分


那么在对比新旧节点的情况下,主要比较的就是是否存在 textchildren 的情况,那么会有如下九种情况


情况老节点 text老节点 children新节点 text新节点 children
1
2
3
4
5
6
7
8
9

按照上面的表格,因为如果新节点有文本节点,其实老节点不管是什么都会被替换掉,那么就可以按照 新节点 text 是否存在来分类,其实 Vue 源码也是这么来分类的:


if (isUndef(vnode.text)) {
// 新虚拟 DOM 有子节点
} else if (oldVnode.text !== vnode.text) {
// 如果新虚拟 DOM 是文本节点,直接用 textContent 替换掉
nodeOps.setTextContent(elm, vnode.text)
}

那么如果有子节点的话,那应该怎么分类呢?我们可以按照每种情况需要做什么来进行分类,比如说:



  • 第一种情况,我们啥都不用做,因此也可以不用考虑

  • 第二种情况,我们应该把原来 DOM 的 textContent 设置为 ''

  • 第三种情况,我们也应该把原来 DOM 的 textContent 设置为 ''

  • 第四种情况,我们应该加入新的子节点

  • 第五种情况,这个情况比较复杂,需要对比新老子节点的不同

  • 第六种情况,我们应该把原来的 textContent 设置为 '' 后再加入新的子节点


那么通过以上六种情况 (新虚拟 DOM 不含有 text,也就是不是文本节点的情况),我们可以很容易地进行归类:



  • 分类 1️⃣: 第二种情况第三种情况。进行的是操作是:把原来 DOM 的 textContent 设置为 ''

  • 分类 2️⃣: 第四种情况第六种情况。进行的是操作是:如果老虚拟 DOM 有 text,就置空,然后加入新的子节点

  • 分类 3️⃣:第五种情况。进行的是操作是:需要进行精细比较,即对比新老子节点的不同


其实源码也是这么来进行分类的,而且之前说的 同层比较 也就得出来了,因为每次比较都是比较的同一个父节点每一个子元素 (这里的子元素包括文本节点和子节点) 是否相同,而 深度优先遍历(dfs) 是因为每次比较中,如果该节点有子节点 (这里的子节点指的是有 children 属性,而不包括文本节点) 的话需要进行递归遍历,知道最后到文本节点结束。



⭕️ 这里需要搞清楚子节点和子元素的区别和联系



然后我们来看看源码是怎么写吧,只看新虚拟 DOM 不含有 text,也就是不是文本节点的情况:


if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch)
// 递归处理,精细比较
// 对应分类 3️⃣
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (__DEV__) {
checkDuplicateKeys(ch) // 可以忽略不看
}
// 对应分类 2️⃣
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 对应分类 1️⃣
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 对应分类 1️⃣
nodeOps.setTextContent(elm, '')
}
}

❓我们可以看到源码把分类 1️⃣ 拆开来了,这是因为如果老虚拟 DOM 有子节,那么可能绑定了一些函数,需要进行解绑等一系列操作,作者也没自信看,大致瞄了一眼,但是如果我们要求不高,如果只是想自己手动实现 Diff 算法,那么没有拆开的必要。


作者觉得这么讲可能比网上其他介绍 Diff 算法的要好,其他的可能直接给你说源码是怎么写的,可能没有说明白为啥这么写,但是通过之前这么分析讲解后可能你对为什么这么写会有更深的理解和帮助吧。


updateChildren 函数



同层比较



因为当都含有子节点,即都包含 children 属性后,需要精细比较不同,不能像之前那些情况一样进行简单处理就可以了
那么这个函数中就会介绍大家经常说的 头头、尾尾、尾头、头尾 比较了,其实这不是 Vue 提出来的,是很早就提出来的算法,就一直沿用至今,大家可以参考【snabbdom 库】


🌟 在这之前我们要定义四个指针 newStartIdxnewEndIdxoldStartIdxoldEndIdx,分别指向 新头节点新尾节点旧头节点旧尾节点


循环条件如下:


while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
......
}

其实这个比较也就是按人类的习惯来进行比较的,比较顺序如下 :



  • 1️⃣ 新头节点旧头节点++newStartIdx++oldStartIdx

  • 2️⃣ 新尾节点旧尾节点--newEndIdx--oldEndIdx

  • 3️⃣ 新尾节点旧头节点:需要将 旧头节点 移动到 旧尾节点之前,为什么要这么做,讲起来比较复杂,记住就好,然后 --newEndIdx++oldStartIdx

  • 4️⃣ 新头节点旧尾节点:需要将 旧尾节点 移动到 旧头节点之前,为什么要这么做,讲起来比较复杂,记住就好,然后 ++newStartIdx--oldEndIdx

  • 5️⃣ 如果都没有匹配的话,就把 新头节点 在旧节点列表 (也就是 children 属性的值) 中进行查找,查找方式按照如下:

    • 如果有 key 就把 keyoldKeyToIdx 进行匹配,oldKeyToIdx 根据旧节点列表中元素的 key 生成对应的下标

    • 如果没有,就按顺序遍历旧节点列表找到该节点所在的下标

    • 如果在旧节点列表是否找到也分为两种情况:

      • 找到了,那么只要将 新头节点 添加到 旧头节点 之前即可

      • 没找到,那么需要创建新的元素然后添加到 旧头节点 之前

      • 然后把这个节点设置为 undefined






根据循环条件我们可以得到两种剩余情况,如下:



  • 6️⃣ 如果 oldStartIdx > oldEndIdx 说明老节点先遍历完成,那么新节点比老节点多,就要把 newStartIdxnewEndIdx 之间的元素添加

  • 7️⃣ 如果 newStartIdx > newEndIdx 说明新节点先遍历完成,那么老节点比新节点多,就要把 oldStartIdxoldEndIdx 之间的元素删除


其实我们上面还没有考虑如果节点为 undefined 的情况,因为在上面也提到过,如果四种都不匹配后会将该节点置为 undefined,也只有旧节点列表中才有,因此要在开头考虑这两种情况:



  • 8️⃣ 当 oldStartVnodeundefined++oldStartIdx

  • 9️⃣ 当 oldEndVnodeundefined--oldEndIdx


那么我们来看源码怎么写的吧,其中用到的函数可以查看源码附录


// https://github.com/vuejs/vue/blob/main/src/core/vdom/patch.ts
// 439 行至 556 行
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
// 情况 8️⃣
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
// 情况 9️⃣
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 情况 1️⃣
patchVnode(...)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 情况 2️⃣
patchVnode(...)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
// 情况 3️⃣
patchVnode(...)
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
// 情况 4️⃣
patchVnode(...)
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 情况 5️⃣
if (isUndef(oldKeyToIdx)) // 创建 key -> index 的 Map
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 找到 新头节点 的下标
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) {
// New element
// 如果没找到
createElm(...)
} else {
// 如果找到了
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(...)
oldCh[idxInOld] = undefined
canMove &&
nodeOps.insertBefore(
parentElm,
vnodeToMove.elm,
oldStartVnode.elm
)
} else {
// same key but different element. treat as new element
createElm(...)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
// 情况 6️⃣
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(...)
} else if (newStartIdx > newEndIdx) {
// 情况 7️⃣
removeVnodes(...)
}


如果问为什么这么比较,回答就是经过很多人很多年的讨论得出的,其实只要记住过程就行了,如果想要更深了解 Diff 算法,可以去 B 站看【尚硅谷】Vue源码解析之虚拟DOM和diff算法



v-for 中为什么要加 key


这个问题面试很常见,但是可能大部分人也就只会背八股,没有完全理解,那么经过以上的介绍,我们可以得到自己的理解:



  • 首先,如果不加 key 的话,那么就不会去 Map 里匹配 (O(1)),而是循环遍历整个列表 (O(n)),肯定加 key 要快一点,性能更高

  • 其次,如果不加 key 那么在插入或删除的时候就会出现,原本不是同一个节点的元素被认为是相同节点,上面也有说过是 sameVnode 函数判断的,因此可能会有额外 DOM 操作



为什么说可能有额外 DOM 操作呢?这个和插入的地方有关,之后会讨论,同理删除也一样



证明 key 的性能


我们分为三个实验:没有 key、key 为 index、key 唯一,仅证明加了 key 可以进行最小化更新操作。


实验代码


有小伙伴评论说可以把代码贴上这样更好,那么作者就把代码附上 🥳:


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
.box {
display: flex;
flex-direction: row;
}
.item {
flex: 1;
}
</style>
</head>

<body>
<div id="app">
<div class="box">
<div class="item">
<h3>没有 key</h3>
<p v-for="(item, index) in list">{{ item }}</p>
</div>
<div class="item">
<h3>key 为 index</h3>
<p v-for="(item, index) in list" :key="index">{{ item }}</p>
</div>
<div class="item">
<h3>key 唯一</h3>
<p v-for="(item, index) in list" :key="item">{{ item }}</p>
</div>
</div>
<button @click="click1">push(4)</button>
<button @click="click2">splice(1, 0, 666)</button>
<button @click="click3">unshift(999)</button>
<br /><br />
<button @click="click4">pop()</button>
<button @click="click5">splice(1, 1)</button>
<button @click="click6">shift()</button>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
show: false,
list: [1, 2, 3],
},
methods: {
click1() {
this.list.push(4);
},
click2() {
this.list.splice(1, 0, 666);
},
click3() {
this.list.unshift(999);
},
click4() {
this.list.pop();
},
click5() {
this.list.splice(1, 1);
},
click6() {
this.list.shift();
}
},
})
</script>
</body>

</html>

增加实验


实验如下所示,我们首先更改原文字,然后点击按钮**「观察节点发生变化的个数」**:


在队尾增加


在这里插入图片描述


在队内增加


在这里插入图片描述


在队首增加


在这里插入图片描述


删除实验


在队尾删除


在这里插入图片描述


在队内删除


在这里插入图片描述


在队首删除


在这里插入图片描述


实验结论


增加实验


表格为每次实验中,每种情况的最小更新量,假设列表原来的长度为 n


实验没有 keykey 为 indexkey 唯一
在队尾增加111
在队中增加n - i + 1n - i + 11
在队首增加n + 1n + 11

删除实验


表格为每次实验中,每种情况的最小更新量,假设列表原来的长度为 n


实验没有 keykey 为 indexkey 唯一
在队尾删除111
在队中删除n - in - i1
在队首删除nn1

通过以上实验和表格可以得到加上 key 的性能和最小量更新的个数是最小的,虽然在 在队尾增加在队尾删除 的最小更新量相同,但是之前也说了,如果没有 key 是要循环整个列表查找的,时间复杂度是 O(n),而加了 key 的查找时间复杂度为 O(1),因此总体来说加了 key 的性能要更好。


写在最后


本文从源码和实验的角度介绍了 Diff 算法,相信大家对 Diff 算法有了更深的了解了,如果有问题可私信交流或者评论区交流,如果大家喜欢的话可以点赞 ➕ 收藏 🌟


源码函数附录



列举一些源码中出现的简单函数



setTextContent


function setTextContent(node: Node, text: string) {
node.textContent = text
}

isUndef


function isUndef(v: any): v is undefined | null {
return v === undefined || v === null
}

isDef


function isDef<T>(v: T): v is NonNullable<T> {
return v !== undefined && v !== null
}

insertBefore


function insertBefore(
parentNode: Node,
newNode: Node,
referenceNode: Node
) {
parentNode.insertBefore(newNode, referenceNode)
}

nextSibling


function nextSibling(node: Node) {
return node.nextSibling
}

createKeyToOldIdx


function createKeyToOldIdx(children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}

作者:Karl_fang
来源:juejin.cn/post/7204752219915747388
收起阅读 »

技术管理者应有的 4 种基本思维模式

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

在看文章之前先思考两个问题:



  • 人和人的差别究竟在哪?

  • 人和人之间为什么会有差别?


在各种场合我们经常听到这样一句话:「听懂了很多道理,却依然过不好这一生」。
这里有两个逻辑,一个是知行合一的逻辑,另一个是人的成长在于是否超越了昨天的自己。


今天我们聊一聊作为一个技术管理者应该要有的 4 种基本思维:掌控者思维、杠杆思维,终局思维,闭环思维


掌控者思维


在聊掌控者思维之前我们先看一下掌控者思维的反面:受害者思维。


受害者思维又称为弱者思维,有受害者思维的人在任何时候都会把自己当作一个受害者或弱者,极端者会把整个世界都投射为「伤害者」,「都是别人不对,都是别人对不起我」。


受害者思维是一种思维定势,其本质上是一种忽视自己的主观能动性的行为。


在《乡村爱情 13》里面,王木生是老总儿子,富二代,但是干啥啥不行,还总是给自己找借口,说大环境不好,其他没这背景的都比他强。赵本山饰演的他爹王大拿就教训他:「自己没能力就说没能力,怎么你到哪,哪都大环境不好,咋,你是破坏大环境的人啊?」


这就是比较明显的受害者思维,也就是我们常说的「甩锅」。
受害者思维不仅仅只有这一种表现,在《拆掉思维里的墙》里面,作者总结了 5 种受害者思维的表现。



  1. 推卸责任,保住面子: 上面王木生就是明显的例子,还有像一些网上的评论说的「便秘都怪地球没引力」都是类似的表现。在这种情况下,受害者思维就会认为自己没有责任,不需要承担责任,相信地球没引力,相信大环境不好……

  2. 安心做坏事:这种表现就是为自己的行为找到一个自洽的逻辑,让自己有一个完美的故事,从而在做坏事的时候能心安理得。比如 2001 被判死刑的「杀人魔头张君」(实施团伙抢劫,犯故意杀人罪 2 2次,致 28 人死亡、22 人重伤)在 2001.4.17 一审审理结束后对公众说的话:「我还要向没有枪的受害者和你们的家庭说声‘对不起’,现在想起来,以前有些事情确实做错了,但我没办法,因为我要生存。」

  3. 让我们一起分享凄惨故事会:寻找同伴,和他们分享你的受害过程,在他们那里得到安慰。比如有个女孩失恋了,她的闺蜜会陪她喝酒,说男人没有一个好东西;

  4. 用受害获得同情和帮助:第 4 种和第 3 种有些类似,其区别是上面是通过群体得到安慰,得到是一种情感或者心理上的安慰,而这种是更直接的帮助或者说利益,如我们在职场里面看到的一些人会假装自己不会,从而让热心的同事帮忙解决,自己偷懒,或者在路上我们看到的一些乞丐,可能他们的收入比你想象中多很多;

  5. 自我伤害,绑架他人:这种就更极端了,生活中常见于情侣分手,某一方说,你要是离开我,我就自杀,或者电视剧中的男主站在女主楼下淋雨以求得原谅或者求得表白成功。


如此种种,这些可以让你有短暂的快乐和安全感,但是慢慢就会失去自信、勇气以及自我改进的能力,就会真的变成一个受害者,一个弱者。


我们活在这个尘世,很多事情已经无法掌握,如古罗马哲学家爱比克泰德(Epictetus, 约55~约135年)所说:「我们登上的并非我们所选择的舞台,演出并非我们所选择的剧本」,如果连我们自己也无法掌控,让自己的快乐、成功都掌控在别人手里,这会是怎样一个光景呢?


当你带一个技术团队的时候,当你的团队遇到问题的时候,当你的下属遇到他认为过不去的坎的时候,当发生线上事故的时候,当老板怼到脸上的时候,你会怎么办呢?


当一个受害者吗?是大环境不好?是技术太难?还是坏人太多?


不,绝对不是这样,一个技术管理者应该是强于面对现实,敢于直面问题,像汪涵在我是歌手第三季总决赛面对大型事故时所说的那样:「没事儿不惹事,事儿来了也不要怕事」,以一种掌控的状态来扛起事情,解决问题。


这就是我们所说的掌控者思维,一种强者思维,一种充分发挥自己主观能动性的行为。


掌控者思维的核心思考是:「如果把所有经历过的事情重新倒推一遍,所有条件都不改变,只有自己改变,你能否得到一个更好的结果呢


掌控者思维是从责任心,自我成长,主动改变等方面来提升一个技术管理者的修养,以达到自我的精进。
并且不管什么情况,你都可以负全责。只要你愿意,你就可以做得更好。


除了不怕事,负起责任,还有一些是我们必须要做的。



  1. 学习,提升自己的认知,把自己从知道自己不知道变成知道自己知道,换句话说,保持有计划的学习;

  2. 自省,持续的自省,比如当你有一件事做得不好,自我反思一下哪里做得不好,下一次如何改进,或者更深入一些读一些相关的系统的书,写一篇文章或文档来复盘,掌控自己,从卷自己开始;

  3. 计划,凡事预则立,不预则废,如下棋一般,走一步,算三步,把事情想在前头,如美国首席数据科学家 DJ Patil 所说流传很广的那个便签上的话:「Dream in Years. Plan in Months Evaluate in Weeks. Ship Daily. 」

  4. 知行合一,只有做到,才是真的知道。


当然,做一个掌控者会累一些,但是与自由相比,累又算得了什么呢?


杠杆思维


阿基米德说:「给我一个支点,我就能撬起整个地球。」。


一个优秀的人,都有「杠杆思维」,懂得利用杠杆原理,以较小的付出,撬动更大的回报。这里我们所要说的是杠杆思维中的团队杠杆。


当我们从一个开发变成一个技术管理者时,就不再是一个人在战斗了,你的主要责任就不再是写好代码,而是「使众人行」。


时间对于每个人都是公平的,一个人一天都有 24 小时,它是有限的,而我们所面对的这个世界,工作这些都是无限的。


在一个健康经营的互联网企业中,需求也是无限的,而开发同学的时间都是有限的,除了优先级,我们还能做什么呢?此时有人会说:加人啊。是的,加人。但是加人不是随便加的,他有一个底层逻辑,这个就是今天我们要说的团队杠杆。


团队杠杆的本质是叠加效率,人的时间是有限的,而事情是无限的。


什么是团队杠杆?


通过团队,团队的管理者确定好方向,通过良好的机制和人才梯队,系统化成功的路径,叠加个体的优势,创造出远超个人贡献总和的价值,让 1+1>2,这就是团队杠杆。


既然是杠杆,就一定有一个支点,咱们的支点是什么呢?


对一个团队来说,其支点是已经达成了共识的目标和价值观。


目标是什么,是方向,是团队成员能看到的远方。


为什么是达成了共识?是因为只有达成了共识才能保证目标的统一,才能有意愿,有内在的动力去做事情,才能起到杠杆的效果。


目标和价值观只是最基础的,对于一个团队来说,不同的时候其支点也会不同。



  • 在野蛮生长的团队中,核心岗位上的同学的能力提升是主要支点,这里的核心岗位上的能力提升有两层意思,一个是培养员工,给他们训练的机会,培训,从而快速成长;另一个层面是考虑换一个人,当你这认为这个岗位上的员工的能力、态度差不多到顶了,那就可以考虑用更好、更合适的人来替换,这里的逻辑是选拔大于培养

  • 在快速扩张的团队中,流程和效率是主要支点,通过流程提升协同的效率,通过流程让新人快速融入团队;

  • 在稳定发展的团队中,标准和系统是主要支点,当业务趋于稳定,通过标准和系统固化操作,以系统代码流程和人工。


有了支点,我们就需要开始寻找杠杆,对于一个技术团队来说,团队的杠杆有多大,由人决定。
一个团队里面的人是怎样的,人才梯队是怎样的,人才密度是怎样的?


像麦肯锡,每年都会从全球各个顶尖大学,比如哈佛大学、斯坦福大学、麻省理工学院等,招一大批刚毕业的年轻人,不管是不是商学院毕业的。这些绝顶聪明的年轻人,就是麦肯锡充沛而有效的「团队杠杆」。


对于我们一个普通的互联网创业公司来说,作为一个技术团队的负责人,在有限的范围内选出符合要求的同学,同时尽我所能的带好团队。对于如何来带好团队,我们可以从如下 4 个大的方面来



  1. 信任:不管是平级,还是上下级,信任是必须品,通过良好的沟通,多次的合作,一起的奋斗达成信任,这样才是一个好的团队;

  2. 习惯:管理者以身作则培养团队良好的工作习惯,如做事闭环,分级处理,不越红线等等;

  3. 标准:制定能落地的标准,让大家知道什么是好的,什么是对的;

  4. 能力:流程机制决定事情的下限,人才梯队和个人能力决定事情的上限,管理者要帮忙团队成员成长,发现他们的短板,坦率的沟通并提出改进期望,提升成员的技能,以达成更好的结果。


以上带好团队的表述有些虚,但是是这么个逻辑,各团队各公司术法不同,但方向一致。


用好团队杠杆,一个人走得更快,一群人走得更远。


终局思维


什么是终局思维?


所谓终局思维,是指从终点或者未来的某个时间节点出发,回看现在所做选择,并进行推演的一种思考模式。


这里可能有人会问,什么是终局,是否有终局?这就有点哲学的味道了,先假定有吧,至少在某个未来的时间段是会有一个确定的终局。


我们经常听到人们感叹,如果这辈子能够再来一回,很多错误就不会犯,很多人也不会错过,这算是终局思维的一种表现。只是这个终局思维是等到了终局再提起,有些晚了,我们可以再早一些。


我们在做一件事情的时候,如果能思考一下这件事情的最终达成的目标,或者把最终的形态在脑海里面勾画出来,以终为始,站在未来看当下,修正当下正在做的事情,那么这件事情成功的概率可能就不一样了。


终局思维有 4 个核心点:



  1. 认清方向,所谓的终局思维,一定是你要知道终局是什么,一定要有目标有方向;

  2. 拉长时间周期看问题,在一个较长时间维里来反复推演的逻辑;

  3. 从历史或者更宏观的层面看问题,不局限于当下,不局限于眼前的一亩三分地,把视野拉到历史长河等更大的层面;

  4. 反推机制,终局思维中非常重要的点,从未来反向推演现在要做什么,或者从现在推演未来怎样。


终局思维有什么用?


当一个企业的老板大概知道了未来是什么样子,又能不断地判断和复盘自己的能力,他自然就有了非常强的战略驱动能力,也非常敢为未来投资。


当一个技术管理者知道了团队未来是怎么样的,又能反思当前的情况,从未来形态反思当下的状态,他自然就知道现在最重要的是做什么。


当一个开发同学知道了自己在职场上想成为什么样的人,又能反思自己提升自己,他自然知道现在应该做什么,应该学什么,应该选择什么样的团队,什么样的路。


但是,这里一定会有一个痛苦的过程,甚至不止一个,可能是一直在痛苦中,折腾自己,反思自己,提升自己,一直有危机感,一直在学习中。


心理学研究说,人类对于外部世界的认识可分为三个区域:舒适区(comfort zone),学习区(stretch zone),和恐慌区(stress zone)。我们反思自己、提升自己的过程基本都是在学习区,人只有在学习区的时候,才会是进步的时候。


闭环思维


经常听到人人说:「凡事有交代,件件有着落,事事有回音」,这是典型的闭环思维,是一个职场人的必备思维。作为一个技术管理者更应该具备闭环思维,因为这将是你带团队的核心逻辑之一,它强调责任、敏感性和团队协作,是使众人行的必备。


以「逆向」的逻辑来推演一下,假设你带的团队成员都具备闭环思维,将是什么样的场景?



  • 高效率:闭环思维要求在一定时间内,无论任务完成的效果如何,都要认真、负责的反馈给发起人。这可以帮助团队及时发现问题并加以解决,从而提高工作效率。

  • 强协作:闭环思维需要团队成员之间相互配合,洞察干系人每个阶段的需求,并在截止期内给对方反馈。这可以增强团队协作,提高团队的工作效率和成果。

  • 靠谱:闭环思维要求做事靠谱,做事清楚明白,不会出现意外差错,就算出了问题也会提前告知,让对方可以启用方案 B。这样的技术团队会让人觉得可靠和可信,提高团队在公司中的信任度。

  • 好形象:闭环思维是一种良好的职业素养,可以让你和你的团队建立良好的职业形象。在职场中,「闭环思维」不仅仅说明了一个员工拥有完成任务的能力,还体现出「良好的合作意识」,这样的你会让上级、兄弟部门觉得很「靠谱」,能够放心的将工作交给你和你的团队。


那么如何做到闭环思维呢?


闭环思维有两层逻辑,一个是完整,即有完整的闭环,做事情的时候需要考虑事情的起点、目标、影响因素和结果,明确需求和关键点,以及可能遇到的问题,在过程中设置检查点,让事情可控;另一个是主动反馈,在我们完成一项工作或参加一项事情,不管执行效果如何,都要及时将结果反馈给发起人。


第一层逻辑我们可以使用 PDCA 环来辅助达成,第二层反馈逻辑还是需要刻意去练习,甚至给自己设置一些任务提醒来达到反馈的目的,并且在反馈时尽量做到 STAR 的表述。


番外


最近 chatGPT 一直在火,于是就今天的题目我问了 chatGPT 一些问题:



  • 技术管理者必备的4种基本思维模式

  • 技术管理者必备的思维模式

  • 技术管理的四种思维

  • 技术管理者必备的4种思维


大概的答案如下:


openAI 的回答


问:技术管理者必备的思维模式


作为一名技术管理者,以下是一些必备的思维模式:



  1. 系统思维模式:技术管理者需要具备系统思维模式,能够理解和处理复杂系统的各种关系和相互作用,从而在整个系统中发现问题并提供解决方案。

  2. 创新思维模式:技术管理者需要能够持续地推动创新,寻找新的解决方案并将其应用到业务中,从而实现业务目标。

  3. 问题解决思维模式:技术管理者需要能够分析和解决各种问题,包括技术问题、组织问题和人员问题等。

  4. 客户导向思维模式:技术管理者需要关注客户需求和体验,从客户的角度出发思考问题,为客户提供高质量的产品和服务。

  5. 团队合作思维模式:技术管理者需要具备团队合作意识,能够有效地管理和领导团队,激发员工的潜力,协调各种资源,实现业务目标。

  6. 风险管理思维模式:技术管理者需要对业务中可能出现的风险进行预测和管理,制定相应的应对措施,确保业务的可持续发展。

  7. 数据驱动思维模式:技术管理者需要依据数据进行决策和管理,从数据中发现问题和机会,制定相应的战略和计划,提高业务效率和效果。

  8. 学习和成长思维模式:技术管理者需要保持学习和成长的态度,不断学习新知识和技能,从他人的经验中汲取营养,不断提高自身的领导能力和管理水平。


phind 的回答


问:技术管理者必备的4种基本思维模式


答案1


作为技术管理者,必备的4种基本思维模式如下:




  1. 系统思维:技术管理者需要具备系统思维,可以将复杂的问题分解为简单的部分,然后逐一解决。例如,当公司需要开发一个新产品时,技术管理者需要思考每个阶段的具体步骤,确定每个步骤的时间表和责任人,确保整个过程的顺利进行。




  2. 创新思维:技术管理者需要具备创新思维,可以在技术方面寻找新的解决方案,以提高公司的竞争力。例如,当公司需要开发一项新技术时,技术管理者需要思考如何利用现有技术来开发出更好的产品,或者如何开发出全新的技术来满足市场需求。




  3. 沟通思维:技术管理者需要具备良好的沟通能力,可以与员工、客户和其他部门进行有效的沟通。例如,当公司需要与客户沟通产品需求时,技术管理者需要了解客户的需求,并将这些需求传达给开发团队。




  4. 领导思维:技术管理者需要具备领导思维,可以激励员工,推动项目进展,达成公司目标。例如,当公司需要完成一个重要项目时,技术管理者需要领导开发团队,确保项目按时完成,并向管理层报告项目进展情况。




技术管理者不仅需要具备以上4种基本思维模式,还需要具备自我驱动、架构把握、目标管理等核心能力,才能胜任管理岗位。在实际工作中,技术管理者应该灵活运用这些思维模式和能力,不断提升自己的管理水平。


答案 2


根据多个来源,以下是技术管理必备的四种思维和其他相关思维模型:




  1. 抽象思维:这种思维能力可以帮助技术人快速抽取面对问题的关键要素和本质,从而更好地分析和解决问题。




  2. 系统思维:这种思维能力可以帮助技术人将问题看作一个整体,理解各个组成部分之间的相互作用和影响,从而更好地进行系统设计和管理。




  3. 创新思维:这种思维能力可以帮助技术人寻找新的解决方案和创新点,从而更好地推动技术进步和发展。




  4. 逻辑思维:这种思维能力可以帮助技术人分析和推理问题,从而更好地发现问题根源和解决方案。




除了以上四种思维,以下是其他常用的思维模型:




  • PEST分析:这是一种常用的管理咨询框架,可以帮助企业从政治、经济、社会和技术等多个角度分析市场环境和竞争对手,从而更好地制定战略和决策。




  • PDCA循环:这是一种管理模型,可以帮助企业进行持续改进和优化,包括计划、执行、检查和行动四个环节。




  • SMART原则:这是一种目标管理模型,可以帮助企业制定具体、可衡量、可达成、有意义和时限的目标,从而更好地推动业务发展。




  • STAR法:这是一种面试技巧,可以帮助面试者回答问题更具体、更有条理、更有说服力,包括情境(situation)、任务(task)、行动(action)和结果(result)四个要素。




  • 5W2H法:这是一种问题分析和解决模型,可以帮助企业和个人从五个W(what、why、who、when、where)和两个H(how、how much)多个角度分析问题和制定解决方案。




  • PERT 技术:这是一种项目管理工具,可以帮助企业规划和安排整个项目行程,跟踪实施阶段,从而更好地控制项目进度和成本。




总之,技术管理必备的思维能力和模型是多种多样的,需要根据具体情况选择和应用。除了上述思维能力和模型外,还有很多其他的思维工具和技术,需要技术人员不断学习和掌握,以更好


答案 3


根据多个来源的信息,以下是四种技术管理必备的思维能力:




  1. 抽象思维:这种思维能力可以帮助技术人员快速抽取问题的关键要素和本质,从而更快地理解问题和解决问题。例如,当面对一个新的编程语言时,抽象思维可以帮助技术人员快速理解其核心概念和语法规则。




  2. 产品思维:这种思维能力可以帮助技术人员更好地理解用户需求,并将技术解决方案转化为实际的产品。例如,一个架构师需要了解产品的功能需求,以便设计出最佳的技术架构来支持这些需求。




  3. 创新思维:这种思维能力可以帮助技术人员不断创新,寻找新的解决方案和技术。例如,当面对一个复杂的技术问题时,创新思维可以帮助技术人员找到一种独特的解决方案,从而提高效率和质量。




  4. 商业思维:这种思维能力可以帮助技术人员更好地理解商业环境和商业需求,以便为企业提供最佳的技术支持和解决方案。例如,在设计一个新的软件系统时,商业思维可以帮助技术人员理解客户的商业需求,并设计出最佳的系统架构来支持这些需求。




除了以上四种思维能力外,还有其他常见的思维模型和方法可以帮助技术人员更好地管理和解决问题,例如:



  • PDCA 循环:这是一种常见的问题解决方法,包括计划、实施、检查和行动四个阶段,可以帮助技术人员系统地解决问题。

  • SMART 原则:这是一种有效的目标设定方法,包括具体、可衡量、可实现、相关和时间限制五个要素,可以帮助技术人员设定清晰的目标和指标。

  • 5W2H 法:这是一种问题分析方法,包括什么、为什么、谁、何时、何地、如何和多少七个问题,可以帮助技术人员全面地分析和解决问题。


作者:潘锦
来源:juejin.cn/post/7203880677195661373
收起阅读 »

三十岁前端的破冰尝试

@charset "UTF-8";.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:15px;overflow-x:hidden;color:#2b2...
继续阅读 »

大多数人没有意识到精力的有限而盲目学习,从没有停下来认真咀嚼已有的东西。



本人简介


JavaScrip码农,今年三十,宿主是非互联网类型的外企,提供的内存虽然不大,但也基本够存活。


工作之余,我的主题就是咸鱼。但或许是我的咸度不够,最近开始腐烂了,尤其是夜深人静,主要的信息输入被关闭之后,我就感觉内在的信息流在脑海里乱窜,各种健康指数开始飙升。就像是一台老旧的电脑,非要带最新的显卡游戏,发出嘤嘤嘤的EMO声,最后在卡死在昏睡页面。


大多时候醒来会一切安好,像是被删去了前一晚的日志。但有时也会存有一些没删除干净的缓存,它们就像是病毒,随着第二天的重启复苏。我会感到无比的寒冷,冷到我哪怕是饥饿也不敢出门,只有戴上口罩会给我一丝丝的勇气。


这种寒冷会刺激着我无病呻吟,我会感到惊恐和害怕,害怕某天被宿主的回收机制发现这里的不正常,然后被文明的光辉抹除,就如新冠背后那鲜红的死亡人数一样。


或许是幼年求学寄人篱下时烙下的病根,但那时候心田干涸了还可以哭泣。如今呢,心田之上早已是白雪皑皑。


这些年也有人帮助过我,我也努力挣扎过,但大多时候毫无章法,不仅伤了别人的心,也盲目地消耗着心中的热血,愧疚与自责的泪水最终只是让冰层越积越深。


今天也不知哪根筋抽抽了,想着破冰。


嗯,就是字面上的意思,满脑子都是“破冰”二字……


破冰项目


发表这个稿子算是破冰的第一步~


项目的组织架构初步定为凌凌漆,敏捷周期为一周,其中周日进行复盘和制定新计划,其余作为执行日。由于项目长期且紧迫,年假就不予考虑了,病假可以另算,津贴方面目前只考虑早餐,其他看项目发展情况再做调整。


硬件层面


目前作息相当紊乱,供电稳定性差,从近几年的硬件体验报告可以看出,总体运行还算正常,但小毛病层出不穷,电压不稳是当前主要矛盾。OKR如下:


O:保持一个良好的作息

KR1: 保证每天八小时的睡眠。

KR2:保证每天凌晨前关灯睡下。

KR3:保证每天早上九点前起床。


软件层面


英语是硬伤,其次是底层算法需要重写,不然跑着跑着还是会宕机。


翻译是个不错的路子,但数据源是个头痛的问题……肯定得找和技术相关的东西来翻译,并且可以有反馈。嗯…… 想到可以找掘金里已经有的翻译文章,截取其中一小段来进行快速试错。


至于底层算法的问题,此前在leetcode练过一段时间,但仅停留在已知的变得熟练,未知的依旧不会。


因此我觉得有必要先梳理出关于算法的个人认知的知识体系……


总结下来下一阶段任务:



  1. 选择一篇翻译文章,找到其原文,选其中完整的一段进行翻译。

  2. 根据当前认知画个关于算法的思维导图。


下周日会出这周的运行报告以及新一期的计划表。


最后随想


若是觉得我这样的尝试也想试一试,欢迎在评论附上自己的链接,一起尝试,

作者:行僧
来源:juejin.cn/post/7152143987225133086
相互借鉴,共同进步~

收起阅读 »

聊一聊过度设计!

.markdown-body{word-break:break-word;line-height:1.75;font-weight:400;font-size:16px;overflow-x:hidden;color:#252933}.markdown-bod...
继续阅读 »

  新手程序员在做设计时,因为缺乏经验,很容易写出欠设计的代码,但有一些经验的程序员,尤其是在刚学习过设计模式之后,很容易写出过度设计的代码,而这种代码比新手程序员的代码更可怕,过度设计的代码不仅写出来时的成本很高,后续维护的成本也高。因为相对于毫无设计的代码,过度设计的代码有比较高的理解成本。说这么多,到底什么是过度设计?


什么是过度设计?


  为了解释清楚,我这里用个类比,假如你想拧一颗螺丝,正常的解决方案是找一把螺丝刀,这很合理对吧。 但是有些人就想:“我就要一个不止能拧螺丝的工具,我想要一个可以干各种事的工具!”,于是就花大价钱搞了把瑞士军刀。在你解决“拧螺丝”问题的时候,重心早已从解决问题转变为搞一个工具,这就是过度设计。

在这里插入图片描述
  再举个更技术的例子,假设你出去面试,面试官让你写一个程序,可以实现两个数的加减乘除,方法出入参都给你提供好了 int calc(int x, int y, char op),普通程序员可能会写出以下实现。


    public int calc(int x, int y, int op) {
if (op == '+') {
return x + y;
} else if (op == '-') {
return x - y;
} else if (op == '*') {
return x * y;
} else {
return x / y;
}
}
复制代码

  而高级程序员会运用设计模式,写出这样的代码:


public interface Strategy {
int calc(int x, int y);
}

public class AddStrategy implements Strategy{
@Override
public int calc(int x, int y) {
return x + y;
}
}

public class MinusStrategy implements Strategy{
@Override
public int calc(int x, int y) {
return x - y;
}
}
/**
* 其他实现
*/

public class Main {
public int calc(int x, int y, int op) {
Strategy add = new AddStrategy();
Strategy minux = new MinusStrategy();
Strategy multi = new MultiStrategy();
Strategy div = new DivStrategy();
if (op == '+') {
return add.calc(x, y);
} else if (op == '-') {
return minux.calc(x, y);
} else if (op == '*') {
return multi.calc(x, y);
} else {
return div.calc(x, y);
}
}
}
复制代码

  策略模式好处在于将计算(calc)和具体的实现(strategy)拆分,后续如果修改具体实现,也不需要改动计算的逻辑,而且之后也可以加各种新的计算,比如求模、次幂……,扩展性明显增强,很是牛x。 但光从代码量来看,复杂度也明显增加。回到我们原始的需求上来看,如果我们只是需要实现两个整数的加减乘除,这明显过度设计了。


过度设计的坏处


  个人总结过度设计有两大坏处,首先就是前期的设计和开发的成本问题。过度设计的方案,首先设计的过程就需要投入额外的时间成本,其次越复杂的方案实现成本也就越高、耗时越长,如果是在快速迭代的业务中,这些可能都会决定到业务的生死。其次即便是代码正常上线后,其复杂度也会导致后期的维护成本高,比如当你想将这些代码交接给别人时,别人也需要付出额外的学习成本。


  如果成本问题你都可以接受,接下来这个问题可能影响更大,那就是过度设计可能会影响到代码的灵活性,这点听起来和做设计的目的有些矛盾,做设计不就是为了提升代码的灵活性和扩展性吗!实际上很多过度设计的方案搞错了扩展点,导致该灵活的地方不灵活,不该灵活的地方瞎灵活。在机器学习领域,有个术语叫做“过拟合”,指的是算法模型在测试数据上表现完美,但在更广泛的数据上表现非常差,模式缺少通用性。 过度设计也会出现类似的现象,就是缺少通用性,在面对稍有差异的需求上时可能就需要伤筋动骨级别的改造了。


如何避免过度设计


  既然过度设计有着成本高和欠灵活的问题,那如何避免过度设计呢!我这里总结了几个方法,希望可以帮到大家。


充分理解问题本身


  在设计的过程中,要确保充分理解了真正的问题是什么,明确真正的需求是什么,这样才可以避免做出错误的设计。


保持简单


  过度设计毫无例外都是复杂的设计,很多时候未来有诸多的不确定性,如果过早的针对某个不确定的问题做出方案,很可能就白做了,等遇到真正问题的时候再去解决问题就行。


小步快跑


  不要一开始就想着做出完美的方案,很多时候优秀的方案不是设计出来的,而是逐渐演变出来的,一点点优化已有的设计方案比一开始就设计出一个完美的方案容易得多。


征求其他人的意见


  如果你不确定自己的方案是不是过度设计了,可以咨询下其他人的,尤其是比较资深的人,交叉验证可以快速让你确认问题。


总结


  其实在业务的快速迭代之下,很难判定当前的设计是欠设计还是过度设计,你当前设计了一个简单的方案,未来可能无法适应更复杂的业务需求,但如果你当前设计了一个复杂的方案,有可能会浪费时间……。 在面对类似这种不确定性的时候,我个人还是比较推崇大道至简的哲学,当前用最简单的方案,等需要复杂性扩展的时候再去重构代码。

作者:xindoo
来源:juejin.cn/post/7204423284905738298

收起阅读 »

三行代码让你的git记录保持整洁

Git
前言 笔者最近在主导一个项目的架构迁移工作,由于迁移项目的历史包袱较重,人员合作较多,在迁移过程中免不了进行多分支、多次commit的情况,时间一长,git的提交记录便混乱不堪,随便截一个图形化的git提交历史给大家感受一下。 各种分支疯狂打架宛如后宫争宠的...
继续阅读 »

前言


笔者最近在主导一个项目的架构迁移工作,由于迁移项目的历史包袱较重,人员合作较多,在迁移过程中免不了进行多分支、多次commit的情况,时间一长,git的提交记录便混乱不堪,随便截一个图形化的git提交历史给大家感受一下。



各种分支疯狂打架宛如后宫争宠的妃子们,之所以会出现这种情况,主要还是因为滥用git merge命令并且不考虑后续的理解成本导致的。如今在大厂工作的程序员们,频繁接受变更的需求,一旦一开始考虑不周到,就一定会出现了大量无意义的commit log,加上“敏捷”理念的推广,产品的快速迭代上线变成了核心指标,这些无意义的commit log便被“下次再处理”,久而久之就混乱不堪了。


而我们在看一些开源仓库时,会发现他们的commit记录十分整洁,其实这并不是社区的程序员能力更强,而是因为他们没有KPI大棒的鞭笞,在提交代码前会花时间整理自己的commit log。而这就是本文的主角了——“Git Rebase”。


git rebase和git merge


git rebase,中文翻译为“变基”,通常用于分支合并。既然提到了分支合并,那就一定离不开git merge这个命令。


相信每个新手程序员刚进入职场的时候,都会听到“xxx你把这个分支merge一下”这样的话。那么问题来了,假如你有6个程序员一起工作, 你就会有6个程序员的分支, 如果你使用merge, 你的代码历史树就会有六个branch跟这个主的branch交织在一起。



上图是 git merge 操作的流程示意图,Merge命令会保留所有commit的历史时间。每个人对代码的提交是各式各样的。尽管这些时间对于程序本身并没有任何意义。但是merge的命令初衷就是为了保留这些时间不被修改。于是也就形成了以merge时间为基准的网状历史结构。每个分支上都会继续保留各自的代码记录,主分支上只保留merge的历史记录。子分支随时都有可能被删除。子分子删除以后,你能够看到的记录也就是,merge某branch到某branch上了。这个历史记录描述基本上是没有意义的。


git rebase 中文翻译为“变基”,变得这个基指的是基准。如何理解这个基准呢?我们看一下下图。



我们可以看到经过变基后的feature分支的基准分支发生了变化,变成了最新的master。这就是所谓的“变基”。


通过上面的两张图可以很明显的发现,这两种合并分支的方式最大的区别在于,merge后的分支,会保留两个分支的操作记录,这在git commit log 树中会以交叉的形式保存。而rebase后的分支会基于最新的master分支,从而不会形成分叉,自始至终都是一条干净的直线。



关于 git rebasegit merge 的详细用法不在本文的介绍范围内,详情可以参考互联网上的其他资料。



在变基过程中,我们通常需要进行commit的修改,而这也为我们整理git记录提供了一个可选方案。


保持最近的几条记录整洁


假设我们有一个仓库,我在这个仓库里执行了4次提交,通过 git reflog 命令查看提交记录如下。



如果我们想将Commit-3、Commit-2和Commit-1的提交合并成一次提交(假设某次提交至改了一些pom文件),我们可以直接执行下面的命令


git rebase -i HEAD~3
复制代码

-i 指的是 --interactiveHEAD~3 指的是最近三次commit。


当然我们也可以直接指定最新的一个想保留的 Commit的ID,在上面的例子中就是Commit-0的ID,因此我们也可以写成


git rebase -i d2b9b78
复制代码

执行该命令后,我们会进入到这么如下一个界面:



这个界面是一个Vim界面,我们可以在这个界面中查看、编辑变更记录。有关Vim的操作,可以看我之前写的文章和录制的视频👉《和Vim的初次见面》


在看前三行之前,我们先来看一下第5行的命令加深一下我们对git rebase的认识。



翻译过来就是,将d2b9b78..0e65e22这几个分支变基到d2b9b78这个分支,也就是将Commit-3/2/1/0这几次变更合并到Commit-0上。


回到前面三行,这三行表示的是我们需要操作的三个 Commit,每行最前面的是对该 Commit 操作的 Command。而每个命令指的是什么,命令行里都已经详细的告诉我们了。




  • pick:使用该commit

  • squash:使用该 Commit,但会被合并到前一个 Commit 当中

  • fixup:就像 squash 那样,但会抛弃这个 Commit 的 Commit message


因此我们可以直接改成下面这样




这里使用fixup,而不是squash的主要原因是squash会让你再输入一遍commit的log,图省事的话,可以无脑选择fixup模式。



然后执行:wq退出vim编辑器,我们可以看到控制台已经输出Successful了。



这个时候我们再来看下log 记录,执行git log --oneline


于是最近三次的提交记录就被合并成一条提交记录了。


保持中间某些记录整洁


那如果不是最后的几个commit合并,而是中间连续的几个Commit记录,可以用上述方法整理合并吗?答案是可以的,只不过需要注意一下。


我们重新创建一个新的仓库



如果这次我们想将"third commit"和"second commit"合并为一个提交,其实和上面的方式一样,我们只需执行git rebase -i HEAD~3,然后将中间的提交改成fixup/squash模式即可,如下图所示:




之所以是HEAD~3,是因为我们要做的变更是基于first commit做的,因此我们也可以写成git rebase -i a1f3929



我们来看下更改完的commit log,如下图所示:



是不是就干掉了third commit了。


三行代码让git提交记录保持整洁


上面我们都是在本地的git仓库中进行的commit记录整理,但是在实际的开发过程中,我们基本上都是写完就直接push到远程仓库了,那应该如何让远程的开发分支也保持记录的整洁呢?


第一种做法是在push代码前就做在本地整理好自己的代码,但是这种做法并不适用于那种本地无法部署,需要部署到远程环境才能调试的场景。


这时我们只需要执行git push -f命令,将自己的修改同步到远程分支即可。


-f是force强制的意思,之所以要强制推送是因为本地分支的变更和远程分支出现了分歧,需要用本地的变更覆盖远程的。


而远程分支更新后,如果其他人也在这条分支上更改的话,还需要执行一个git pull命令来同步远程分支。


这里我们来总结下让git提交记录保持整洁的三行代码。


git rebase -i xxx
git push -f
git pull
复制代码


❗️❗️❗️Tips:由于rebase和push -f是有些危险的操作,因此只建议在自己的分支上执行哦。


作者:插猹的闰土
链接:https://juejin.cn/post/7203989318272237624
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

被泼冷水后,谁能超越微服务?

历史总会重演。一切刚过去的,又会被重新提起。开源项目Codename One的联合创始人Shai,曾是Sun Microsystems开源LWUIT项目的共同作者,参与了无数开源项目。作为最早一批Java开发者,最近感慨道:单体,又回来了!Shai说道:我已经...
继续阅读 »

历史总会重演。一切刚过去的,又会被重新提起。开源项目Codename One的联合创始人Shai,曾是Sun Microsystems开源LWUIT项目的共同作者,参与了无数开源项目。作为最早一批Java开发者,最近感慨道:单体,又回来了!

Shai说道:我已经在这个圈子里很久时间了,看到了一次次被抛弃、被重新发现的想法,超越“时髦词汇”,并凯旋而归。

他进一步举例,“近年来,SQL也挣扎过后,死而复生。我们再次热爱关系数据库。我认为单体架构将再次迎来奇幻之旅。微服务和无服务器是云供应商推动的趋势,目的当然是在向我们兜售更多的云计算资源。然而对于大多数用例来说,微服务在财务上意义不大。是的,供应商当然也可以降低成本。但当他们扩大规模时,他们会以股息来覆盖掉成本。单是可观测性成本的增加,就让‘大型云’供应商的腰包鼓起来了!”

作为从业近30年的资深技术大神,为何做此感叹?本文通过一场“利用模块降低架构成本”的探讨,帮助大家梳理现在的架构设计难题,希望对诸君有所启发。

问题背景

我最近领导了一个会议小组,讨论了微服务与单体服务的主题。组内认为,单块的规模不如微服务。这对于亚马逊、eBay等所取代的那些庞然大物来说可能是正确的。这些确实是巨大的代码库,其中的每一次修改都是痛苦的,而且它们的扩展都是具有挑战性的。但这不是一个公平的比较。较新的方法通常优于旧的方法。但如果我们用更新的工具构建一个整体,我们会得到更好的可扩展性吗?它的局限性是什么?现代的单体(也称巨石)到底该是什么样子?

单体回归范例:Modulith

Spring Modulith是一个模块化的单体结构,可以让我们使用动态隔离件构建单体结构。通过这种方法,我们可以分离测试、开发、文档和依赖项。这有助于微服务开发的独立方面,而所涉及的开销很少。它消除了远程调用和功能复制(存储、身份验证等)的开销。

Spring Modulith不是基于Java平台模块化(Jigsaw)。他们在测试期间和运行时强制分离,这是一个常规的Spring Boot项目。它有一些额外的运行时功能,可以实现模块化的可观测性,但它主要是“最佳实践”的执行者。这种分离的价值超出了我们通常使用微服务的价值,但也有一些权衡。

举个例子,传统的Spring monolith将采用分层架构,其包如下:

com.debugagent.myapp
com.debugagent.myapp.services
com.debugagent.myapp.db
com.debugagent.myapp.rest

这很有价值,因为它可以帮助我们避免层之间的依赖关系;例如,DB层不应依赖于服务层。我们可以使用这样的模块,并有效地将依赖关系图推向一个方向:向下。但随着我们的成长,这没有多大意义。每一层都将充满业务逻辑类和数据库复杂性。

有了Modulith,我们的架构看起来更像这样:

com.debugagent.myapp.customers
com.debugagent.myapp.customers.services
com.debugagent.myapp.customers.db
com.debugagent.myapp.customers.rest

com.debugagent.myapp.invoicing
com.debugagent.myapp.invoicing.services
com.debugagent.myapp.invoicing.db
com.debugagent.myapp.invoicing.rest

com.debugagent.myapp.hr
com.debugagent.myapp.hr.services
com.debugagent.myapp.hr.db
com.debugagent.myapp.hr.rest

这看起来非常接近一个合适的微服务架构。我们根据业务逻辑分离了所有部分。在这里,可以更好地控制交叉依赖关系,团队可以专注于自己的孤立区域,而不必互相踩脚。这是微服务的价值之一,且没有开销。

我们可以使用注释进一步深入地和声明性地实现分离。我们可以定义哪个模块使用哪个并强制单向依赖关系,因此人力资源模块将与发票无关。客户模块也不会。我们可以在客户和发票之间建立单向关系,并使用事件进行反馈。Modulith中的事件是简单、快速和事务性的。它们消除了模块之间的依赖关系,无需麻烦。这可以用微服务实现,但很难实现。比如,开票需要向不同的模块公开接口。如何防止客户使用该界面?

有了模块,我们就可以做到。对用户可以更改代码并提供访问权限,但这需要经过代码审查,这会带来自己的问题。请注意,对于模块,我们仍然可以依赖常见的微服务,如功能标志、消息传递系统等。您可以在文档和Nicolas Fränkel的博客中阅读有关Spring Modulith的更多信息。

模块系统中的每个依赖项都被映射并记录在代码中。Spring实现包括使用方便的最新图表自动记录所有内容的能力。你可能会认为,依赖性是Terraform的原因。对于这样的“高级”设计来说,这是正确的地方吗?

对于Modulith部署,像Terraform这样的基础设施即代码(IaC)解决方案仍然存在,但它们会简单得多。问题是责任的划分。正如下图所展示,微服务并没有消除整体结构的复杂性。我们只把“难啃的骨头”踢给了DevOps团队。更糟糕的是,我们没有给他们正确的工具来理解这种复杂性,所以他们不得不从外部管理。


图源:Twitter

这就是为什么我们行业的基础设施成本在上升,而传统行业的基础设施价格却在下降。当DevOps团队遇到问题时,他们会投入资源。这显然不是正确的做法。

其他模块

我们可以使用标准Java平台模块(Jigsaw)来构建Spring Boot应用程序。这样做的好处是可以分解应用程序和标准Java语法,但有时可能会很尴尬。当使用外部库或将一些工作拆分为通用工具时,可能会更有效。

另一个选项是Maven中的模块系统。这个系统允许我们将构建分解为多个单独的项目。这是一个非常方便的过程,可以让我们省去大量项目的麻烦。每个项目都是独立的,易于使用。它可以使用自己的构建过程。然后,当我们构建主项目时,这些全部都变成了一个单体。在某种程度上,这才是我们真正想要的。

单体架构:扩展,有解吗

可以使用大多数微服务扩展工具来扩展我们的单体们。许多与扩展和集群相关的开发都是在单体架构的情况下进行的。这是一个更简单的过程,因为只有一个移动部分:应用程序。我们复制其他实例并观察它们。没有哪项服务是失败的。我们有细粒度的性能工具,所有的功能都可以作为一个统一的版本。

我认为扩展单体为微服务比直接构建微服务更简单——

  • 我们可以使用分析工具,并获得瓶颈的合理近似值。

  • 我们的团队可以轻松地(并且经济实惠地)设置运行测试的登台环境。

  • 我们拥有整个系统及其依赖关系的单一视图。

  • 我们可以单独测试单个模块并验证性能假设。

跟踪和可观测性工具非常棒。但它们也会影响生产,有时还会产生噪音。当我们试图解决伸缩瓶颈或性能问题时,这些工具可能会让设计者踩一些坑。

我们可以将Kubernetes与monolits一起使用,就像将其与微服务一起使用一样有效。镜像尺寸会更大(如果我们使用GraalVM这样的工具,则可能不会太大)。有了这一点,我们可以跨区域复制monolith ,并提供与微服务相同的故障转移行为。相当多的开发人员将monolics部署到Lambdas。笔者不太喜欢这种方法,因为非常昂贵。

单体的瓶颈问题:有解

但仍有一点是巨大的障碍:数据库。由于微服务固有地具有多个独立的数据库,因此它们实现了巨大的规模。单体架构通常与单个数据存储一起工作。这通常是应用程序的真正瓶颈。有多种方法可以扩展现代数据库。集群和分布式缓存是强大的工具,可以让我们达到在微服务架构中很难达到的性能水平。

在一个单体结构中,也并不需要单个数据库。例如:在使用Redis进行缓存时,选择使用SQL数据库也是很常见的事情。但我们也可以为时间序列或空间数据使用单独的数据库。我们也可以使用单独的数据库来提高性能,尽管根据笔者经验,这种情况从未发生过。将数据保存在同一数据库中的好处是巨大的。

回归单体的好处

事实上,这样做有一个惊人的好处,我们可以在不依赖“最终一致性”的情况下完成交易。当我们尝试调试和复制分布式系统时,可能会遇到一个很难在本地复制的过渡状态,甚至很难通过查看可观测性数据来完全理解。

原始性能消除了大量网络开销。通过适当调整的二级缓存,我们可以进一步删除80-90%的读IO。在微服务中,要实现这一点要困难得多,而且可能不会删除网络调用的开销。

正如我之前提到的,应用程序的复杂性在微服务架构中不会消失。我们只是把它搬到了另一个地方。所以从这个层面讲,微服务并不算真正的进步,因为在此过程中平白添加了许多移动部件,增加了整体复杂性。因此,回归更智能、更简单的统一架构更有意义。

再看微服务的卖点

编程语言的选择是微服务亲和力的首要指标之一。微服务的兴起与Python和JavaScript的兴起相关。这两种语言非常适合小型应用程序,对于较大型的应用就不太适用了。

Kubernetes使得扩展此类部署相对容易,因此为已经增长的趋势增添了动力。微服务也有一些相对快速的升降能力。这可以以更细粒度的方式控制成本。在这方面,微服务被出售给组织,作为降低成本的一种方式。

这并非完全没有优点。如果以前的服务器部署需要强大(昂贵)的服务器,那么这一论点可能有一定道理。这可能适用于极端使用的情况,比如:突然面临非常高的负载,但随后没有堵塞。在这些情况下,可以从托管的Kubernetes提供商动态(廉价)获取资源。

微服务的主要卖点之一是组织调度方面。这使得各个敏捷团队能够在不完全了解“大局”的情况下解决小问题。问题也在于此,这就会创造一种“单干”文化,让每个团队都“自己做自己的事情”。在缩减规模的过程中,尤其是在代码“腐烂”的情况下,问题更甚。系统可能仍能工作数年,但实际上无法维护。

互联网建立在单体之上为什么要离开呢

笔者组内中的一个共识是,我们应该始终从单体开始。它更容易构建,如果我们选择使用微服务,我们可以稍后将单体分解。

提及具体某个软件相关的复杂性,我们讨论单个模块而不是单个应用程序要更有意义些。二者在资源使用和财务浪费上的差异是巨大的。在这个追求“降本”的时代,为什么人们还要不知变通地默认构建微服务,而不是动态的模块化单体架构呢?

我们可以从这两大“架构阵营”学到很多东西。诚然,微服务为亚马逊创造了奇迹。但公平地说,他们的云成本已包含在这个奇迹之中。所以,一位的搞微服务教条肯定是有问题的。

另一方面,互联网是建立在单体之上的。它们中的大多数都不是模块化的。两者都有普遍适用的技术。因此,笔者看来,正确的选择是构建一个模块化的单体结构,先搭建好合适的身份验证基础设施,如果我们想在未来转向微服务,我们可以利用这些基础设施来进行解构拆分。

后记

在设计应用时,我们目前更多是面临“二选一”的架构选择:单体和微服务。它们二者通常被视为相反的方法。

在小型系统演进过程中,有这样一个不争的事实:单体应用程序往往会随着时间的推移而在架构上降级,即使在其生命周期开始阶段就定义其为架构。随着时间的推移,各种架构的禁止事项会不知不觉地进入项目,久而久之,系统变得更难改变,进化性受到影响。

另一方面,微服务提供了更强的分离手段,但同时也带来了许多复杂性,因为即使对于小型应用程序,团队也必须应对分布式系统的挑战。

单体回归,也是具体的有条件的回归。我们看到,趋势的改变,代表着某段时期具体任务或者目标正在变化。出于目标的变化,我们对于微服务和单体架构的二选一的选择问题,也不能再教条式的看待。

事物往往都在螺旋式的演进,对于架构而言,亦如是。

来源:mp.weixin.qq.com/s/2peN_MezvkR9UwtalhG6kA

原文链接:dzone.com/articles/is-it-time-to-go-back-to-the-monolith

收起阅读 »