2024年2月

本文中你可以创建使用 Azure 机器学习所需的资源,包含工作区和计算实例。

关注TechLead,分享AI全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人。

file

一、Azure机器学习工作区与计算实例简要介绍

工作区

若要使用 Azure 机器学习,你首先需要一个工作区。 工作区是用于查看和管理所创建的全部项目和资源的中心位置。

计算实例

计算实例是预配置的云计算资源,可用于训练、自动执行、管理和跟踪机器学习模型。 开始使用 Azure 机器学习 SDK 和 CLI 的最快方法便是利用计算示例。 本教程的其余部分将使用它来运行 Jupyter 笔记本和 Python 脚本。


二、创建工作区

工作区是机器学习活动的顶级资源,为使用 Azure 机器学习时创建的所有项目提供一个集中的查看和管理位置。

1. 登录到 Azure 机器学习工作室

访问:
https://ml.azure.com/

file

2. 选择“创建工作区”

file

3. 提供以下信息来配置新工作区:

工作区名称
输入用于标识工作区的唯一名称。 名称在整个资源组中必须唯一。 使用易于记忆且区别于其他人所创建工作区的名称。 工作区名称不区分大小写。
订阅
选择要使用的 Azure 订阅。
资源组
使用订阅中的现有资源组,或者输入一个名称以创建新的资源组。 资源组保存 Azure 解决方案的相关资源。 需要“参与者”或“所有者”角色才能使用现有资源组。 有关访问权限的详细信息,请参阅管理对 Azure 机器学习工作区的访问权限。
区域
选择离你的用户和数据资源最近的 Azure 区域来创建工作区。

4. 选择“创建”以创建工作区

这将创建一个工作区以及所有必需的资源。


三、创建计算实例

如果还没有计算实例,现在请创建一个:

  1. 在左侧导航中,选择“笔记本”。

  2. 在页面中间,选择“创建计算”。
    仅当工作区中还没有计算实例时,才会显示此选项。
    file

3.提供名称。 保留第一页上的所有默认值。

4. 保留页面其余部分的默认值。

5. 选择“创建”。


四、工作室实战

4.1 工作室快速导览

工作室是 Azure 机器学习的 Web 门户。 此门户将无代码和代码优先体验结合起来,打造包容的数据科学平台。

查看左侧导航栏上的工作室部分:

工作室的“创作”部分包含多种创建机器学习模型入门的方法。 方法:

  1. 通过“笔记本”部分,可以创建 Jupyter 笔记本、复制示例笔记本以及运行笔记本和 Python 脚本。
  2. 通过“自动化 ML”步骤,可以创建机器学习模型,而无需编写代码。
  3. 通过“设计器”,可以通过拖放方式使用预生成的组件来生成模型。
  4. 工作室的“资产”部分可帮助你跟踪在运行作业时创建的资产。 如果你有新的工作区,则这些部分中还没有任何内容。

通过工作室的“管理”部分,可以创建和管理链接到工作区的计算和外部服务。 还可以在该部分创建和管理“数据标签”项目。

file

4.2 从示例笔记本中学习

使用工作室中提供的示例笔记本可帮助你了解如何训练和部署模型。 许多其他文章和教程中对此都有引用。

  1. 在左侧导航中,选择“笔记本”。
  2. 在顶部,选择“示例”。
  • 将 SDK v2 文件夹中的笔记本用于显示 SDK 当前版本 v2 的示例。
  • 这些笔记本为只读,而且定期更新。
  • 打开笔记本时,选择顶部的“克隆此笔记本”按钮,将笔记本的副本和所有关联文件都添加到你自己的文件中。 “文件”部分中即会创建一个包含该笔记本的新文件夹。

file

4.3 创建新的 Notebook

从“示例”克隆笔记本时,文件中会添加一个副本,你可以开始运行或修改该副本。 许多教程都将镜像这些示例笔记本。

但也可以创建新的空笔记本,然后将教程中的代码复制/粘贴到笔记本中。 为此,请执行以下操作:

  1. 仍然在“笔记本”部分中,选择“文件”以返回到你的文件,

  2. 选择 + 以添加文件。

  3. 选择“创建新文件”。

file

4.4 停止计算实例

如果不打算现在使用它,请停止计算实例:

  1. 在工作室的左侧,选择“计算”。
  2. 在顶部选项卡中,选择“计算实例”
  3. 在列表中选择该计算实例。
  4. 在顶部工具栏中,选择“停止”。

4.5 删除所有资源

如果你不打算使用已创建的任何资源,请删除它们,以免产生任何费用:

  1. 在 Azure 门户中,选择最左侧的“资源组” 。

  2. 从列表中选择你创建的资源组。

  3. 选择“删除资源组”。

  4. 输入资源组名称。 然后选择“删除”。

file

4.6 工作区管理

file

关注TechLead,分享AI全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人。
如有帮助,请多关注
TeahLead KrisChang,10+年的互联网和人工智能从业经验,10年+技术和业务团队管理经验,同济软件工程本科,复旦工程管理硕士,阿里云认证云服务资深架构师,上亿营收AI产品业务负责人。

前言

近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。本项目为前后端分离开发,后端基于
Java21

SpringBoot3
开发,后端使用
Spring Security

JWT

Spring Data JPA
等技术栈,前端提供了
vue

angular

react

uniapp

微信小程序
等多种脚手架工程。

项目地址:
https://gitee.com/breezefaith/fast-alden

项目中使用七牛云对象存储Kodo作为云端文件存储中心,本文主要介绍如何在SpringBoot中集成七牛云OSS,并结合前端使用Element Plus库的Upload组件实现文件上传功能。

实现步骤

引入maven依赖

在pom.xml中引入七牛云及其相关依赖,本文还引入了lombok用于简化代码。

<dependencies>
  <!-- 七牛云SDK -->
  <dependency>
    <groupId>com.qiniu</groupId>
    <artifactId>qiniu-java-sdk</artifactId>
    <version>[7.13.0, 7.13.99]</version>
  </dependency>
  <!-- Lombok -->
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.30</version>
    <optional>true</optional>
  </dependency>
</dependencies>

修改配置文件


application.yml
中可以自定义七牛云相关配置信息。

fast-alden:
  file:
    oss:
      qiniu:
        access-key: *** # 请从七牛云工作台-个人中心-密钥管理获取
        secret-key: *** # 请从七牛云工作台-个人中心-密钥管理获取
        bucket: demo # 七牛云存储空间名称
        directory: upload/ # 自定义存储空间内目录
        domain: https://qiniu.demo.com/ # 存储空间自定义域名,请提前在存储空间中进行配置

创建七牛云配置类

/**
 * 七牛云OSS相关配置
 */
@Configuration
@ConfigurationProperties(prefix = "fast-alden.file.oss.qiniu")
@Getter
@Setter
public class QiniuConfig {
    /**
     * AC
     */
    private String accessKey;
    /**
     * SC
     */
    private String secretKey;
    /**
     * 存储空间
     */
    private String bucket;
    /**
     * 上传目录
     */
    private String directory;
    /**
     * 访问域名
     */
    private String domain;
}

创建文件操作服务类

创建文件操作服务类接口。

/**
 * 文件操作服务
 */
public interface FileService {
    /**
     * 文件上传
     *
     * @param file 待上传的文件
     * @return 访问该文件的url
     * @throws IOException
     */
    String upload(MultipartFile file) throws IOException;
}

创建文件操作服务实现类,基于七牛云SDK实现。

/**
 * 七牛云对象存储文件服务
 */
@Service("fileService")
public class QiniuFileServiceImpl implements FileService {
    private final QiniuConfig qiniuConfig;

    public QiniuFileServiceImpl(QiniuConfig qiniuConfig) {
        this.qiniuConfig = qiniuConfig;
    }

    @Override
    public String upload(MultipartFile file) throws IOException {
        if (file.isEmpty()) {
            throw new RuntimeException("文件是空的");
        }
        // 创建上传token
        Auth auth = Auth.create(qiniuConfig.getAccessKey(), qiniuConfig.getSecretKey());
        String upToken = auth.uploadToken(qiniuConfig.getBucket());

        // 设置上传配置,Region要与存储空间所属的存储区域保持一致
        Configuration cfg = new Configuration(Region.huadongZheJiang2());

        // 创建上传管理器
        UploadManager uploadManager = new UploadManager(cfg);

        String originalFilename = file.getOriginalFilename();
        // 构造文件目录和文件名
        assert originalFilename != null;
        String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
        String fileKey = qiniuConfig.getDirectory() + UUID.randomUUID() + suffix;

        // 上传文件
        Response response = uploadManager.put(file.getInputStream(), fileKey, upToken, null, null);

        // 返回文件url
        return qiniuConfig.getDomain() + fileKey;
    }
}

上述代码中有一行用到了
Region.huadongZheJiang2()
,此处要与自己的存储空间所属的存储区域保持一致,本文中所使用的存储空间属于
华东-浙江2
区域。

创建文件操作控制器

@RestController
@RequestMapping("/file")
public class FileController {
    private final FileService fileService;

    public FileController(FileService fileService) {
        this.fileService = fileService;
    }

    @PostMapping("/upload")
    public String upload(MultipartFile multipartFile) throws IOException {
        return fileService.upload(multipartFile);
    }
}

前端实现

本工程前端基于Vue3组合式API开发,使用Element Plus作为UI库,借助Upload组件实现文件上传,本文在使用Upload组件时并没有直接使用其
action
属性,而是通过
http-request

on-success
属性实现了自定义的文件上传过程。

<script setup>
  import axios from "axios";
  import { ElButton, ElMessage } from "element-plus";

  const uploadFile = async (options) => {
    const formData = new FormData();
    formData.append("file", options.file);
    return axios.post(`/file/upload`, formData);
  };

  const onUploadFileSuccess = async () => {
    ElMessage({
      message: "上传成功", type: "success"
    });
  }
</script>

<template>
  <ElForm>
    <ElFormItem>
      <ElUpload action="" :http-request="uploadFile" :on-success="onUploadFileSuccess">
        <ElButton type="primary">点击上传文件</ElButton>
      </ElUpload>
    </ElFormItem>
  </ElForm>
</template>

运行效果

image

总结

本文主要介绍如何在SpringBoot中集成七牛云OSS,并结合前端使用Element Plus库的Upload组件实现文件上传功能。如有错误,还望批评指正。

在后续实践中我也是及时更新自己的学习心得和经验总结,希望与诸位看官一起进步。

促销业务概述

什么是促销?

促销是商家用来吸引消费者购物的一种手段,目的是让更多的人知道并购买他们的产品,这样就能卖得更多。促销的方法有很多种,比如,价格优惠、赠品、优惠券、折扣、买一赠一等形式。

特别是在新零售行业,促销更加重要,由于新零售是线上和线下结合的,顾客可以在线上看到促销信息,然后在实体店体验和购买产品。线上线下的联动,能进一步增加顾客购买的机会。

促销的价值

促销是一种强大的运营工具,它可以帮助运营者实现多个目标,包括吸引新用户、提高转化率、激活用户、增加留存率以及促进传播。通过促销活动,还可以有效地推广商品,提高销量,增加客单价,以及清理滞销库存。这些目标的实现对于运营者来说至关重要,因此促销被视为一项重要的策略。

对消费者而言,促销意味着:

  • 能省钱:促销能让用户以更低的价格买到东西,尤其在节日促销期间,可以省不少钱。
  • 尝试新品:一般来说,新品都会配套促销活动,这样降低了用户尝试新产品的成本和风险。用户更倾向于在促销时尝试新品,即使产品未能满足预期,较低的价格也意味着较小的损失。
  • 购物更有趣:促销常常伴随着新奇的购物体验,如特殊的包装、限量版产品或增值服务。这些都增加了购物的乐趣,提升了用户的满意度。
  • 决策简化:面对多种选择时,促销活动往往成为用户做出购买决策的重要因素。特别是对那些对多个品牌或产品持中立态度的消费者来说,促销提供了明确的购买动机。

对商家而言,促销意味着:

  • 塑造品牌形象:合理的促销活动可以加强商品在市场中的定位。例如,高端品牌可以通过有限的折扣活动来保持其稀有性。创意的促销活动也可以增强品牌形象,吸引特定的目标群体。
  • 增加市场份额:促销可以帮助新品牌、新产品迅速扩大市场份额,提高知名度。
  • 了解市场反应:通过观察促销对不同商品的影响,企业可以获取关于产品受欢迎程度、价格敏感度等重要市场信息。
  • 快速清理库存:促销可以加快商品的销售速度,特别是对季节性或即将过时的产品,这样就能减少库存积压。

促销与营销的关系

促销是营销策略中的一部分,主要关注于短期的销售增长。它通过各种手段和活动来直接刺激消费者购买行为,比如打折、优惠券、买一赠一、限时特卖等。

促销的目的是增加产品或服务的短期需求,提升销售额,清理库存,以及在特定时期内增加市场份额。促销通常是短期的,针对特定产品或服务,并且通常具有紧迫性,鼓励消费者在限定时间内做出购买决策。

营销是一个更为广泛的概念,涵盖了从市场研究、产品开发、定价策略、促销活动到销售渠道管理等各个方面。营销的主要目标是满足消费者需求,并通过建立品牌认知度和忠诚度来长期维护客户关系。营销策略不仅包括促销活动,还涉及广告、公共关系、品牌建设、市场定位等多个方面。

与促销相比,营销更加注重长期战略,旨在通过了解和满足市场需求来实现持续的业务增长。

促销场景

在新零售领域,我们面对多样化的促销工具使用场景,每种工具都设计用来支持不同类型的营销活动。例如,特价促销、秒杀活动、满减优惠、买赠活动、满额打折、优惠券发放等。

然而并非每种促销工具都适用于所有营销场景。实际上,每种工具都有其特定的应用范围和效果最大化的场景。因此,选择适当的促销策略和工具对于达成运营目标至关重要,这需要深入了解各种工具的特点和局限性,以确保我们的促销活动能够有效地满足市场需求。

对于零售商家,核心目标是将商品(货)更有效地推销给消费者(人),而促销的目标是提升该过程的运营效率。

从消费者生命周期看,不同阶段的促销场景:

从商品生命周期看,不同阶段的促销场景:

促销工具分类

在探讨促销业务时,我们首先要明确,促销活动的核心是提供优惠。这些优惠的形式多种多样,设计它们的目的是为了适应不同的市场场景并实现不同的业务目标。例如,一些促销活动可能会提供直接的价格降低,而另一些则可能要求消费者达到一定的购买门槛才能享受优惠。不同的促销形式不仅在业务层面上有所区分,而且在系统设计上也需要不同的处理。

在深入探讨营销工具的设计之前,我们需要对促销活动进行细致的分类。从前端体验的角度来看,不同类型的活动需要不同的设计重点。而从后端系统的角度来看,规则引擎需要根据不同的分类处理复杂的逻辑。

根据优惠形式的不同,促销工具可以进一步细分为:

  • 单品级:针对单一商品提供直接价格降低,如一口价、直接降价、折扣、秒杀等。
  • 订单级:基于一组商品的总金额提供优惠,如满减、满折、满赠、加价购等。
  • 抵扣类:在订单的最终支付金额上提供抵扣,如使用优惠券、余额、红包、积分等。
  • 返还类:基于订单的实付金额或特定策略提供返利,如满返积分、满返优惠券等。

了解这些分类对于设计有效且针对性强的促销策略至关重要,同时也有助于我们在系统架构中更好地实现这些功能。

促销活动是如何运作的?

一场促销活动涉及多个环节,主要包括以下几个阶段:

促销策略规划
:首先,需要明确促销活动的目标和预期结果,例如提升销量、增加品牌知名度或清仓存货。活动将根据这些目标进行。其次,需要确定目标受众。例如针对新客户、老客户或特定人群,了解目标受众的需求和期望,能更有效地设计和执行促销活动。

促销内容设计
:设计促销活动内容,确定优惠类型,如打折、买赠、积分奖励等,以吸引不同消费者。制定优惠门槛和条件,如最低消费金额、特定商品优惠,或会员专享优惠,确保公平、吸引力,并激励消费增加或成会员。

促销准备与推广
:确保有充足的库存支持促销,防止库存不足导致销售问题。准备广告、传单、海报等营销物料,以在活动期间吸引和引导顾客。在线上、线下多个渠道发布促销信息,包括微信社群、社交媒体、线下门店等,吸引更多客户参与活动。

促销实施与管理
:对促销活动的进展进行详细的过程管理,确保每一个环节都能够严格按照预定的计划进行,保证整个活动的顺利进行。在促销活动进行的过程中,及时地调整和改变促销策略,积极解决和处理在促销过程中可能出现的各种问题,以保证促销活动能够得到最好的效果。

数据分析与评估
:最后收集和分析促销活动的数据,包括参与人数,销售额和客户反馈。根据这些数据评估活动效果,然后确定是否达到目标。如果活动效果好,将经验总结沉淀下来。

促销系统概念模型设计

通过对促销业务的分析,我们可以抽象出促销系统的关键概念模型:

促销域模型:

  • 促销活动:零售商为提升特定产品销售或品牌认知度,组织的营销活动,如限时折扣、优惠活动、买赠活动等。
  • 卡券活动:一种营销策略,发放优惠券来吸引消费者购买商品,或提高对品牌的忠诚度。优惠券可能提供折扣、免费试用、额外积分等。
  • 活动叠加互斥规则:规定了不同促销活动是否可同时应用于一次购买,如有些优惠不能与其他优惠同时使用(互斥),有些则可与其他活动共同应用(叠加)。

优惠域模型:

  • 优惠模型:零售商或品牌商提供给顾客的一种具体的福利,可以是折扣、固定金额减免等。一般来说,顾客必须满足特定行为才能享受到,例如购买商品、访问商店、推荐其他顾客等,目的是刺激销售并奖励消费者。
    • 优惠级别:分为商品级、订单级、权益级,这些级别定义了优惠价值如何被分配和应用的。
      • 商品级:优惠价值应用于符合优惠门槛的单个商品或多个商品。例如,买一送一场景,优惠金额减免会直接应用于这些买赠的商品。
      • 订单级:优惠价值应用于整个交易,通常是基于销售总计来计算,同时之前应用的商品级价格也会跟着减少。例如,一个促销是整单满100元减20元,那么这个减免会应用于整个订单的总金额。
      • 权益级:优惠价值应用于一个账户,通常只适用于延迟的奖励。例如,顾客因为购物而获得积分,这些积分累积在账户中,可以在未来用作折扣或兑换商品。
    • 优惠模式:分为立享、抵扣、返还模式。
      • 立享:在交易过程中立即兑换优惠价值,如满减、满折、满赠等。
      • 抵扣:之前积累的优惠价值,在后续交易订单的最终支付金额上提供抵扣,如使用优惠券、余额、红包、积分等。
      • 返还:基于订单的实付金额或特定策略提供返利,可在未来购买时使用,如满返积分、满返优惠券等。
  • 优惠内容:详细说明优惠的具体内容,如折扣率、优惠金额、赠送的商品或服务等。
    • 优惠内容类型:价格替代、折扣、固定金额减免、积分/优惠券或其他奖励等。
  • 优惠门槛:消费者享受优惠需要达到的条件,如最低消费金额、购买特定商品、特定时间购买等。
    • 门槛类型:商品相关(特定商品、品牌、分类)、购买数量或金额、购买时间、销售渠道、客户相关(特定客户账户、会员等级)、特定支付方式等等。

卡券域模型:

  • 卡券模板:用于创建具体优惠券的基本属性和规则。模板定义了优惠券的基本属性,如有效期、折扣额度、使用条件等。
  • 客户卡券实例:根据卡券模板发放给特定客户的优惠券,每个实例都有唯一的标识,包含特定客户和具体使用条件。

权益域模型:

  • 客户权益账户:记录和管理客户在特定商家或品牌下的积分、优惠、卡券等权益的账户,通常用于追踪顾客的购买历史、积分累计和兑换等。

为啥要抽象出“优惠”模型?

优惠规则通常是促销活动的一部分,为啥不能合并到活动模型中,需要抽象出一个“优惠”模型?

  • 业务关注点不同:虽然优惠规则通常是促销活动的一部分,但促销活动本质上是对客户的一种行动号召,并承诺客户满足特定行为,就能给予优惠。促销活动主要关注如何吸引消费者参与,可能涵盖推广、营销策划和特定的销售目标。而优惠是客户实际收到的具体折扣、返利、积分累积或其他福利。
  • 灵活性和扩展性:将优惠作为一个独立模型,可以更灵活地扩展促销系统。例如,新增一个促销活动,会员专享优惠,只需基于现有的优惠模型,设置会员专享的优惠门槛和优惠内容即可,不影响现有的优惠处理逻辑。同时,优惠不仅可以来源于促销活动,还可以来源于其他场景,例如会员等级所带来的优惠。将优惠作为一个独立模型,可以将优惠业务进行解耦。
  • 简化会计和财务处理:从会计和财务角度看,不同促销活动的处理方式可能完全不一样,将优惠模型独立出来,也是为了简化会计和财务处理,针对不同的优惠模式、优惠级别,进行标准化的处理。

促销系统整体架构

应用层负责接收用户的请求并提供相应的服务和功能,模块包括:

  • 促销活动管理:负责创建各类商品级、订单级、抵扣类、返还类的促销活动。通过该模块,商家可以根据市场需求和销售策略,灵活地设计和调整促销活动的内容和形式。
  • C端交易流程:为客户提供了一系列优惠计算、权益抵扣和权益返还的服务能力。
    • 优惠计算根据购买金额和优惠规则,自动计算出最终优惠金额。
    • 权益抵扣允许客户使用自己的权益抵扣订单金额,减少支付总额。
    • 权益返还根据商家设置的权益规则,将客户在交易过程中部分金额作为权益,返还给他们,以便将来再次使用,例如积分、红包、集点等。这些服务能力可以提升客户的购物体验,增加他们对品牌的忠诚度。
  • 数据分析:负责分析促销活动的效果,优化促销策略。通过对促销活动进行深入的数据分析,可以了解哪些策略对于提高销售额和增加市场份额效果最好。这些数据分析结果将为决策者提供有价值的见解,帮助他们做出更明智的决策。

领域层是业务逻辑的核心,处理促销系统的核心业务逻辑,并沉淀可复用的服务能力,模块包括:

  • 促销域:提供促销活动的管理、推广、活动约束设置、叠加互斥设置等能力。
  • 优惠域:提供优惠规则设置、优惠门槛判定、各种优惠计算、优惠生效等能力。
  • 卡券域:管理卡券模板,以及卡券实例的生命周期管理,包括发放、核销和过期等。
  • 权益域:管理客户的权益账户,如积分、红包、集点等。

促销系统与其依赖系统的关系:

  • 客户系统:提供客户的基本信息、购买历史、偏好设置、客户等级等信息。
  • 组织机构:促销系统通常需要根据组织机构的不同部门、区域、门店来定制特定的促销活动。
  • 商品系统:提供商家价格、分类、描述等促销相关的商品资料。
  • 库存系统:促销系统需要库存系统提供库存数据,来确保活动商品有足够的库存量。促销活动通常和库存水平息息相关,特别是库存清理、商品促销热卖的场景。
  • 订单系统:在订单创建、结算过程中,会处理优惠计算、优惠生效的逻辑。
  • 数据系统:促销系统依赖数据系统进行历史数据分析,预测促销活动的影响,以及衡量促销效果。

总结

本文直接讨论了促销系统的设计。

首先,分析了促销和营销的不同,并强调了新零售中促销活动的重要性和复杂性。然后,详述了各种促销工具以及其在消费者和商品生命周期中的应用。

其次,深入讨论了促销活动的运行流程,包括策略规划、内容设计、准备与推广、实施与管理、数据分析与评估。

最后,提出了一个抽象的促销系统模型,并详细解释了其组成部分,如优惠模型、卡券模型等。也解释了抽象出“优惠”模型的必要性,并介绍了系统的总体架构和依赖系统。


本着花小钱办大事,不花钱也办事的原则,为了避免花钱买设备,那如何更便捷地学习/测试Android多屏显示的内容呢?本文就给大家介绍一种模拟Android多个物理屏幕显示的方法。

01

Android Emulator旧方式的缺憾

早前的文章中,曾经介绍了使用Android Emulator模拟多屏显示的方法


Android Emulator - 模拟器多屏输出

这种方法可以满足一定的测试需求,但缺憾是只有主屏是物理屏幕,其他副屏都只是虚拟屏幕。

dumpsys SurfaceFlinger看两个Display的信息:

主屏 isVirtual=false

副屏 isVirtual=true

dumpsys display看两个Display的信息:

主屏 type INTERNAL

副屏 type VIRTUAL

在日益复杂的应用场景下,仅模拟虚拟屏幕出来已无法满足开发测试的需求,
那有没有模拟多个物理屏幕的方法呢?接下来就介绍一种Google官方推荐的方法。

02

多(物理)显示屏

先看看模拟出来的多显示屏的效果。整一个三屏幕的,设置三个屏幕的大小

  • display0 :width=720,height=1280

  • display1 :width=1920,height=1080

  • display2 :width=720,height=1280

瞅瞅效果怎么样?

再检查下是不是都是物理屏,而不是虚拟屏呢?

dumpsys SurfaceFlinger看三个Display的信息:
三块屏幕都是 isVirtual=false

dumpsys display看两个Display的信息:

Display 0,主屏是内置屏幕

Display 2,
副屏是一块HDMI外置屏幕

Display 3,
副屏是一块HDMI外置屏幕

03

多(物理)显示屏模拟方法

Android Graphics 显示系统 - 如何模拟多(物理)显示屏?

契子

在实际业务会我们会使用第三方的缓存例如:Reids、Memcache等;但是,并且我们在查询使用缓存时都得尽可能的保证缓存的一致性,在读取时得保证尽可能的保证缓存拿到的是数据库的最新数据,那么在实现的逻辑上一般都为这样:

1、请求线程先读取缓存实现

2、如果缓存没有数据的话触发读取数据库动作

3、将从数据库读取的数据写入缓存

线程安全问题


一般在这里进行缓存加载时都会使用延迟双删的策略来实现缓存的更新,尽可能的避免出现脏缓存的情况。Redis延迟双删:
延时双删(redis-mysql)数据一致性思考 - 知乎

那么在用户请求打到服务端的缓存实现时,如果,只是单纯单个用户时那就不用考虑多个线程同时进入缓存策略导致缓存复写性能开销问题;但是,实际业务中肯定会出现多个用户请求同时请求同一ID资源的情况。

出现多个用户请求同一ID资源的情况可能会是这样

1、用户A请求进入缓存A点,并且判断缓存不存在开始读取数据库数据

2、用户B请求进入缓存A点,判断缓存发现缓存也不存在开始读取数据库数据

3、用户A请求读取完数据库数据并且将最新的获取到的数据库数据回写缓存;然后再响应用户端

4、用户B请求读取完数据库数据并且将最新的获取到的数据库数据回写缓存;然后再响应用户端

通过上面的步骤分解我们可以发现3、4点时可能会重复执行,现在举例只是2个请求的情况,试想一下如果实际情况出现很多请求时会不会出现缓存雪崩的情况?缓存雪崩:
缓存雪崩产生原因与解决方案 - 知乎


同步锁


看到这里时可能已经想起了单例模式(
单例模式 - 知乎
)的情况,单例模式也是为了解决多个线程同时调用类变量导致重复创建对象,那么这里同理-多个请求访问导致频繁读取数据库使缓存实现逻辑形同虚设。根据单例模式的情况那我们能不能在这里设计一个同步块或者同步锁呢?很明显也是可以的,我们一起来看下如何实现


Codes

    Lock lock = new ReentrantLock();
    public List<Resource> getRsource(Long resourceId){
        // 查询缓存
        List<Resource> resourceEntites = cacheService.findByCacheKey(resourceId);
        if(CollectionUtils.isEmpty(resourceEntites)) {
            // 加锁
            lock.lock();
            try {// 如果缓存不存在
                // 查询数据库
                ResourceEntites = resourceService.findById(resourceId);
                // 回写缓存
                cacheService.setByCacheKey(resourceId, ResourceEntites);
            } finally {
                // 释放锁
                lock.unlock();
            }
        }
        return resourceEntites;
    }

如上面代码实现所示使用ReentrantLock所在同步锁,但缓存不存在时会进入查询数据库的逻辑。初看之下逻辑看似并没有什么问,但是,细心的人已经发现问题所在了,首先就是缓存为空判断这里,如果,缓存为空的情况下用户请求的线程会进入数据库代码块,由于同步锁的存在所以此代码块只能同时只能被一个线程所持有,那么后续请求的线程将会阻塞,直到第一次获取到的线程释放锁,那么后续的线程就会被唤醒开始竞争锁;

同步双检锁

这里有各问题就是后续阻塞的线程会进入查询数据库数据的操作,并且也会执行回写缓存的逻辑,因为缓存这里判断已经进入查询数据库的锁机,那么这里也应该与单例模式一样使用双检锁,就是防止后续线程重复执行查询数据库的逻辑。

修改后的代码

    Lock lock = new ReentrantLock();
    public List<Resource> getRsource(Long resourceId){
        // 查询缓存
        List<Resource> resourceEntites = cacheService.findByCacheKey(resourceId);
        if(CollectionUtils.isEmpty(resourceEntites)) {
            // 加锁
            lock.lock();
            // 再次检查防止阻塞线程复写
            if(CollectionUtils.isEmpty(resourceEntites)) {
                try {// 如果缓存不存在
                    // 查询数据库
                    ResourceEntites = resourceService.findById(resourceId);
                    // 回写缓存
                    cacheService.setByCacheKey(resourceId, ResourceEntites);
                } finally {
                    // 释放锁
                    lock.unlock();
                }
            }
        }
        return resourceEntites;
    }

锁的细粒度问题

写到这里已经解决了多线程下缓存复写的问题了,但是在实际业务中我们缓存可能存在于多个地方并且不同的查询ID获取不同的数据库数据,那么无论是通过同步代码块还是同步锁都是锁住是一块逻辑,在当前这个线程释放前其他任何的线程都无法访问,这时的性能肯定会大大下降,例如:用户A请求的资源ID为1,用户B请求的资源ID为2;用户A线程率先进入同步锁块的代码,此时,用户B请求的线程就被阻塞了;这里从业务逻辑上来说用户B请求的线程不应该被阻塞,因为用户B查询的是资源ID为2的数据,并不会跟用户A线程请求的资源ID为1的数据冲突,但是是通过同步锁时我们无法将锁的细粒度更小化;这时我们就面临着一个问题:如何只锁住当前的资源而不是方法或代码块?

我们应该实现一个可以根据当前的入参的资源ID为据点的同步锁,只有在访问同一ID或资源的时候才会阻塞线程,不同ID的时候还是按照正常情况执行以提高性能;

分段锁


综述以上的情况这里需要引入一个锁概念:分段锁

此锁实现的思路来自于JDK7下的ConcurrentHashMap实现原理,JDK7下的ConcurrentHashMap利用了Fragment的概念来实现的:
ConcurrentHashMap并发安全的实现原理~java7_hashmap为什么1.7要用分段锁保证并发-CSDN博客

我们来看代码的实现

**
 * 并发分段锁
 * 原理是利用ConcurrentHashMap储存锁,ReentrantReadWriteLock实现读共享、写互斥
 * 在ReentrantReadWriteLock中存在两种锁的实现,读写和写锁、在多线程数据安全性情况下,读读并行、读写、写读互斥
 * 使用读锁可以大幅度提高多线程Read的并发性能,而在,写的情况由于考虑多线程数据安全问题,读锁加锁时会与写锁互斥、写锁加锁时也会与读锁互斥
 * 在Jvm中锁的细粒度默认最小只有对象锁、类锁,无论是类锁还是对象锁最小原子性都只能针对某个对象内进行加锁,如果,多个线程根据不同的ID查询不同数据库数据时,如果使用对象锁那么就会导致先来线程先获取锁
 * 没有释放锁那么后续的多个线程将会阻塞,但是,从逻辑上来说多个线程只有在查询同一ID时才需要阻塞;这里通过ConcurrentHashMap来存储Lock对象从而达到锁住ID的效果
 * 通过ConcurrentHashMap来存储Lock对象,K为当前锁的对象,这里为取K的Hashcode ^ Hashcode >>> 16,高16低16位降低哈希冲突,提高锁的分布
 * ConcurrentHashMap本身是通过Cas和Sync來保证多线程下的数据安全,避免链表闭环
 * 在Jdk1.8之后ConcurrentHashMap的实现换成Cas和Sync,而在Jdk1.7时使用的则是Segment的数据结构来实现,在1.7中在解决多线程数据安全问题则是通过分段来实现
 *
 * @Author: Song L.Lu
 * @Since: 2023-06-12 14:13
 **/
public class ConcurrentReadWriteLock<K> extends ConcurrentHashMap<Integer, ReentrantReadWriteLock> {

    public ConcurrentReadWriteLock() {
        // 初始化HashMap容量为16
        super(16);
    }

    /**
     * 获取读锁
     *
     * @param k
     */
    public void readLock(K k) {
        Assert.notNull(k, "Lock key must not be null");
        ReentrantReadWriteLock lock = acquireNx(k);
        lock.readLock().lock();
    }

    /**
     * 释放读锁
     *
     * @param k
     */
    public void releaseReadLock(K k) {
        Assert.notNull(k, "Lock key must not be null");
        try {
            ReentrantReadWriteLock lock = acquire(k);
            if (exists(k))
                if (lock.getReadHoldCount() > 0)
                    lock.readLock().unlock();
        } finally {
            release(k);
        }

    }

    /**
     * 获取写入锁
     *
     * @param k
     */
    public void writeLock(K k) {
        Assert.notNull(k, "Lock key must not be null");
        ReentrantReadWriteLock lock = acquireNx(k);
        lock.writeLock().lock();
    }

    /**
     * 释放写入锁
     *
     * @param k
     */
    public void releaseWriteLock(K k) {
        Assert.notNull(k, "Lock key must not be null");
        try {
            ReentrantReadWriteLock lock = acquire(k);
            if (exists(k))
                lock.writeLock().unlock();
        } finally {
            release(k);
        }
    }

    /**
     * 尝试从HashMap获取已经存在的所对象
     * 如果,所不存在则将创建新的锁放入HashMap
     *
     * @param k 需要锁住的对象,本质是取的时该对象中的Hashcode
     * @return
     */
    private ReentrantReadWriteLock acquireNx(K k) {
        int hashcode = hashcode(k);
        // 尝试从HashMap获取锁对象
        ReentrantReadWriteLock lock = get(hashcode);
        if (Objects.isNull(lock)) { // 如果没有锁对象则创建一个新的锁对象
            try {
                // 这里使用ConcurrentHashMap作为锁对象的存储结构,避免,在多线程环境带来的数据安全性问题
                putIfAbsent(hashcode, new ReentrantReadWriteLock());
                lock = get(hashcode);
            } catch (Throwable t) {
                release(k); // 避免死锁
            }
        }
        return lock;
    }

    /**
     * 尝试从HashMap获取已经存在的所对象
     *
     * @param k 需要锁住的对象,本质是取的时该对象中的Hashcode
     * @return
     */
    private ReentrantReadWriteLock acquire(K k) {
        // 尝试从HashMap获取锁对象
        int hashcode = hashcode(k);
        return get(hashcode);
    }

    /**
     * 释放锁,从HashMap移除该锁
     *
     * @param k
     */
    private void release(K k) {
        remove((hashcode(k)));
    }

    /**
     * 锁对象是否存在
     *
     * @param k
     * @return
     */
    private boolean exists(K k) {
        return containsKey(hashcode(k));
    }

    /**
     * 生产锁对象的Hashcode
     * 取出当前对象的Hashcode,通过>>>无符号右移16位,将该对象的Hashcode的高16位和低16位进行或异运算;降低Hash冲突
     *
     * @param k
     * @return
     */
    private int hashcode(K k) {
        int hashcode = k.hashCode();
        return k.hashCode() ^ (hashcode >>> 16);
    }
}

其原理就是利用Map存储当前的锁实现,Map的K为资源ID,V为同步锁;

结束语

当然这里还是有需要优化的情况,例如:如何维护当前的锁对象,需不需要使用对象池的方式来维护锁?如果使用了对象池那么内存不足是否使用内存淘汰策略来保证对象择优?