设计原则Go设计模式(3)-设计原则是从相对高的维度来进行代码设计,设计的再好,代码编写不优雅,代码质量也难以得到保障。

即使当时写的很好,如何保证随着业务变化、其他同学的修改,代码仍然是优雅的呢?另外,怎样的代码称之为优雅,又让谁来评判呢?

所以代码编写至少涉及三个问题:

高质量代码的标准是什么?

如何编写高质量代码?

如何保证代码一直高质量?

当然,在代码编写方面,我仍然是个学生,大家要是觉得有价值可以参考一下,如果有错误的地方,也希望大家多多批评指正。

1高质量代码的标准

代码质量评价有很高的主观性。一般最常用到几个评判代码质量的标准有:可维护性、可读性、可扩展性、灵活性、简洁性、可复用性、可测试性。其中,可维护性、可读性、可扩展性又是提到最多的、最重要的三个评价标准。

要写出高质量代码,需要善用面向对象设计思想、设计原则、设计模式、编码规范、重构技巧等。

Go设计模式(3)-设计原则Go设计模式(2)-面向对象分析与设计更多讲的是可维护性、可扩展性、灵活性、可复用性。而可读性、简洁性、可测试性则与基础的编码能力相关。

2如何编写高质量的代码

2.1编程规范

仅从编写看起来优雅的代码层面讲,起效最快的是符合编程规范。编程规范其实分两部分:

  1. 通用规范,各种语言都能用
  2. 指定语言规范,这是针对各种语言自己特性编写的,如Go、Java、PHP等,网上有很多可以拿来参考

这里我们仅讲一下通用编程规范。通用编程规范一般包含三个方面,命名与注释、代码风格、编程技巧。

命名与注释

命名与注释主要是为了增加可读性。但有部分程序员命名能力实在堪忧(说的就是我),好在找到了一个神器https://unbug.github.io/codelf/,可以查一下其他人是怎么命名的。

  1. 命名的关键是能准确达意
  2. 借助类的信息来简化属性、函数的命名,利用函数的信息来简化函数参数的命名
  3. 命名要可读、可搜索,不要使用生僻的、不好读的英文单词
  4. 接口的2中命名方式:在接口中带前缀"I";在接口的实现类中带后缀"Impl"。抽象类的2中命名方式:带上前缀“Abstract";不带前缀
  5. 注释的内容:做什么、为什么、怎么做。复杂的类和接口,还要写明”如何用“
  6. 类和函数一定要写注释,而且要写的尽可能全面详细
代码风格
  1. 函数的代码行数不要超过一屏幕的大小,比如50行
  2. 一行代码最好不要超过IDE的显示宽度
  3. 善用空行分割单元块
  4. 推荐两格缩进,节省空间。一定不要用tab键缩进
  5. 将大括号跟上一条语句同一行,可以节省代码行数,另起新的一行,结构清晰
  6. 在GoogleJava编程规范中,依赖类按照字母序从小到大排列。类中先写成员变量后写函数,成员变量之间或函数之间,先写静态成员变量或函数,后写普通变量或函数,并且按照作用域大小依次排列
编程技巧
  1. 将复杂的逻辑提炼拆分成函数和类
  2. 通过拆分成多个函数或将参数封装为对象的方式,来处理参数过多
  3. 函数中不要使用参数来做代码执行逻辑的控制
  4. 函数设计要职责单一
  5. 移除过深的嵌套层次
  6. 用字面常量取代魔法数
  7. 用解释性变量来解释复杂表达式

2.2代码可测试性

代码的可测试性是指针对代码编写单元测试的难易程度。容易编写单元测试就说明代码可测试性好,反之意味代码设计的并不是很合理。

提高代码可测试性有两个手段

  1. 依赖注入可以通过mock的方法将不可控的依赖变得可控
  2. 利用二次封装来解决某些代码行为不可控的情况

代码的可测试性也是检查代码是否高质量的一个手段。写完代码之后,一定记得编写单元测试。首先需要思考有哪些测试用例,然后开始编写单元测试,此时可能发现有部分单元测试无法编写。原因一般有如下几条:

  1. 未决行为:如代码和时间、随机数相关。一般使用二次封装来解决。
  2. 全局变量:全局变量会导致上一次运行的结果可能影响下一个运行。一般使用reset全局变量解决。可以看看能否不用全局变量。
  3. 静态方法:不好处理,非要测试只能mock。可以看看能否不使用静态方法。
  4. 复杂继承:父类需要mock某个依赖对象才能进行单元测试,那所有的子类、子类的子类都需要mock出这个对象。能做单元测试,就是比较麻烦。可以看看是否能改为组合。
  5. 高耦合代码:依赖十几个外部对象完成工作,需要mock十几个外部对象来进行单元测试。看看是否可以通过重构解耦。

3如何保证代码一直高质量

要想代码随着业务的变更、新人的更改一直保持高质量,除了不断的重构别无它法。这也分情况,如果是新项目,大家技术水平高、都有不断重构的意识、代码Review也会考虑代码质量问题,保持代码高质量相对简单一些。如果是老旧项目、当时活多时间紧,代码质量已经很差了,把这种代码提升到较高质量会难很多。

基于这两种情况,重构方案也有所不同。

3.1重构

对于第一种情况,使用编程规范中的方法进行优化。当然新的业务还是需要用到面向对象设计思想、设计原则、设计模式等。

对于第二种情况,就需要做大型重构了。代码是否需要做大型重构,一线开发人员最有发言权。如果觉得这个代码进行细微改动后不知道会引发什么、或者感觉改不动了、或者这个代码的开发严重影响了项目进度,那肯定是要重构了,不重构难道留着过年?当然,重构是个技术活,但有个前提,需要上下一心,大家统一认识,要有重构的决心。

大型重构主要靠解耦。代码松耦合、高内聚,是控制代码复杂度的有效手段。为了解耦,一般使用如下方案:

  1. 封装与抽象:有效地隐藏实现的复杂性,隔离实现的 易变性,给依赖的模块提供稳定且易用的抽象接口
  2. 引入中间层:简化模块或类之间的依赖关系
  3. 模块化:分而治之
  4. 使用设计思想和原则:单一职责原则、基于接口而非实现编程、依赖注入、多用组合少用继承、迪米特发展

3.2单元测试

重构有风险,重构需谨慎。很多时候大家不敢做重构,主要怕重构引起大问题,影响业务。那如何能保证重构不引起问题呢?理论上没法百分之百保证不会产生问题,但是我们可以通过一些手段来规避风险,其中最有效的就是单元测试。

写单元测试难度不大,所以部分程序员不想写。另外有时候项目会很紧急,没有写单元测试的时间。所以关键问题是团队需要建立对单元测试正确的认识,如果没有单元测试,代码不准上线,通过种种手段,保证单元测试的编写。

其实单元测试有很多好处。

写单元测试的过程本身就是代码Review和重构的过程,能有效地发现代码中的bug和代码设计上的问题。

另外重构的过程中,如果单元测试仍然能够跑通,说明重构质量是可以的。

4实例

上面聊了这么多理论,总得看点代码才行。正好前两天家里人要去医院,所以简单写了一个抢号的功能。当时写的比较急,属于能用就行,趁这次写文章,就拿这个代码来做优化吧。

这个代码是PHP的,毕竟脚本写起来更快一些(PHP是最好的语言)。代码编写和语言关系不大,就优化PHP版的吧,如果大家有兴趣,可以写Go版的。

这个功能只支持一家医院,我希望优化完后,可以快速支持多家医院的抢号。另外默认都是通过微信进行预约。我们只是用这个用例来锻炼,希望大家不要用来做不好的事情。

我们先看一下代码以前的样子:

<?php
$tm          = Msectime();
$docName     = 'docName';
$patientName = '患者名';
$wxID        = '微信ID';
$regDay      = '预约时间';

//返回当前的毫秒时间戳
function Msectime()
{
    list($msec, $sec) = explode(' ', microtime());
    $msectime         = (float) sprintf('%.0f', (floatval($msec) + floatval($sec)) * 1000);
    return $msectime;
}

//http Get请求
function SendRequest($url)
{
    $curl = curl_init();
    curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($curl, CURLOPT_TIMEOUT, 5);
    curl_setopt($curl, CURLOPT_URL, $url);

    $res    = curl_exec($curl);
    $resobj = json_decode($res, true);
    curl_close($curl);
    return $resobj;
}
//http Post请求
function SendPostRequest($url,$postData)
{
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_POST, 1);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
    $output = curl_exec($ch);
    $resobj = json_decode($output, true);
    curl_close($ch);
    return $resobj;
}



//1.获取个人id
$uid = -1;
$uInfoUrl = "http://hospital?act=userinfo_oid&uid=$wxID&tm=$tm&oid=$wxID";
$uInfoS   = SendRequest($uInfoUrl);
$uInfo    = json_decode($uInfoS['data'], true);
$uid      = $uInfo['id'];

//2.获取病人id
$sListUrl = "http://hospital?act=member&uid=$uid&oid=$wxID";
$sListS   = SendRequest($sListUrl);
$sList    = json_decode($sListS['data'], true);
$sickid   = -1;
foreach ($sList as $item) {
    if ($item['name'] == $patientName) {
        $sickid = $item['id'];
        break;
    }
}
//3.获取医生可用时间段的id
$url       = "http://hospital/ajax.ashx?act=bespeak_v1&deptid=417&clsid=2&tm=$tm";
$response  = SendRequest($url);
$docList   = json_decode($response['data'], true);
$bespeakid = -1;
$aorp      = 0; //0上午 1下午
foreach ($docList as $item) {
    if ($item['name'] == $docName && $item['bdate'] == $regDay) {
        if ($item['pm'] != 0 && $item['pm'] != '约满') { //下午有号
            $aorp      = 1;
            $bespeakid = (int)($item['id']);
            var_dump($item);
            break;
        }else if ($item['am'] != 0 && $item['am'] != '约满') { //上午有号
            $aorp      = 0;
            $bespeakid = (int)($item['id']);
            var_dump($item);
            break;
        }
    }
}

//4.注册
if($uid == -1 || $sickid == -1 || $bespeakid == -1){
    var_dump($uid,$sickid,$bespeakid,'failed');
    exit;
}

$regUrl = "http://hospital?act=bespeak";
$postData = array(
    'oid' => $wxID,
    'uid' => $uid,
    'sickid' => $sickid,
    'bespeakid' => $bespeakid,
    'aorp' => $aorp,
);
$res = SendPostRequest($regUrl,$postData);
if($res['result'] == 'ok'){
    var_dump('succcess',$postData);
}else{
    var_dump('failed',$postData,$res);
}

通过代码可以看出该功能主要包含以下函数:获得当前时间、发送请求、获取该微信号对应的用户id信息、获取病人id信息、获取医生可用时间段、注册功能。而且初步判断整个使用微信注册流程就包含如上步骤。

代码有如下问题:使用面向过程、命名待优化、缺乏注释、复杂逻辑没有拆分为函数和类、有重复逻辑、不支持多个医院、请求链接与参数配置散乱。

我们可以做如下设计:获取当前时间放到工具类里;发送请求放到网络类里;和医院进行交互的操作可以放到一个类里,但是解析结果功能需要能够替换,毕竟每家医院返回结果可能不一致;创建工厂类用于选择不同医院;

修改后代码为:

<?php

//工具类
class Utils
{
    //返回当前的毫秒时间戳
    public function msectime()
    {
        list($msec, $sec) = explode(' ', microtime());
        $msectime         = (float) sprintf('%.0f', (floatval($msec) + floatval($sec)) * 1000);
        return $msectime;
    }
    //拼接URL
    public function buildQuery($url,$arr)
    {
        return $url."?".http_build_query($arr);
    }
}

//Http客户端
class HttpClient
{
    //http Get请求
    function sendGetRequest($url)
    {
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($curl, CURLOPT_TIMEOUT, 5);
        curl_setopt($curl, CURLOPT_URL, $url);
        $output    = curl_exec($curl);
        curl_close($curl);
        return $output;
    }
    //http Post请求
    function sendPostRequest($url,$postData)
    {
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
        $output = curl_exec($ch);
        curl_close($ch);
        return $output;
    }
}

//和医院系统交互类
class HospitalOperation
{
    public $httpClient;
    public $hospital;

    function __construct($hospital)
    {
        $this->httpClient = new HttpClient();
        $this->hospital = $hospital;
    }

    //1.获取个人id
    public function getUserId()
    {
        var_dump("开始执行getUserId");
        $url = $this->hospital->getUinfoUrl();
        $uInfo   = $this->httpClient->sendGetRequest($url);
        if(empty($uInfo)){
            var_dump("执行getUserId失败");
            return -1;
        }
        $uid = $this->hospital->getUid($uInfo);
        var_dump("getUserId成功,uid为$uid");
        return $uid;
    }

    //2.获取病人id
    public function getPatientId()
    {
        var_dump("开始执行getPatientId");
        $url = $this->hospital->getPatientUrl();var_dump($url);
        $listInfo   = $this->httpClient->sendGetRequest($url);
        if(empty($listInfo)){
            var_dump("执行getPatientId失败");
            return -1;
        }
        $patientId = $this->hospital->getPatientId($listInfo);
        var_dump("getPatientId成功,patientId为$patientId");
        return $patientId;
    }

    //3.获取医生可用时间段的id
    public function getBresPeakId()
    {
        var_dump("开始执行getBresPeakId");
        $url       = $this->hospital->getBresPeakUrl();
        $bresInfo  = $this->httpClient->sendGetRequest($url);
        if(empty($bresInfo)){
            var_dump("执行getBresPeakId失败");
            return -1;
        }
        $bresPeakId = $this->hospital->getBresPeakId($bresInfo);
        if($bresPeakId == -1){
            var_dump("getBresPeakId失败,没有合适的时间");
        }else{
            var_dump("getBresPeakId成功,bresPeakId为$bresPeakId");
        }
        return $bresPeakId;
    }

    //4.注册
    public function registe()
    {
        var_dump("开始执行registe");
        $url = $this->hospital->getRegistUrl();
        $postData = $this->hospital->getPostData();
        foreach($postData as $checkPoint){
            if($checkPoint == -1){
                var_dump('check postdata failed',$postData);
                return -1;
            }
        }
        $regInfo = $this->httpClient->sendPostRequest($url,$postData);
        $res = $this->hospital->getRegistRes($regInfo);
        if($res === true){
            var_dump('registe succcess',$postData);
        }else{
            var_dump('registe failed',$postData,$res);
        }
        return $res;
    }

    //5.执行整个流程
    function run(){
        $res = $this->getUserId();
        if($res == -1){
            exit;
        }
        $res = $this->getPatientId();
        if($res == -1){
            exit;
        }
        $res = $this->getBresPeakId();
        if($res == -1){
            exit;
        }
        $res = $this->registe();
        if($res == -1){
            exit;
        }
    }
}

//返回数据解码
class Decode
{
    public function decodeFunc($data)
    {
        $data = json_decode($data, true);
        return $data;
    }
}

//医院类
class Hospital
{
    protected $wxId; //微信ID
    protected $regDepId;//预约诊室
    protected $docName;//医生姓名
    protected $patientName;//患者姓名
    protected $regDay;//看病日期

    protected $uInfoUrl; //根据微信号获取个人信息URL
    protected $patientListUrl; //获取患者列表URL
    protected $bresPeakUrl; //医生出诊时间URL
    protected $regUrl; //注册URL

    protected $uId; //微信ID对应的用户ID
    protected $postData;//注册需要提交的数据

    public $utils;//工具类
    public $decode;//接口返回数据解析方式

    function __construct($wxId,$regDepId,$docName,$patientName,$regDay,$uInfoUrl,$patientListUrl,$bresPeakUrl,$regUrl,$decode)
    {
        $this->wxId = $wxId;
        $this->regDepId = $regDepId;
        $this->docName = $docName;
        $this->patientName = $patientName;
        $this->regDay = $regDay;

        $this->uInfoUrl = $uInfoUrl;
        $this->patientListUrl = $patientListUrl;
        $this->bresPeakUrl = $bresPeakUrl;
        $this->regUrl = $regUrl;
        $this->decode = $decode;
        $this->utils = new Utils();
        $this->initPostData();
    }

    public function setPostDataItem($key,$value)
    {
        $this->postData[$key] = $value;
    }
    public function getPostData()
    {
        return $this->postData;
    }

    public function initPostData(){}

    public function getUinfoUrl(){}
    /**
     * 获得微信对应的用户id,-1表示获取失败
     */
    public function getUid($res):int {}

    public function getPatientUrl(){}
    /**
     * 获得患者Id,-1表示获取失败
     */
    public function getPatientId($res):int{}

    public function getBresPeakUrl(){}
    /**
     * 获得医生问诊时段Id,-1表示获取失败
     */
    public function getBresPeakId($res):int{}

    public function getRegistUrl(){}
    /**
     * 获得注册结果,-1表示注册失败
     */
    public function getRegistRes($res):int{}
}


/**
 * Class HospitalA
 * 继承自Hospital类,主要用于
 * 1. 生成各个接口的URL
 * 2. 解析返回数据,获取想要的结果
 * 3. 最终生成注册的数据postData
 */
class HospitalA extends Hospital
{
    public function initPostData()
    {
        $this->postData = array(
            'oid' => $this->wxId,
            'uid' => -1,
            'sickid' => -1,
            'bespeakid' => -1,
            'aorp' => -1,
        );
    }

    public function getUinfoUrl()
    {
        $arr = array(
            'act'=>'userinfo_oid',
            'uid'=>$this->wxId,
            'tm'=>$this->utils->msectime(),
            'oid'=>$this->wxId,
        );
        $url = $this->utils->buildQuery($this->uInfoUrl,$arr);
        return $url;
    }

    public function getUid($res):int
    {
        $res = $this->decode->decodeFunc($res);
        if($res['result'] != 'ok'){
            return -1;
        }
        $uInfo = $this->decode->decodeFunc($res['data']);
        $this->setPostDataItem('uid',$uInfo['id']);
        $this->uId = $uInfo['id'];
        return $uInfo['id'];
    }

    public function getPatientUrl()
    {
        $arr = array(
            'act'=>'member',
            'uid'=>$this->uId,
            'oid'=>$this->wxId,
        );
        $url = $this->utils->buildQuery($this->patientListUrl,$arr);
        return $url;
    }

    public function getPatientId($res):int
    {
        $res = $this->decode->decodeFunc($res);
        if($res['result'] != 'ok'){
            return -1;
        }
        $list = $this->decode->decodeFunc($res['data']);
        foreach ($list as $item) {
            if ($item['name'] == $this->patientName) {
                $this->setPostDataItem('sickid',$item['id']);
                return $item['id'];
            }
        }
        return -1;
    }

    public function getBresPeakUrl()
    {
        $arr = array(
            'act'=>'bespeak_v1',
            'deptid'=>$this->regDepId,
            'clsid' => 2,
            'tm'=>$this->utils->msectime(),
        );
        $url = $this->utils->buildQuery($this->bresPeakUrl,$arr);
        return $url;
    }

    public function getBresPeakId($res):int
    {
        $res = $this->decode->decodeFunc($res);
        if($res['result'] != 'ok'){
            return -1;
        }
        $docList = $this->decode->decodeFunc($res['data']);
        $bespeakid = -1;
        $aorp      = 0; //0上午 1下午
        $flag = 0;
        foreach ($docList as $item) {
            if ($item['name'] == $this->docName && $item['bdate'] == $this->regDay) {
                if ($item['pm'] != 0 && $item['pm'] != '约满') { //下午有号
                    $aorp      = 1;
                    $flag = 1;
                }else if ($item['am'] != 0 && $item['am'] != '约满') { //上午有号
                    $aorp      = 0;
                    $flag = 1;
                }
                if($flag == 1){
                    $bespeakid = (int)($item['id']);
                    var_dump('选择医生为',$item);
                    break;
                }
            }
        }
        $this->setPostDataItem('bespeakid',$bespeakid);
        $this->setPostDataItem('aorp',$aorp);
        return $bespeakid;
    }

    public function getRegistUrl()
    {
        $arr = array(
            'act'=>'bespeak',
        );
        $url = $this->utils->buildQuery($this->regUrl,$arr);
        return $url;
    }

    public function getRegistRes($res):int
    {
        $res = $this->decode->decodeFunc($res);
        if($res['result'] != 'ok'){
            return -1;
        }
        return 1;
    }

}

function main(){
    $decode = new Decode();
    $docName     = '**';
    $patientName = '***';
    $wxId        = '***';
    $regDepId    = 0;
    $regDay      = '2021-03-22';
    $uInfoUrl = '***';
    $patientListUrl = '***';
    $bresPeakUrl = '***';
    $regUrl = '***';
    $hospitalName = 'A';
    switch ($hospitalName){
        case 'A': $hospital = new HospitalA($wxId,$regDepId,$docName,$patientName,$regDay,$uInfoUrl,$patientListUrl,$bresPeakUrl,$regUrl,$decode);
    }

    $oper = new HospitalOperation($hospital);
    $oper->run();
}

/**
 * 使用:
 * 对接新的医院,可继承Hospital类,实现类中的函数。确保最终postData中包含所需数据,用于注册
 * 如果解析方式不一样,或者有医院的返回数据编码进行了更改,可继承Decode类,实现新的Decode类,进行替换
 * 对于新医院,在main中根据医院名称,使用工厂方法生成对应的医院对象
 * 可以将URL等参数做配置化,可减少初始化传入的数据
 */
main();

改写后的代码还有一定的优化空间,如错误返回、函数返回限制、父类和子类的关系限制、代码可测试性检查等。大家有时间可以尝试再优化一下。

总结

写出高质量的代码需要付出的精力比随便写要多得多,但随着不断的练习,速度和质量都会很快的提升。需要先有这个意识、然后掌握一定方法、然后不断实践,最终慢慢成功。