cf之app解读
2012-11-14 16:45
274 查看
笔者小菜,接触CF以来一直在读代码,其中重点是app,针对app的增删改读,以及app状态监控与错误处理,笔者进行了一定粒度的总结,不太可能很详细,敬请指教。本文的思路像是讲一个悠长的故事,一个个函数地讲,既是为了满足实验室修改代码的需求,也是为了让读者能够沿着代码的轨迹,对照着本文一步步地走下去。
HealthManager
run函数中定义了一个处理nats连接错误的函数、一个EM连接错误的函数后,调用了三个函数:
configure_timers
一、此函数首先执行update_from_db,这里采用了next_tick和add_periodic_timer合用的方式,前者实现将一个块调度到reactor的下一次迭代时执行,后者实现间隔一段时间执行,代码里有很多这样的写法,这里不详述,可参考实验室resouer的博文Researchon EventMachine。update_from_db函数主要做两件事
1.通过ensure_connected函数连接数据库后,有代码如下
old_droplet_ids =Set.new(@droplets.keys) App.all.each do |droplet| old_droplet_ids.delete(droplet.id) update_droplet(droplet) end上面这段代码中表示将实例变量@droplets中的数据用数据库中读出来的droplet的信息更新,其实所有的droplet都存在述数据库中,但我们使用的时候不能每次从数据库中访问,所以在hm的run函数中需要首先取出来。
2.在update_from_db中更新了一些状态信息,包括app的数量、started的app的数量、mem等。
二、然后,在update_from_db后,同样地,采用next_tick和add_periodic_timer结合的方式,执行analyze_all_apps函数,不同的是add_periodic_timer的外面包了一个add_timer(@droplet_lost),注释很清楚,由于在analyze_all_apps函数中,主要完成的工作是对于所有droplet的分析,即坏掉的要stop,冗余的要delete,所以需要等一个droplet_lost的时间以保证所有droplet的心跳都已收到,有关心跳也就是状态app的监测后文详述。
在analyze_all_apps中调用两个函数:
prepare_analysis(collect_stats) #为下面做一些初始化,构建了一个名为@analysis的键值对集合 perform_and_schedule_next_quantum
这里perform_and_schedule_next_quantu中循环n次做函数perform_quantum,n为droplet的个数。perform_quantum中调用了实现主要功能的analyze_app函数,然后更新键值对集合analysis中instances的值以及表示已当掉的droplet的数量crashed的值。
下面介绍重点analyze_app,这个函数针对某个app,其思路是这样的:
1.初始化,若APP_STABLE_STATES包含当前droplet的state,则标记extra_instances和missing_indices都为空
2.若此instance对应的droplet的状态是STOPPED||droplet的index已超过droplet的数量||droplet的版本不是当前当前活跃着的版本,那么extra_instance变量记为true,下面变量extra_instances则记录下此instance
3.若此instance对应的droplet是STARTED的并且此app曾经完成过stage过程(sanity函数检查是否完成stage),并且状态是DOWN||droplet距离上次使用的时间超过重启时间,则missing_indices记录下此instances的index
4.根据前面的标记与记录变量,若extra_instances有值,则调用stop_instance函数将其记录的instance停掉,若missing_indices有值,则调用start_instances函数将此其记录的instance重启
三、处理请求队列
如果请求队列不为空,则每隔1秒调用deque_a_batch_of_requests函数处理请求,此函数中调用publish_start_message函数对每个请求进行处理。此函数稍候详述。
至此config_timers函数介绍完毕,简单小结,此函数就是用数据库中droplet信息更新了内存中的droplet变量;管理app,stop一些失效的instance,start一些missing_indices的instance;处理一些遗留的请求。
register_as_component
hm启动的时候向Component注册自己的信息,这里的Component,有组件注册时,会为这个组件启动一个server,我们能够访问相应的url以获得组件的信息。注册完毕即可向Component的varz写信息了。有关varz用法与原理,请阅读实验室cherry_sun的博文cloudfoundry的状态监控:varzsubscribe_to_messages
在这个函数中hm订阅一些消息,订阅了如下类型的消息:dea.heartbeat#dea发来的instance的心跳
Droplet.exited#droplet退出的消息
Droplet.updated#droplet更新的消息
Healthmanager.status#请求healthmanager的状态的消息
Healthmanager.health#询问healthmanager是否健康的消息
router.active_apps#router发来的询问有效app的消息
这些subscribe除了后三项在下文中的状态监控部分会有讲解,然后hm又发布了一个消息告诉大家它已经在工作了NATS.publish('healthmanager.start')。这些订阅消息会在下文中的状态监控中介绍。
app的状态监控
app的实现主要就是在dea中的agent.rb文件中。下面讨论几种app状态监控的情况:1.当cc创建完一个instance后
如上文所述,在process_dea_start()中处理了instance的创建,在检测到instance创建成功后(detect_app_ready函数中检测app的状态是否为Running),就send_single_heartbeat(instance),此函数是向hm发送一个心跳。
Heartbeat的内容包括:
heartbeat = { :droplets => [generate_heartbeat(instance)], :dea => VCAP::Component.uuid, :prod => @prod } NATS.publish('dea.heartbeat', heartbeat.to_json)
这里publish了一个类型为dea.heartbeat的消息,此消息被HM订阅,在[cloud_controller/health_manager/lib/health_manager.rb]中hm启动时就在函数subscribe_to_messages中订阅了许多消息:
NATS.subscribe('dea.heartbeat') do |message| @logger.debug { "heartbeat: #{message}" } process_heartbeat_message(message) end
那么当NATS接收到类型为dea.heartbeat的消息时先写log,然后回调proces_heartbeat_message函数,那么现在详细讲解下hm收到心跳后做的事,也就是函数process_heartbeat_message:
取出heartbeat传过来的instance记为instance;再取出数据库中的droplet_entry:
droplet_id = heartbeat['droplet'].to_s instance = heartbeat['instance'] droplet_entry = @droplets[droplet_id]
然后做一些事,这里的判断比较多,下面是一段不是很美观的似伪代码解释如下:
if(droplet_entry){ 表示droplet是存在的,则: if(heartbeat的state是starting或running){ 从droplet_entry中根据heartbeat提供的index和version取出index_entry,如果数 据库中的instance与heartbeat传过来的instance不是同一个,那么stop这个instances } else if(state是CRASHED){ 表示这个心跳是CRASHED心跳,更新crash的时间戳 } }else { 表示这个app是unknown的,则: 算出这个instance的时间戳距离现在的时间instance_uptime,如果这个hm已经运行了足够久(>threshold), 但instancer仍很久没有发心跳(instance_uptime>定值threshold),则stop这个instance. }上文中多次用到的stop_instances解释如下:
参数为相应的droplet_id和instances。整理stop消息,包括将message的op置为stop后发送publish消息:
NATS.publish("cloudcontrollers.hm.requests.#{@cc_partition}", stop_message)
grep了一下发现cc中的[cloud_controller/cloud_controller/app/subscriptions/health_manager_channel.rb]订阅了这条消息,这个health_manager_channel.rb文件其实就是接收一些消息的,真正处理的文件是在models中,所以里面有调用处理函数的语句:App.process_health_manager_message(payload),我们进入到app.rb中的这个函数:考虑一种情况,如果传过来的app不存在了,则立刻发送一个dea.stop的消息、如果存在,再去处理这个传过来的消息,在appmanager中的health_manager_message_received函数中进行处理:
case payload[:op] when/START/i …… when/STOP/i if payload[:last_updated]==app.last_updated stop_msg = { :droplet=> app.id, :instances=> payload[:instances]} NATS.publish('dea.stop',Yajl::Encoder.encode(stop_msg)) end when/SPINDOWN/i ……这里判断传过来的消息的op是什么,当是stop时,判断如果收到的是app的旧的版本,则不管,将其留在系统中,如果正是最新版本,那么同样地发布一个类型为dea.stop的消息。
显然,在dea的agent.rb文件中订阅了此消息,在process_dea_stop 函数中对其进行处理,这里与app的stop指令实现的方式是一样的。
遍历数据库中的所有droplet对应的instance,找到那个传过来的instance:
instances.each_value do |instance| …… if (version_matched && instance_matched && index_matched && state_matched) ……找到后,设置instance的exit_reason值,下面会有用处的。另外如果它的状态已经为CRASHED,则将state值改为DELETED,无论是什么状态都最后会去执行stop_droplet(),此函数中会去调用函数send_exited_message 发送一些退出信息:
unregister_instance_from_router(instance) #告诉router,这个instance已经没有了, #以后不要将访问app的请求路由过来了 send_exited_notification(instance) #此函数中会发送类型为droplet.exited的消息 droplet.exited的消息被hm订阅,在process_exited_message()中处理,这个函数主要是在instance被stop后,更新一些值。 droplet_entry = @droplets[droplet_id] if droplet_entry version_entry = droplet_entry[:versions][version] if version_entry index_entry = version_entry[:indices][index] end ……这一段就是取出数据库中记录的droplet的值,这一段有利于理解这常用的几个变量的意义以及之间的关系。
下面就是根据exit_reason进行处理,exit_reason大致有四种:CRASHED、DEA_SHUTDOWN、DEA_EVACUATI、STOPPED,前三种都是属于意外事故或者instance不完整,是需要进恢复的stop,而STOPPED是正常的stop。所以下面的代码中,如果exit的reason是RESTART_REASONS (RESTART_REASONS是一个set包括CRASHED, DEA_SHUTDOWN, DEA_EVACUATION这三个值,也就是instance本身坏了、dea关掉了以及dea当了这三种原因),那么分类进行一些状态和时间戳的设置,再调用start_instances函数进行instance的恢复。
现在有必要总结下instance的几种exit_reason以及几种state:
CRASHED
在[dea/lib/dea/agent.rb]中的process_dea_start 函数中,也就是新建一个instance的回调函数中,instance因为stage过程未完成,此instance必须被stop,这里会设置其exit_reason为CRASHED,state也是CRASHED,代码如下所示:
instance[:state]=:CRASHED instance[:exit_reason] = :CRASHED instance[:state_timestamp] = Time.now.to_i stop_droplet(instance)CRASHED是一个很糟糕的exit_reason,表示这个instance是不完整的,如果有DEA_EVACUATION和DEA_SHUTDOWN的情况也不能取代这个糟糕的原因,从下面的代码中就可看到:
instances.each_value do|instance| # skip any crashed instances next if instance[:state] == :CRASHED instance[:exit_reason] = :DEA_EVACUATION instances.each_value do|instance| # skip any crashed instances instance[:exit_reason] = :DEA_SHUTDOWN unless instance[:state] == :CRASHED当instance的exit_reason为空时,就是不知道什么原因时,就做最坏处理,此时也会设置成CRASHED,state也会设置成CRASHED,在dea的sned_exited_message函数中代码如下:
unless instance[:exit_reason] instance[:exit_reason] = :CRASHED instance[:state] = :CRASHED end
DEA_SHUTDOWN
['TERM', 'INT', 'QUIT'].each { |s| trap(s) { shutdown() } }这是在dea的agent.rb中捕获到这三者时会调用shutdown函数,shutdown函数中skip掉CRASHED状态的instance,将dea上的其他所有的instance的exit_reason置为DEA_SHUTDOWN。
DEA_EVACUATION
trap('USR2') { evacuate_apps_then_quit() }Dea捕获到USR2错误时调用函数evacuate_apps_then_quit(),这里同样的,skip掉CRASHED状态的instance,将dea上的所有其他的instance的exit_reason置为DEA_EVACUATION。
STOPPED
当instance的状态很正常,如state是STARTING或者是RUNNING时,又需要stop这个instance时,基本这种情况就是正常的stop命令,这时会将exit_reason设置为STOPPED,函数process_dea_stop中代码如下:
instances.each_value do |instance| version_matched = version.nil? || instance[:version] == version instance_matched = instance_ids.nil? || instance_ids.include?(instance[:instance_id]) index_matched = indices.nil? || indices.include?(instance[:instance_index]) state_matched = states.nil? || states.include?(instance[:state].to_s) if (version_matched && instance_matched && index_matched && state_matched) instance[:exit_reason] = :STOPPED if [:STARTING, :RUNNING].include?(instance[:state]) if instance[:state] == :CRASHED instance[:state] = :DELETED instance[:stop_processed] = false end stop_droplet(instance) end end
这里涉及instance的几个状态,状态有CRASHED、STOPPED
CRASHED
在没有完成stage过程的情况下被设置,是很糟糕的状态,在设置其他状态时会skip掉这个状态,如在dea的stop_droplet函数中:
instance[:state] = :STOPPED unless instance[:state] == :CRASHED
在process_dea_stop中找到了相应的需要stop的instance后,判断此instance的状态,如果它的state是CRASHED的话,那么就将其state状态改为DELETED,注意这里改的是state不是exit_reason,也就是说,对于这样糟糕的状态的instance,stop请求被hm接收后,会因为exit_reason为CRASHED而重新start,那么原来的instance的state在恢复之前就被设置为DELETED,表示已经不存在了,代码如下:
if instance[:state] == :CRASHED instance[:state] = :DELETED instance[:stop_processed] = false end
另外对于CRASHED的instance,系统有一个定时工作的crashes_reaper来检测然后做delete处理,这个会在后面的dea启动时加的几个EM计时器里面详细讲述。
STOPPED、STARTED
即正常的被stop了的instance和已经start的instance。dea的update_app_from_params函数中调用的update_app_state(app)函数,判断此次更新是将app开启还是停止,然后将app的state设置成相应的:
def update_app_state(app) return if body_params.nil? state = body_params[:state] return if state.nil? || app.state.to_s =~ /#{state}/i case state when /STARTED/i app.state = 'STARTED' when /STOPPED/i app.state = 'STOPPED' end end
flapping
总的来说,Flapping状态表示一个instance是不稳定的,当一个instance总是crash,那么它的状态就会被设为flapping。
在healthmanager的version2.0中,也就是最新版中,对于flapping的状态交代的比较清楚,由于实验室里集群中hm没有更新到2.0,所以以上所有的分析都是基于hm旧版的,hm2.0对于app状态监控的处理更加完善,同时也稍改了一些函数的名称,比如,下面要讲的hm2.0中的process_droplet_exited(message) 函数实际就是hm1.0中的process_exited_message函数,他们都是接收到droplet.exit类型的消息之后的回调函数,订阅的代码如下:
NATS.subscribe('droplet.exited')do|message| process_droplet_exited(message) end在函数process_droplet_exited(message)中:
Case message['reason'] when CRASHED varz.inc(:crashed_instances) droplet.process_exit_crash(message) when DEA_SHUTDOWN, DEA_EVACUATION droplet.process_exit_dea(message) when STOPPED droplet.process_exit_stopped(message) end判断退出的原因是什么,如果是"CRASHED",则调用process_exit_crash函数,此函数中,首先计算在flapping_timeout时间内的crash的次数:
instance['crashes']=0iftimestamp_older_than?(instance['crash_timestamp'],AppState.flapping_timeout) instance['crashes'] = 1 instance['crash_timestamp'] = message['crash_timestamp']然后判断如果instance它crash的次数已超过了AppState.flapping_death,那么将其状态置为FLAPPING
if instance['crashes']>AppState.flapping_death instance['state'] = FLAPPING end
2.Dea启动
在dea启动时订阅了一些消息,之后,又加了很多的EM的计时器,这里是app状态监控的很重要的内容,下面依次介绍。
EM.add_periodic_timer(@heartbeat_interval) { send_heartbeat }
这个是定时向hm发送所有的instance的心跳,这个过程跟单个instance启动时发送的心跳处理是相同的,都是发送类型为dea.heartbeat的消息。
EM.add_periodic_timer(@advertise_interval) { send_advertise }
这个是定时发送一个广告,做什么呢,其实是监测这个dea的mem的,grep发现这个消息只被cc订阅了,这里对于mem监测的处理也有点特殊的。
在[cc/app/subscriptions/dea_advertise_channel]中订阅了此消息,在处理此消息时又publish了一个dea.locate的消息,而dea.locate消息只被dea订阅了,但在处理此消息的函数中又是publish了类型为dea.advertise的消息。有点乱,画个图就是这样的:
左边代表的是em定时器的循环,不断发送advertise消息,一般监测的实现方式就是定期发送,但这里还有一个从cc发出的locate消息,这个消息会使得dea再发一个advertise,这个循环相当于cc主动请求的。个人理解可能是对于mem的监测很重要,需要保证实时的监控,所以才这样。在dea启动时、创建完instance后、删除droplet时,都需要发送advertise告诉cc,目前这个dea有多少可用的mem,advertise内容如下,计算了一个可用mem:
advertise_message = { :id => VCAP::Component.uuid, :available_memory => @max_memory - @reserved_mem, :runtimes => @runtime_names, :prod => @prod } NATS.publish('dea.advertise', advertise_message.to_json)
EM.add_periodic_timer(CRASHES_REAPER_INTERVAL) { crashes_reaper }
这个是一个对于CRASHED的app的反应器,定期执行函数crashed_reaper,进行清理。
@droplets.each_value do |instances| # delete all crashed instances that are older than an hour instances.delete_if do |_, instance| delete_instance = instance[:state] == :CRASHED&& Time.now.to_i - instance[:state_timestamp] > CRASHES_REAPER_TIMEOUT if delete_instance @logger.debug("Crashes reaper deleted: #{instance[:instance_id]}") EM.system("rm -rf #{instance[:dir]}") unless @disable_dir_cleanup end delete_instance end end这里函数很容易理解,遍历所有的instance,删掉状态为CRASHED超过CRASHES_REAPER_TIMEOUT(1h)的状态。
3.Healthmanager启动
还记得hm在启动后发送了一个消息告诉大家这个hm在工作了。其实此消息只被dea订阅了。在dea中收到此消息后回调函数process_healthmanager_start,这个函数中记了log后调用send_heartbeat函数,此函数遍历所有的droplet的所有instance,每个instance都向这个新启的hm发送一个心跳。心跳的处理就跟上文中的一样的。
App的增删改读
CF中最核心的就是app的处理,app有apps、push、delete、update、start、stop、reststart这几个重要的命令,分别对应app的读、增、删、改、启动、停止、重启。下面就依次介绍这几个命令的实现,因为笔者阅读的时候比较细也比较乱,所以这里采用的是函数到函数的理解,某些函数可能会深入讲解,某些会大致讲解,同时会注重总结归纳,不足之处请指教。首先从vmc开始,vmc是通向应用平台的一个命令行接口,[vmc / lib / cli / commands / apps.rb]目录下可看到关于app的诸多命令的接口的定义,在[vmc / lib / vmc / client.rb]中是app的各命令接口的一些实现函数。
vmc apps
在apps.rb中list函数表示对所有apps的列表显示
apps = client.apps apps.sort! {|a, b| a[:name] <=> b[:name] } return display JSON.pretty_generate(apps || []) if @options[:json调用client.rb文件中的apps函数,此函数返回apps的所有状态,包括name、instances、health、url、services。
apps_table = table do |t| t.headings = 'Application', '# ', 'Health', 'URLS', 'Services' apps.each do |app| t << [app[:name], app[:instances], health(app), app[:uris].join(', '), app[:services].join(', ')] end end display apps_table这里将client返回的上述的app状态做成表显示在命令行中。
在client.rb中的apps函数里:
def apps check_login_status json_get(VMC::APPS_PATH) end
首先检查登录状态,然后发出http的get请求,这里VMC::APPS_PATH在[vmc / lib / vmc / const.rb ]中定义,值为"apps",所以最后发送了这样一个请求,url="apps",这样的一个请求被路由到cloudcontroller(cc)中,在cc中的[cloud_controller / cloud_controller / config / routes.rb]中定义了cc的路由表,其中有一行如下:
get 'apps' => 'apps#list', :as=>:list_apps
这一行第一列表示的是http方法,第二列表示url,第三列表示在cc的controllers处理这个请求的文件名以及对应的函数名,第四列是一个别名。如上面例子,表示对于url为apps的get请求,需要到cc的controllers中的apps_controller.rb文件中的list函数中处理,相应的还有。这里相信大家早已理解ror的目录结构与MVC模型了,简单地说就是MVC讲Model、View、Controller独立,model负责数据和规则,View负责视图,Controller是整体的调度包括接受输入并调用Model和View,这里cc中没有View,需要关注的就是[cc\cloud_controller\app]下的controllers和models文件夹。文件夹中的文件是通过名字匹配的,如controllers中的services_controller.rb和models中的service.rb相对应,具体可参考Ruby
on Rails。在apps_controller.rb的list函数中有,下面的内容就很简单了,不再详述,通过这个例子读者就理解了app操作的大致流程,那么下面的介绍就会简单很多。
vmc push
push过程分为三步,分别为建一个app实例变量、执行stage、start一个instance
第一步
有个比较抽象的图如下:
上图中,首先cli询问需要上传的app的本地地址,然后进入do_push函数,这个函数中,询问是否需要保存配置,然后取得app的各种信息,如appname、url、mem、runtime等,然后检查是否有同名的app存在,检查app的数量是否超过限制,检查是否有足够内存,检查app部署的目录,如果都ok的,将app的所有信息存到manifest中,调用函数[vmc/lib/vmc/client.rb]中的client.create_app(appname,
manifest)函数 ,代码如下所示:
def create_app(name,manifest={}) check_login_status app = manifest.dup app[:name] = name app[:instances] ||= 1 json_post(VMC::APPS_PATH, app) end首先检查用户登录状态,然后整理有关app的信息即manifest以及appname和instance个数,发出post请求,post请求:
def json_post(url,payload) http_post(url, payload.to_json, 'application/json') end可看到post请求中的url为常量值VMC::APPS_PATH,即"apps",这样的一个请求会被router路由给cc,在cc中能够根据url以及http请求方法找到cc中对应的处理函数,上文已做介绍。cc中处理此请求的是[cc/cloud_controller/app/controllers/apps_controller.rb]中create函数,在create函数中,取出http中的payload,根据用户名和post请求中的appname生成相应的一个名为app的变量以供下面用,然后调用了update_app_from_params(app)
,此函数结束后会返回给vmc的client一个响应,这是后话。
update_app_from_params(app) 函数其实就是处理app命令中的各种更新,是多个命令的处理函数,包括push、update、stop等:
1.更新app的版本,加1
2.更新app的状态。调用函数update_app_state(app)来设置app的state是STARTED还是STOPPED。因为当我们update一个app时,都是调用update_app_from_params这个函数,所以对于app的操作需要在函数开始时就判断,并设置app的state
3.进行一系列的检查。包括检查上传此app的用户app数量是否未超限、此用户的url个数是否未超限
4.给app分配mem、取得post传来的数据中的有关于stage的设置,计算出此app对应的instance个数的变化
5.将更新后的app保存
6.调用update_app_services函数,进行service更新,如果有关service的binding做了变化,也就是在body_params不为空,那么app的package_state会被设置为PENDING,表示会被stage。由于在vmc的do_push函数中service的部分还没做到,所以这里在update_app_services开始会退出,接下来。cc的update_app_from_params 函数会询问stage_app(app) if app.needs_staging?,这里不会stage。
在create函数中调用完update_app_from_params函数后发送了一个响应render :json => {:result => 'success', :redirect => app_url }, :location => app_url, :status => 302。
第二步
在cli的do_push函数中收到响应后就会询问是否绑定服务。处理完服务的绑定后调用函数 upload_app_bits(appname, @application)将包括service在内的所有的数据打包,调用函数client.upload_appa函数,这个函数中检查登录状态后,发出url为”apps”的http的post请求。同样地,这个请求会被路由到cc中,cc中[cc/cloud_controller/app/controllers/apps_controller.rb]的create函数处理此请求。这次同样进行app的更新,但service的内容不为空,app的package_state在更新service时被设置为pending,接下来调用函数stage_app(app)
完成stage过程。在create函数中调用完update_app_from_params函数后又会发送一个响应给vmc。
第三步,start一个instance
在do_push函数中调用upload函数后会执行start函数来启动一个instance:
upload_app_bits(appname, @application) start(appname, true) unless no_start当cc需要启动一个droplet的instance:
1.cc先用nats.publish{'dea.discover'}找到一个dea。
2.找到dea后向它publish了类型为dea.#{uuid}.start的消息,dea启动时subscribe了一个自己uuid的消息nats.subscrribe(“dea.#{uuid}.start”){},那么dea收到这个消息后回调函数process_dea_start(msg)
3.在此函数中dea会从msg中解析出参数、创建instance、向router注册,以后有关于app的访问就会被router路由到这个instance。
vmc delete
vmc update
update命令也是被router路由到cc,在cc的routers.rb文件中定义了被路由到app_controllers.rb中的update函数。
相关文章推荐
- cf之app解读
- 【使用Html5 CfxixiJS制作APP】如何用iscroll制作水平滚动的List布局
- LocalBroadcastManager-让你的app广播更安全-源码解读
- 行内人解读开发一个App需要多少钱?
- (转)linphone-android-客户端APP-工程解读
- 使用Application Loader上传APP流程解读[APP发布]
- linphone-android-客户端APP-工程解读
- [解读]html5游戏app_Sinuous
- TensorFlow中flags传递参数 解读tf.app.flags
- Android ApiDemo示例解读系列之九:App->Activity->Persistent State
- Android ApiDemo示例解读系列之十:App->Activity->QuickContactsDemo
- 业内人士解读:开发一个App到底要多少钱?
- AppWidget API文档翻译+little解读
- Hybrid App经验解读 一
- 移动APP切图术语解读:什么是@1x @2x和@3x【转自25学堂】
- 内部Hybrid App经验解读
- create-react-app源码解读之为什么不搞个山寨版的create-react-app呢?
- 小米校招产品作业解读:设计一款日记APP
- 第三方App接入微信登录 解读
- 海云安带你解读移动金融APP安全报告