丰富的线上&线下活动,深入探索云世界
做任务,得社区积分和周边
最真实的开发者用云体验
让每位学生受益于普惠算力
让创作激发创新
资深技术专家手把手带教
遇见技术追梦人
技术交流,直击现场
海量开发者使用工具、手册,免费下载
极速、全面、稳定、安全的开源镜像
开发手册、白皮书、案例集等实战精华
为开发者定制的Chrome浏览器插件
作者:晴筱、石超、张小路
“我背上有个背篓,里面装了很多血泪换来的经验教训,我看着你们在台下嗷嗷待哺想要这个背篓里的东西,但事实上我给不了你们”,实践出真知。
Uglyiseasytoidentifybecausethemessesalwayshavesomethingincommon,butnotbeauty.
--C++之父BjarneStroustrup
代码质量与其整洁度成正比。
--《代码整洁之道》作者RobertC.Martin
别人眼中的软件系统犹如灯火辉煌的摩天大厦,维护者眼中的软件系统犹如私搭乱建的城中村,我们要在这座城中村里生存,一直维护这些代码,添加新功能等,要让大家生活得更好,我们写代码不仅追求正确性,还有健壮性和可维护性。
图1开发理想与现实
要点1:语义简单明确
这是块存储SDK的一段代码,判断限流目标值是否合法;写代码时考虑读者,优先采取易于读者理解的写法。
#defineTHROTL_UNSET-2#defineTHROTL_NO_LIMIT-1boolthrottle_is_quota_valid(int64_tvalue){//复杂的判断条件//请你在三秒内说出value如何取值是合法的?if(value<0&&value!=THROTL_UNSET&&value!=THROTL_NO_LIMIT){returnfalse;}returntrue;}boolthrottle_is_quota_valid(int64_tvalue){//这是修改后的代码,value取值合法有三种情况,一目了然returnvalue>=0||value==THROTL_UNSET||value==THROTL_NO_LIMIT;}
要点2:简洁≠代码短
voidRecycleBin::Load(BindCallbackR1*done){......FOREACH(iter,fileStats){RecycleFileitem;Statusstatus=ParseDeletedFileName(iter->path,&item.timestamp);if(!status.IsOk(){......}item.fileName=iter->path;item.size=iter->size;item.physicalSize=iter->refCount>10:iter->physicalSize;......//这是修改前的代码//earliestTimestamp[item.medium]=//item.timestamp!=0&&item.timestamp 要点3:提前返错 提前返错能减少主体逻辑的缩进数量,让主体代码逻辑显得更醒目。 BadCase如下: StatusFoo(){Statusstatus=Check1();if(!status.IsOk()){returnstatus;}else{status=Check2();if(!status.IsOk()){returnstatus;}else{status=Check3();if(!status.IsOk()){returnstatus;}else{DoSomeRealWork();returnOK;//四层潜套if}}}}GoodCase如下: StatusFoo(){Statusstatus=Check1();if(!status.IsOk()){returnstatus;}status=Check2();if(!status.IsOk()){returnstatus;}status=Check3();if(!status.IsOk()){returnstatus;}DoSomeRealWork();returnOK;} 要点4:利用析构函数做清理工作 利用C++析构函数做清理工作,在复杂冗长代码中不会漏掉。典型的清理工作有执行回调、关闭文件、释放内存等。 voidFoo(RpcController*ctrl,constFooRequest*request,FooResponse*response,Closure*done){Statusstatus=Check1(request);if(!status.IsOk()){response->set_errorcode(status.Code());//第一处done->Run();return;}status=Check2(request);if(!status.IsOk()){response->set_errorcode(status.Code());//第二处done->Run();return;}DoSomeRealWork(...);//第三处done->Run();}GoodCase如下: voidFoo(RpcController*ctrl,constFooRequest*request,FooResponse*response,Closure*_done){//仅一处,不遗漏erpc::ScopedCallbackdone(_done);Statusstatus=Check1(request);if(!status.IsOk()){response->set_errorcode(status.Code());return;}status=Check2(request);if(!status.IsOk()){response->set_errorcode(status.Code());return;}DoSomeRealWork(...);} 要点5:用朴素直观的算法 这是块存储旁路系统的一段代码,它根据垃圾比对数据分片进行排序;在非关键路径上,优先使用朴素直观的算法,此时代码可维护性更重要。 voidCompactTask::checkFileUtilizationRewrite(){//此处采取朴素的排序算法,并未采取更高效的TopK算法std::sort(sealedFilesUsage.begin(),sealedFilesUsage.end(),GarbageCollectionCompare);int64_tsealedFileMaxSize=INT64_FLAG(lsm_CompactionSealedMaxSize);int32_tsealedFileMaxNum=INT32_FLAG(lsm_CompactionSealedMaxFileNum);int64_ttargetFileSize=0;int32_tsourceFileCnt=0;//前者简单清淅,并在几十个File中选择前几个文件的场景并不算太慢FOREACH(itr,sealedFilesUsage){LogicalFileIdfileId=itr->fileId;constFileUsage*usage=baseMap->GetFileUsage(fileId);constFile*file=fileSet->GetFile(fileId);targetFileSize+=usage->blocks*mBlockSize;sourceFileCnt++;if(targetFileSize>sealedFileMaxSize||sourceFileCnt>sealedFileMaxNum){break;}mRewriteSealedFiles[fileId]=true;}......} 要点6:用轮循代替条件变量 这是块存储IO路径的一段代码,从内存中卸载数据分片时等待在途inflight的IO请求返回;在非关键路径上使用简单的轮循代替精巧的条件变量同步,代码简洁且不容易出bug。 voidUserRequestControl::WaitForPendingIOs(){erpc::ExponentialBackoffdelayTimeBackOff;delayTimeBackOff.Reset(INT64_FLAG(lsm_UnloadWaitingBackoffBaseUs),INT64_FLAG(lsm_UnloadWaitingBackoffLimitUs),INT64_FLAG(lsm_UnloadWaitingBackoffScaleFactor));//轮循等待在途的请求返回//请思考如何用条件变量实现精确的同步while(!mWriteQueue.empty()||!mReadQueue.empty()){uint64_tdelayTime=delayTimeBackOff.Next();PGLOG_INFO(sLogger,(__FUNCTION__,"Waitingforinflightrequestsduringsegmentunload")("Segment",mSegment->GetName())("WriteRequests",mWriteQueue.size())("ReadRequests",mReadQueue.size())("DelayTimeInUs",delayTime));easy_coroutine_usleep(delayTime);//退避等待}} 要点7:使用timed_wait代替wait 在典型的生产者消费者实现中,使用timedwait代替wait,避免生产者未正确设置条件变量造成消费者卡死无法服务的窘境。 pthread_mutex_tmutex;pthread_cond_tnonEmptyCondition;std::listqueue;voidConsumerLoop(){pthread_mutex_lock(&mutex);while(true){while(queue.empty()){structtimespects;ts.tv_sec=1;ts.tv_nsec=0;//使用timewaitpthread_cond_timedwait(&nonEmptyCondition,&mutex,timespec);}Task*firstTask=queue.front();queue.pop_front();consume(firstTask);}pthread_mutex_unlock(&mutex);} 要点8:用协程代替异步回调 这是块存储BlockServer加载数据分片的代码;用异步回调方式难以实现这样的复杂控制逻辑,用协程却能轻松实现。 //load.cppStatusLoadTask::Execute(){Statusstatus;#defineRUN_STEP(func)\status=func();if(!status.IsOk()){...}//串行执行下列步骤RUN_STEP(doPrepareDirs);......//十几步RUN_STEP(doTask);#undefRUN_STEP......}//files.cppStatusFileMap::SealFilesForLiveDevice(){Statusstatus=OK;std::vector*>sealDones;STLDeleteElementsGuard*>>donesDeleter(&sealDones);//并行seal每个文件FOREACH(iter,mActiveFiles){File*file=iter->second;sealDones.push_back(newSyncClosureR1());Closure*work=stone::NewClosure(this,&FileMap::doSealFileForLiveDevice,file,static_cast*>(sealDones.back()));InvokeCoroutineInCurrentThread(work);}//收集结果FOREACH(done,sealDones){(*done)->Wait();if(!(*done)->GetResult0().IsOk()){status=(*done)->GetResult0();}}returnstatus;} 要点9:在关键对象增加magic字段 这是块存储核心主路径的一段代码;在关键数据结构中增加magic字段和断言检查,能及时发现内存错误(例:内存踩坏)。 通常在下列两类结构增加magic: 1)关键的数据结构,如数据分片结构; 2)异步操作的上下文结构,如用户IOBuffer请求; //stream.hclassStream{public:Stream();~Stream();voidRead(ReadArgs*args);......private://增加magic字段//通常使用uint32或uint64uint64_tmObjectMagic;......};//stream.cpp//定义magic常量//常量值选择hexdump时能识别的字符串,以便在gdb查看coredump时快速识别//此处使用“STREAM”的ASCII串staticuint64_tSTREAM_OBJECT_MAGIC=0x4e4d474553564544LL;Stream::Stream():mObjectMagic(STREAM_OBJECT_MAGIC)//在构造函数中赋值{......}Stream::~Stream(){//在析构函数中检查并破坏magic字段,预防double-free错误easy_assert(mObjectMagic==STREAM_OBJECT_MAGIC);mObjectMagic=FREED_OBJECT_MAGIC;......}voidDeviceSegment::Read(ReadArgs*args){//在重要的函数中检查magic字段,预防use-after-free错误easy_assert(mObjectMagic==DEVICE_SEGMENT_OBJECT_MAGIC);......} 要点10:SanityCheck()合法性检查 这是块存储核心模块的一段代码StreamWriter负责管理正在写入的Stream,它为每个写请求选择合适的Stream写入,并处理文件满、写失败等异常情形;曾在线下测试发现由于未添加合法性检查,导致内存踩坏的meta错误数据持久化到磁盘中,在数据分片发生迁移时,从磁盘加载错误的meta数据持续夯死,不可恢复。在重要操作前后及定时器中检查数据结构中的重要的不变式假设,这样尽早发现代码bug在重要的操作前后或是在定时器中执行检查。 classStreamWriter{public:......private:structStreamGroup{WriteAttemptListfailureQueue;WriteAttemptListinflightQueue;WriteAttemptListpendingQueue;uint64_tcommitSeq;uint64_tlastSeq;};uint32_tmStreamGroupCount;StreamGroupmStreamGroups[STREAM_GROUP_COUNT];......};voidStreamWriter::sanityCheck(){#ifndefNDEBUG//expensivechecksfor(uint32_ti=0;i 要点11:用告警代替进程崩溃 在多租户系统中,单租户出现严重问题不应影响其他租户的服务。 在块存储,我们仅允许检查对象magic和线程是否正确的断言。其它断言由告警代替。 我们当前使用的内核配置HZ=1000,jiffies变量每49天溢出,Linux将jiffies变量初始值设置为负数,使系统启动后5分钟发生第一次溢出;让这段容易出错的危险代码每天都被执行到,这些再也不用担心出现黑天鹅事件了。 linux/include/linux/jiffies.h/**Havethe32bitjiffiesvaluewrap5minutesafterboot*sojiffieswrapbugsshowupearlier.*/#defineINITIAL_JIFFIES((unsignedlong)(unsignedint)(-300*HZ))/**TheseinlinesdealwithtimerwrappingcorrectlyYouare*stronglyencouragedtousethem*1.Becausepeopleotherwiseforget*2.Becauseifthetimerwrapchangesinfutureyouwon'thaveto*alteryourdrivercode.**time_after(a,b)returnstrueifthetimeaisaftertimeb.*/#definetime_after(a,b)\(typecheck(unsignedlong,a)&&\typecheck(unsignedlong,b)&&\((long)((b)-(a))<0)) 要点14:避免有歧义的函数名和参数表 图2测试原则、可测性 要点1:边界测试 TEST_F(...,SharedDisk_StopOneBs)(...){BenchMarkStart(mOption);//for循环反复注入mCluster->StopServer(0);mCluster->StartServer(0);//修复前无第12行无代码,无下限检查,全部失败时CasePASS//共享盘开盘后线程死锁必IOHang,有测试无断言遗漏Bug导致P1故障EXPECT_GT(mIoBench->GetLastPrintIops(),0);EXPECT_GT(mIoBench->GetMaxLatency(),0);//断言检查,边界上限EXPECT_GT(20*1000000,mIoBench->GetMaxLatency());//Dosomethingbelow}StatusPRConfig::Register)(...){assertIoThread();//修复前缺少=,导致SeverCrashif(unlikely(mRegistrants.size()>=MAX_REGISTRANT_NUM)){LOG_ERROR(...);returnSC_RESERVATION_CONFLICT;}//Dosomethingbelow} 要点2:状态/分支测试 以下是块存储两个历史生产Bug;状态流程图,影响数据正确性和服务可用性的关键路径、异常分支、状态组合需测试覆盖。 voidWalStreamWriterPool::tryCreateWalWriter(){AssertCoroutine();ASSERT_DEBUG(mIsCreating);Statusstatus=OK;while(...){WalStreamWriter*writer=mWalManager->CreateWalWriter();status=writer->Open();//修复前无第14行代码部分,未处理Commit,失败导致丢掉WAL文件,进而丢数据if(status.IsOk()){status=mWalManager->Commit();}//Dosomethingbelow}voidRPCController::StartCancel(){if(_session){if(_pendingRpc!=NULL){//修复前无第29行代码,线程Hang进而IOHang//未测试覆盖callStartCancelbeforehandshake_session->need_cancel=true;}else{easy_session_cancel(_session);}}else{easy_error_log(...);}} 要点3:重复/幂等性测试 StatusCompressOffsetTable::Seal(){//Dosomethingbeforestatus=mTableFile->Seal();if(!status.IsOk()){PGLOG_ERROR(...);returnstatus;}mIsSealed=true;//修复前无第14行代码,文件写入已完成,清空缓存,释放内存mEasyPool.reset();//Dosomethingbelow} 要点4:兼容性测试 兼容性包含:协议兼容性、API兼容性、版本升级兼容性、数据格式兼容性;对于所有依赖的兼容性假设需通过测试自动化覆盖,兼容性问题是很难测试覆盖并且问题高发的部分,兼容性问题应该在设计阶段、编码阶段提前预防,避免兼容性问题,而非寄希望于兼容性测试来兜底。 voidActiveManager::SubmitIO({//【版本兼容性】SDK和Server线程不对齐,旧版本SDK不支持切线程if(UNLIKELY(GetCurrentThread()!=serverThread))PGLOG_WARNING(..."Serverthreadmismatch");response->ErrorCode=SERVER_BUSY;done->Run();}voidChunkListAccessor::SetChunkInfoAndLocations(){uint8_tflags=mFileNodePtr->fileFlags;boolisLogFile=IsFlatLogFile(flags);ASSERT(//【协议兼容性】Master和SDK异常场景定长误判(isLogFile&&vecChunkInfoNode[0].version<=masterChunkInfo.version)||!isLogFile);//Dosomethingbelow}//【API兼容性】Server和Master的错误码不一致,数据分片反复加载/卸载//Master侧,device_load.cpp//if(status.Code()==LSM_SEGMENT_EXIST_OTHER_VERSION))//Server侧,device_load.cpp//returnLSM_NOT_OWN_SEGMENT; 要点5:防御性测试 要点6:避免写出不稳定Case Case不稳定真是一个让人头大问题,总结了一些不稳定的测试常见原因,希望大家记住并知行合一。 TEST_F(FastPathSmokeTestFixture,Resize){//...DosomethingResizeVolume(uri,DEVICE_SIZE*2);Statusstatus=OK;do{//状态依赖,未检查resize是否成功,导致错误的认为是越界io处理status=Write(handle,wbuf.get(),0,4096);if(status.Code()==OK){break;}easy_coroutine_usleep(100*1000);}while(1);//...Dosomething}//volume_iov_split_test.cppTEST(VolumeIovSplitTest,Iovsplit_Random){//...Dosomethingsize_ttotalLength=0;//修改前无+1,0是非法随机值,造成Case低概率失败totalLength=rand()%(10*1024*1024)+1//...Dosomething}二、本地工具2.1Docker单机集群对于分布式系统,能够在开发机上自测端到端的跨模块/跨集群的功能测试,极大的提高测试效率和开发幸福感。在开发调试期间,Docker集群用完即抛,拥有属于自己的无污染的“一手”功能测试集群,代码主路径必现的进程Crash均可在开发阶段发现。Docker使用极少的系统资源,有效地将不同容器互相隔离,快速创建分布式应用程序,非常适合集群测试使用。 块存储在没有Docker单机集群之前,测试集群级功能测试至少需要12台物理机,我们通过将块存储、盘古和女娲的服务装进容器中,实现单机OneBox,在开发机上(物理机/虚拟机/Docker/Mac均可,无OS依赖),一键秒级部署和销毁一个集群,基于Docker单机集群实现DockerFuntionTest,沿用单元测试的gtest,上手门槛低,极大的提高了测试效率和开发幸福感,DockerFuntionTest是在代码门禁中运行,即代码提交入库之前自动触发测试,在代码入库之前,百分之百拦截必现的进程Crash问题。 图3块存储Docker单机集群 研发效能低下的团队的一个典型表现,质量强依赖全链路端到端(End-To-End,简称E2E),测试环境维护成本极高,常常因为环境污染导致无效测试,是否能够将全链路E2E测试实现白屏化,告别环境修复? 在开发期间调试,不可避免有大Size的Patch修改,代码门禁UnitTest/SmokeTest/FunitionTest仅覆盖功能测试,涉及到IO性能、运维、用户态文件系统、用户态网络协议的代码逻辑修改,无法在代码门禁覆盖。面对这个问题,块存储开发者可以在开发机编译出包,测试平台白屏自助验证E2E测试,操作共3个步骤:编译上传包→提交测试任务→查看测试结果。降低测试门槛可以有效的提高测试的主观能动性,进而提高测试运行频次,当测试不再是负担的时候,大家更愿意做测试,谁会拒绝投资少收益高的事呢? 图4测试平台自助E2E 图5EBSUT/ST/FT 图6块存储代码门禁 预防胜于治疗,研究表明高效的CodeReview可以发现70-90%的bug,Review作用如下: 图7CodeReview可以发现70-90%的bug 对于Submitter和Reviewer的共同建议,开放的心态,良好的互动,Submitter给到reviewer更多的输入后,有益于问题的挖掘。 图8一个Review互动的优秀案例 要点1:ForSubmitter-一次提交不要超过400行代码 图9缺陷密度vs代码行数 要点2:ForSubmitter-做自己的第一个reviewer 自己的狗粮自己先吃,自我Review有以下几个注意事项: 图10CodeReview一个Description的优秀案例 要点1:ForReviewer-控制review速度 图11缺陷密度vs检查速度 要点2:ForReviewer-Review的重心 要点3:ForReviewer-Tips 南门:主干开发(trunkbaseddevelopment)是持续集成(continuousintegration)的最高阶段 在开发模式上,块存储学习了微软、Google的大库主干开发方式,过去多分支开发初期并行迭代,开发周期宽松,但多分支开发很容易漏提交,块存储也曾因漏提交bugfix导致一起P1S1故障,多分支合并冲突多、迭代慢,需要长期占用多套测试环境,有限的测试资源回归频次相对少。块存储所有持续集成测试资源均集中在主干分支回归,“集中力量办大事”。 主干开发对开发者提出了很高的要求,不仅要具备有功能特性拆分的能力,而且需要确保每一次代码提交,都能够达到准上线的质量标准,这也倒闭了测试左移的编码习惯,在提交代码入库之前进行测试编码。 图12块存储主干开发 发布模式分两种,公有云是主干分支发布,专有云是LTS(LongTimeSupport)拉Release分支发布(专有云发布节奏需follow客户要求,公有云发布节奏自主可控)。主干发布,既不是目的,也不是手段。主干发布是结果,是测试能力不断提升后水到渠成的结果,块存储持续集成交付详见第八章。切勿为了主干发布而主干发布,严格控制发布变量和发布节奏,避免为了修复一个bug,引入了另一个更严重的bug,只有经过成熟的测试验证方可进行发布,欲速则不达,敬畏生产。 除了代码门禁的功能Case覆盖之外,FeatureOwner需要补充代码后置全链路测试E2E和BVTCase,大版本发布需要经过大规模故障演练的验证方可发布上线,UT/ST/FT每月回归三千多轮,对于信用分低的Case(即不稳定Case)运行轮数权重翻五倍,即每月运行上万轮,通过高频测试尽量多暴露各层面的不稳定因素,倒逼人肉环节的自动化。块存储按照不同的场景提供了多种测试脚手架进行测分级,越轻量的测试回归轮数越多,更多详细说明如下图所示。 图13块存储测试分级 对于测试环境的标准化,采用两个思路:云原生用完即抛、资源池化集中管理。 图14块存储集群健康度检查 图15git-poison投毒/解药/银针 图16“集群-模版-Bug-版本”四维度生产画像 质量防控的思路和疫情防控的思路很像,首先在在源头处,通过代码门禁及时发现,就像是疫情的入境检查;持续集成,每日主干回归,缩短反馈弧,就像是现在“每3天一核酸,全员检测”,一有问题,马上定位修复。 图17块存储从开发到上线 通过微服务化,将单次版本百人协同的成本降低到十人以下,彻底告别“发大车”,各自聚焦,各行其道,互不干扰。 图18块存储分模块发布 图19分模块发布后兼容性测试 开发工具:工欲善其事,必先利其器。工具没有好与坏,要看你如何使用,一个高效的工具事半功倍,推荐一些日常工具: 工作方法:良好的工作方法,可以让自己的成长速度形成复利,推荐阅读两本书《直击本质》和《系统之美》,或许你可以从书中找到不同人成长速率差异的答案,这里推荐三个工作方法: 对于校招同学,职场童年最重要的是养成良好的工作习惯,身份从学生到工程师,必然伴随着成长的阵痛感,从学生思维转向职场思维,从学习驱动转向任务交付驱动,运用学校里基础知识,长期锻炼的学习思维,快速达到独立交付状态。不论任务多小,要能独立负责,交付好结果,事事有着落,承担任务,从小到中到大,从简单到复杂,从尝试型到突破型。