找回密码
 会员注册
查看: 14|回复: 0

OOP思想在TCCAPIXGORM源码中的应用

[复制链接]

7

主题

0

回帖

22

积分

新手上路

积分
22
发表于 2024-9-30 02:08:58 | 显示全部楼层 |阅读模式
动手点关注干货不迷路名词解释OOP面向对象程序设计(Object Oriented Programming,OOP)是一种计算机编程架构。OOP 的一条基本原则是计算机程序由单个能够起到子程序作用的单元或对象组合而成。OOP 达到了软件工程的三个主要目标:重用性、灵活性和扩展性。面向对象编程的三大特点:封装性、继承性和多态性。TCC动态配置中心 TCC(ToutiaoConfigCenter)是提供给业务方的一套平台+SDK 的配置管理解决方案,提供的功能有权限管理、配置管理、版本管理、灰度发布、多地区多环境支持等。与百度开源的“百度分布式配置中心 BRCC”功能类似。APIXGolang 实现的 web 框架,可参考开源项目 Gin。GORMGolang 编写的热门数据库 ORM 框架。背景大力智能学习灯于 2019 年 10 月份上线,截止 2021 年底,台灯出货量已超过 100w 台,完成了从 0 到 1 的探索。在成立之初,很多方向的产品为了尽早拿到用户反馈,要求快速迭代,研发在代码实现上相对快糙猛,早期阶段这无可厚非,但慢慢地,自习室、系统工具、知识宇宙等应用已经变成灯上核心基建,如果还按之前的野蛮生长的方式将会为台灯的成长埋下隐患。在这样的背景下,大力智能服务端推动 OOP 技术专项的落地,希望能够:提升团队成员自身的编码水平;统一团队内部编程风格;支撑业务快速迭代。TCC、APIX、GORM 都是日常项目中经常会依赖到的外部包,本文从这些项目的源码出发,在学习的过程中,解读良好的代码设计在其中的应用,希望能帮忙大家更好的理解和应用 OOP 思想,写出更优秀的代码。OOP 原则单一职责原则(SRP)一个类只负责一个职责(功能模块)。开放封闭原则(OCP)一个类、方法或模块的扩展性要保持开放,可扩展但不影响源代码(封闭式更改)替换原则(LSP)子类可以替换父类,并且不会导致程序错误。接口隔离原则(ISP)一个类对另一个类的依赖应该建立在最小的接口上。依赖倒置原则(DIP)高层次的模块不应该依赖于低层次的模块,它们应该依赖于抽象。参数可选,开箱即用—函数式选项模式解决问题:在设计一个函数时,当存在配置参数较多,同时参数可选时,函数式选项模式是一个很好的选择,它既有为不熟悉的调用者准备好的默认配置,还有为需要定制的调用者提供自由修改配置的能力,且支持未来灵活扩展属性。TCC 在创建BConfigClient对象时使用了该模式。BConfigClient是用于发送 http 请求获取后端服务中 key 对应的 value 值,其中getoptions结构体是 BConfigClient 的配置类,包含请求的 cluster、addr、auth 等信息,小写开头,属于内部结构体,不允许外部直接创建和修改,但同时对外提供了GetOption的方法去修改getoptions中的属性,其中WithCluster、WithAddr、WithAuth是快捷生成GetOption的函数。这样的方式很好地控制了哪些属性能被外部修改,哪些是不行的。当getoptions需要增加新属性时,给定一个默认值,对应增加一个新GetOption方法即可,对于历史调用方来说无感,能向前兼容式的升级,符合 OOP 中的对修改关闭,对扩展开放的开闭设计原则。typegetoptionsstruct{clusterstringaddrstringauthbool}//GetOptionrepresentsoptionofgetoptypeGetOptionfunc(o*getoptions)//WithClustersetsclusterofgetcontextfuncWithCluster(clusterstring)GetOption{returnfunc(o*getoptions){o.cluster=cluster}}//WithAddrsetsaddrforhttprequestinsteadgetfromconsulfuncWithAddr(addrstring)GetOption{returnfunc(o*getoptions){o.addr=addr}}//WithAuthSettheGDPRCertifyOn.funcWithAuth(authbool)GetOption{returnfunc(o*getoptions){o.auth=auth}}NewBConfigClient方法接受一个可变长度的GetOption,意味着调用者可以不用传任何参数,开箱即用,也可以根据自己的需要灵活添加。函数内部首先初始化一个默认配置,然后循环执行GetOption方法,将用户定义的操作赋值给默认配置。//NewBConfigClientcreatesinstanceofBConfigClientfuncNewBConfigClient(opts...GetOption)*BConfigClient{oo:=getoptions{cluster:defaultCluster}for_,op:=rangeopts{op(&oo)}c:=&BConfigClient{ooo}......returnc}通过组合扩展功能—装饰模式解决问题:当已有类功能不够便捷时,通过组合的方式实现对已有类的功能扩展,实现了对已有代码的黑盒复用。TCC 使用了装饰模式扩展了原来已有的ClientV2的能力。在下面的DemotionClient结构体中组合了ClientV2的引用,对外提供了GetInt和GetBool两个方法,包掉了对原始 string 类型的转换,对外提供了更为便捷的方法。//Get获取key对应的value.func(c*ClientV2)Get(ctxcontext.Context,keystring)(string,error)typeDemotionClientstruct{*ClientV2}funcNewDemotionClient(serviceNamestring,config*ConfigV2)(*DemotionClient,error){clientV2,err:=NewClientV2(serviceName,config)iferr!=nil{returnnil,err}client:=&DemotionClient{clientV2}returnclient,nil}//GetIntparsevaluetointfunc(d*DemotionClient)GetInt(ctxcontext.Context,keystring)(int,error){value,err:=d.Get(ctx,key)iferr!=nil{return0,err}ret,err:=strconv.Atoi(value)iferr!=nil{return0,fmt.Errorf("GetIntError:Key=%s;value=%sisnotint",key,value)}returnret,nil}//GetBoolparsevaluetobool://ifvalue=="0"returnfalse;//ifvalue=="1"returntrue;//ifvalue!="0"&value!="1"returnerror;func(d*DemotionClient)GetBool(ctxcontext.Context,keystring)(bool,error){......//类似GetInt方法}由于 Golang 语言对嵌入类型的支持,DemotionClient在扩展能力的同时,ClientV2的原本方法也能正常调用,这样语法糖的设计让组合操作达到了继承的效果,且符合 OOP 中替换原则。与 Java 语言对比,如下面的例子,类 A 和类 B 实现了IHi的接口,类 C 组合了接口IHi, 如果需要暴露IHi的方法,则类 C 需要添加一个代理方法,这样 java 语言的组合在代码量上会多于继承方式,而 Golang 中无需额外代码即可提供支持。publicinterfaceIHi{publicvoidhi();}publicclassAimplementsIHi{@Overridepublicvoidhi(){System.out.println("Hi,IamA.");}}publicclassBimplementsIHi{@Overridepublicvoidhi(){System.out.println("Hi,IamB.");}}publicclassC{IHelloh;publicvoidhi(){h.hi();}}publicstaticvoidmain(Stringargs[]){Cc=newC();c.h=newA();c.hi();c.h=newB();c.hi();}隐藏复杂对象构造过程—工厂模式解决问题:将对象复杂的构造逻辑隐藏在内部,调用者不用关心细节,同时集中变化。TCC 创建LogCounnter时使用了工厂模式,该类作用是根据错误日志出现的频率判断是否需要打印日志,如果在指定的时间里,错误日志的触发超过指定次数,则需要记录日志。NewLogCounter方法通过入参 LogMode 枚举类型即可生成不同规格配置的LogCounter,可以无需再去理解 TriggerLogCount、TriggerLogDuration、Enable 的含义。typeLogModestringconst(LowModeLogMode="low"MediumModeLogMode="medium"HighModeLogMode="high"AlwaysModeLogMode="always"ForbiddenModeLogMode="forbidden")//InTriggerLogDuration,iferrortimes0{logCounter.Enable=truelogCounter.TriggerLogCount=triggerLogCountlogCounter.TriggerLogDuration=triggerLogDuration}returnlogCounter}func(r*LogCounter)CheckPrintLog()boolfunc(r*LogCounter)CheckDiffTime(lastErrorTime,newErrorTimetime.Time)bool识别变化隔离变化,简单工厂是一个显而易见的实现方式。它符合了 DRY 原则(Don't Repeat Yourself!),创建逻辑存放在单一的位置,即使它变化,也只需要修改一处就可以了。DRY 很简单,但却是确保我们代码容易维护和复用的关键。DRY 原则同时还提醒我们:对系统职能进行良好的分割,职责清晰的界限一定程度上保证了代码的单一性。[引用自 https://blog.51cto.com/weijie/82767]一步步构建复杂对象—建造者模式解决问题:使用多个简单的对象一步一步构建成一个复杂的对象。APIX 在创建请求的匹配函数Matcher时使用了建造者模式。APIX 中提供了指定对哪些 request 生效的中间件,定义和使用方式如下,CondHandlersChain结构体中定义了匹配函数Matcher和命中后执行的处理函数HandlersChain。以“对路径前缀为`/wechat` 的请求开启微信认证中间件”为例子,Matcher 函数不用开发者从头实现一个,只需要初始化 SimpleMatcherBuilder 对象,设置请求前缀后,直接 Build 出来即可,它将复杂的匹配逻辑隐藏在内部,非常好用。//ConditionalhandlerschaintypeCondHandlersChainstruct{//匹配函数Matcherfunc(method,pathstring)bool//命中匹配后,执行的处理函数ChainHandlersChain}//对路径前缀为`/wechat`的请求开启微信认证中间件mw1:=apix.CondHandlersChain{Matcher:new(apix.SimpleMatcherBuilder).PrefixPath("/wechat").Build(),Chain:apix.HandlersChain{wxsession.NewMiddleware()},}//注册中间件e.CondUse(mw1)SimpleMatcherBuilder是一个建造者,它实现了MatcherBuilder接口,该类支持 method、pathPrefix 和 paths 三种匹配方式,业务方通过Method()、PrefixPath()、FullPath()三个方法的组合调用即可构造出期望的匹配函数。typeMatcherBuilderinterface{Build()func(method,pathstring)bool}var_MatcherBuilder=(*SimpleMatcherBuilder)(nil)//SimpleMatcherBuilderbuildamatcherforCondHandlersChain.//An`AND`logicwillbeappliedtoallfields(ifprovided).typeSimpleMatcherBuilderstruct{methodstringpathPrefixstringpaths[]string}func(m*SimpleMatcherBuilder)Method(methodstring)*SimpleMatcherBuilder{m.method=methodreturnm}func(m*SimpleMatcherBuilder)PrefixPath(pathstring)*SimpleMatcherBuilder{m.pathPrefix=pathreturnm}func(m*SimpleMatcherBuilder)FullPath(path...string)*SimpleMatcherBuilder{m.paths=append(m.paths,path...)returnm}func(m*SimpleMatcherBuilder)Build()func(method,pathstring)bool{method,prefix:=m.method,m.pathPrefixpaths:=make(map[string]struct{},len(m.paths))for_,p:=rangem.paths{paths[p]=struct{}{}}returnfunc(m,pstring)bool{ifmethod!=""&m!=method{returnfalse}ifprefix!=""&!strings.HasPrefix(p,prefix){returnfalse}iflen(paths)==0{returntrue}_,ok:=paths[p]returnok}}var_MatcherBuilder=(AndMBuilder)(nil)var_MatcherBuilder=(OrMBuilder)(nil)var_MatcherBuilder=(*NotMBuilder)(nil)var_MatcherBuilder=(*ExcludePathBuilder)(nil)......除此之外,ExcludePathBuilder,AndMBuilder、OrMBuilder、*NotMBuilder也实现了MatcherBuilder接口,某些对象内部又嵌套了对MatcherBuilder的调用,达到了多条件组合起来匹配的目的,非常灵活。//路径以`/api/v2`开头的请求中,除了`/api/v2/legacy`外,都开启中间件mb1:=new(apix.SimpleMatcherBuilder).PrefixPath("/api/v2")mb2:=new(apix.ExcludePathBuilder).FullPath("/api/v2/legacy")mw3:=apix.CondHandlersChain{Matcher:apix.AndMBuilder{mb1,mb2}.Build(),Chain:apix.HandlersChain{...},}e.CondUse(mw1,mw2)工厂方法模式 VS 建造者模式工厂方法模式注重的是整体对象的创建方法,而建造者模式注重的是部件构建的过程,旨在通过一步一步地精确构造创建出一个复杂的对象。举个简单例子来说明两者的差异,如要制造一个超人,如果使用工厂方法模式,直接产生出来的就是一个力大无穷、能够飞翔、内裤外穿的超人;而如果使用建造者模式,则需要组装手、头、脚、躯干等部分,然后再把内裤外穿,于是一个超人就诞生了。[引用自 https://www.cnblogs.com/ChinaHook/p/7471470.html]Web 中间件—责任链模式解决问题:当业务处理流程很长时,可将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到没有对象处理它为止。APIX 应用了责任链模式来实现中间件的功能,类似的逻辑可参考文章“Gin 中间件的编写和使用”。首先要定义中间件接口,即下文中的HandlerFunc,然后定义HandlersChain将一组处理函数组合成一个处理链条,最后将HandlersChain插入Context中。开始执行时,是调用Context的Next函数,遍历每个HandlerFunc,然后将Context自身的引用传入,index是记录当前执行到第几个中间件,当过程中出现不满足继续进行的条件时,可以调用Abort()来终止流程。//定义中间件的接口typeHandlerFuncfunc(*Context)//将一组处理函数组合成一个处理链条typeHandlersChain[]HandlerFunc//处理的上下文typeContextstruct{//...//handlers是一个包含执行函数的数组//typeHandlersChain[]HandlerFunchandlersHandlersChain//index表示当前执行到哪个位置了indexint8//...}//Next会按照顺序将一个个中间件执行完毕//并且Next也可以在中间件中进行调用,达到请求前以及请求后的处理func(c*Context)Next(){c.index++forc.index part3 -> Handler -> part 4 -> part2。依赖注入,控制反转—观察者模式解决问题:解耦观察者和被观察者,尤其是存在多个观察者的场景。TCC 使用了观察者模式实现了当某 key 的 value 发生变更时执行回调的逻辑。TccClient对外提供AddListener方法,允许业务注册对某 key 变更的监听,同时开启定时轮询,如果 key 的值与上次不同就回调业务的 callback 方法。这里的观察者是调用 AddListener 的发起者,被观察者是 TCC 的 key。Callback可以看作只有一个函数的接口,TccClient的通知回调不依赖于具体的实现,而是依赖于抽象,同时Callback对象不是在内部构建的,而是在运行时传入的,让被观察者不再依赖观察者,通过依赖注入达到控制反转的目的。//Callbackforlistener,外部监听者需要实现该方法传入,用于回调typeCallbackfunc(valuestring,errerror)//一个监听者实体typelistenerstruct{keystringcallbackCallbacklastVersionCodestringlastValuestringlastErrerror}//检测监听的key是否有发生变化,如果有,则回调callback函数func(l*listener)update(value,versionCodestring,errerror){ifversionCode==l.lastVersionCode&err==l.lastErr{return}ifvalue==l.lastValue&err==l.lastErr{//version_codeupdated,butvaluenotupdatedl.lastVersionCode=versionCodereturn}deferfunc(){ifr:=recover();r!=nil{logs.Errorf("[TCC]listenercallbackpanic,key:%s,%v",l.key,r)}}()l.callback(value,err)l.lastVersionCode=versionCodel.lastValue=valuel.lastErr=err}//AddListeneraddlistenerofkey,ifkey'svalueupdated,callbackwillbecalledfunc(c*ClientV2)AddListener(keystring,callbackCallback,opts...ListenOption)error{listenOps:=listenOptions{}for_,op:=rangeopts{op(&listenOps)}listener:=listener{key:key,callback:callback,}iflistenOps.curValue==nil{listener.update(c.getWithCache(context.Background(),key))}else{listener.lastValue=*listenOps.curValue}c.listenerMu.Lock()deferc.listenerMu.Unlock()if_,ok:=c.listeners[key];ok{returnfmt.Errorf("[TCC]listeneralreadyexist,key:%s",key)}c.listeners[key]=&listener//一个client启动一个监听者if!c.listening{goc.listen()c.listening=true}returnnil}//轮询监听func(c*ClientV2)listen(){for{time.Sleep(c.listenInterval)listeners:=c.getListeners()forkey:=rangelisteners{listeners[key].update(c.getWithCache(context.Background(),key))}}}//加锁防止多线程同时修改listeners,同时拷贝一份map在循环监听时使用。func(c*ClientV2)getListeners()map[string]*listener{c.listenerMu.Lock()deferc.listenerMu.Unlock()listeners:=make(map[string]*listener,len(c.listeners))forkey:=rangec.listeners{listeners[key]=c.listeners[key]}returnlisteners}什么是控制反转(Ioc—Inversion of Control)控制反转不是一种技术,只是一种思想,一个重要的面向对象编程的法则,它能指导我们如何设计出松耦合、更优良的程序,传统应用程序都是由我们在类内部主动创建依赖对象,从而导致类与类之间高耦合,难于测试;有了 IoC 容器后,把创建和查找依赖对象的控制权交给了容器,由容器进行注入组合对象,所以对象与对象之间是 松散耦合,这样也方便测试,利于功能复用,更重要的是使得程序的整个体系结构变得非常灵活。[引用自 https://blog.csdn.net/bestone0213/article/details/47424255]什么是依赖注入(DI—Dependency Injection)组件之间依赖关系由容器在运行期决定,形象的说,即由容器动态的将某个依赖关系注入到组件之中。依赖注入的目的并非为软件系统带来更多功能,而是为了提升组件重用的频率,并为系统搭建一个灵活、可扩展的平台。通过依赖注入机制,我们只需要通过简单的配置,而无需任何代码就可指定目标需要的资源,完成自身的业务逻辑,而不需要关心具体的资源来自何处,由谁实现。[引用自 https://blog.csdn.net/bestone0213/article/details/47424255]控制反转和依赖注入是同一个概念的不同角度描述。简而言之,当依赖的外部组件时,不要直接从内部 new,而是从外部传入。替代 IF—策略模式解决场景:支持不同策略的灵活切换,避免多层控制语句的不优雅实现,避免出现如下场景:ifxxx{//dosomething}elseifxxx{//dosomething}elseifxxx{//dosomething}elseifxxx{//dosomething}else{}通常的做法是定义了一个公共接口,各种不同的算法以不同的方式实现这个接口,环境角色使用这个接口调用不同的算法。在 GORM 的 clause/clause.go 中使用到策略模式实现 SQL 的拼装。现实业务中 SQL 语句千变万化,GORM 将 SQL 的拼接过程,拆分成了一个个小的子句,这些子句统一实现clause.Interface这个接口,然后各自在Build方法中实现自己的构造逻辑。以最简单的分页查询为例,在使用 db 链式调用构建 SQL 时,对Limit、Offset、Order的函数调用最终转化成了Limit子句和OrderBy子句,两者都实现了clause.Interface接口。db.WithContext(ctx).Model(&Course{}).Order("course_idDESC").Limit(0).Offset(100)//Limitspecifythenumberofrecordstoberetrievedfunc(db*DB)Limit(limitint)(tx*DB){tx=db.getInstance()tx.Statement.AddClause(clause.Limit{Limit:limit})return}//Offsetspecifythenumberofrecordstoskipbeforestartingtoreturntherecordsfunc(db*DB)Offset(offsetint)(tx*DB){tx=db.getInstance()tx.Statement.AddClause(clause.Limit{Offsetffset})return}//Orderspecifyorderwhenretrieverecordsfromdatabase//db.Order("nameDESC")//db.Order(clause.OrderByColumn{Column:clause.Column{Name:"name"},Desc:true})func(db*DB)Order(valueinterface{})(tx*DB){tx=db.getInstance()switchv:=value.(type){caseclause.OrderByColumn:tx.Statement.AddClause(clause.OrderBy{Columns:[]clause.OrderByColumn{v},})casestring:ifv!=""{tx.Statement.AddClause(clause.OrderBy{Columns:[]clause.OrderByColumn{{Column:clause.Column{Name:v,Raw:true},}},})}}return}Clause 的接口定义://InterfaceclauseinterfacetypeInterfaceinterface{Name()stringBuild(Builder)MergeClause(*Clause)}Limit Clause 的定义://LimitlimitclausetypeLimitstruct{LimitintOffsetint}//Buildbuildwhereclausefunc(limitLimit)Build(builderBuilder){iflimit.Limit>0{builder.WriteString("LIMIT")builder.WriteString(strconv.Itoa(limit.Limit))}iflimit.Offset>0{iflimit.Limit>0{builder.WriteString("")}builder.WriteString("OFFSET")builder.WriteString(strconv.Itoa(limit.Offset))}}//Namewhereclausenamefunc(limitLimit)Name()string{......}//MergeClausemergelimitbyclausefunc(limitLimit)MergeClause(clause*Clause){......}OrderBy Clause 的定义:typeOrderByColumnstruct{ColumnColumnDescboolReorderbool}typeOrderBystruct{Columns[]OrderByColumnExpressionExpression}//Buildbuildwhereclausefunc(orderByOrderBy)Build(builderBuilder){iforderBy.Expression!=nil{orderBy.Expression.Build(builder)}else{foridx,column:=rangeorderBy.Columns{ifidx>0{builder.WriteByte(',')}builder.WriteQuoted(column.Column)ifcolumn.Desc{builder.WriteString("DESC")}}}}//Namewhereclausenamefunc(limitLimit)Name()string{......}//MergeClausemergeorderbyclausefunc(limitLimit)MergeClause(clause*Clause){......}下面的截图中列举了实现clause.Interface接口的所有类,以后 SQL 支持新子句时,创建一个类实现clause.Interface接口,并在函数调用的地方实例化该类,其余执行的代码皆可不变,符合 OOP 中的开闭原则和依赖倒置原则。Lazy 加载,线程安全—单例模式解决场景:变量只想初始化一次。APIX 在埋点中间件中通过单例模式实现了对变量延迟且线程安全地赋值。Metrics()用来生成 Metric 埋点中间件,在加载的过程,由于 APIX 的路由表还未注册完毕,所以需要把两个变量 metricMap 和 pathMap 的初始化放在中间件的执行过程中,但服务器启动后,这两个变量的值是固定的,没必要反复初始化,其次大量请求过来时,中间件的逻辑会并发执行,存在线程不安全的问题。故在实现的过程中用到了sync.Once对象,只要声明类型的 once 变量,就可以直接使用它的 Do 方法,Do 方法的参数是一个无参数,无返回的函数。funcMetrics()HandlerFunc{......metricMap:=make(map[string]m3.Metric)//key:handlernamepathMap:=make(map[string][]string)//key:handlername,value:pathsonce:=sync.Once{}//protectmapsinitreturnfunc(c*Context){//whyinitinhandlerchainnotearlier:routeshaven'tbeenregisteredintotheenginewhenthemiddlewarefunccreated.once.Do(func(){for_,r:=rangec.engine.Routes(){metricMap[r.Handler]=cli.NewMetric(r.Handler+".calledby",tagMethod,tagURI,tagErrCode,tagFromCluster,tagToCluster,tagFrom,tagEnv)pathMap[r.Handler]=append(pathMap[r.Handler],r.Path)}})c.Next()......}}Sync.Oncesync.Once的源码很短,它通过对一个标识值,原子性的修改和加载,来减少锁竞争的。typeOncestruct{doneuint32mMutex}func(o*Once)Do(ffunc()){//加载标识值,判断是否已被执行过ifatomic.LoadUint32(&o.done)==0{o.doSlow(f)}}func(o*Once)doSlow(ffunc()){//还没执行过函数o.m.Lock()defero.m.Unlock()ifo.done==0{//doublecheck是否已被执行过函数deferatomic.StoreUint32(&o.done,1)//修改标识值f()//执行函数}}它有两个特性,一是不管调用 Do 方法多少次,里面的函数只会执行一次;二是如果开始有两个并发调用,可以保证第二个调用不会立即返回,会在获取锁的时候阻塞,等第一个调用执行完毕之后,第二个调用进行二次校验之后就直接返回了。Sync.Once 有个问题,Do 的过程并不关注 f 函数执行的结果是成功还是失败,当 f()执行失败时,由于本身的机制,没有机会再次初始化了。如果你需要二次初始化,可以看看下面传送门中关于“sync.Once 重试”的文章。传送门百度分布式配置中心 BRCC:https://cloud.tencent.com/developer/article/1798554Gin github 地址:https://github.com/gin-gonic/ginGORM 官网:https://gorm.io/zh_CN/docs/Gin 中间件的编写和使用:https://www.moemona.com/2020/10/804/Go 语言动手写 Web 框架:https://geektutu.com/post/gee-day5.htmlsync.Once 重试:https://studygolang.com/articles/31348参考和引用文献https://blog.51cto.com/weijie/82767https://www.jianshu.com/p/150523db21a9https://geektutu.com/post/gee-day5.htmlhttps://www.musicpoet.top/20200409/7-major-design-principles-of-oop.htmlhttps://www.cnblogs.com/ChinaHook/p/7471470.htmlhttps://blog.csdn.net/bestone0213/article/details/47424255加入我们大力智能是大力教育旗下的智能学习平台,针对书桌场景,革新产品形态和技术,践行科学的教育理念,推动家庭学习的数字化和智能化。通过持续创新,服务教育生态参与者,实现“创新教育,成就每一个人”的目标。加入我们研发团队,在这里你有机会接触到人工智能、大数据、高可用与高性能服务、软硬件一体化、跨端研发等丰富的技术栈。同时也有机会使用这些技术加速字节大力教育的创新与探索。让技术激发人的潜能,也释放你的潜能。在招岗位:服务端开发工程师、前端开发工程师、客户端开发工程师(Android/iOS)岗位地点:上海岗位投递链接:欢迎优秀的你,一起来做优秀的事~
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-16 00:21 , Processed in 0.516581 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表