V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
alibaichuan
V2EX  ›  推广

APM 从入门到放弃:可用性监控体系和优化手段的剖析

  •  1
     
  •   alibaichuan · 2016-11-04 14:25:39 +08:00 · 3352 次点击
    这是一个创建于 2741 天前的主题,其中的信息可能已经有所发展或是发生改变。

    阿里百川码力 APP 监控 来了! 这个 APP 监控 和手淘一起成长 历经千锤百炼 走过千 BUG 万坑 如今百川起产品 为了让你的 APP 更好 用户更爽!

    在移动互联网时代,一款应用是否成功,用户体验是一个关键的因素。 APM 的发展使得用户体验越来越完善,本文通过 90 年代互联产品性能优化的发展过程到今天移动互联网时代下的 APM 可用性监控体系,如何去解决日渐复杂的业务导致功能不断迭代所突发的致命 bug ,以及日益增长的用户和膨胀的数据导致流量过大所出现的一些问题。

    在《黑客帝国》电影中较为经典的一幕是让 Neo 在红药丸和蓝药丸中做出选择。红药丸作为一个跟踪程序,帮助 Neo 定位物理身体位置,无论在哪里,出现任何问题都能够第一时间定位并解决。而开发者基本都知道,想解决大部分的功能性问题的难点基本就在定位上,而电影里面出现的一些人工智能、机器学习、虚拟现实的技术,也只能够在科幻电影中才能看到。

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwAFP9FT9AB5keYlvqf22Riarwnp0Jd0EwNSPg32uPU3LeDVaAw177DVA/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1 季度活跃设备增长趋势

    今天,在移动终端爆发以及用户需求的推动下,移动应用的“数量”和“体量”急速扩大, APP 性能数据在优化产品上变得越来越重要,国内大批 APM 厂商仿佛一夜间遍地开花,整个监控体系也从服务端到 APP 端再到 H5 端不断的加强和改变策略来适应不同的场景需求,使得监控和优化的本质上已经发生了变化。

    APM 的雏形发展

    在 1996 年时, Tivo 与 HP 公司就从应用程序层面出发,他们认为网络无疑就是应用的速度。直至 1998 年,面向以组件为中心基础建设监控的 APM 产品出现,直到 2011 年,移动设备的普及和 APP 应用市场的爆发,让大家对移动端的性能体验要求也越来越苛刻。

    在这个时候,国外的 APM 行业 New Relic 和 AppDynamics 已经在 APM 领域拔得头筹,国内一些 APM 厂商看准移动的这个趋势, APM 仿佛一夜之间遍地开花,直至今日,作为国内比较具有代表性的 APM 厂商有:听云、 OneAPM 、云智慧、博睿等,当前 BAT 领域也跻身这一领域,阿里百川码力 APM (简称“码力 APM ”)也在云栖大会中发布公测。开发者无需从零开始构建性能探针、数据平台和控制台,就可以通过可视化、可运维的方式长期监控应用性能、及时解决应用中存在的问题。

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwcd0l9cnMh65YUoXPL211VwLSnJW7xQtz5drFek2ibrtT7Wo2m46JtHw/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1 ▲ APM 业务与 IT 发展关系变迁

    APM 可用性度量体系

    如今,国内 APM 业务竞争越来越激烈,大家纷纷在可用性、用户体验上发力。比如,大家用手机淘宝,明显感觉稳定性和流畅度比国内其他电商 APP 好很多,这不仅仅是因为他们有一堆优秀的开发工程师,更关键是其背后那一套完善的性能监控度量体系。

    通过性能监控体系, app 上发生的性能指标都会被实时上报,而码力 APM 服务端会基于这些指标进行聚类和分析,聚合出问题和性能瓶颈,同时完善的日志信息也将支持开发工程师及时修复和优化。

    阿里技术专家陈武认为,在性能优化方面,以往的度量是通过 APP 的打开率来进行对比,很多都是非常主观。而度量体系里面面临的一个很大的问题是常态化。那么,应该如何建立起这一套可视化的性能度量的体系呢?

    阿里百川将影响用户使用的性能指标分为可用性度量和体验度量。

    1 、 可用性度量

    可用性包含 app 可用性和服务可用性。 app 可用性问题中最常见的就是 crash ,而用户遇到 crash 之后,大部分会选择直接卸载 app ;服务可用性问题则包含网络连接和服务端错误,这类问题往往可能造成用户购买、订阅等关键操作不可用,从而导致资损,而这类问题若长期未能解决,也会导致用户流失。

    这类问题需要第一时间被修复,越早修复,止损的效果就越好。

    这需要客户端探针具有强大的采集能力。探针 SDK 将负责采集用户由于线程异常、内存溢出、手机杀进程等各种原因导致的崩溃,并捕获到尽量全面的环境信息,和用户操作轨迹来帮助开发者还原用户操作,定位问题。同时,对网络请求部分也是同样,探针 SDK 需要支持自动采集网络性能指标,并捕获错误网络请求的日志,来辅助开发工程师解决问题。

    但是探针在用户 app 端采集的均是单一的事件,若有 1000 个用户出现可用性问题,那么服务端接收到的可能就是 1000 份日志。让开发工程师在海量的日志中排查问题,显然可行性不高。这就需要 APM 服务端实时对这些日志进行语义分析以及高效的聚类,比如,将 1000 条用户日志聚合为 3 个问题,通过控制台反馈给开发者。这将大大提升开发工程师排查和解决问题的效率。

    http://mmbiz.qpic.cn/mmbiz_jpg/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwsEuHJcGoj9499qDc2T7HyYrNJiakZZICVGSUYOyn7ESK2jCqAthP8dA/640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1

    2 、 APP 体验度量

    APP 体验是影响用户留存和活跃的关键,大家对 APP 使用过程中“如丝般顺滑”都具有天然的好感。但是目前市场大部分 APP 的体验依旧非常差,用户常会面对卡顿、图片加载失败、页面长时间等待等各种不良体验。这个时候,非常需要有一个系统体系化的去陈列和度量这些体验类问题。

    APM 控制台对卡顿的处理方式和崩溃类似,同类型的卡顿将被聚类在一起,发生该卡顿的用户详细日志也聚合在一起可以翻页查阅。而对图片加载失败等,页面元素无法正常显示的问题,则可以关注该图片所在静态资源的服务主机是否异常(单分钟请求量过多、图片过大等)。若该静态资源服务正常,则可以关注请求该图片的 URL 的错误率,可以反推是否为图片本身的问题。

    在性能优化的量化方面,如何帮助企业去做定制?陈武认为,应该串联关键路径所需要的全部 URL ,从关键路径整体来看服务的健康度指标,而非关注全部的 URL 。比如通过网络性能监控,开发者无需对所有的 URL 进行关注,不同的开发者关注的核心业务不同,大家关注的 URL 也不一样。比如,在电商的场景,一个关键的路径是用户通过登录,打开商品,进入详情,然后下单到支付,通过把对应的关键路径所有的 URL 整合在一起,保障这条关键链路的性能,才能够强化核心业务的服务以及稳定性。

    APM 的可用性检测方式

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwmhF757WbDdZ4Sg1JibAD9vFDTsiagJzEPeFeFNXl2KCL7gFLsKVGSbOg/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1 ▲ 阿里百川码力 APM 的监控体系

    对于加强应用的可用性, APM 一般都采取应用监控结合服务监控的形式,使得开发者实现端到端的全链路性能管理。在码力 APM 监控体系中,阿里巴巴技术专家熊奇介绍了码力 APM 在监控体系里面的应用监控、服务监控、数据库以及消息推送等性能监控,主要通过以下方式来完成:

    ★ 在应用监控上,采集了 iOS 、 Android 应用的内存、 CPU 、崩溃、网络等方面的性能数据;

    ★ 在服务监控上,支持 Tomcat 、 Jetty 、 JBoss 容器和 Spring 、 Struts 等框架的性能检测;

    ★ 支持 MySQL 等 SQL 数据库和 Redis 、 Mem cache 等 NoSQL 数据库的性能检测;

    ★ 码力 APM 还提供了支持淘宝消息服务 TMC 、分布式框架 Dubbo 、淘宝 API 调用的性能检测。

    对于数据采集之后会统一进入可以承载海量数据的存储系统和日志系统,统计系统会利用落地的数据完成数据的计算处理、生成报表,帮助开发者长期跟踪应用和服务的性能,而告警系统则会根据规则在问题发生时发出短信、邮件等即时告警,从而帮助开发者及时解决问题,降低损失。

    可用性的度量检测方式-性能

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVw2DHvGCUNVjsHUpTZ1kzaibgBg11Dh6hFnPSg03fHh4VBZJZXialjEsbw/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

    在应用开发时,程序错误、主线程卡顿和资源使用超过系统限制导致的崩溃,是最严重、也是需要首先解决的问题。

    通常开发者会借助模拟器、 Instrument 或者自动化测试发现一部分问题,但是测试往往难以覆盖用户使用场景下的设备、网络等环境。如果借助于社交媒体或者邮件反馈渠道,虽然可以有限地拿到真实的用户反馈,但是用户往往不能清楚的描述出复现问题所需的信息,往复沟通成本极高。所以,在客户端上,码力 APM 通过以下检测方式来收集应用崩溃信息。

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwpAjWhT6B0pHQNNYqBFY1ExJ377gkyEACUvq6BcibDNQcSQaibLiadfdgw/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

    码力 APM 在信号捕获方式中,通过 sigaction 设置信号中断时的回调,这样,就可以在回调中根据程序运行状态生成对应的崩溃日志。此外,对于 SIGARBT(abnormal termination),我们还需要通过 NSSetUncaughtExceptionHandler 来获取未捕获异常的堆栈,来补全崩溃信息。

    而后,把崩溃日志上报到码力 APM ,会依据崩溃日志的堆栈信息,聚合同一类型的崩溃后写入数据存储。同时,告警系统可以依据崩溃次数、崩溃率等规则,即时发出告警。

    此外,码力 Apm 提供了 dSYM 上报脚本,在 Xcode 的 build phrase 中添加脚本,就可以在编译成功后自动上报 dSYM 文件。通过对 dSYM 文件的解析,重新聚合后写入数据存储,聚合可以减少高达 90%数据库行数;同时,也实现了崩溃日志符号化。不依赖 mac 环境符号化,更好地利用云计算平台服务更多开发者。

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVw6PZ1wZeiapPMaaehKoc0mQw2ia5r67iaB5TU57acTZg1pWjXBnmOiaypGA/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

    第二种技术是卡顿检测,卡顿检测的基础是 RunLoop ,通过 RunLoop Observer 监听主线程 RunLoop 状态的变更。在这里,把 RunLoop 当作在操场上跑圈的运动员,把 Before Sources 当做每圈的起点,同时另外开启一条线程作为计时员,每 5 秒判断一次 RunLoop 是否跑过一圈。如果 5 秒内 RunLoop 没有完成一次 RunLoop ,则视为主线程卡顿。在发现主线程卡顿后,会生成卡顿日志,如果是复现的卡顿,可以选择不重复上报。

    此外,针对设备不同的运行时期,如启动阶段、后台阶段、空闲阶段,我们会动态调整阈值,降低检测的开销。

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwPuNPWcguwzLhh2NvODyH1yktqDbb3cAtwwvskNNPicQhuI7zmKIFOow/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

    对于无法通过信号捕获、卡顿检测的崩溃,码力 APM 引入了应用中止检测,中止检测虽然不能还原崩溃现场,但是可以揭示问题的存在。在应用进入 active 状态时,码力 APM 在持久存储上设立一个标志位,表示程序在正常运行。在应用退出 active 状态或检测到崩溃时,码力 APM 就清除持久存储上的标志位,表示程序在已知的情况下退出。这样,在下一次应用启动时,如果持久存储上的标志位为真,则说明应用上一次运行在未知情况下退出,这种情况码力 APM 就计为应用非正常中止上报。

    同时,为了过滤因为电量耗尽导致的关机,码力 APM 还增加了电量检测,在低电量时,清除标志位,避免中止误报。

    可用性的度量检测方式-网络

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwo8tDq2JiafwPYph0EX6zkibcNb6ibOndjKrmkn6JZgnM1M7h4ewd9OUrw/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

    请求错误、流量开销高、被运营商劫持等网络问题是应用开发时另一类棘手的问题。当然我们也可以借助模拟器、 Instrument 或者自动化测试发现简单的网络问题,但是测试难以覆盖复杂的用户网络环境,也难以导出网络性能数据进行长期比对监控。如果使用手工埋点的方式记录网络性能,一方面,我们需要应对多种系统网络接口,另一方面,我们需要同步应用网络代码和埋点代码,维护成本将会居高不下。

    为了监控应用在真实网络环境中的性能,码力 APM 中引入了无痕埋点的网络性能监控,在网络检测中引入三种注入技术,帮助开发者长期监控应用的网络性能,优化产品用户体验。

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwicdMGwriaibicbiaeric0Y0xXSRxsA0DvIEmCcDfiboflzjNE7oae51gBWCxA/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

    第一种是 Method Swizzling 。每一个 NSObject 类都包含一个 isa 指针,指向 objc_class 结构体,而每一个 objc_class 结构体又包含一个 methodLists 指针,指向 objc_method_list 结构体数组,在 objc_method_list 里又包含一个 objc_method 结构体成员,且每一个 objc_method 包含一个 method_imp 指针,指向方法实现。

    因此,只要能修改 method_imp 的值,我们就能替换原有的实现。在<objc/runtime>中,通过 class_getClassMethod 和 class_getInstanceMethod 取得 objc_method 结构体指针,而后通过 method_getImplementation 取得方法的原始实现地址 originIMP ,之后在 imp_implementationWithBlock 生成新实现 imp 的参数 block 里,调用原始实现,就可以原有行为前后加入网络性能埋点行为。最后调用 method_setImplementation 替换方法实现。这样,任何调用都将使用新的实现。

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwrIt76tiaxpIBHSa0ZZYG9Jib9TGYez72ZY75eQIFlFqNI7XhUNNqI19A/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

    第二种技术是 Proxy 。在 Objective-C 里, NSProxy 是除 NSObject 外唯一的根类。 NSProxy 是一个实现了 NSObject 协议的抽象类,它的正常运作需要子类 override -methodSignatureForSelector:方法为 sel 提供方法签名,以及-forwardInvocation:方法来完成调用的转发。

    使用 Proxy 来注入 NSURLConnection 、 NSURLSession 等对 delegate 的回调。具体来说,在 delegate proxy 收到消息时,如果不是目标协议方法,则通过消息转发机制,转发给原 delegate ;如果是目标协议方法,则直接调用 proxy 实现,在 proxy 实现中委托调用原 delegate ;此外,多数协议和协议方法都是可选的,因此,在 proxy 的实现中需要实现-conformsToProtocol:和-respondsToSelector:方法来声明 proxy 额外加入的协议和方法。这样,我们就能在不影响原有回调的同时,增加网络性能埋点逻辑。

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVwNsC7oQGJODf4u0gUgZ8uNSic1KfcLwEBsxlwy3oLV1zbaiaUaiaddCn7Q/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

    第三种技术是 fishhook 。使用 fishhook 来替换动态链接库中的 C 函数实现,具体来说是 CFNetwork 和 CoreFoundation 中的相关函数。这里,以开车的模型来解释动态链接。设想一名新手司机开车从巴黎到罗马,因为他不知道路线,于是他先去咨询老司机;老司机告诉他正确的线路,这一次他可能还会绕点路,但下一次,他就会按照老司机的建议直接开到罗马。

    相应的,在程序运行时,动态链接的 C 函数 dynamic(...)地址记录在__DATA segment 下的__la_symbol_ptr 中;初始时,程序只知道 dynamic 函数的符号名而不知道函数的实现地址;首次调用时,程序通过__TEXT segment 中的__stub_helper 取得绑定信息,通过 dyld_stub_binder 来更新__la_symbol_ptr 中的符号实现地址;这样,再次调用时,就可以通过__la_symbol_ptr 直接找到 dynamic 函数的实现;如果我们需要替换 dynamic 函数的实现,只需要修改__la_symbol_ptr 即可。具体的实现方式,可以参阅 Facebook 的开源框架 fishhook 。

    加强可用性的优化手段

    通过以上两种检测方式,基本能够大部分的性能和网络需求,使得开发者能够满足如今移动互联网下用户的苛刻的需求,那么,建立起来的度量体系后,了解的具体的问题后,我们应该如何去解决这些问题来提升可用性呢?

    1 、网络安全

    运营商、 DNS 被劫持问题是应用开发时一类棘手的问题, 解决方案也比较多。 51 信用卡技术总监汪睿认为, 51 信用卡作为金融属性的产品,基于安全考虑会放在第一位。解决方案主要是基于全栈 HTTPS 的方案来处理,但会带来一些成本和性能上的损耗。甚至可以像 FaceBook 、 google 等一些解决方案,使用 HTTP2.0 方式,这取决于公司和开发者自身去评估实现的成本。汪睿还介绍了早起的一个过渡方案,那就是 HTTP 的 DNS 方式,通过获取一个 IP 表通过 IP 来直接连接,可以避免 HTTP 劫持的问题。

    而网络是一个端到端的技术,阿里高级技术专家陈武认为,从电商的场景看,首先要保证服务端的稳定性,服务端可以有反刷,限流,单元化,异地容灾,服务降级等策略保证连接的稳定性。另外,客户端的角度主要看连接链路和数据量。链路里面资源可以做多 CDN 的备份,通过 HTTP DNS 或者 HTTPS , HTTP2.0 来反劫持。在链路稳定的基础上,接着去保证传输的效率,这里面可以通过就近接入,连接复用,提升压缩率,使用二进制协议等技术来减少包大小。当然,这里面最重要的是端到端的网络监控体系,这样在网络服务治理上会更有抓手。

    2 、系统降级

    降级的解决方案,是系统性能保障的最后一道防线,从性能优化的角度上说,没有 100%完善的设计,总会有一些意料突发的情况导致性能恶化。所以,在系统设计时,必须做好降级设计。

    饿了么移动首席架构师王朝成认为,在饿了么 517 大促活动上,服务器端承受非常大的压力,这个时候会通过降级部分服务的方式,来确保大促秒杀这种场景得以正常运行。但是,在用户端上,以及 APP ,还在不断积极的发送用户请求和数据,反而增加服务器集群的压力。这个时候,王朝成表示,他们会考虑把一部分的 SDK 或者 APP 上的服务也进行降级,来减少服务端在分析数据上的压力。

    降级分为手动降级和智能降级,在策略上分为流量降级、效果降级、功能性降级。流量降级主要表现在通过主动拒绝处理部分流量早餐部分用户服务不可用。而效果降级和功能性降级都表现为服务质量的降级,一个是通过在流量高峰时期用相对低质量、低延时的服务来保障所有用户的服务可用性,另外一个是通过减少功能的方式来提高用户的服务可用性。

    3 、网络性能

    从数据结构上,需要根据不同的业务场景来选择合适的数据结构,在数据流量较少的情况可能客户端上表现不出什么区别,当在数据流量过大,且数据结构复杂的时候很可能就是直接影响到 APP 的性能。

    类似餐饮领域“饿了么”这样的应用,数据发送的频率使得据量会非常大,对用户来说可能没有什么感知,但是商家接收大量的订单,数据量影响很大,感知比较明显。王朝成认为,可以考虑一些新的协议( Protobuf, Flatbuf )来优化数据量,比如 HTTP2.0 可以压缩 http 协议的 header ,使用 encoder 来减少需要传输的 header 大小,通过通讯双方各自 cache 一份 header fields 表,对于相同的数据不再通过每次请求和响应发送,又减少了需要传输的大小。再一个是采取二进制的协议,只认 0 和 1 的组合,通过把原来 http1.x 的 header 和 body 部分用 frame 重新封装,实现方便且健壮。通过内容压缩与并发传输机制,在低速、不稳定的无线条件下,较少其 http body 的发送大小,改善用户体验和资源效率。

    http://mmbiz.qpic.cn/mmbiz_png/yh0sDLwcT2GwzVBQOtWLcK3AtmcqQdVw6pfvkZozGkdiayYSwVZCXz2j0KrDNnZHy5fgDqugbnvSWbtKBd85hgQ/640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1 ▲ http1.x 和 http2.0 协议关系

    同时,阿里高级技术专家陈武也表示,如果在链路没有问题的情况下,那么必须在整个网络传输层要尽量快,不然很容易出现 timeout 。所以,第一要从协议层,在协议层里面通过 http2.0 来减少包头的压缩,同时支持服务端 push 消息,且通过双通通道,对通道复用更快。第二是从数据层,数据可以通过二进制压缩。在整个网络连通率较低的时候,将打包拆成小包,达到很好的传输效果。

    4 、动态热修复

    所谓热修复,就是使用热补丁动态修复技术,通过向用户发送 Patch ,在用户无感知的情况下完成一些致命 bug 的修复。 51 信用卡客户端负责人汪睿认为,在移动客户端上最大的一个问题是发版,对于 iOS 的用户来说,整个修复流程比较漫长。需要提交审核,但是在这段时间有可能已经错过很多用户。他认为,热修复技术能够很快并及时的在线进行修复,通常在使用的过程中就完成的修复过程。

    在热修复技术上, Android 常用的是基于 Android dex 分包方案,而 iOS 可以利用 JSPatch ,它可以使得你用 JavaScript 书写原生 iOS APP ,只需要在项目中引入极小的引擎,就可以用 JavaScript 调用任何的 Objective-C 的原生接口。

    总结

    以上所谈到的性能优化手段基本是为了解决三种情况所造成的问题: 1. 日渐复杂的业务导致功能不断迭代所突发的致命 bug 修复方式, 2. 日益增长的用户和膨胀的数据导致流量过大, 3.网络安全和内存开销的问题。

    本文通过不同的场景来分析移动性能优化的模式,可以通过确定场景下解决某一类型的问题。当然,我们不能仅仅通过了解性能优化所解决的问题以及手段,更重要的是需要清楚该问题所发生的场景、原因需要的成本。

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   1057 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 27ms · UTC 22:11 · PVG 06:11 · LAX 15:11 · JFK 18:11
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.