作 者 | 晴筱 石超 张小路
“我背上有个背篓,里面装了很多血泪换来的经验教训,我看着你们在台下嗷嗷待哺想要这个背篓里的东西,但事实上我给不了你们”,实践出真知。
编码习惯(开发、测试、Review,Bad/Good Case)
研发流程(源码控制、每日构建、缺陷管理)
实践方法(效率工具、新人踩雷、学习推荐)
一 编码习惯
Ugly is easy to identify because the messes always have something in common, but not beauty.
— C++ 之父 Bjarne Stroustrup
代码质量与其整洁度成正比。
1.1 开发
别人眼中的软件系统犹如灯火辉煌的摩天大厦,维护者眼中的软件系统犹如私搭乱建的城中村,我们要在这座城中村里生存,一直维护这些代码,添加新功能等,要让大家生活得更好,我们写代码不仅追求正确性,还有健壮性 和 可维护性。
图1 开发 理想与现实
?要点1 :语义简单明确
这是块存储 SDK 的一段代码,判断限流目标值是否合法;写代码时考虑读者,优先采取易于读者理解的写法。
# defineTHROTL_UNSET -2
# defineTHROTL_NO_LIMIT -1
boolthrottle_is_quota_valid( int64_t value)
{
// 复杂的判断条件
// 请你在三秒内说出 value 如何取值是合法的?
if( value< 0&& value!= THROTL_UNSET && value!= THROTL_NO_LIMIT)
{
returnfalse;
}
returntrue;
}
boolthrottle_is_quota_valid( int64_t value)
{
// 这是修改后的代码,value 取值合法有三种情况,一目了然
returnvalue>= 0|| value== THROTL_UNSET || value== THROTL_NO_LIMIT;
}
?要点2 :简洁 ≠ 代码短
这是块存储的一段代码,它遍历回收站中的所有文件,统计每种介质上文件最早的时间戳;简洁≠代码短,复杂的问 表达式反而不如 if..else 方便理解。
void RecycleBin::Load(BindCallbackR1<Status>* done)
{
……
FOREACH(iter, fileStats)
{
RecycleFile item;
Status status = ParseDeletedFileName(iter->path, &item.timestamp);
if(!status.IsOk { …… }
item.fileName = iter->path;
item.size = iter->size;
item.physicalSize = iter->refCount > 1? 0: iter->physicalSize;
……
// 这是修改前的代码
// earliestTimestamp[item.medium] =
// item.timestamp != 0 && item.timestamp < earliestTimestamp[item.medium] ?
// item.timestamp : earliestTimestamp[item.medium];
// }
// 这是修改后的代码
if(item.timestamp != 0&&
item.timestamp < earliestTimestamp[item.medium])
{
earliestTimestamp[item.medium] = item.timestamp;
}
}
……
}
?要点3 :提前返错
提前返错能减少主体逻辑的缩进数量,让主体代码逻辑显得更醒目。
Bad Case如下:
Status Foo
{
Status status= Check1;
if(! status.IsOk)
{
returnstatus;
}
else
{
status= Check2;
if(! status.IsOk)
{
returnstatus;
}
else
{
status= Check3;
if(! status.IsOk)
{
returnstatus;
}
else
{
DoSomeRealWork;
returnOK;
// 四层潜套 if
}
}
}
}
?要点4 :利用析构函数做清理工作
利用 C++ 析构函数做清理工作,在复杂冗长代码中不会漏掉。典型的清理工作有执行回调、关闭文件、释放内存等。
Bad Case如下:
void Foo(RpcController* ctrl,
constFooRequest* request,
FooResponse* response,
Closure* done)
{
Status status = 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;
}
Good Case如下:
voidFoo(RpcController* ctrl,
constFooRequest* request,
FooResponse* response,
Closure* _done)
{
// 仅一处,不遗漏
erpc:: ScopedCallback done(_done);
Status status = 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)
{
LogicalFileId fileId = 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。
void UserRequestControl::WaitForPendingIOs
{
erpc::ExponentialBackoff delayTimeBackOff;
delayTimeBackOff.Reset(
INT64_FLAG(lsm_UnloadWaitingBackoffBaseUs),
INT64_FLAG(lsm_UnloadWaitingBackoffLimitUs),
INT64_FLAG(lsm_UnloadWaitingBackoffScaleFactor));
// 轮循等待在途的请求返回
// 请思考如何用条件变量实现精确的同步
while(!mWriteQueue. empty|| !mReadQueue. empty)
{
uint64_t delayTime = delayTimeBackOff.Next;
PGLOG_INFO(sLogger,
( __FUNCTION__, “Waiting for inflight requests during segment unload”)
( “Segment”, mSegment->GetName)
( “Write Requests”, mWriteQueue.size)
( “ReadRequests”, mReadQueue.size)
( “DelayTimeInUs”, delayTime));
easy_coroutine_usleep(delayTime); // 退避等待
}
}
?要点7 :使用 timed_wait 代替 wait
在典型的生产者消费者实现中,使用 timedwait 代替 wait,避免生产者未正确设置条件变量造成消费者卡死无法服务的窘境。
pthread_mutex_tmutex;
pthread_cond_tnonEmptyCondition;
std:: list<Task*> queue;
voidConsumerLoop
{
pthread_mutex_lock(&mutex);
while( true)
{
while( queue.empty)
{
structtimespects;
ts.tv_sec = 1;
ts.tv_nsec = 0;
// 使用timewait
pthread_cond_timedwait(&nonEmptyCondition, &mutex, timespec);
}
Task* firstTask = queue.front;
queue.pop_front;
consume(firstTask);
}
pthread_mutex_unlock(&mutex);
}
要点8:用协程代替异步回调
这是块存储 BlockServer 加载数据分片的代码;用异步回调方式难以实现这样的复杂控制逻辑,用协程却能轻松实现。
// load.cpp
Status LoadTask::Execute
{
Status status;
# defineRUN_STEP(func)
status = func;
if(!status.IsOk) { … }
// 串行执行下列步骤
RUN_STEP(doPrepareDirs);
…… // 十几步
RUN_STEP(doTask);
# undefRUN_STEP
……
}
// files.cpp
Status FileMap::SealFilesForLiveDevice
{
Status status = OK;
std:: vector<SyncClosureR1<Status>*> sealDones;
STLDeleteElementsGuard< std:: vector<SyncClosureR1<Status>*> >
donesDeleter(&sealDones);
// 并行 seal 每个文件
FOREACH(iter, mActiveFiles)
{
File* file = iter->second;
sealDones.push_back( newSyncClosureR1<Status>);
Closure* work = stone::NewClosure(
this,
&FileMap::doSealFileForLiveDevice,
file,
static_cast<BindCallbackR1<Status>*>(sealDones.back));
InvokeCoroutineInCurrentThread(work);
}
// 收集结果
FOREACH(done, sealDones)
{
(*done)->Wait;
if(!(*done)->GetResult0.IsOk)
{
status = (*done)->GetResult0;
}
}
returnstatus;
}
?要点9 :在关键对象增加 magic 字段
这是块存储核心主路径的一段代码;在关键数据结构中增加 magic 字段和断言检查,能及时发现内存错误(例:内存踩坏)。
?通常在下列两类结构增加 magic :
1)关键的数据结构,如 数据分片 结构 ;
2)异步操作的上下文结构,如用户IO Buffer请求;
// stream.h
classStream
{
public:
Stream;
~Stream;
voidRead(ReadArgs* args);
……
private:
// 增加 magic 字段
// 通常使用 uint32 或 uint64
uint64_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
{
WriteAttemptList failureQueue;
WriteAttemptList inflightQueue;
WriteAttemptList pendingQueue;
uint64_tcommitSeq;
uint64_tlastSeq;
};
uint32_tmStreamGroupCount;
StreamGroup mStreamGroups[STREAM_GROUP_COUNT];
……
};
voidStreamWriter::sanityCheck
{
# ifndefNDEBUG // expensive checks
for( uint32_ti = 0; i < mStreamGroupCount; i++)
{
// Check that sequence in “failureQueue”, “inflightQueue” and “pendingQueue” are ordered.
constStreamGroup* group = &mStreamGroups[i];
uint64_tprevSeq = group->commitSeq;
constWriteAttemptList* queues[] = {
&group->failureQueue,
&group->inflightQueue,
&group->pendingQueue
};
for( size_tk = 0; k < easy_count_of(queues); k++)
{
FOREACH(iter, *queues[k])
{
constWriteRequest* write = iter->write;
PANGU_ASSERT(prevSeq <= write->seq); // SanityCheck
prevSeq = write->seq + write->lbaRange.rangeSize;
}
}
ASSERT(prevSeq == group->lastSeq); // SanityCheck
}
……
# endif// NDEBUG
}
?要点11 :用告警代替进程崩溃
这是块存储核心路径的一段代码,在加载数据分片时通过交叉校验对数据正确性进行合法性检查,遇到严重错误时发起电话告警,以此代替 assert,避免生产集群大规模故障时,数据分片持续调度造成整个集群进程Crash的 级联故障 。
在多租户系统中,单租户出现严重问题不应影响其他租户的服务。
在块存储,我们仅允许检查对象 magic 和线程是否正确的断言。其它断言由告警代替。
Status LoadTask::doTailScanFiles
{
……
for(id = FIRST_REAL_FILE_ID; id < mFileSet->GetTotalFileCount; id++)
{
File* file = mDiskFileSet->GetFile(id);
if(file->GetLogicalLength < logicalLengthInIndex)
{
constchar* msg = “BUG!! Found a data on disk with shorter length ”
“than in map. This is probably caused by length reduction of ”
“that file.”; // 记录详细的日志,包括文件名、期望长度、实际长度等
PGLOG_FATAL(sLogger, ( __FUNCTION__, msg)
(“Stream”, mStream->GetName)
(“File”, file->GetFileName)
(“FileId”, file->GetFileId)
(“FileLengthOnDisk”, file->GetFileLength)
(“FileLengthInIndex”, physicalLengthInIndex)
(“LogicalLengthOnDisk”, file->GetLogicalLength)
(“LogicalLengthInIndex”, logicalLengthInIndex)
(“MissingSize”, physicalLengthInIndex – file->GetFileLength));
SERVICE_ADD_COUNTER(“LSM:CriticalIssueCount”, 1); // 触发电话告警
returnLSM_FILE_CORRUPTED;
}
}
}
要点12 :时间溢出之一
我们当前使用的内核配置 HZ=1000,jiffies 变量每49天溢出,Linux 将 jiffies 变量初始值设置为负数,使系统启动后5分钟发生第一次溢出;让这段容易出错的危险代码每天都被执行到,这些再也不用担心出现黑天鹅事件了。
linux/include/linux/jiffies.h
/*?* Have the 32 bit jiffies value wrap 5 minutes after boot
* so jiffies wrap bugs show up earlier.
*/
# defineINITIAL_JIFFIES ((unsigned long)(unsigned int) (-300*HZ))
/*
* These inlines deal with timer wrapping correctly
You are?* strongly encouraged to use them
* 1. Because people otherwise forget
* 2. Because if the timer wrap changes in future you won’t have to
* alter your driver code.
*
* time_after(a,b) returns true if the time a is after time b.
*/
# definetime_after(a,b)
(typecheck(unsigned long, a) &&
typecheck(unsigned long, b) &&
(( long)((b) – (a)) < 0))
?要点13 :时间溢出之二
习惯上代码中以微秒表示时间, int32 能表示的最大时间仅为 2147 秒,约 35 分钟,容易溢出;历史上块存储值班同学在处理另一个 P4 故障时,为缓解分布式集群中心管控节点压力,临时调整 flag 增加调用中心管控节点 RPC 的调用间隔。新的 flag 在运行时产生 int32 整型溢出,进程崩溃,引起整个集群级联故障,服务中断造成 P1 生产故障 ;血琳琳的教训:总是用 int64/uint64 表示时间。
?要点14 :避免有歧义的函数名和参数表
这是 libeasy 的一段代码,这是基于时间轮实现的定时器,用来代替 libev timer;函数名和参数表要符合直觉,大多数使用者没空读你的注释,小部分使用者读了你的注释也看不明白。
// easy/src/io/easy_timer.h
// ———————————————————————————-
// following interface, use easy_timer_sched from th(io thread or worker thread),
// ** DON NOT support async call **
//
inteasy_timer_start_on_th( easy_baseth_t*th, easy_timer_t*timer) ;
inteasy_timer_stop_on_th( easy_baseth_t*th, easy_timer_t*timer) ;
1.2 测试
测试代码其实是产品代码的“用户”,写测试代码前考虑如何“使用”产品;好的测试是 what,包含 given when then;差的测试是 how,每次方法更改时都必须完全重写测试,或许需要重新考虑系统本身的体系结构。?
图2 测试原则、可测性
要点1 :边界测试
以下是块存储两个历史生产Bug;关注上下限,边界条件最易出错。
TEST_F(…, SharedDisk_StopOneBs)(…)
{
BenchMarkStart(mOption);
// for循环反复注入
mCluster->StopServer( 0);
mCluster->StartServer( 0);
// 修复前无第12行无代码,无下限检查,全部失败时Case PASS
// 共享盘开盘后线程死锁必IO Hang,有测试无断言遗漏Bug导致P1故障
EXPECT_GT(mIoBench->GetLastPrintIops, 0);
EXPECT_GT(mIoBench->GetMaxLatency, 0);
// 断言检查,边界上限
EXPECT_GT( 20* 1000000, mIoBench->GetMaxLatency);
// Do something below
}
Status PRConfig::Register )(…)
{
assertIoThread;
// 修复前缺少=,导致Sever Crash
if(unlikely(mRegistrants.size >= MAX_REGISTRANT_NUM))
{
LOG_ERROR(…);
returnSC_RESERVATION_CONFLICT;
}
// Do something below
}
?要点2 :状态/分支测试
以下是块存储两个历史生产Bug;状态流程图,影响数据正确性和服务可用性的关键路径、异常分支、状态组合需测试覆盖。
void WalStreamWriterPool::tryCreateWalWriter
{
AssertCoroutine;
ASSERT_DEBUG(mIsCreating);
Status status = OK;
while(…)
{
WalStreamWriter *writer = mWalManager->CreateWalWriter;
status = writer->Open;
// 修复前无第14行代码部分,未处理Commit,失败导致丢掉WAL文件,进而丢数据
if(status.IsOk)
{
status = mWalManager->Commit;
}
// Do something below
}
void RPCController::StartCancel
{
if(_session) {
if(_pendingRpc != NULL) {
// 修复前无第29行代码,线程Hang进而IOHang
// 未测试覆盖call StartCancel before handshake
_session->need_cancel = true;
} else{
easy_session_cancel(_session);
}
} else{
easy_error_log(…);
}
}
?要点3 :重复/幂等性测试
以下是块存储两个历史生产Bug;第一个如下图所示,未释放内存,长时间运行造成内存泄漏;第二个是19年遇到的一个问题,BM 在 处理 open device 时没处理好幂等问题,导致磁盘 open 成功后仍然 I/O hang ;有一些问题只有经过长时间的重复测试才能发现,关注代码中每一次重试,敏感接口的 API 幂等性需测试覆盖。
Status CompressOffsetTable::Seal
{
// Do something before
status = mTableFile->Seal;
if(!status.IsOk)
{
PGLOG_ERROR(…);
returnstatus;
}
mIsSealed = true;
// 修复前无第14行代码,文件写入已完成,清空缓存,释放内存
mEasyPool.reset;
// Do something below
}
?要点4 :兼容性测试
兼容性包含:协议兼容性、API兼容性、版本升级兼容性、数据格式兼容性;对于所有依赖的兼容性假设需通过测试自动化覆盖,兼容性问题是很难测试覆盖并且问题高发的部分,兼容性问题应该在设计阶段、编码阶段提前预防,避免兼容性问题,而非寄希望于兼容性测试来兜底。
voidActiveManager::SubmitIO(
{
// 【版本兼容性】 SDK 和 Server线程不对齐,旧版本SDK不支持切线程
if(UNLIKELY(GetCurrentThread != serverThread))
PGLOG_WARNING(… “Server thread mismatch”);
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);
// Do something below
}
// 【API兼容性】 Server 和 Master的错误码不一致,数据分片反复加载/卸载
// Master侧,device_load.cpp
// if(status.Code == LSM_SEGMENT_EXIST_OTHER_VERSION))
// Server侧,device_load.cpp
// return LSM_NOT_OWN_SEGMENT;
?要点5 :防御性测试
系统服务上限的边界是多少?客户端无退避重试、突发大流量等造成的故障数不胜数,关注系统在最差情况下的表现,明确系统的能力边界,对系统服务边界的数学模型进行理论分析和实验验证,通过极限压测验证系统最大可服务能力符合设计预期,推荐参考 接近不可接受的负载边界[1]。
?要点6 :避免写出不稳定Case
Case不稳定真是一个让人头大问题,总结了一些不稳定的测试常见原因,希望大家记住并知行合一。
测试不聚焦,无脑复制粘贴,等价类测试爆炸
异步等待,基于时间假设,sleep 并发,未能在预期的窗口期交互
有顺序依赖的测试,共享某个状态
资源溢出,数据库链接满、内存 OOM 析构随机 core
析构未严格保序或者未构造
多线程共享资源的错误用法导致概率 crash
有未处理完的任务就退出
TEST_F(FastPathSmokeTestFixture, Resize)
{
// … Do something
ResizeVolume(uri, DEVICE_SIZE * 2);
Status status = OK;
do{
// 状态依赖,未检查resize 是否成功,导致错误的认为是越界io处理
status = Write(handle, wbuf. get, 0, 4096);
if(status.Code == OK)
{
break;
}
easy_coroutine_usleep( 100* 1000);
} while( 1);
// … Do something
}
// volume_iov_split_test.cpp
TEST(VolumeIovSplitTest, Iovsplit_Random)
{
// … Do something
size_t totalLength = 0;
// 修改前无+1,0是非法随机值,造成Case低概率失败
totalLength = rand % ( 10* 1024* 1024) + 1
// … Do something
}
二 本地工具
2.1 Docker单机集群
对于分布式系统,能够在开发机上自测端到端的跨模块/跨集群的功能测试,极大的提高测试效率和开发幸福感。在开发调试期间,Docker集群用完即抛,拥有属于自己的无污染的“一手”功能测试集群,代码主路径必现的进程Crash均可在开发阶段发现。Docker使用极少的系统资源,有效地将不同容器互相隔离,快速创建分布式应用程序,非常适合集群测试使用。
?块存储在没有 Docker 单机集群之前,测试集群级功能测试至少需要12台物理机,我们通过将块存储、盘古和女娲的服务装进容器中,实现单机 OneBox,在开发机上(物理机 /虚拟机/Docker/Mac 均可,无OS依赖),一键秒级部署和销毁一个集群,基于Docker单机集群实现Docker Funtion Test,沿用单元测试的 gtest,上手门槛低,极大的提高了测试效率和开发幸福感,Docker Funtion Test 是在代码门禁中运行,即代码提交入库之前自动触发测试,在代码入库之前,百分之百拦截必现的进程Crash问题。
图3 块存储 Docker单机集群
2.2 本地出包自助E2E
研发效能低下的团队的一个典型表现,质量强依赖全链路端到端(End-To-End,简称E2E),测试环境维护成本极高,常常因为环境污染导致无效测试,是否能够将全链路E2E测试实现白屏化,告别环境修复?
?在开发期间调试,不可避免有大Size的Patch修改,代码门禁Unit Test / Smoke Test / Funition Test仅覆盖功能测试,涉及到IO性能、运维、用户态文件系统、用户态 络协议的代码逻辑修改,无法在代码门禁覆盖。面对这个问题,块存储开发者可以在开发机编译出包,测试平台白屏自助验证E2E测试,操作共3个步骤:编译上传包 → 提交测试任务 → 查看测试结果。降低测试门槛可以有效的提高测试的主观能动性,进而提高测试运行频次,当测试不再是负担的时候,大家更愿意做测试,谁会拒绝投资少收益高的事呢?
??图4 测试平台自助E2E
三 单元测试
3.1 编写测试样例
对于块存储主仓库,增量代码覆盖率当前强制卡点85%,生产代码与测试代码需要同步原子提交,否则将因覆盖率不足阻塞提交,这也倒逼了大家养成测试左移的工作习惯,编写 Unit Test / Smoke Test / Funition Test 是块存储每位开发者提交代码的必备技能,团队同学柏亿曾讲过一句话,“如果我写了代码而没有加自动化Case,就相当于我做了一件华丽的衣服,又丢到了垃圾桶里,弃如敝履 ”,通过自动化门禁测试来保证系统设计、代码实现不被后续修改Break,降低系统质量风险,依赖假设足以保证,更多Good/Bad Case参考《Software Engineering at Google》的 “单元测试”章节[2]。
??图5 EBS UT/ST/FT
3.2 代码门禁说明
代码门禁(简称CI)即代码提交之前自动运行的测试,测试全量通过后方可提交,类似于函数计算,Test as a Service。代码门禁是 测试左移[3] 的必备单品。块存储 CI 门禁基于Google 开源云原生CI框架Tekton实现,支持分布式编译和分布式测试,Kubernetes 门禁集群中的Cpu、Mem、Disk资源限制Limit,每个Case独占容器, 仅Cpu超卖,相当于模拟主频降频,增加了发现bug的概率,曾多次触发大量低概率时序bug;代码门禁包含编译构建、单元测试、冒烟测试、功能测试、代码风格检查、静态代码扫描、增量代码覆盖率卡点等检查项。在推广测试左移的近三年,块存储门禁Case数量逐年翻倍。
图6 块存储代码门禁
四 Code Review
预防胜于治疗,研究表明高效的 Code Review 可以发现70-90%的 bug,Review 作用如下:
提高团队代码
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!