Docker從入門到掉坑(三):容器太多,操作好麻煩

前邊的兩篇文章裏面,我們講解了基於docker來部署基礎的SpringBoot容器,如果閱讀本文之前沒有相關基礎的話,可以回看之前的教程。

不知道大家在初次使用docker的時候是否有遇到這種場景,每次部署微服務都是需要執行docker run xxx,docker kill xxx 等命令來操作容器。假設說一個系統中依賴了多個docker容器,那麼對於每個docker容器的部署豈不是都需要手動編寫命令來啟動和關閉,這樣做就會增加運維人員的開發工作量,同時也容易出錯。

Docker Compose 編排技術

在前邊的文章中,我們講解了Docker容器化技術的發展,但是隨着我們的Docker越來越多的時候,對於容器的管理也是特別麻煩,因此Docker Compose技術也就誕生了。

Docker Compose技術是通過一份文件來定義和運行一系列複雜應用的Docker工具,通過Docker-compose文件來啟動多個容器,網上有很多關於Docker-compose的實戰案例,但是都會有些細節地方有所遺漏,所以下邊我將通過一個簡單的案例一步步地帶各位從淺入深地對Docker-compose進行學習。

基於Docker Compose來進行對SpringBoot微服務應用的打包集成

我們還是按照老樣子來構建一套基礎的SpringBoot微服務項目,首先我們來看看基礎版本的項目結構:

首先是我們pom文件的配置內容:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.sise.idea</groupId>
    <artifactId>springboot-docker</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>spring-boot-docker</name>
    <url>http://maven.apache.org</url>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.3.RELEASE</version>
    </parent>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.16.18</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>springboot-docker</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

 

然後是java程序的內容代碼,這裏面有常規的controller,application類,代碼如下所示:

啟動類Application

package com.sise.docker;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * @author idea
 * @data 2019/11/20
 */
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }
}

 

控制器 DockerController

package com.sise.docker.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author idea
 * @data 2019/11/20
 */
@RestController
@RequestMapping(value = "/docker")
public class DockerController {


    @GetMapping(value = "/test")
    public String test(){
        System.out.println("=========docker test=========");
        return "this is docker test";
    }
}

 

yml配置文件:

server:
  port: 7089

 

接下來便是docker-compose打包時候要用到的配置文件了。這裏採用的方式通常都是針對必要的docker容器編寫一份dockerfile,然後統一由Docker Compose進行打包管理,假設我們的微服務中需要引用到了MySQL,MongoDB等應用,那麼整體架構如下圖所示:

那麼我們先從簡單的單個容器入手,看看該如何對SpringBoot做Docker Compose的管理,下邊是一份打包SpringBoot進入Docker容器的Dockerfile文件:

#需要依賴的其他鏡像
FROM openjdk:8-jdk-alpine
# Spring Boot應用程序為Tomcat創建的默認工作目錄。作用是在你的主機”/var/lib/docker”目錄下創建一個臨時的文件,並且鏈接到容器中#的”/tmp”目錄。
VOLUME /tmp

#是指將原先的src文件 添加到我們需要打包的鏡像裏面
ADD target/springboot-docker.jar app.jar

#設置鏡像的時區,避免出現8小時的誤差
ENV TZ=Asia/Shanghai

#容器暴露的端口號 和SpringBoot的yml文件暴露的端口號要一致
EXPOSE 7089

#輸入的啟動參數內容 下邊這段內容相當於運行了java -Xms256m -Xmx512m -jar app.jar 
ENTRYPOINT ["java","-Xms256m","-Xmx512m","-jar","app.jar"]

 

接着便是加入docker-compose.yml文件的環節了,下邊是腳本的內容:

#docker引擎對應所支持的docker-compose文本格式
version: '3'
services:

  #服務的名稱
  springboot-docker:
    build:
      context: .
      # 構建這個容器時所需要使用的dockerfile文件
      dockerfile: springboot-dockerfile
    ports:
      # docker容器和宿主機之間的端口映射
      - "7089:7089"

 

docker-compose.ym配置文件有着特殊的規則,通常我們都是先定義version版本號,然後便是列舉一系列與容器相關的services內容。

接下來將這份docker服務進行打包,部署到相關的linux服務器上邊,這裏我採用的是一台阿里雲上邊購買的服務器來演示。

目前該文件還沒有進行打包處理,所以沒有target目錄,因此dockerfile文件構建的時候是不會成功的,因此需要先進行mvn的打包:

mvn package

 

接着便是進行Docker-Compose命令的輸入了:

[root@izwz9ic9ggky8kub9x1ptuz springboot-docker]# docker-compose up -d
Starting springboot-docker_springboot-docker_1 ... done
[root@izwz9ic9ggky8kub9x1ptuz springboot-docker]# 

 

你會發現這次輸入的命令和之前教程中提及的docker指令有些出入,變成了docker-compose 指令,這條指令是專門針對Docker compose文件所設計的,加入了一個-d的參數用於表示後台運行該容器。由於我們的docker-compose文件中知識編寫了對於SpringBoot容器的打包,因此啟動的時候只會显示一個docker容器。

為了驗證docker-compose指令是否生效,我們可以通過docker–compose ps命令來進行驗證。

這裏邊我們使用 docker logs [容器id] 指令可以進入容器查看日誌的打印情況:

 

docker logs ad83c82b014d

 

最後我們通過請求之前寫好的接口便會看到相關的響應:

 

基礎版本的SpringBoot+Docker compose案例已經搭建好了,還記得我在開頭畫的那張圖片嗎:

通常在實際開發中,我們所面對的docker容器並不是那麼的簡單,還有可能會依賴到多個容器,那麼這個時候該如何來編寫docker compose文件呢?

下邊我們對原先的SpringBoot項目增加對於MySQLMongoDB的依賴,為了方便下邊的場景模擬,這裏我們增加兩個實體類:

用戶類

package com.sise.docker.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @author idea
 * @data 2019/11/23
 */
@AllArgsConstructor
@NoArgsConstructor
@Data
public class User {

    private Integer id;

    private String username;
}

 

汽車類:

package com.sise.docker.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;

/**
 * @author idea
 * @data 2019/11/23
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Car {

    @Id
    private Integer id;

    private String number;
}

 

增加對於mongodb,mysql的pom依賴內容

 <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.21</version>
        </dependency>

 

編寫相關的dao層:

package com.sise.docker.dao;

import com.sise.docker.domain.Car;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;

/**
 * @author idea
 * @data 2019/11/23
 */
@Repository
public interface CarDao extends MongoRepository<Car, Integer> {
}
 

package com.sise.docker.dao;

import com.sise.docker.domain.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;

import java.sql.ResultSet;
import java.sql.SQLException;

/**
 * @author idea
 * @data 2019/11/23
 */
@Repository
public class UserDao {

    @Autowired
    private JdbcTemplate jdbcTemplate;


    public void insert() {
        String time = String.valueOf(System.currentTimeMillis());
        String sql = "insert into t_user (username) values ('idea-" + time + "')";
        jdbcTemplate.update(sql);
        System.out.println("==========執行插入語句==========");
    }

    class UserMapper implements RowMapper<User> {

        @Override
        public User mapRow(ResultSet resultSet, int i) throws SQLException {
            User unitPO = new User();
            unitPO.setId(resultSet.getInt("id"));
            unitPO.setUsername(resultSet.getString("username"));
            return unitPO;
        }
    }
}

 

在控制器中添加相關的函數入口:

package com.sise.docker.controller;

import com.sise.docker.dao.CarDao;
import com.sise.docker.dao.UserDao;
import com.sise.docker.domain.Car;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Random;

/**
 * @author idea
 * @data 2019/11/20
 */
@RestController
@RequestMapping(value = "/docker")
public class DockerController {

    @Autowired
    private UserDao userDao;
    @Autowired
    private CarDao carDao;

    @GetMapping(value = "/insert-mongodb")
    public String insertMongoDB() {
        Car car = new Car();
        car.setId(new Random().nextInt(15000000));
        String number = String.valueOf(System.currentTimeMillis());
        car.setNumber(number);
        carDao.save(car);
        return "this is insert-mongodb";
    }

    @GetMapping(value = "/insert-mysql")
    public String insertMySQL() {
        userDao.insert();
        return "this is insert-mysql";
    }

    @GetMapping(value = "/test2")
    public String test() {
        System.out.println("=========docker test222=========");
        return "this is docker test";
    }
}

 

對原先的docker-compose.yml文件添加相應的內容,主要是增加對於mongodb和mysql的依賴模塊,

#docker引擎對應所支持的docker-compose文本格式
version: '3'
services:

  #服務的名稱
  springboot-docker:
    container_name: docker-springboot
    build:
      context: .
      dockerfile: springboot-dockerfile
    ports:
      - "7089:7089"

    depends_on:
      - mongodb


  mongodb:
    #容器的名稱
    container_name: docker-mongodb
    image: daocloud.io/library/mongo:latest
    ports:
      - "27017:27017"

  mysql:
    #鏡像的版本
    image: mysql:5.7
    container_name: docker-mysql
    ports:
      - 3309:3306
    environment:
       MYSQL_DATABASE: test
       MYSQL_ROOT_PASSWORD: root
       MYSQL_ROOT_USER: root
       MYSQL_ROOT_HOST: '%'

 

這裏頭我嘗試將application.yml文件通過不同的profile來進行區分:

應上篇文章中有讀者問到,不同環境不同配置的指定問題,這裡有一種思路,springboot依舊保持原有的按照profile來識別不同環境的配置,具體打包之後讀取的配置,可以通過springboot-dockerfile這份文件的ENTRYPOINT 參數來指定,例如下邊這種格式:

FROM openjdk:8-jdk-alpine

VOLUME /tmp

ADD target/springboot-docker.jar springboot-docker.jar

#設置鏡像的時區,避免出現8小時的誤差
ENV TZ=Asia/Shanghai

EXPOSE 7089
#這裏可以通過-D參數在對jar打包運行的時候指定需要讀取的配置問題
ENTRYPOINT ["java","-Xms256m","-Xmx512m","-Dspring.profiles.active=prod","-jar","springboot-docker.jar"]

 

最後便是我們的yml配置文件內容,由於配置類docker容器的依賴,所以這裏面對於yml的寫法不再是通過ip來訪問相應的數據庫了,而是需要通過service-name的映射來達成目標。

application-prod.yml

server:
  port: 7089

spring:
    data:
      mongodb:
        uri: mongodb://mongodb:27017
        database: test

    datasource:
             driver-class-name: com.mysql.jdbc.Driver
             url: jdbc:mysql://mysql:3306/test?useUnicode=true&amp;characterEncoding=UTF-8
             username: root
             password: root

 

當相關的代碼和文件都整理好了之後,將這份代碼發送到服務器上進行打包。

mvn package

 

接着我們便可以進行docker-compose的啟動了。

這裡有個小坑需要注意一下,由於之前我們已經對單獨的springboot容器進行過打包了,所以在執行docker-compose up指令的時候會優先使用已有的容器,而不是重新創建容器。

這個時候需要先將原先的image鏡像進行手動刪除,再打包操作:

[root@izwz9ic9ggky8kub9x1ptuz springboot-docker]# docker images
REPOSITORY                                           TAG                 IMAGE ID            CREATED             SIZE
springboot-docker                  latest              86f32bd9257f        4 hours ago         128MB
<none>                                               <none>              411616c3d7f7        2 days ago          679MB
<none>                                               <none>              77044e3ad9c2        2 days ago          679MB
<none>                                               <none>              5d9328dd1aca        2 days ago          679MB
springbootmongodocker_springappserver                latest              36237acf08e1        3 days ago          695MB

 

刪除鏡像的命令:

docker rmi 【鏡像id】

 

此時再重新進行docker-compose指令的打包操作即可:

 

docker-compose up

 

啟動之後,可以通過docker-compose自帶的一些指令來進行操作,常用的一些指令我都歸納在了下邊:

 

docker-compose [Command]

Commands:
  build              構建或重建服務
  bundle             從compose配置文件中產生一個docker綁定
  config             驗證並查看compose配置文件
  create             創建服務
  down               停止並移除容器、網絡、鏡像和數據卷
  events             從容器中接收實時的事件
  exec               在一個運行中的容器上執行一個命令
  help               獲取命令的幫助信息
  images             列出所有鏡像
  kill               通過發送SIGKILL信號來停止指定服務的容器
  logs               從容器中查看服務日誌輸出
  pause              暫停服務
  port               打印綁定的公共端口
  ps                 列出所有運行中的容器
  pull               拉取並下載指定服務鏡像
  push               Push service images
  restart            重啟YAML文件中定義的服務
  rm                 刪除指定已經停止服務的容器
  run                在一個服務上執行一條命令
  scale              設置指定服務運行容器的個數
  start              在容器中啟動指定服務
  stop               停止已運行的服務
  top                显示各個服務容器內運行的進程
  unpause            恢復容器服務
  up                 創建並啟動容器
  version            显示Docker-Compose版本信息

 

最後對相應的接口做檢測:

 

相關的完整代碼我已經上傳到了gitee地址,如果有需要的朋友可以前往進行下載。

代碼地址:https://gitee.com/IdeaHome_admin/wfw

 

實踐完畢之後,你可能會覺得有了docker-compose之後,對於多個docker容器來進行管理顯得就特別輕鬆了。

 

但是往往現實中並沒有這麼簡單,docker-compose存在着一個弊端,那就是不能做跨機器之間的docker容器進行管理

 

因此隨者技術的發展,後邊也慢慢出現了一種叫做Kubernetes的技術。Kubernetes(俗稱k8s)是一個開源的,用於管理雲平台中多個主機上的容器化的應用,Kubernetes的目標是讓部署容器化的應用簡單並且高效(powerful),Kubernetes提供了應用部署,規劃,更新,維護的一種機制。

 

Kubernetes這類技術對於小白來說入門的難度較高,後邊可能會抽空專門來寫一篇適合小白閱讀的k8s入門文章。

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

使用Amazon EMR和Apache Hudi在S3上插入,更新,刪除數據

將數據存儲在Amazon S3中可帶來很多好處,包括規模、可靠性、成本效率等方面。最重要的是,你可以利用Amazon EMR中的Apache Spark,Hive和Presto之類的開源工具來處理和分析數據。 儘管這些工具功能強大,但是在處理需要進行增量數據處理以及記錄級別插入,更新和刪除場景時,仍然非常具有挑戰。

與客戶交談時,我們發現有些場景需要處理對單條記錄的增量更新,例如:

  • 遵守數據隱私法規,在該法規中,用戶選擇忘記或更改應用程序對數據使用方式的協議。
  • 使用流數據,當你必須要處理特定的數據插入和更新事件時。
  • 實現變更數據捕獲(CDC)架構來跟蹤和提取企業數據倉庫或運營數據存儲中的數據庫變更日誌。
  • 恢復遲到的數據,或分析特定時間點的數據。

從今天開始,EMR 5.28.0版包含Apache Hudi(孵化中),因此你不再需要構建自定義解決方案來執行記錄級別的插入,更新和刪除操作。Hudi是Uber於2016年開始開發,以解決攝取和ETL管道效率低下的問題。最近幾個月,EMR團隊與Apache Hudi社區緊密合作,提供了一些補丁,包括將Hudi更新為Spark 2.4.4,支持Spark Avro,增加了對AWS Glue Data Catalog的支持,以及多個缺陷修復。

使用Hudi,即可以在S3上執行記錄級別的插入,更新和刪除,從而使你能夠遵守數據隱私法律、消費實時流、捕獲更新的數據、恢復遲到的數據和以開放的、供應商無關的格式跟蹤歷史記錄和回滾。 創建數據集和表,然後Hudi管理底層數據格式。Hudi使用Apache Parquet和Apache Avro進行數據存儲,並內置集成Spark,Hive和Presto,使你能夠使用與現在所使用的相同工具來查詢Hudi數據集,並且幾乎實時地訪問新數據。

啟動EMR群集時,只要選擇以下組件之一(Hive,Spark,Presto),就可以自動安裝和配置Hudi的庫和工具。你可以使用Spark創建新的Hudi數據集,以及插入,更新和刪除數據。每個Hudi數據集都會在集群的已配置元存儲庫(包括AWS Glue Data Catalog)中進行註冊,並显示為可以通過Spark,Hive和Presto查詢的表。

Hudi支持兩種存儲類型,這些存儲類型定義了如何寫入,索引和從S3讀取數據:

  • 寫時複製(Copy On Write)– 數據以列格式(Parquet)存儲,並且在寫入時更新數據數據會創建新版本文件。此存儲類型最適合用於讀取繁重的工作負載,因為數據集的最新版本在高效的列式文件中始終可用。

  • 讀時合併(Merge On Read)– 將組合列(Parquet)格式和基於行(Avro)格式來存儲數據; 更新記錄至基於行的增量文件中,並在以後進行壓縮,以創建列式文件的新版本。 此存儲類型最適合於繁重的寫工作負載,因為新提交(commit)會以增量文件格式快速寫入,但是要讀取數據集,則需要將壓縮的列文件與增量文件合併。

下面讓我們快速預覽下如何在EMR集群中設置和使用Hudi數據集。

結合Apache Hudi與Amazon EMR

從EMR控制台開始創建集群。在高級選項中,選擇EMR版本5.28.0(第一個包括Hudi的版本)和以下應用程序:Spark,Hive和Tez。在硬件選項中,添加了3個任務節點,以確保有足夠的能力運行Spark和Hive。

群集就緒后,使用在安全性選項中選擇的密鑰對,通過SSH進入主節點並訪問Spark Shell。 使用以下命令來啟動Spark Shell以將其與Hudi一起使用:

$ spark-shell --conf "spark.serializer=org.apache.spark.serializer.KryoSerializer"
              --conf "spark.sql.hive.convertMetastoreParquet=false"
              --jars /usr/lib/hudi/hudi-spark-bundle.jar,/usr/lib/spark/external/lib/spark-avro.jar

使用以下Scala代碼將一些示例ELB日誌導入寫時複製存儲類型的Hudi數據集中:

import org.apache.spark.sql.SaveMode
import org.apache.spark.sql.functions._
import org.apache.hudi.DataSourceWriteOptions
import org.apache.hudi.config.HoodieWriteConfig
import org.apache.hudi.hive.MultiPartKeysValueExtractor

//Set up various input values as variables
val inputDataPath = "s3://athena-examples-us-west-2/elb/parquet/year=2015/month=1/day=1/"
val hudiTableName = "elb_logs_hudi_cow"
val hudiTablePath = "s3://MY-BUCKET/PATH/" + hudiTableName

// Set up our Hudi Data Source Options
val hudiOptions = Map[String,String](
    DataSourceWriteOptions.RECORDKEY_FIELD_OPT_KEY -> "request_ip",
    DataSourceWriteOptions.PARTITIONPATH_FIELD_OPT_KEY -> "request_verb", 
    HoodieWriteConfig.TABLE_NAME -> hudiTableName, 
    DataSourceWriteOptions.OPERATION_OPT_KEY ->
        DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL, 
    DataSourceWriteOptions.PRECOMBINE_FIELD_OPT_KEY -> "request_timestamp", 
    DataSourceWriteOptions.HIVE_SYNC_ENABLED_OPT_KEY -> "true", 
    DataSourceWriteOptions.HIVE_TABLE_OPT_KEY -> hudiTableName, 
    DataSourceWriteOptions.HIVE_PARTITION_FIELDS_OPT_KEY -> "request_verb", 
    DataSourceWriteOptions.HIVE_ASSUME_DATE_PARTITION_OPT_KEY -> "false", 
    DataSourceWriteOptions.HIVE_PARTITION_EXTRACTOR_CLASS_OPT_KEY ->
        classOf[MultiPartKeysValueExtractor].getName)

// Read data from S3 and create a DataFrame with Partition and Record Key
val inputDF = spark.read.format("parquet").load(inputDataPath)

// Write data into the Hudi dataset
inputDF.write
       .format("org.apache.hudi")
       .options(hudiOptions)
       .mode(SaveMode.Overwrite)
       .save(hudiTablePath)

在Spark Shell中,現在就可以計算Hudi數據集中的記錄:

scala> inputDF2.count()
res1: Long = 10491958

在選項(options)中,使用了與為集群中的Hive Metastore集成,以便在默認數據庫(default)中創建表。 通過這種方式,我可以使用Hive查詢Hudi數據集中的數據:

hive> use default;
hive> select count(*) from elb_logs_hudi_cow;
...
OK
10491958

現在可以更新或刪除數據集中的單條記錄。 在Spark Shell中,設置了一些用來查詢更新記錄的變量,並準備用來選擇要更改的列的值的SQL語句:

val requestIpToUpdate = "243.80.62.181"
val sqlStatement = s"SELECT elb_name FROM elb_logs_hudi_cow WHERE request_ip = '$requestIpToUpdate'"

執行SQL語句以查看列的當前值:

scala> spark.sql(sqlStatement).show()
+------------+                                                                  
|    elb_name|
+------------+
|elb_demo_003|
+------------+

然後,選擇並更新記錄:

// Create a DataFrame with a single record and update column value
val updateDF = inputDF.filter(col("request_ip") === requestIpToUpdate)
                      .withColumn("elb_name", lit("elb_demo_001"))

現在用一種類似於創建Hudi數據集的語法來更新它。 但是這次寫入的DataFrame僅包含一條記錄:

// Write the DataFrame as an update to existing Hudi dataset
updateDF.write
        .format("org.apache.hudi")
        .options(hudiOptions)
        .option(DataSourceWriteOptions.OPERATION_OPT_KEY,
                DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL)
        .mode(SaveMode.Append)
        .save(hudiTablePath)

在Spark Shell中,檢查更新的結果:

scala> spark.sql(sqlStatement).show()
+------------+                                                                  
|    elb_name|
+------------+
|elb_demo_001|
+------------+

現在想刪除相同的記錄。要刪除它,可在寫選項中傳入了EmptyHoodieRecordPayload有效負載:

// Write the DataFrame with an EmptyHoodieRecordPayload for deleting a record
updateDF.write
        .format("org.apache.hudi")
        .options(hudiOptions)
        .option(DataSourceWriteOptions.OPERATION_OPT_KEY,
                DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL)
        .option(DataSourceWriteOptions.PAYLOAD_CLASS_OPT_KEY,
                "org.apache.hudi.EmptyHoodieRecordPayload")
        .mode(SaveMode.Append)
        .save(hudiTablePath)

在Spark Shell中,可以看到該記錄不再可用:

scala> spark.sql(sqlStatement).show()
+--------+                                                                      
|elb_name|
+--------+
+--------+

Hudi是如何管理所有的更新和刪除? 我們可以通過Hudi命令行界面(CLI)連接到數據集,便可以看到這些更改被解釋為提交(commits):

可以看到,此數據集是寫時複製數據集,這意味着每次對記錄進行更新時,包含該記錄的文件將被重寫以包含更新后的值。 你可以查看每次提交(commit)寫入了多少記錄。表格的底行描述了數據集的初始創建,上方是單條記錄更新,頂部是單條記錄刪除。

使用Hudi,你可以回滾到每個提交。 例如,可以使用以下方法回滾刪除操作:

hudi:elb_logs_hudi_cow->commit rollback --commit 20191104121031

在Spark Shell中,記錄現在回退到更新之後的位置:

scala> spark.sql(sqlStatement).show()
+------------+                                                                  
|    elb_name|
+------------+
|elb_demo_001|
+------------+

寫入時複製是默認存儲類型。 通過將其添加到我們的hudiOptions中,我們可以重複上述步驟來創建和更新讀時合併數據集類型:

DataSourceWriteOptions.STORAGE_TYPE_OPT_KEY -> "MERGE_ON_READ"

如果更新讀時合併數據集並使用Hudi CLI查看提交(commit)時,則可以看到讀時合併寫時複製相比有何不同。使用讀時合併,你僅寫入更新的行,而不像寫時複製一樣寫入整個文件。這就是為什麼讀時合併對於需要更多寫入或使用較少讀取次數更新或刪除繁重工作負載的用例很有幫助的原因。增量提交作為Avro記錄(基於行的存儲)寫入磁盤,而壓縮數據作為Parquet文件(列存儲)寫入。為避免創建過多的增量文件,Hudi會自動壓縮數據集,以便使得讀取盡可能地高效。

創建讀時合併數據集時,將創建兩個Hive表:

  • 第一個表的名稱與數據集的名稱相同。
  • 第二個表的名稱後面附加了字符_rt; _rt後綴表示實時。

查詢時,第一個表返回已壓縮的數據,並不會显示最新的增量提交。使用此表可提供最佳性能,但會忽略最新數據。查詢實時表會將壓縮的數據與讀取時的增量提交合併,因此該數據集稱為讀時合併。這將導致可以使用最新數據,但會導致性能開銷,並且性能不如查詢壓縮數據。這樣,數據工程師和分析人員可以靈活地在性能和數據新鮮度之間進行選擇。

已可用

EMR 5.28.0的所有地區現在都可以使用此新功能。將Hudi與EMR結合使用無需額外費用。你可以在EMR文檔中了解更多有關Hudi的信息。 這個新工具可以簡化你在S3中處理,更新和刪除數據的方式。也讓我們知道你打算將其用於哪些場景!

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

RALM: 實時 Look-alike 算法在微信看一看中的應用

嘉賓:劉雨丹 騰訊 高級研究員

整理:Jane Zhang

來源:DataFunTalk

出品:DataFun

注:歡迎關注DataFunTalk同名公眾號,收看第一手原創技術文章。

導讀:本次分享是微信看一看團隊在 KDD2019 上發表的一篇論文。長尾問題是推薦系統中的經典問題,但現今流行的點擊率預估方法無法從根本上解決這個問題。文章在 look-alike 方法基礎上,針對微信看一看的應用場景設計了一套實時 look-alike 框架,在解決長尾問題的同時也滿足了資訊推薦的高時效性要求。

▌背景

微信大家可能都用過,微信中的“看一看”是 feed 推薦流的形式,涵蓋了騰訊整個生態鏈的內容分發平台,包括騰訊新聞、公眾號文章、騰訊視頻等。每天總分發量在千萬級以上,面對如此大的分發量,要滿足不同興趣偏好的用戶需求,使用傳統的方法時遇到了一些問題。我們針對發現的問題做了優化和改進,接下來分享下我們優化的過程。

▌未緩解的馬太效應

馬太效應,簡單解釋,在內容的生態系統中,自然分髮狀態會造成一種現象:頭部10%的內容佔據了系統90%的流量、曝光量or點擊量,剩下90%的內容,集中在長尾的10%里。這對於內容的生產方、內容系統的生態和使用系統的用戶來說,都是不健康的狀態。造成這種現象的原因,是因為系統分發能力不夠強,無法處理信息過載的現象,推薦系統設計的初衷就是為了解決馬太效應問題。

回顧推薦系統的發展,從最開始的規則匹配 -> 協同過濾 -> 線性模型 -> deep learning,逐步緩解了馬太效應現象,但沒有完全解決。

造成這個現象的原因是傳統模型、CTR 預估和 deep model,都對部分特徵有依賴,沒有把特徵完全發掘出來,導致模型推薦結果是趨熱的,使生態系統內優質長尾內容投放依然困難。因為 CTR model 最終趨向於行為特徵,或者后驗結果較好的數據,對於優質長尾內容,如小眾興趣的音樂、電影、深度報道的新聞專題等,獲得的相應曝光依舊困難,處於馬太效應 long tail 90%的部分,這會影響推薦系統的生態,導致推薦系統內容越來越窄。

▌為什麼無法準確投放長尾?

怎樣解決這個問題?這個問題歸根結底是對內容的建模不夠完整。我們嘗試分析下問題出在哪:

先看下推薦系統建模流程。首先得到原始樣本,這是業務下的訓練數據,形式是三元組:userid,itemid 和 label。如果是 timeline 的樣本,那就是點擊或者不點擊。原始樣本中,一條樣本可以完整表示一個用戶在某個時間點對一個 item 產生了一次行為,把這個三元組當作信息的最完整形式。對於這個完整形式,直接建模很簡單,如傳統的 item CF,或者協同過濾。協同過濾是最初級的方法,直接對 uid,itemid,label 做擬合,因為可以完全利用初始樣本的信息,擬合的準確性非常好。弱點也很明顯,對原始樣本中沒有包含的 userid 或者 itemid,沒有泛化推理能力,後續新曝光的 user 和 item 是無法處理的。這個問題,就是我們要做的第二步驟,對原始樣本做抽象。既然無法獲取所有的 userid 和 itemid,那就要對 user 或者 item 做一層抽象,如 user 抽象成基礎畫像:年齡、性別或所處地域;item 抽象成語義特徵:topic、tag 等;item 歷史行為特徵,簡單做統計:過去一段時間的點擊率、曝光率、曝光次數。最後基於泛化過的特徵做擬合,得到最終模型。

問題出在哪?做原始特徵抽象,抽象意味着發生了信息損失,這部分信息損失導致模型擬合時走向了比較偏的道路。舉個簡單的例子:同一個 item,有相同的 topic tag,歷史點擊率和歷史曝光次數和點擊次數也相同,可以說這兩個 item 是相同的嗎?顯然有可能是不同的。使用統計特徵無法完整表達,同樣的 item 點擊都是0.5,PV 都是1000 or 2000。有些 item 被這群用戶看過,有些 item 被那群用戶看過。儘管語義特徵和行為特徵都相同,但兩群 user 不同,Item 的受眾也不同。這裏說的抽象的方式,是不完整的 item 行為建模,也是對 item 歷史行為不完整的刻畫,這就導致了整個 model,對 item 后驗數據十分依賴,導致推薦結果趨向於 CTR 表現好或者 PV 表現好的 item。最終后驗數據表現好的數據又會更進一步被模型推薦且曝光,這樣會造成惡性循環:一方面,加劇了頭部效應的影響,使模型陷入局部最優;另一方面,整個推薦系統邊界效應收窄,用戶趨向於看之前表現好的數據,很少看到能拓寬推薦系統邊界或者用戶視野的長尾數據。

▌Look-alike 模型

問題就是這樣產生的, 可以思考一下,問題的本質是什麼?就是因為模型無法對 item 行為完整建模,這一步信息損失太大,怎麼解決這個問題呢?我們首先想到了一種方案:look-alike。

這是廣告領域的經典方案,這類模型的方法也很簡單,首先可以有一個候選集合的 item,我們要推這部分 item,怎麼推呢?第一個步驟:找到歷史上已知的、廣告主提供的對 item 表達過興趣的用戶,這部分用戶稱為種子用戶。然後使用用戶相似度法方法,找到和種子用戶最相似的目標人群,稱為目標用戶,把這部分 item 直接推給目標用戶。這個方法在廣告系統中,是用來做定向投放的,效果很好。為什麼呢?我們來看下模型的整體思路。

把相關的 item 找到對它發生過歷史行為的種子用戶,直接用種子用戶的特徵,作為模型的輸入,這是正樣本;從全局用戶中負採樣一部分用戶作為負樣本。用歷史行為的用戶的特徵來學習 item 的歷史行為,相當於把不同用戶看過的 item 區分開,其實是對 item 的歷史行為特徵的完整建模。之前提到,行為樣本是信息量最大的樣本,它們沒有經過抽象,如果能完整的用受眾用戶的行為來計算 item 的特徵,可以說是最完整的 item 歷史特徵的建模。

Look-alike 在廣告領域的應用已經很完善,也有很多方式。可以把 look-alike 相關的研究分成兩個方向:第一種是基於相似度的 look-alike,這種 look-alike 比較簡單,大體思路是把所有用戶做 user embedding,映射到低維的向量中,對它做基於 k-means 或者局部敏感 hash 做聚類,根據當前用戶屬於哪個聚類,把這個種子用戶的類感興趣的內容推給目標用戶。這種方法的特點:性能強。因為簡單,只需要找簇中心,或者向量相似度的計算,因為簡單、性能好,模型準確性低。

第二種是和第一種相反的,基於回歸。包括 LR,或者樹模型,或者 DNN or deep model 的方法,主要思路是直接建模種子用戶的特徵。把種子用戶當做模型的正樣本, 針對每個 item 訓練一個回歸模型,做二分類,得出種子用戶的特徵規律。這種方法的優點是:準確性高,因為會針對每個 item 建模。缺點也明顯:訓練開銷大,針對每個 item 都要單獨訓練一個模型。對於廣告來說,可以接受,因為廣告的候選集沒有那麼大,更新頻率也沒那麼高。

但是對於我們的推薦場景,有一些問題:1. 對內容時效性要求高,如推薦的新聞專題,必須在5分鐘或10分鐘內要觸達用戶;2. 候選集更新頻率高,我們每天的候選集上千萬,每分鐘、每一秒都有新內容,如果新內容無法進入推薦池,會影響推薦效果。

▌核心需求

在我們的場景下,如果還用廣告領域的經典的 look-alike,是無法解決的。如果要對每個候選集建模,採用 regression-base 的方法,如每分鐘都要對新加進來的候選集做建模,包括積累種子用戶、做負採樣、訓練,等模型收斂后離線預測 target user 的相似分,這對於線上的時效性是不能接受的。

對於 similarity base 的方法,它的問題是計算過於簡單,如果直接和 CTR 模型 PK,核心指標會下降,得出來的結論是:傳統的 look-alike 不能直接照搬到我們的系統中。

針對我們的需求,我們整理出來了應該滿足的3點核心需求:

  1. 實時。新 item 分發不需要重新訓練模型,要能實時完成種子用戶的擴展;

  2. 高效。因為線上加到 rank 模型 CTR 的後面, 要保持模型核心指標 CTR 的前提下,再去加強長尾內容分發,這樣模型才有意義。要學習準確性和多樣性的用戶表達方式。

  3. 快速。Look-alike 模型要部署到線上,實時預測種子用戶和目標用戶群體的相似度,要滿足線上實時計算的耗時性能要求,也要精簡模型預測的計算次數。

▌RALM:Real-time Attention based Look-alike Model

基於這三個核心需求,我們提出了一個新的方法,全稱是 real-time attention based look-alike model,簡稱 RALM。我先簡單講下 RALM 核心的三個點。

  1. 核心點

① 模型可總結為 user-users 的 model。回想下經典的 CTR 預估模型,是 user2item 的 point-wise 的處理流程建模。User、item、label,我們做的最大的變化,是借鑒了 look-alike 的思想,把 item 替換成種子用戶。用種子用戶的用戶特徵,代替 item 的行為特徵。所以模型從 user2item 的 model,變成 user2user 的 model。圖中右側是 target user,左側是 seeds。

② 完善的 seeds representation。用種子用戶代替 item 行為特徵。這樣面臨的問題是:怎樣更好地表達一個人群。這個 seeds representation,是我們研究中的核心步驟,要得到一個高效、自適應更新的種子用戶的表達方式。

③ real-time。最終目標是部署在線上,實時預測種子用戶群體相似度,需要是能夠實現 real-time 的框架。

上述是模型表達的思路。I 是一個 item,把 item 用 seeds 的 embedding 的集合來表示,seeds embedding,是組成這個種子用戶的每個用戶的 embedding 的函數。學習了 seeds representation,就是這個函數 f。

  1. 整體結構

接下來看下離線訓練部分,這是離線訓練的整體結構。

模型離線訓練分成兩個階段:右側 user representation learning, 左側第二階段是 look alike learning。user representation learning 模型結構,最後的目標是通過一個用戶在不同領域的行為,學習到用戶在所有領域的多樣性且兼顧準確性的用戶興趣的高階畫像。這個畫像在這個位置是低維特徵,向量特徵通過 user presentation learning 的目標學到了所有用戶的 embedding 之後,第二階段是 look alike learning。Look alike learning 模型,是一個 user to user 的 model,右側是目標用戶的特徵輸入,左側是種子用戶人群的 embedding 輸入,左邊種子用戶是一群用戶的 embedding 堆疊到一起,輸入其實是一個矩陣。這兩邊的輸入來源都是第一階段 representation learning 輸出的 embedding。Look alike 的目標是學習目標用戶和候選 item 種子用戶的相似度,最上面是學習兩次相似分的,最後完成種子用戶的擴展。

▌User Representation Learning

按順序來分析下,第一階段,是用戶的表示學習,user representation learning。

這個模型大家看着會比較眼熟,它是用 Youtube 的 representation model 中演化過來的。Youbute 的基礎模型很簡單,下面是用戶在不同領域的行為,下面的基礎特徵可能會有離散值,也可能是連續值。如果是離散值,可以通過 embedding lookup,再過一個 pooling,再和所有領域的特徵做 merge,上面過一個全連接,最後輸出 embedding。右側是感興趣的 item,也會做一些 embedding lookup,整個做 sce loss,或者是多分類。要預測的是:用戶在點擊了這麼多 item 之後,下一個要點擊的 item,最後要預測的就是表達用戶興趣的 embedding。這層 merge layer,最初 Youtube 的版本是用一個 concat。可以看到最初版模型在訓練時遇到了一個問題,最下層是用到了用戶很多個域 ( 每個 field 稱為一個域,可能是每個用戶在每個分佈下的行為,如電商購物下行為,或者是公眾號閱讀的行為 )。

訓練時看到一個現象,有些域的行為學的非常強,參數來看學的非常充分,某些 field 參數分佈不大,最後的權重值較小,對最終預估的分數沒有影響。這裡有兩個名詞:強關聯和弱關聯。最終預估結果關係比較大的 field、參數學習較強的,稱為強關聯特徵域;相反,學的不充分的、對最終結果影響小的,稱為弱關聯特徵域。對於強關聯和弱關聯,如果看到參數分佈是這樣的,是不是就表明弱關聯特徵不重要呢?並不是。舉例來說,representation learning 如果訓練目標是在“看一看”中的閱讀行為,對於某些經常使用微信公眾號、或者閱讀的用戶來說,他們在公眾號平台的閱讀歷史就是非常強的關聯特徵,能夠決定再看一看中的興趣。對於這些特徵來說,這些特徵是很強的,對於其他的如在電商中的購物或者是在搜索中的 query,這些是比較弱的,對看一看的影響很有限。再思考另一種 case,比如,看一看通過某種形式,吸引了很多新用戶。新用戶進來之後,沒有在公眾號平台的閱讀歷史,但是他們在購物或者搜索中有歷史行為,此時這些歷史行為會影響他下一次閱讀的文章,或者感興趣的 item。這些特徵對這些用戶來說是非常重要的。但目前,顯然這些用戶是沒有學到這些變化的。

排查了下模型訓練的過程,可以把結果集中在這一點上,就是這個 merge layer,其實是負責把用戶不同域的特徵 merge 到一起。Merge layer,可以看到右側的圖,原始的 deep model 用的是左側的實現方法,直接用 concat。Concat 的優點是,可以學到所有 field 的參數,缺點是,無法根據輸入的不同分佈,來調整權重值。也就是說,如果80%的用戶的閱讀歷史都是看一看的種子用戶,閱讀歷史都是很豐富的,就很有可能對所有用戶都把這個特徵學的很強。如果是少量用戶,就學不到了,少量用戶關注對其它特徵的啟發作用,concat layer 是學不到的。因為它對於大部分用戶來說,已經把參數學的非常重了,小部分用戶不足以對它產生影響。所以需要一個機制,針對不同用戶的特徵域的輸入動態調整 merge layer 的方式,我們想到的最好的辦法是 attention。Attention 是最近在 NLP 中非常火的,很多模型都會用到。為什麼要用 attetnion?

右下角的結構,就是 attention。我們用到的 attention 是把用戶的輸入的所有的域當做 attention 的 query,key 和 value 都是自身 field 的本身。這是一個典型的 self-attetnion,我們最後要做的是,讓模型根據用戶自己的輸入領域的情況,動態調整不同領域的融合方式,相對於之前的 concat 的方式來說,concat 其實是把所有領域的 field 強行放在同一個向量空間中來學習,自然會有學習不充分的情況。Self-attenion merge 是讓不同的域在自己的向量空間中學習充分,再通過不同的權重組合在一起。其實是相當於讓用戶能有屬於自己的表達,而不是被歷史豐富的用戶帶着走。這是一個優化,可以明顯改善強弱特徵、訓練不均衡的問題。

可以看一下這是我之前訓練的時候在某個特徵域用 tensorboard 打出來的參數分佈的情況,可以看到 attention merge layer 前後,訓練參數有很大變化,之前這些參數基本上都是0,之後會激活出一些值,這個是最後 user presentat learning 的值:precession、recall、auc。也可以看到模型加完 attention 之後,在 auc 和 loss 上都有所優化。

經過 user representation learning 之後,我們現在擁有了所有用戶的兼顧多樣性和準確性的 embedding 表達。接下來要做的是怎麼用 embedding 來表達種子用戶人群?

▌Look-alike learning

Look-alike 要做的第一步就是如何表達 seeds user。

一個種子用戶應該包含什麼信息,這裏我們做兩點假設:

  1. 每個用戶都有自己的興趣,但對整個群體的人群信息存在不同的貢獻度,我們稱為群體的共性信息:global info。共性信息和目標用戶無關,只和用戶群體自身有關。

  2. 種子用戶群體的個性信息。種子群體中一定存在一小部分用戶和 target 用戶興趣相似,這時,當 target 人群變化時,信息會變化,稱為 local info。

種子用戶的相對表達=個性信息+共性信息。怎樣學習 local info 和 global info 呢?我們想到的是用不同的 attention 機制,學習出兩個 embedding:local & global embedding,分別表示這兩種信息。對於 local embedding,是右上角的圖,稱為 local attention unit,這個 attention,是一個乘法開始,它的公式是把種子用戶的矩陣乘以 w,再乘以 target user 的 embedding,再做一層 softmax,再乘以種子用戶自己,這是一個典型的乘法 attention。它的作用是提取種子用戶群體中和 target user 相關的部分。捕獲種子用戶的 local info。

第二部分是 global info,用 global attention,只和 user 相關,和 attention merge 的方法類似,也是一個 self-attention。作用是把種子用戶乘以矩陣轉換,再乘以種子用戶自己,所做的就是捕捉用戶群體自身內部的興趣分佈。得到的這兩種 local & global embedding 之後,進行加權和,這就是種子用戶群體的全部信息。另一個問題來了,採用兩種 attention union 來捕獲信息,這意味着要計算很多次矩陣乘法,對線上開銷很大。兩個 embedding 需要多少次計算?這裡有個表達公式,這個 h 是 embedding 的維度,K 是種子用戶用戶的數量,總的計算次數 = h * h * K * 2。對於線上耗時,一次預測超過 1000ms,無法接受。

優化耗時,第一個方法是減少種子用戶的數量,這樣會影響種子用戶的表達完整性;另一種是我們線上採取的方式,使用聚類。找到種子用戶內部比較相似的,把它們聚在一起。這種方法:1. 減少 key 的數量,2. 保持種子用戶的全部信息。聚類的方式比較簡單,用的是 k-means。

簡單看下這個模型,右側是 target user embedding,經過全連接,左邊是 series user embedding 矩陣,兩邊都經過 embedding 之後,首先對種子用戶的 embedding 做聚類,得到 k 個聚類中心,把種子用戶的向量根據 k 個聚類中心做聚和,在類似中心內部做類似於 average 的聚和,然後得到 k 個向量,在這 k 個向量之上,一邊做 global embedding,另一邊和 target user 做 local embedding。有了這兩個 embedding 之後,通過加權和的方式,做 cosine,再去擬合 user 到 item 的 label。這裏的 label 用的是點擊。

細節:

聚類的過程需要迭代,比較耗時,並非每個 batch 都去更新聚類中心,而是採取迭代更新的方式,比如把1000個 batch 一輪,訓練完1000個 batch 之後,這1000個 batch 中,不更新聚類中心;到了第二輪,根據全連接參數的變化,再去更新種子用戶的聚類中心,每通過一輪更新一次聚類中心,保證和核心參數是同步的。這樣既保證了訓練的效率,也保證了訓練的準確性。聚類的優化,使線上的計算次數減小到了 k/K 中,之前 K 是萬級別的數量,現在 k 是百級別的數量,耗時也下降了很多。

根據實驗結果,確定不同聚類中心數 k 帶來的影響,選擇了合適的 k。實驗中,k=20,線上 k 是100左右。模型訓練的 label 優化方式,是一個多分類。對不同的種子用戶人群選擇最相似的用戶。多分類的優化方式和 deep model 相似,採用 negative sampling 的方式。

▌系統架構

線上需要實現實時預測,系統實際部署到線上,需要整套系統架構。簡單介紹下 RALM 的配套體系。

大體過程,分成三個模塊,從最底下的離線訓練,到在線異步處理,到在線服務,接下來分別講一下。

  1. 離線訓練

離線訓練,就是兩個階段的訓練,representation learning,look alike learning,需要一提的是,進行完 look alike learning 之後,可以把 user 經過全連接層的 user 表達緩存起來。全量用戶,有10多億,可以 catch 到 KV 中。可以提供給在線服務做緩存,線上不用做實時全量傳播。

2.在線異步處理

離線訓練結束后,是在線異步處理,主要作用是某些可以離線且和線上請求無關的計算,可以先計算完,如更新種子用戶。每個 item 候選集都會對應一個種子用戶列表,更新種子用戶列表,可以每一分鐘更新一次,這和訪問用戶無關,只和候選集的 item 有關。可以實時拉取用戶的點擊日誌,更新點擊某個候選集的種子列表。

① 可以把 global embedding 預計算 ( gl 只和種子用戶有關,是 self-attenion,可在線做異步處理,如每隔一分鐘算一次 )。

② 計算 k-means 聚類中心,也是只和種子用戶有關,可以提前計算好,如推到推薦系統內存中。

③ 所有的東西都是定時更新,不需要線上實時計算。

3.在線服務

線上把聚類中心、global embedding 和所有用戶的 embedding 都已緩存好,只需要拉取 user embedding,和候選集的 global embedding 和聚類中心。線上只需要計算 local embedding,是 target user 到種子用戶的 attention,這需要根據線上請求的 urn 來實時計算。再計算一次 cosine,就可以得到相似度,這個計算量很小。

▌實驗結果

算出 look alike 相似度之後,相似度的分數,可直接給到排序服務,做曝光依據。這是當時寫論文之前做的 ABtest,對比的是用戶畫像匹配推送的策略,上線之後,在擴大曝光規模的前提下,CTR 基本取向穩定,而且有微小提升,多樣性也提升了很多,這都是相對提升。

▌一些細節和思考

特徵:

為什麼要用第一階段的 user representation learning 得到用戶的高階畫像?高階畫像的作用:包含了用戶在某個領域的全部信息,信息量很大,結合 look alike learning 中的行為,需要去學習用戶群體的特徵。不用高階特徵,怎樣學習用戶群體?比較簡單的方法是通過統計的方式:平均年齡分佈和平均閱讀傾向。這些都是基於離散的統計,信息損失很大。如果有了高階的特徵,高階特徵也是從低階特徵,如基礎畫像、年月分佈,這些都是可以學到高階特徵中。如果能夠直接輸出所有領域的高階特徵,之後的利用、或者作為召回、作為 CTR 特徵,都很方便。

模型調優:

① 防止模型過擬合。look alike 的結構很簡單,這樣做的原因: 直接使用用戶的高階特徵,使用了用戶特徵,如果模型不做處理,容易對高階特徵過擬合。採取了2種方式:

  1. 盡量保證 look alike learning 結構簡單;

  2. 全連接層做 dropout。

② 採用 stacking model 的形式。看一看閱讀、電商、新聞、音樂領域都做一次 user representation learning,這些特徵用 stacking 的模式都放到 look alike model 中學習,這就是不同特徵根據不同目標來訓練的,更加減少了在同一個模型中過擬合的防線。

冷啟動曝光:

Look alike model 中用了種子用戶的表達,如果線上有新的 item,怎樣做曝光?

  1. 初始投放策略。使用基於 user item 的語義特徵做線性模型的預測,當做冷啟動 item 的初始投放。這個初始投放不需要積累很多種子用戶,大概到百級別的種子用戶就可以切到 look alike 邏輯了。

  2. Look alike 出來的相似度分數,怎麼做曝光的依據?如果直接用相似度分數,需要確定曝光閾值,如對於某個 item,高於多少分才曝光。我們使用的是線上試探曝光機制:最初給1000條流量,做曝光,這次曝光后,收集在用戶側的打分,取打分的分佈統計,根據不同業務的要求,曝光 top 5% 或者 top10%, 來砍一個閾值分數,最後取曝光閾值。

本次分享就到這裏,謝謝大家。

▌Q & A

Q:這個算法有沒有在召迴環節用,曝光該如何理解?

A:目前的策略有兩種方式:

  1. 直接採用召回的方式,定一個曝光閾值,直接確定是否曝光;

  2. 把相似分數給到下游的 CTR model 作為參考。

Q:能否將兩階段學習合併成一個端到端學習?

A:End-to-End 方式存在兩個問題:

  1. 整個模型參數量很大,結構比較複雜,採用 End-to-End 方式不一定能學習到或者學習的很充分;

  2. 剛剛講到的 stacking 方式,我們最後需要的是盡可能全的表達用戶的方式,所以右側的 user representation learning 並不是從單一業務領域得出的結果,有可能是在多個領域得到的結果,比如在看一看訓練一版 user representation learning,然後用社交或者電商上的行為,再做一版用戶的表示,最後用 stacking 的方式把它們拼接起來,作為特徵輸入,這樣達到的效果會更好。

Q:如果將第一階段用戶表徵學習換成其他通用能學習表徵用戶向量的模型,效果會有什麼影響?

A:我們單獨用 user representation learning 和其它模型做過對比,比如 CTR 中的 user embedding,是針對當前業務比較精準化的表達,所在在泛化性上沒有 user representation learning 效果好。

▌參考資料

Real-time Attention Based Look-alike Model for Recommender System

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

Java開發中常用jar包整理及使用

本文整理了我自己在Java開發中常用的jar包以及常用的API記錄。

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.8</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.6</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.8</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>1.2.17</version>
        </dependency>

common-lang3

簡介

一個現在最為常用的jar包,封裝了許多常用的工具包

依賴:

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.4</version>
</dependency>

主要常見的類如下:

  • 數組工具類 ArrayUtils
  • 日期工具類 DateUtils DateFormatUtils
  • 字符串工具類 StringUtils
  • 数字工具類 NumberUtils
  • 布爾工具類 BooleanUtils
  • 反射相關工具類 FieldUtils、MethodUtils、MemberUtils、TypeUtils、ConstructorUtils
  • 對象工具類 ObjectUtils
  • 序列化工具類 SerializationUtils

API介紹

這裏我只介紹經常使用的幾個工具類及方法,ArrayUtils,StringUtils,NumberUtils,DateUtils,其他的請查看官方API文檔吧

1.ArrayUtils

方法名 說明
add
remove
clone 複製數組
addAll
removeAll 第二個參數傳入需要刪除的下標(可以指定多個下標)
toObject 把數值(int[],double[])轉為包裝類(Int[],Double[])
indexOf 在數組按順序查找,找到第一個滿足對應的數值的下標
lastIndexOf 在數組按順序查找,找到最後一個滿足對應的數值的下標
contains 數組是否包含某個值
isEmpty 判斷數組是否為空
isNotEmpty 判斷數組是否不為空
reverse 數組反轉
subarray 指定區間截取數組,區間為半開區間,不包含末尾
toArray 接收一個多個對象,把這幾個對象轉為對應類型的數組
toMap 將一個二維數組轉為Map

2.NumberUtils

方法名 說明
min 比較三個數,返回最小值 或比較指定的幾個數,返回最小值
max 比較三個數,返回最大值 或比較指定的幾個數,返回最大值
createInt 從傳入的String中創建對應類型的數值,createDouble,createFloat…
toInt 將指定字符串轉為Int類型,可以選擇指定默認數值,如果字符串為null則返回默認數值,除此之外,還有toDouble,toLong…等轉為不同類型的方法
compare 比較兩個同類型數值的大小
isDigits 判斷字符串是否只包含数字
isParsable 判斷字符串是否可轉換為Long,Int等類型
isNumber 判斷字符串是否為數值(0x,0X開頭等進制數值)

3.DateUtils

方法名 說明
parseDate 將Date對象轉為字符串
isSameDay 判斷兩個Dated對象是否為同一天
isSameDay 判斷兩個Dated對象是否為同一天
addHour 將指定的Date對象加上指定小時,除此之外,還有addMonth,addDay..等

DateFormatUtils正如其名,是用來把時間轉為字符串,這裏就不再多說

4.StringUtils

方法名 說明
join 將指定的數組連接成字符串,並添加指定的分割字符
containOnly 字符串是否只包含某個字符串
substringBefore 截取指定字符串前面的內容
substringAfter 截取指定字符串後面的內容(不包括指定字符串)
substringBetween 截取字符串某區間內容,如substringBetween(“abcde”,”a”,”e”)=”bcd”
difference 比較兩個字符串,返回兩個字符串不同的內容,具體可以看API文檔給出的示例
isBlank 判斷字符串是否為空白,null,””,” “這三個結果都是為true
isEmpty 判斷字符串是否為空(只要不為null,或傳入的String對象的長度不為0即為true)
countMatches 判斷指定的字符串在某個字符串中出現的次數
deleteWhitespace 刪除字符串中的空格
defaultIfBlank 如果字符串為空白,則返回一個指定的默認值(null或某個String)
defaultIfEmpty 如果字符串為空,則返回一個指定的默認值(null或某個String)
capitalize 將指定字符串首字母大寫
abbreviate 將指定字符串的後面三位轉為…
swapCase 將字符串中的字母大小寫反轉,如aBc變為AbC
lowerCase 將字符串的字母全部轉為小寫
upperCase 將字符串的字母全部轉為大寫
left 取字符串左邊幾個字符,如left(“hello”,3)=”hel”,right與此相反
leftPad 字符串的長度不夠,則使用指定字符填充指定字符串,如leftPad(“hel”,5,”z”)=”zzhel”,rightPad方法與此相反
prependIfMissing 指定字符串不以某段字符串開頭,則自動添加開頭,如prependIfMissing(“hello”,”li”)=”lihello”
prependIfMissing 指定字符串不以某段字符串開頭(忽略大小寫),則自動添加開頭
getCommonPrefix 獲得多個字符串相同的開頭內容,接收參數為多個字符串
removeEnd 刪除字符串中結尾(滿足是以某段內容結尾),如removeEnd(“hello”,”llo”)=”he”
removeEndIgnoreCase 與上面一樣,忽略大小寫
removeStart 與上面的相反
remove 刪除字符串中的指定內容,如remove(“hello”,”l”)=”heo”
removeIgnoreCase 刪除字符串中的指定內容,如remove(“hello”,”l”)=”heo”
strip 清除字符串開頭和末尾指定的字符(第二個參數為null,用來清除字符串開頭和末尾的空格),如strip(” abcxy”,”xy”)=” abc”,strip(” abcxy”,”yx”)=” abc”
stripStart 清除字符串開頭指定字符
stripEnd 清除字符串末尾指定的字符

common-io

簡介

常用的IO流工具包

<!-- https://mvnrepository.com/artifact/commons-io/commons-io -->
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.6</version>
</dependency>

API

我們主要關心的就是Utils後綴的那幾個類即可,可以看到,common-io庫提供了FileUtils,FileSystemUtils,FileNameUtils,FileFilterUtils,IOUtils

FileUtils

  • 寫出文件
  • 讀取文件
  • 創建一個有父級文件夾的文件夾
  • 複製文件和文件夾
  • 刪除文件和文件夾
  • URL轉文件
  • 通過過濾器和擴展名來篩選文件和文件夾
  • 比較文件內容
  • 文件最後修改時間
  • 文件校驗

FileSystemUtils

關於文件系統的相關操作,如查看C盤的大小,剩餘大小等操作

IOUtils

字面意思,是封裝了IO流的各種操作的工具類

Log4j

簡介

Log4J 是 Apache 的一個開源項目,通過在項目中使用 Log4J,我們可以控制日誌信息輸出到控制台、文件、GUI 組件、甚至是數據庫中。

我們可以控制每一條日誌的輸出格式,通過定義日誌的輸出級別,可以更靈活的控制日誌的輸出過程,方便項目的調試。

依賴:

<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>

結構

Log4J 主要由 Loggers (日誌記錄器)、Appenders(輸出端)和 Layout(日誌格式化器)組成。

其中Loggers 控制日誌的輸出級別與日誌是否輸出;
Appenders 指定日誌的輸出方式(輸出到控制台、文件等);
Layout 控制日誌信息的輸出格式。

日誌級別:

級別 說明
OFF 最高日誌級別,關閉左右日誌
FATAL 將會導致應用程序退出的錯誤
ERROR 發生錯誤事件,但仍不影響系統的繼續運行
WARN 警告,即潛在的錯誤情形
INFO 一般和在粗粒度級別上,強調應用程序的運行全程
DEBUG 一般用於細粒度級別上,對調試應用程序非常有幫助
ALL 最低等級,打開所有日誌記錄

我們主要使用這四個:Error>Warn>Info>Debug

使用

我們可以使用兩種方式來運行Log4j,一種是java代碼方式,另外一種則是配置文件方式

例子(Java方式)

public class Log4JTest {
    public static void main(String[] args) {   
        //獲取Logger對象的實例(傳入當前類)         
        Logger logger = Logger.getLogger(Log4JTest.class);
        //使用默認的配置信息,不需要寫log4j.properties
        BasicConfigurator.configure();
        //設置日誌輸出級別為WARN,這將覆蓋配置文件中設置的級別,只有日誌級別低於WARN的日誌才輸出
        logger.setLevel(Level.WARN);
        logger.debug("這是debug");
        logger.info("這是info");
        logger.warn("這是warn");
        logger.error("這是error");
        logger.fatal("這是fatal");
    }
}

例子(配置文件方式)

上面的例子,我們想要實現打印Log,但是每次都要寫一遍,浪費時間和精力,所以,Log4j提供了另外一種方式來配置好我們的信息

創建一個名為log4j.properties的文件,此文件需要放在項目的根目錄(約定),如果是maven項目,直接放在resources文件夾中即可

log4j.properties

#控制台
log4j.appender.Console=org.apache.log4j.ConsoleAppender
log4j.appender.Console.layout=org.apache.log4j.PatternLayout
log4j.appender.Console.layout.ConversionPattern=%d [%t] %-5p [%c] - %m%n

#log jdbc
log4j.logger.java.sql.ResultSet=INFO
log4j.logger.org.apache=WARN
log4j.logger.java.sql.Connection=DEBUG
log4j.logger.java.sql.Statement=DEBUG
log4j.logger.java.sql.PreparedStatement=DEBUG

#log mybatis設置
#log4j.logger.org.apache.ibatis=DEBUG
log4j.logger.org.apache.ibatis.jdbc=error
log4j.logger.org.apache.ibatis.io=info
log4j.logger.org.apache.ibatis.datasource=info

#springMVC日誌
log4j.logger.org.springframework.web=WARN

# 文件輸出配置
log4j.appender.A = org.apache.log4j.DailyRollingFileAppender
log4j.appender.A.File = D:/log.txt #指定日誌的輸出路徑
log4j.appender.A.Append = true
log4j.appender.A.Threshold = DEBUG
log4j.appender.A.layout = org.apache.log4j.PatternLayout #使用自定義日誌格式化器
log4j.appender.A.layout.ConversionPattern = %-d{yyyy-MM-dd HH:mm:ss}  [ %t:%r ] - [ %p ]  %m%n #指定日誌的輸出格式
log4j.appender.A.encoding=UTF-8 #指定日誌的文件編碼

#指定日誌的輸出級別與輸出端
log4j.rootLogger=DEBUG,Console,A

#指定某個包名日誌級別(不能超過上面定義的級別,否則日誌不會輸出)
log4j.logger.com.wan=DEBUG

之後使用的話就比較簡單了

//Logger的初始化(這個推薦定義為全局變量,方便使用)
Logger logger = Logger.getLogger(Log4JTest.class);
//輸出Log
logger.info("這是info");

參考鏈接:

lombok

簡介

平常我們創建實體類的時候,需要get/set方法,極其麻煩,雖然IDEA等IDE都是有提供了快捷生成,不過,最好的解決方法還是省略不寫

而lombok就是這樣的一個框架,實現省略get/set方法,當然,lombok的功能不只有此,還有equal,toString方法也可以由此框架自動生成

lombok的原理是使用註解,之後就會在編譯過程中,給Class文件自動加上get/set等方法

不過IDEA似乎無法識別,代碼檢查還是會報錯,所以,使用IDEA的時候還得安裝一個插件,在plugin搜索lombok,之後安裝重啟即可,如圖

之後為Java項目添加依賴

<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.8</version>
    <scope>provided</scope>
</dependency>

使用示例

1.實體類省略get/set
估計Kotlin中的data關鍵字就是參照着lombok實現的

//這裏我們只需要為類添加Data註解,就會自動生成對應屬性的get/set方法,toString,equal等方法
@Data
public class User {
    private String username;
    private String password;
}

2.需要無參構造以及get/set方法

@Getter
@Setter
@NoArgsConstructor
public class User {
    private String username;
    private String password;
}

3.鏈式調用set方法

@Data
@Accessors(chain = true)
public class User {
    private String username;
    private String password;
}

//使用
User user = new User();
user.setUsername("helo").setPassword("123");

4.參數不為空

//如果調用此方法,就會抱一個空指針錯誤
public String print(@NotNull String str){
    ...
}

5.只需要toString

@ToString(callSuper=true, includeFieldNames=true)
public class User {
    private String username;
    private String password;
    //省略的get/set方法
}

6.builder模式創建實體類對象

@Data
@Builder
public class User {
    private String username;
    private String password;
}
//使用
User user1 = User.builder().username("user1").password("123").build();

7.工具類

@UtilityClass
public class MyUtils{
    //會將此方法自動轉為靜態方法
    public void print(String str){
        ...
    }
}
//使用
MyUtils.print("hello");

8.自動關閉流

public static void main(String[] args) throws Exception {
    //使用Cleanup會自動調用close方法
    @Cleanup InputStream in = new FileInputStream(args[0]);
    @Cleanup OutputStream out = new FileOutputStream(args[1]);
    byte[] b = new byte[1024];
    while (true) {
        int r = in.read(b);
        if (r == -1) break;
        out.write(b, 0, r);
    }
}

9.省略Logger時的初始化

@Log4j
@Log
public class User{
    //會自動添加此語句
    //Logger logger = Logger.getLogger(User.class);
    ...
}

參考:

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

台北網頁設計公司這麼多,該如何挑選?? 網頁設計報價省錢懶人包"嚨底家"

網頁設計公司推薦更多不同的設計風格,搶佔消費者視覺第一線

※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

人臉檢測和人臉識別原理,微調(Fine-tune)原理

一、MTCNN的原理

  搭建人臉識別系統的第一步是人臉檢測,也就是在圖片中找到人臉的位置。在這個過程中,系統的輸入是一張可能含有人臉的圖片,輸出是人臉位置的矩形框,如下圖所示。一般來說,人臉檢測應該可以正確檢測出圖片中存在的所有人臉,不能用遺漏,也不能有錯檢。  

   

  獲得包含人臉的矩形框后,第二步要做的就是人臉對齊(Face Alignment)。原始圖片中人臉的姿態、位置可能較大的區別,為了之後統一處理,要把人臉“擺正”。為此,需要檢測人臉中的關鍵點(Landmark),如眼睛的位置、鼻子的位置、嘴巴的位置、臉的輪廓點等。根據這些關鍵點可以使用仿射變換將人臉統一校準,以盡量消除姿勢不同帶來的誤差,人臉對齊的過程如下圖所示。

   

  這裏介紹一種基於深度卷積神經網絡的人臉檢測和人臉對齊方法—-MTCNN,它是基於卷積神經網絡的一種高精度的實時人臉檢測和對齊技術。MT是英文單詞Multi-task的縮寫,意思就是這種方法可以同時完成人臉檢測的人臉對齊兩項任務。相比於傳統方法,MTCNN的性能更好,可以更精確的定位人臉,此外,MTCNN也可以做到實時的檢測。

  MTCNN由三個神經網絡組成,分別是P-Net、R-Net、O-Net。在使用這些網絡之前,首先要將原始圖片縮放到不同尺度,形成一個“圖像金字塔”,如下圖所示。

   

  接着會對每個尺度的圖片通過神經網絡計算一遍。這樣做的原因在於:原始圖片中的人臉存在不同的尺度,如有的人臉比較大,有的人臉比較小。對於比較小的人臉,可以在放大后的圖片上檢測;對於比較大的人臉,可以在縮小后的圖片上進行檢測。這樣,就可以在統一的尺度下檢測人臉了。

  現在再來討論第一個網絡P-Net的結構,如下圖所示

   

  P-Net的輸入是一個寬和高皆為12像素,同時是3通道的RGB圖像,該網絡要判斷這個12×12的圖像中是否含有人臉,並且給出人臉框和關鍵點的位置。因此對應的輸出應該由3部分組成:

  (1)第一個部分要判斷該圖像是否是人臉(上圖中的face classification),輸出向量的形狀為1x1x2,也就是兩個值,分別為該圖像是人臉的概率,以及該圖像不是人臉的概率。這兩個值加起來應該嚴格等1。之所以使用兩個值來表示,是為了方便定義交叉熵損失。
  (2)第二個部分給出框的精確位置(上圖中的bounding box regression),一般稱之為框回歸。P-Net輸入的12×12的圖像塊可能並不是完美的人臉框的位置,如有的時候人臉並不正好為方形,有的時候12×12的圖像塊可能偏左或偏右,因此需要輸出當前框位置相對於完美的人臉框位置的偏移。這個偏移由四個變量組成。一般地,對於圖像中的框,可以用四個數來表示它的位置:框左上角的橫坐標、框左上角的縱坐標、框的寬度、框的高度。因此,框回歸輸出的值是:框左上角的橫坐標的相對偏移、框左上角的縱坐標的相對偏移、框的寬度的誤差、框的 高度的誤差。輸出向量的形狀就是上圖中的1x1x4。
  (3)第三個部分給出人臉的5個關鍵點的位置。5個關鍵點分別為:左眼的位置、右眼的位置、鼻子的位置、左嘴角的位置、右嘴角的位置。每個關鍵點又需要橫坐標和縱坐標來表示,因此輸出一共是10維(即1x1x10)

  上面的介紹大致就是P-Net的結構了。在實際計算中,通過P-Net中第一層卷積的移動,會對圖像中每一個12×12的區域做一次人臉檢測,得到的結構如下圖所示:

   

  圖中框的大小各有不同,除了框回歸的影響外,主要是因為將圖片金字塔的各個尺度都使用P-Net計算了一遍,因此形成了大小不同的人臉框。P-Net的結果還是比較粗糙的,所以接下來又使用R-Net進一步調優。R-Net的網絡結構如下圖所示。

   

  這個結構與之前的P-Net非常類似,P-Net的輸入是12x12x3的圖像,R-Net是24x24x3的圖像,也就是說,R-Net判斷24x24x3的圖像中是否含有人臉,以及預測關鍵點的位置。R-Net的輸出和P-Net完全一樣,同樣有人臉判別、框回歸、關鍵點位置預測三部分組成。

  在實際應用中,對每個P-Net輸出可能為人臉的區域都放縮到24×24的大小,在輸入到R-Net中,進行進一步的判定。得到的結果如下圖所示:

   

  顯然R-Net消除了P-Net中很多誤判的情況。

  進一步把所有得到的區域縮放成48×48的大小,輸入到最後的O-Net中,O-Net的結構同樣與P-Net類似,不同點在於它的輸入是48x48x3的圖像,網絡的通道數和層數也更多了。O-Net的網絡的結構如下圖所示:

   

  檢測結果如下圖所示:

   

  從P-Net到R-Net,最後再到O-Net,網絡輸入的圖片越來越大,卷積層的通道數越來越多,內部的層數也越來越多,因此它們識別人臉的準確率應該是越來越高的。同時,P-Net的運行速度是最快的,R-Net的速度其次,O-Net的運行速度最慢。之所以要使用三個網絡,是因為如果一開始直接對圖中的每個區域使用O-Net,速度會非常慢慢。實際上P-Net先做了一遍過濾,將過濾后的結果再交給R-Net進行過濾,最後將過濾后的結果交給效果最好但速度較慢的O-Net進行判別。這樣在每一步都提前減少了需要判別的數量,有效降低了處理時間。

  最後介紹MTCNN的損失定義和訓練過程。MTCNN中每個網絡都有三部分輸出,因此損失也由三部分組成。針對人臉判別部分,直接使用交叉熵損失,針對框回歸和關鍵點判定,直接使用L2損失。最後這三部分損失各自乘以自身的權重再加起來,就形成最後的總損失了。在訓練P-Net和R-Net時,更關心框位置的準確性,而較少關注關鍵點判定的損失,因此關鍵點判定損失的權重很小。對於O-Net,關鍵點判定損失的權重較大。

二、使用深度卷積網絡提取特徵

  經過人臉檢測和人臉對齊兩個步驟,就獲得了包含人臉的區域圖像,接下來就要進行人臉識別了。這一步一般是使用深度卷積網絡,將輸入的人臉圖像轉換為一個向量的表示,也就是所謂的“特徵”。

  如何針對人臉來提取特徵?可以先來回憶VGG16的網絡結構(見),輸入神經網絡的是圖像,經過一系列卷積計算后,全連接分類得到類別概率。

  在通常的圖像應用中,可以去掉全連接層,使用卷積層的最後一層當作圖像的“特徵”。但如果對人臉識別問題同樣採用這種方法,即使用卷積層最後一層做為人臉的“向量表示”,效果其實是不好的。這其中的原因和改進方法是什麼?在後面會談到,這裏先談談希望這種人臉的“向量表示”應該具有哪些性質。

  在理想的狀況下,希望“向量表示”之間的距離可以直接反映人臉的相似度

  對於同一個人的兩張人臉圖像,對應的向量之間的歐幾里得距離應該比較小。對於不同人的兩張人臉圖像,對應的向量之間的歐幾里得距離應該比較大。

  例如,設人臉圖像為$x_{1}$,$x_{2}$,對應的特徵為$f(x_{1})$,$f(x_{2})$,當$x_{1}$,$x_{2}$對應是同一個人的人臉時,$f(x_{1})$,$f(x_{2})$的距離$\left \| f(x_{1}),f(x_{2}) \right \|$2應該很小,而當$x_{1}$,$x_{2}$是不同人的人臉時,$f(x_{1})$,$f(x_{2})$的距離$\left \| f(x_{1}),f(x_{2}) \right \|$2應該很大。

  在原始的CNN模型中,使用的是Softmax損失。Softmax是類別間的損失,對於人臉來說,每一類就是一個人。儘管使用Softmax損失可以區別出每個人,但其本質上沒有對每一類的向量表示之間的距離做出要求。

  舉個例子,使用CNN對MNIST進行分類,設計一個特殊的卷積網絡,讓其最後一層的向量變為2維,此時可以畫出每一類對應的2維向量(圖中一種顏色對應一種類別),如下圖所示:

   

  上圖是我們直接使用softmax訓練得到的結果,它就不符合我們希望特徵具有的特點:

  (1)我們希望同一類對應的向量表示盡可能接近。但這裏同一類(如紫色),可能具有很大的類間距離;
  (2)我們希望不同類對應的向量應該盡可能遠。但在圖中靠中心的位置,各個類別的距離都很近;

  對於人臉圖像同樣會出現類似的情況,對此,有很改進方法。這裏介紹其中兩種:一種是三元組損失函數(Triplet Loss),一種是中心損失函數。 

三、三元組損失的定義

  三元組損失函數的原理:既然目標是特徵之間的距離應該具備某些性質,那麼我們就圍繞這個距離來設計損失。具體的,我們每次都在訓練數據中抽出三張人臉圖像,第一張圖像記為$x_{i}^{a}$,第二張圖像記為$x_{i}^{p}$,第三張圖像記為$x_{i}^{n}$。在這樣的一個“三元組”中,$x_{i}^{a}$和$x_{i}^{p}$對應的是同一個人的圖像,而$x_{i}^{n}$是另外一個不同的人的人臉圖像。因此,距離$\left \| f(x_{i}^{a})-f(x_{i}^{p}) \right \|_{2}$應該較小,而距離$\left \| f(x_{i}^{a})-f(x_{i}^{n}) \right \|_{2}$應該較大。嚴格來說,三元組損失要求下面的式子成立:

   $\left \| f(x_{i}^{a})- f(x_{i}^{p})\right \|_{2}^{2}+\alpha <\left \| f(x_{i}^{a})- f(x_{i}^{p})\right \|_{2}^{2}$

  然後計算相同人臉之間與不同人臉之間距離的平方

   $\left [ \left \| f(x_{i}^{a})-f(x_{i}^{p}) \right \|_{2}^{2}+\alpha -\left \| f(x_{i}^{a})-f(x_{i}^{n}) \right \|_{2}^{2} \right ]_{+}$

  上式表達相同人臉間的距離平方至少要比不同人臉間的距離平方小α(取平方主要是為了方便求導),據此,上式實際上就是相當於一個損失函數。這樣的話,當三元組的距離滿足 $\left \| f(x_{i}^{a})- f(x_{i}^{p})\right \|_{2}^{2}+\alpha <\left \| f(x_{i}^{a})- f(x_{i}^{p})\right \|_{2}^{2}$時,不產生任何損失,此時$L_{i}=0$。當距離不滿足上述等式時,就會有值為$\left \| f(x_{i}^{a})-f(x_{i}^{p}) \right \|_{2}^{2}+\alpha -\left \| f(x_{i}^{a})-f(x_{i}^{n}) \right \|_{2}^{2}$的損失。此外,在訓練時會固定$\left \| f(x) \right \|_{2}=1$,以保證特徵不會無限地“遠離”。

  三元組損失直接對距離進行優化,因此可以解決人臉的特徵表示問題。但是在訓練過程中,三元組的選擇非常地有技巧性。如果每次都是隨機選擇三元組,雖然模型可以正確的收斂,但是並不能達到最好的性能。如果加入”難例挖掘”,即每次都選擇最難分辨率的三元組進行訓練,模型又往往不能正確的收斂。對此,又提出每次都選擇那些“半難”(Semi-hard)的數據進行訓練,讓模型在可以收斂的同時也保持良好的性能。此外,使用三元組損失訓練人臉模型通常還需要非常大的人臉數據集,才能取得較好的效果。

四、中心損失的定義

  與三元組損失不同,中心損失(Center Loss)不直接對距離進行優化,它保留了原有的分類模型,但又為每個類(在人臉模型中,一個類就對應一個人)指定了一個類別中心。同一類的圖像對應的特徵都應該盡量靠近自己的類別中心,不同類的類別中心盡量遠離。與三元組損失函數相比,使用中心損失訓練人臉模型不需要使用特別的採樣方法,而且利用較少的圖像就可以達到與單元組損失相似的效果。下面我們一起來學習中心損失的定義:

   還是設輸入的人臉圖像為$x_{i}$,該人臉對應的類別為$y_{i}$,對每個類別都規定一個類別中心,記作$c_{yi}$。希望每個人臉圖像對應的特徵$f(x_{i})$都盡可能接近其中心$c_{yi}$。因此定義中心損失為:

    $L_{i}=\frac{1}{2}\left \| f(x_{i})-c_{yi}\right \|_{2}^{2}$

  多張圖像的中心損失就是將它們的值加在一起:

   $L_{center}=\sum\limits_{i}L_i$

  這是一個非常簡單的定義。不過還有一個問題沒有解決,那就是如何確定每個類別的中心$c_{yi}$呢?從理論上來說,類別$y_{i}$的最佳中心應該是它對應的所有圖片的特徵的平均值。但如果採取這樣的定義,那麼在每一次梯度下降時,都要對所有圖片計算一次$c_{yi}$,計算複雜度就太高了。針對這種情況,不妨近似一處理下,在初始階段,先隨機確定$c_{yi}$,接着在每個batch內,使用$L_i=\|f(x_i)-c_{yi}\|_2^2$對當前batch內的$c_{yi}$ 也計算梯度,並使用該梯度更新$c_{yi}$ 。此外,不能只使用中心損失來訓練分類模型,還需要加入Softmax損失,也就是說,最終的損失由兩部分構成,即$L = L_{softmax}+\lambda L_{center}$,其中$\lambda $是一個超參數。

  最後來總結使用中心損失來訓練人臉模型的過程。首先隨機初始化各个中心$c_{yi}$,接着不斷地取出batch進行訓練,在每個batch中,使用總的損失$L$,除了使用神經網絡模型的參數對模型進行更新外,也對$c_{yi}$進行計算梯度,並更新中心的位置。

  中心損失可以讓訓練處的特徵具有“內聚性”。還是以MNIST的例子來說,在未加入中心損失時,訓練的結果不具有內聚性。再加入中心損失后,得到的特徵如下圖所示。 

   

從圖中可以看出,當中心損失的權重λ越大時,生成的特徵就會具有越明顯的“內聚性” 。

五、使用特徵設計應用

當提取出特徵后,剩下的問題就非常簡單了。因為這種特徵已經具有了相同人對應的向量的距離小,不同人對應的向量距離大的特點,接下來,一般的應用有以下幾類:

  • 人臉驗證(Face Identification)。就是檢測A、B是否屬於同一個人。只需要計算向量之間的距離,設定合適的報警閾值(threshold)即可。
  • 人臉識別(Face Recognition)。這個應用是最多的,給定一張圖片,檢測數據庫中與之最相似的人臉。顯然可以被轉換為一個求距離的最近鄰問題。
  • 人臉聚類(Face Clustering)。在數據庫中對人臉進行聚類,直接用K-means即可。

 

 

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

Java——內部類詳解

說起內部類,大家肯定感覺熟悉又陌生,因為一定在很多框架源碼中有看到別人使用過,但又感覺自己使用的比較少,今天我就帶你具體來看看內部類。

內部類基礎

所謂內部類就是在類的內部繼續定義其他內部結構類。

在 Java 中,廣泛意義上的內部類一般來說包括這四種:成員內部類、局部內部類、匿名內部類和靜態內部類。下面就先來了解一下這四種內部類的用法。

成員內部類

成員內部類是最普通的內部類,它的定義為位於另一個類的內部,具體使用如下:

class Circle {
    double radius = 0;

    public Circle(double radius) {
        this.radius = radius;
    }

    /**
     * 內部類
     */
    class Draw {
        public void drawSahpe() {
            System.out.println("drawshape");
        }
    }
}

這樣看起來,類 Draw 像是類 Circle 的一個成員, Circle 稱為外部類。成員內部類可以無條件訪問外部類的所有成員屬性和成員方法(包括 private 成員和靜態成員),例如:

class Circle {
    private double radius = 0;
    public static int count =1;
    public Circle(double radius) {
        this.radius = radius;
    }

    /**
     * 內部類
     */
    class Draw {
        public void drawSahpe() {
            // 外部類的private成員
            System.out.println(radius);
            // 外部類的靜態成員
            System.out.println(count);
        }
    }
}

不過要注意的是,當成員內部類擁有和外部類同名的成員變量或者方法時,會發生隱藏現象,即默認情況下訪問的是成員內部類的成員。如果要訪問外部類的同名成員,需要採取以下形式進行訪問:

外部類.this.成員變量
外部類.this.成員方法

雖然成員內部類可以無條件地訪問外部類的成員,而外部類想訪問成員內部類的成員卻不是這麼隨心所欲了。在外部類中如果要訪問成員內部類的成員,必須先創建一個成員內部類的對象,再通過指向這個對象的引用來訪問,其具體形式為:

class Circle {
    private double radius = 0;

    public Circle(double radius) {
        this.radius = radius;
        // 必須先創建成員內部類的對象,再進行訪問
        getDrawInstance().drawSahpe();
    }

    private Draw getDrawInstance() {
        return new Draw();
    }

    /**
     * 內部類
     */
    class Draw {
        public void drawSahpe() {
            // 外部類的private成員
            System.out.println(radius);
        }
    }
}

成員內部類是依附外部類而存在的,也就是說,如果要創建成員內部類的對象,前提是必須存在一個外部類的對象。創建成員內部類對象的一般方式如下:

public class Test {
    public static void main(String[] args)  {
        // 第一種方式
        Outter outter = new Outter();
        // 必須通過Outter對象來創建
        Outter.Inner inner = outter.new Inner();

        // 第二種方式
        Outter.Inner inner1 = outter.getInnerInstance();
    }
}

class Outter {
    private Inner inner = null;
    public Outter() {
    }

    public Inner getInnerInstance() {
        if(inner == null)
            inner = new Inner();
        return inner;
    }

    class Inner {
        public Inner() {
        }
    }
}

內部類可以擁有 private 訪問權限、 protected 訪問權限、 public 訪問權限及包訪問權限。

比如上面的例子,如果成員內部類 Inner 用 private 修飾,則只能在外部類的內部訪問;如果用 public 修飾,則任何地方都能訪問;如果用 protected 修飾,則只能在同一個包下或者繼承外部類的情況下訪問;如果是默認訪問權限,則只能在同一個包下訪問。

這一點和外部類有一點不一樣,外部類只能被 public 和包訪問兩種權限修飾。

我個人是這麼理解的,由於成員內部類看起來像是外部類的一個成員,所以可以像類的成員一樣擁有多種權限修飾。

局部內部類

局部內部類是定義在一個方法或者一個作用域裏面的類,它和成員內部類的區別在於局部內部類的訪問僅限於方法內或者該作用域內。

class People{
    public People() {
    }
}

class Man{
    public Man(){
    }

    public People getWoman(){
        /**
         * 局部內部類
         */
        class Woman extends People{
            int age =0;
        }
        return new Woman();
    }
}

注意,局部內部類就像是方法裏面的一個局部變量一樣,是不能用 public 、 protected 、 private 以及 static 修飾的。

匿名內部類

匿名內部類應該是平時我們編寫代碼時用得最多的,比如創建一個線程的時候:

class Test {

    public static void main(String[] args) {
        Thread thread = new Thread(
                // 匿名內部類
                new Runnable() {
                    @Override
                    public void run() {
                        System.out.println("Thread run");
                    }
                }
        );
    }
}

同樣的,匿名內部類也是不能有訪問修飾符和 static 修飾符的。

匿名內部類是唯一一種沒有構造器的類。正因為其沒有構造器,所以匿名內部類的使用範圍非常有限,大部分匿名內部類用於接口回調。

匿名內部類在編譯的時候由系統自動起名為Outter$1.class。一般來說,匿名內部類用於繼承其他類或是實現接口,並不需要增加額外的方法,只是對繼承方法的實現或是重寫。

靜態內部類

靜態內部類也是定義在另一個類裏面的類,只不過在類的前面多了一個關鍵字 static 。

靜態內部類是不需要依賴於外部類的,這點和類的靜態成員屬性有點類似,並且它不能使用外部類的非 static 成員變量或者方法,這點很好理解,因為在沒有外部類的對象的情況下,可以創建靜態內部類的對象,如果允許訪問外部類的非 static 成員就會產生矛盾,因為外部類的非 static 成員必須依附於具體的對象。

例如:

public class Test {
    public static void main(String[] args)  {
        Outter.Inner inner = new Outter.Inner();
    }
}

class Outter {
    public Outter() {
    }

    /**
     * 靜態
     */
    static class Inner {
        public Inner() {
        }
    }
}

深入理解內部類

通過上面的介紹,相比你已經大致了解的內部類的使用,那麼你的心裏想必會有一個疑惑:

為什麼成員內部類可以無條件訪問外部類的成員?

首先我們先定義一個內部類:

public class Outter {
    private Inner inner = null;

    public Outter() {
    }

    public Inner getInnerInstance() {
        if (inner == null)
            inner = new Inner();
        return inner;
    }

    protected class Inner {
        public Inner() {
        }
    }
}

先用 javac 進行編譯,你可以發現會生成兩個文件: Outter$Inner.class 和 Outter.class 。接下來利用javap -p反編譯 Outter$Inner.class ,其結果如下:

Classfile /D:/project/Test/src/test/java/test/Outter$Inner.class
  Last modified 2019-11-25; size 408 bytes
  MD5 checksum b936e37bc77059b83951429e28f3f225
  Compiled from "Outter.java"
public class Outter$Inner
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Fieldref           #3.#13         // test/Outter$Inner.this$0:Ltest/Outter;
   #2 = Methodref          #4.#14         // java/lang/Object."<init>":()V
   #3 = Class              #16            // test/Outter$Inner
   #4 = Class              #19            // java/lang/Object
   #5 = Utf8               this$0
   #6 = Utf8               Ltest/Outter;
   #7 = Utf8               <init>
   #8 = Utf8               (Ltest/Outter;)V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               SourceFile
  #12 = Utf8               Outter.java
  #13 = NameAndType        #5:#6          // this$0:Ltest/Outter;
  #14 = NameAndType        #7:#20         // "<init>":()V
  #15 = Class              #21            // test/Outter
  #16 = Utf8               test/Outter$Inner
  #17 = Utf8               Inner
  #18 = Utf8               InnerClasses
  #19 = Utf8               java/lang/Object
  #20 = Utf8               ()V
  #21 = Utf8               test/Outter
{
  final Outter this$0;
    descriptor: Ltest/Outter;
    flags: ACC_FINAL, ACC_SYNTHETIC

  public Outter$Inner(Outter);
    descriptor: (Ltest/Outter;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: putfield      #1                  // Field this$0:Ltest/Outter;
         5: aload_0
         6: invokespecial #2                  // Method java/lang/Object."<init>":()V
         9: return
      LineNumberTable:
        line 16: 0
        line 17: 9
}
SourceFile: "Outter.java"
InnerClasses:
     protected #17= #3 of #15; //Inner=class test/Outter$Inner of class test/Outter

32行的內容為:final Outter this$0;

學過 C 的朋友應該能知道,這是一個指向外部類 Outter 對象的指針,也就是說編譯器會默認為成員內部類添加一個指向外部類對象的引用,這樣也就解釋了為什麼成員內部類能夠無條件訪問外部類了。

那麼這個引用是如何賦初值的呢?下面接着看內部類的構造器:public Outter$Inner(Outter);

從這裏可以看出,雖然我們在定義的內部類的構造器是無參構造器,但編譯器還是會默認添加一個參數,該參數的類型為指向外部類對象的一個引用,所以成員內部類中的 Outter this&0 指針便指向了外部類對象,因此可以在成員內部類中隨意訪問外部類的成員。

從這裏也間接說明了成員內部類是依賴於外部類的,如果沒有創建外部類的對象,則無法對 Outter this&0 引用進行初始化賦值,也就無法創建成員內部類的對象了。

為什麼局部內部類和匿名內部類只能訪問局部final變量?

我們還是採用和之前一樣的解答方式,先定義一個類:

public class Outter {

    public static void main(String[] args)  {
        Outter outter = new Outter();
        int b = 10;
        outter.test(b);
    }

    public void test(final int b) {
        final int a = 10;
        new Thread(){
            public void run() {
                System.out.println(a);
                System.out.println(b);
            };
        }.start();
    }
}

通過 javac 編譯 Outter,也會生成兩個文件: Outter.class 和 Outter1.class。默認情況下,編譯器會為匿名內部類和局部內部類起名為 Outter$x.class( x 為正整數)。

根據我提供的類,可以思考一個問題:

當 test 方法執行完畢之後,變量 a 的生命周期就結束了,而此時 Thread 對象的生命周期很可能還沒有結束,那麼在 Thread 的 run 方法中繼續訪問變量 a 就變成不可能了,但是又要實現這樣的效果,怎麼辦呢?

Java 採用了複製的手段來解決這個問題。將 Outter$1.class 反編譯可以得到下面的內容:

Classfile /D:/project/Test/src/test/java/test/Outter$1.class
  Last modified 2019-11-25; size 653 bytes
  MD5 checksum 2e238dafbd73356eba22d473c6469082
  Compiled from "Outter.java"
class test.Outter$1 extends java.lang.Thread
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Fieldref           #6.#23         // test/Outter$1.this$0:Ltest/Outter;
   #2 = Fieldref           #6.#24         // test/Outter$1.val$b:I
   #3 = Methodref          #7.#25         // java/lang/Thread."<init>":()V
   #4 = Fieldref           #26.#27        // java/lang/System.out:Ljava/io/PrintStream;
   #5 = Methodref          #28.#29        // java/io/PrintStream.println:(I)V
   #6 = Class              #30            // test/Outter$1
   #7 = Class              #32            // java/lang/Thread
   #8 = Utf8               val$b
   #9 = Utf8               I
  #10 = Utf8               this$0
  #11 = Utf8               Ltest/Outter;
  #12 = Utf8               <init>
  #13 = Utf8               (Ltest/Outter;I)V
  #14 = Utf8               Code
  #15 = Utf8               LineNumberTable
  #16 = Utf8               run
  #17 = Utf8               ()V
  #18 = Utf8               SourceFile
  #19 = Utf8               Outter.java
  #20 = Utf8               EnclosingMethod
  #21 = Class              #33            // test/Outter
  #22 = NameAndType        #34:#35        // test:(I)V
  #23 = NameAndType        #10:#11        // this$0:Ltest/Outter;
  #24 = NameAndType        #8:#9          // val$b:I
  #25 = NameAndType        #12:#17        // "<init>":()V
  #26 = Class              #36            // java/lang/System
  #27 = NameAndType        #37:#38        // out:Ljava/io/PrintStream;
  #28 = Class              #39            // java/io/PrintStream
  #29 = NameAndType        #40:#35        // println:(I)V
  #30 = Utf8               test/Outter$1
  #31 = Utf8               InnerClasses
  #32 = Utf8               java/lang/Thread
  #33 = Utf8               test/Outter
  #34 = Utf8               test
  #35 = Utf8               (I)V
  #36 = Utf8               java/lang/System
  #37 = Utf8               out
  #38 = Utf8               Ljava/io/PrintStream;
  #39 = Utf8               java/io/PrintStream
  #40 = Utf8               println
{
  final int val$b;
    descriptor: I
    flags: ACC_FINAL, ACC_SYNTHETIC

  final test.Outter this$0;
    descriptor: Ltest/Outter;
    flags: ACC_FINAL, ACC_SYNTHETIC

  test.Outter$1(test.Outter, int);
    descriptor: (Ltest/Outter;I)V
    flags:
    Code:
      stack=2, locals=3, args_size=3
         0: aload_0
         1: aload_1
         2: putfield      #1                  // Field this$0:Ltest/Outter;
         5: aload_0
         6: iload_2
         7: putfield      #2                  // Field val$b:I
        10: aload_0
        11: invokespecial #3                  // Method java/lang/Thread."<init>":()V
        14: return
      LineNumberTable:
        line 10: 0

  public void run();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: bipush        10
         5: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
         8: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        11: aload_0
        12: getfield      #2                  // Field val$b:I
        15: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
        18: return
      LineNumberTable:
        line 12: 0
        line 13: 8
        line 14: 18
}
SourceFile: "Outter.java"
EnclosingMethod: #21.#22                // test.Outter.test
InnerClasses:
     #6; //class test/Outter$1

我們看到在 run 方法中有一條指令:bipush 10

這條指令表示將操作數10壓棧,表示使用的是一個本地局部變量。

這個過程是在編譯期間由編譯器默認進行,如果這個變量的值在編譯期間可以確定,則編譯器默認會在匿名內部類(局部內部類)的常量池中添加一個內容相等的字面量或直接將相應的字節碼嵌入到執行字節碼中。

這樣一來,匿名內部類使用的變量是另一個局部變量,只不過值和方法中局部變量的值相等,因此和方法中的局部變量完全獨立開。

接下來也來看一下 test.Outter$1 的構造方法:test.Outter$1(test.Outter, int);

我們看到匿名內部類 Outter$1 的構造器含有兩個參數,一個是指向外部類對象的引用,一個是 int 型變量,很顯然,這裡是將變量 test 方法中的形參 b 以參數的形式傳進來對匿名內部類中的拷貝(變量 b 的拷貝)進行賦值初始化。

也就說如果局部變量的值在編譯期間就可以確定,則直接在匿名內部裏面創建一個拷貝。如果局部變量的值無法在編譯期間確定,則通過構造器傳參的方式來對拷貝進行初始化賦值。

從上面可以看出,在 run 方法中訪問的變量 b 根本就不是test方法中的局部變量 b 。這樣一來就解決了前面所說的 生命周期不一致的問題。但是新的問題又來了,既然在 run 方法中訪問的變量 b 和test方法中的變量 b 不是同一個變量,那麼當在 run 方法中改變變量 b 的值的話,會出現什麼情況?

會造成數據不一致性,這樣就達不到原本的意圖和要求。為了解決這個問題, Java 編譯器就限定必須將變量 b 限製為 final ,不允許對變量 b 進行更改(對於引用類型的變量,是不允許指向新的對象),這樣數據不一致性的問題就得以解決了。

到這裏,想必大家應該清楚為何 方法中的局部變量和形參都必須用 final 進行限定了。

靜態內部類有特殊的地方嗎?

從前面可以知道,靜態內部類是不依賴於外部類的,也就說可以在不創建外部類對象的情況下創建內部類的對象。

另外,靜態內部類是不持有指向外部類對象的引用的,這個讀者可以自己嘗試反編譯 class 文件看一下就知道了,是沒有 Outter this&0 引用的。

總結

今天介紹了內部類相關的知識,包括其一般的用法以及內部類和外部類的依賴關係,通過對字節碼進行反編譯詳細了解了其實現模式,最後留給大家一個任務自己去實際探索一下靜態內部類的實現。希望通過這篇介紹可以幫大家更加深刻了解內部類。

有興趣的話可以訪問我的博客或者關注我的公眾號、頭條號,說不定會有意外的驚喜。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

政府重視電動機車電池電芯國產化,2020 持續補助購買電動機車

行政院副院長陳其邁在 7 月 15 日參訪 Gogoro 智能工廠,強調政府對電動機車電池電芯國產化的重視,並表示明年還是會持續補助消費者購買電動機車。

行政院副院長陳其邁與政務委員龔明鑫、經濟部次長林全能和環保署副署長沈志修等人前往桃園市龜山區,參訪 Gogoro 的智能工廠。除了現場試乘 5 月發表的新車種 Gogoro 3 車款並體驗交換電池外,也與 Gogoro 執行長陸學森針對電池電芯國產化進程與傳統機車產業合作等議題進行交流。

陳其邁表示,政府非常重視電動機車電池電芯國產化,目前除了經濟部的科專與 A+ 計畫外,對於國產電池的模組設計、儲能技術與電池的智慧管理也提供計畫類型的補助,希望加速國產電芯量產,並建立相關驗證機制,為國內電動機車產業鏈提供幫助。陳其邁強調,政府也相當重視電動機車產業與傳統機車行合作的問題,希望傳統機車行未來不論在營運、銷售與後續維修,都能與電動機車業者有更多合作機會,協助推動傳統機車行升級和轉型。

根據陳其邁的說法,政府在 2019 年對購買電動機車的補助維持不變,2020 年的補助還會持續下去。政府也研議鼓勵民眾淘汰排放污染的老舊機車,補助購買更環保的燃油機車,讓民眾使用的機車兼顧便捷與環保。

Gogoro 執行長陸學森表示,截至 2019 年 6 月底,Gogoro 生產銷售的電動機車已近 18 萬輛,全台已布建 1,283 個電池交換站,在 6 大都會區更是每 5 分鐘車程就有可即時換電的電池充電站。Gogoro 的每一部車子都是在桃園市龜山區的工廠生產製造,而且機車上的所有零組件,均來自全台 192 家供應商的供應鏈,做到電動機車的國產化。陸學森也向陳其邁建言,希望在台灣目前重型電動機車技術仍領先全球 3 到 5 年的優勢下,政府能夠支持電動機車產業,讓台灣的電動機車產業團結一致,一起為台灣打贏「世界盃」。

(合作媒體:。首圖來源: CC BY 2.0)

本站聲明:網站內容來源於EnergyTrend https://www.energytrend.com.tw/ev/,如有侵權,請聯繫我們,我們將及時處理

【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

Springboot 系列(十六)你真的了解 Swagger 文檔嗎?

前言

目前來說,在 Java 領域使用 Springboot 構建微服務是比較流行的,在構建微服務時,我們大多數會選擇暴漏一個 REST API 以供調用。又或者公司採用前後端分離的開發模式,讓前端和後端的工作由完全不同的工程師進行開發完成。不管是微服務還是這種前後端分離開發,維持一份完整的及時更新的 REST API 文檔,會極大的提高我們的工作效率。而傳統的文檔更新方式(如手動編寫),很難保證文檔的及時性,經常會年久失修,失去應有的意義。因此選擇一種新的 API 文檔維護方式很有必要,這也是這篇文章要介紹的內容。

1. OpenAPI 規範介紹

OpenAPI Specification 簡稱 OAS,中文也稱 OpenAPI 描述規範,使用 OpenAPI 文件可以描述整個 API,它制定了一套的適合通用的與語言無關的 REST API 描述規範,如 API 路徑規範、請求方法規範、請求參數規範、返回格式規範等各種相關信息,使人類和計算機都可以不需要訪問源代碼就可以理解和使用服務的功能。

下面是 OpenAPI 規範中建議的 API 設計規範,基本路徑設計規範。

https://api.example.com/v1/users?role=admin&status=active
\________________________/\____/ \______________________/
         server URL       endpoint    query parameters
                            path

對於傳參的設計也有規範,可以像下面這樣:

  • , 例如 /users/{id}
  • , 例如 /users?role=未讀代碼
  • , 例如 X-MyHeader: Value
  • , 例如 Cookie: debug=0; csrftoken=BUSe35dohU3O1MZvDCU

OpenAPI 規範的東西遠遠不止這些,目前 OpenAPI 規範最新版本是 3.0.2,如果你想了解更多的 OpenAPI 規範,可以訪問下面的鏈接。

2. Swagger 介紹

很多人都以為 Swagger 只是一個接口文檔生成框架,其實並不是。 Swagger 是一個圍繞着 OpenAPI Specification(OAS,中文也稱 OpenAPI規範)構建的一組開源工具。可以幫助你從 API 的設計到 API 文檔的輸出再到 API 的測試,直至最後的 API 部署等整個 API 的開發周期提供相應的解決方案,是一個龐大的項目。 Swagger 不僅免費,而且開源,不管你是企業用戶還是個人玩家,都可以使用 Swagger 提供的方案構建令人驚艷的 REST API

Swagger 有幾個主要的產品。

  • – 一個基於瀏覽器的 Open API 規範編輯器。
  • – 一個將 OpenAPI 規範呈現為可交互在線文檔的工具。
  • – 一個根據 OpenAPI 生成調用代碼的工具。

如果你想了解更多信息,可以訪問 Swagger 官方網站 。

3. Springfox 介紹

源於 Java 中 Spring 框架的流行,讓一個叫做 Marrty Pitt 的老外有了為 SpringMVC 添加接口描述的想法,因此他創建了一個遵守 OpenAPI 規範(OAS)的項目,取名為 swagger-springmvc,這個項目可以讓 Spring 項目自動生成 JSON 格式的 OpenAPI 文檔。這個框架也仿照了 Spring 項目的開發習慣,使用註解來進行信息配置。

後來這個項目發展成為 Springfox,再後來擴展出 springfox-swagger2 ,為了讓 JSON 格式的 API 文檔更好的呈現,又出現了 springfox-swagger-ui 用來展示和測試生成的 OpenAPI 。這裏的 springfox-swagger-ui 其實就是上面介紹的 Swagger-ui,只是它被通過 webjar 的方式打包到 jar 包內,並通過 maven 的方式引入進來。

上面提到了 Springfox-swagger2 也是通過註解進行信息配置的,那麼是怎麼使用的呢?下面列舉常用的一些註解,這些註解在下面的 Springboot 整合 Swagger 中會用到。

註解 示例 描述
@ApiModel @ApiModel(value = “用戶對象”) 描述一個實體對象
@ApiModelProperty @ApiModelProperty(value = “用戶ID”, required = true, example = “1000”) 描述屬性信息,執行描述,是否必須,給出示例
@Api @Api(value = “用戶操作 API(v1)”, tags = “用戶操作接口”) 用在接口類上,為接口類添加描述
@ApiOperation @ApiOperation(value = “新增用戶”) 描述類的一個方法或者說一個接口
@ApiParam @ApiParam(value = “用戶名”, required = true) 描述單個參數

更多的 Springfox 介紹,可以訪問 Springfox 官方網站。

4. Springboot 整合 Swagger

就目前來說 ,Springboot 框架是非常流行的微服務框架,在微服務框架下,很多時候我們都是直接提供 REST API 的。REST API 如果沒有文檔的話,使用者就很頭疼了。不過不用擔心,上面說了有一位叫 Marrty Pitt 的老外已經創建了一個發展成為 Springfox 的項目,可以方便的提供 JSON 格式的 OpenAPI 規範和文檔支持。且擴展出了 springfox-swagger-ui 用於頁面的展示。

需要注意的是,這裏使用的所謂的 Swagger 其實和真正的 Swagger 並不是一個東西,這裏使用的是 Springfox 提供的 Swagger 實現。它們都是基於 OpenAPI 規範進行 API 構建。所以也都可以 Swagger-ui 進行 API 的頁面呈現。

4.1. 創建項目

如何創建一個 Springboot 項目這裏不提,你可以直接從 下載一個標準項目,也可以使用 idea 快速創建一個 Springboot 項目,也可以順便拷貝一個 Springboot 項目過來測試,總之,方式多種多樣,任你選擇。

下面演示如何在 Springboot 項目中使用 swagger2。

4.2. 引入依賴

這裏主要是引入了 springfox-swagger2,可以通過註解生成 JSON 格式的 OpenAPI 接口文檔,然後由於 Springfox 需要依賴 jackson,所以引入之。springfox-swagger-ui 可以把生成的 OpenAPI 接口文檔显示為頁面。Lombok 的引入可以通過註解為實體類生成 get/set 方法。

<dependencies> 
    <!-- Spring Boot web 開發整合 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <exclusions>
            <exclusion>
                <artifactId>spring-boot-starter-json</artifactId>
                <groupId>org.springframework.boot</groupId>
            </exclusion>
        </exclusions>
    </dependency>

    <!-- 引入swagger2的依賴-->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.9.2</version>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.9.2</version>
    </dependency>
    
    <!-- jackson相關依賴 -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.5.4</version>
    </dependency>

    <!-- Lombok 工具 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

4.3. 配置 Springfox-swagger

Springfox-swagger 的配置通過一個 Docket 來包裝,Docket 里的 apiInfo 方法可以傳入關於接口總體的描述信息。而 apis 方法可以指定要掃描的包的具體路徑。在類上添加 @Configuration 聲明這是一個配置類,最後使用 @EnableSwagger2 開啟 Springfox-swagger2。

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

/**
 * <p>
 * Springfox-swagger2 配置
 *
 * @Author niujinpeng
 * @Date 2019/11/19 23:17
 */
@Configuration
@EnableSwagger2
public class SwaggerConfig {

    @Bean
    public Docket createRestApi() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("net.codingme.boot.controller"))
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("未讀代碼 API")
                .description("公眾號:未讀代碼(weidudaima) springboot-swagger2 在線借口文檔")
                .termsOfServiceUrl("https://www.codingme.net")
                .contact("達西呀")
                .version("1.0")
                .build();
    }
}

4.4. 代碼編寫

文章不會把所有代碼一一列出來,這沒有太大意義,所以只貼出主要代碼,完整代碼會上傳到 Github,並在文章底部附上 Github 鏈接。

參數實體類 User.java,使用 @ApiModel@ApiModelProperty 描述參數對象,使用 @NotNull 進行數據校驗,使用 @Data 為參數實體類自動生成 get/set 方法。

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;

import javax.validation.constraints.NotNull;
import java.util.Date;

/**
 * <p>
 * 用戶實體類
 *
 * @Author niujinpeng
 * @Date 2018/12/19 17:13
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel(value = "用戶對象")
public class User {

    /**
     * 用戶ID
     *
     * @Id 主鍵
     * @GeneratedValue 自增主鍵
     */
    @NotNull(message = "用戶 ID 不能為空")
    @ApiModelProperty(value = "用戶ID", required = true, example = "1000")
    private Integer id;

    /**
     * 用戶名
     */
    @NotNull(message = "用戶名不能為空")
    @ApiModelProperty(value = "用戶名", required = true)
    private String username;
    /**
     * 密碼
     */
    @NotNull(message = "密碼不能為空")
    @ApiModelProperty(value = "用戶密碼", required = true)
    private String password;
    /**
     * 年齡
     */
    @ApiModelProperty(value = "用戶年齡", example = "18")
    private Integer age;
    /**
     * 生日
     */
    @DateTimeFormat(pattern = "yyyy-MM-dd hh:mm:ss")
    @ApiModelProperty(value = "用戶生日")
    private Date birthday;
    /**
     * 技能
     */
    @ApiModelProperty(value = "用戶技能")
    private String skills;
}

編寫 Controller 層,使用 @Api 描述接口類,使用 @ApiOperation 描述接口,使用 @ApiParam 描述接口參數。代碼中在查詢用戶信息的兩個接口上都添加了 tags = "用戶查詢" 標記,這樣這兩個方法在生成 Swagger 接口文檔時候會分到一個共同的標籤組裡。

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.extern.slf4j.Slf4j;
import net.codingme.boot.domain.Response;
import net.codingme.boot.domain.User;
import net.codingme.boot.enums.ResponseEnum;
import net.codingme.boot.utils.ResponseUtill;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.List;

/**
 * <p>
 * 用戶操作
 *
 * @Author niujinpeng
 * @Date 2019/11/19 23:17
 */

@Slf4j
@RestController(value = "/v1")
@Api(value = "用戶操作 API(v1)", tags = "用戶操作接口")
public class UserController {

    @ApiOperation(value = "新增用戶")
    @PostMapping(value = "/user")
    public Response create(@Valid User user, BindingResult bindingResult) throws Exception {
        if (bindingResult.hasErrors()) {
            String message = bindingResult.getFieldError().getDefaultMessage();
            log.info(message);
            return ResponseUtill.error(ResponseEnum.ERROR.getCode(), message);
        } else {
            // 新增用戶信息 do something
            return ResponseUtill.success("用戶[" + user.getUsername() + "]信息已新增");
        }
    }

    @ApiOperation(value = "刪除用戶")
    @DeleteMapping(value = "/user/{username}")
    public Response delete(@PathVariable("username")
                           @ApiParam(value = "用戶名", required = true) String name) throws Exception {
        // 刪除用戶信息 do something
        return ResponseUtill.success("用戶[" + name + "]信息已刪除");
    }

    @ApiOperation(value = "修改用戶")
    @PutMapping(value = "/user")
    public Response update(@Valid User user, BindingResult bindingResult) throws Exception {
        if (bindingResult.hasErrors()) {
            String message = bindingResult.getFieldError().getDefaultMessage();
            log.info(message);
            return ResponseUtill.error(ResponseEnum.ERROR.getCode(), message);
        } else {
            String username = user.getUsername();
            return ResponseUtill.success("用戶[" + username + "]信息已修改");
        }
    }

    @ApiOperation(value = "獲取單個用戶信息", tags = "用戶查詢")
    @GetMapping(value = "/user/{username}")
    public Response get(@PathVariable("username")
                        @NotNull(message = "用戶名稱不能為空")
                        @ApiParam(value = "用戶名", required = true) String username) throws Exception {
        // 查詢用戶信息 do something
        User user = new User();
        user.setId(10000);
        user.setUsername(username);
        user.setAge(99);
        user.setSkills("cnp");
        return ResponseUtill.success(user);
    }

    @ApiOperation(value = "獲取用戶列表", tags = "用戶查詢")
    @GetMapping(value = "/user")
    public Response selectAll() throws Exception {
        // 查詢用戶信息列表 do something
        User user = new User();
        user.setId(10000);
        user.setUsername("未讀代碼");
        user.setAge(99);
        user.setSkills("cnp");
        List<User> userList = new ArrayList<>();
        userList.add(user);
        return ResponseUtill.success(userList);
    }
}

最後,為了讓代碼變得更加符合規範和好用,使用一個統一的類進行接口響應。

@Data
@AllArgsConstructor
@NoArgsConstructor
@ApiModel(value = "響應信息")
public class Response {
    /**
     * 響應碼
     */
    @ApiModelProperty(value = "響應碼")
    private String code;
    /**
     * 響應信息
     */
    @ApiModelProperty(value = "響應信息")
    private String message;

    /**
     * 響應數據
     */
    @ApiModelProperty(value = "響應數據")
    private Collection content;
}

4.5. 運行訪問

直接啟動 Springboog 項目,可以看到控制台輸出掃描到的各個接口的訪問路徑,其中就有 /2/api-docs

這個也就是生成的 OpenAPI 規範的描述 JSON 訪問路徑,訪問可以看到。

因為上面我們在引入依賴時,也引入了 springfox-swagger-ui 包,所以還可以訪問 API 的頁面文檔。訪問路徑是 /swagger-ui.html,訪問看到的效果可以看下圖。

也可以看到用戶查詢的兩個方法會歸到了一起,原因就是這兩個方法的註解上使用相同的 tag 屬性。

4.7. 調用測試

springfox-swagger-ui 不僅是生成了 API 文檔,還提供了調用測試功能。下面是在頁面上測試獲取單個用戶信息的過程。

  1. 點擊接口 [/user/{username}] 獲取單個用戶信息。
  2. 點擊 **Try it out** 進入測試傳參頁面。
  3. 輸入參數,點擊 Execute 藍色按鈕執行調用。
  4. 查看返回信息。

下面是測試時的響應截圖。

5. 常見報錯

如果你在程序運行中經常發現像下面這樣的報錯。

java.lang.NumberFormatException: For input string: ""
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65) ~[na:1.8.0_111]
    at java.lang.Long.parseLong(Long.java:601) ~[na:1.8.0_111]
    at java.lang.Long.valueOf(Long.java:803) ~[na:1.8.0_111]
    at io.swagger.models.parameters.AbstractSerializableParameter.getExample(AbstractSerializableParameter.java:412) ~[swagger-models-1.5.20.jar:1.5.20]
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_111]
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_111]
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_111]
    at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_111]
    at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:536) [jackson-databind-2.5.4.jar:2.5.4]
    at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:666) [jackson-databind-2.5.4.jar:2.5.4]
    at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:156) [jackson-databind-2.5.4.jar:2.5.4]
    at com.fasterxml.jackson.databind.ser.impl.IndexedListSerializer.serializeContents(IndexedListSerializer.java:113) [jackson-databind-2.5.4.jar:2.5.4]

那麼你需要檢查使用了 @ApiModelProperty 註解且字段類型為数字類型的屬性上,@ApiModelProperty 註解是否設置了 example 值,如果沒有,那就需要設置一下,像下面這樣。

@NotNull(message = "用戶 ID 不能為空")
@ApiModelProperty(value = "用戶ID", required = true, example = "1000")
private Integer id;

文中代碼都已經上傳到

參考文檔

個人網站:
如果你喜歡這篇文章,可以關注公眾號,一起成長。
關注公眾號回復資源可以沒有套路的獲取全網最火的的 Java 核心知識整理&面試核心資料。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益

簡單的學習,實現,領域事件,事件存儲,事件溯源

為什麼寫這篇文章

自己以前都走了彎路,以為學習戰術設計就會DDD了,其實DDD的精華在戰略設計,但是對於我們菜鳥來說,學習一些技術概念也是挺好的
經常看到這些術語,概念太多,也想簡單學習一下,記憶力比較差記錄一下實現的細節

領域事件

1.領域事件是過去發生的與業務有關的事實,一但發生就不可更改,所以存儲事件時只能追加

3.領域事件具有時間點的特徵,所有事件連接起來會形成明顯的時間軸

4.領域事件會導致目標對象狀態的變化,聚合根的行為會產生領域事件,所以會改變聚合的狀態

在聚合根裏面維護一個領域事件的聚合,每一個事件對應一個Handle,通過反射維護一個數據字典,通過事件查找到指定的Handle

領域事件實現的方式:目前看到有3種方式,MediatR,消息隊列 ,發布訂閱模式

eShopOnContainers 中使用的是MediatR

ENode 中使用的是EQueue,EQueue是一個純C#寫的消息隊列

使用已經寫好的消息隊列Rabbitmq ,kafka

事件存儲,事件溯源,事件快照

事件存儲:存儲所有聚合根裏面發生過的事件

1.事件存儲中可以做併發的處理,比如Command 重複,領域事件的重複

2.領域事件的重複通過聚合根Id+版本號判斷,可以在數據庫中建立聯合唯一索引,在存儲事件時檢測重複,記錄重複的事件,根據業務做處理

3.這裏要保證存儲事件與發布領域事件的一致性

如何保證存儲事件與發布領域事件的一致性

先存儲事件然後在發布領域事件,如果發生異常,就一直重試,一直到成功為止,也可以做一定的處理,比如重試到一定的次數,就通知,進行人工處理

我選擇了CAP + Policy + Dapper

事件溯源:在事件存儲中記錄導致狀態變化的一系列領域事件。通過持久化記錄改變狀態的事件,通過重新播放獲得狀態改變的歷史。 事件回放可以返回系統到任何狀態

聚合快照:聚合的生命周期各有長短,有的聚合裏面有大量的事件,,事件越多加載事件以及重建聚合的執行效率就會越來越低,快照裏面存儲的是聚合

1.定時存儲整個聚合根:使用定時器每隔一段時間就存儲聚合到快照表中

2.定量存儲整個聚合根:根據事件存儲中的數量來存儲聚合到快照表中

事件溯源的實現方式

1.首先我們需要實現聚合In Memory,

2.在CommandHandler中訂閱 Command命令,

創建聚合時 ,在內存中維護一個數據字典,key為:聚合根的Id,value為:聚合

修改,刪除,聚合時,根據聚合根的Id,查詢出聚合

如果內存中聚合不存在時:根據聚合根的Id 從聚合快照表中查詢出聚合,然後根據聚合快照存儲的時間,聚合根Id,查詢事件存儲中的所有事件,然後回放事件,得到聚合最終的狀態

記錄遇到的問題

由於基礎非常的差,所以實現的方式都是以最簡單的方式來寫的,存在許多的問題,代碼中有問題的地方希望大家提出來,讓我學習一下

代碼的實現目前還沒有寫快照的部分,也沒有處理EventStorage中的命令重複與聚合根+版本號重複,具體的請看湯總的ENode,裏面有全部的實現

1.怎樣保證存儲事件,發布事件的最終一致性

2.怎麼解析EventStorage中的事件,回放事件

先存儲事件,當事件存儲成功之後,在發布事件

存儲事件失敗:就一直重試,發布事件失敗,使用的是CAP,CAP內部使用的是本地消息表的方式,如果發布事件失敗,也一直重試,如果服務器重啟了,Rabbitmq裏面消息為Ack,消息沒有丟,重連後會繼續執行

存儲事件,發布事件

    /// <summary>
    /// 存儲聚合根中的事件到EventStorage 發布事件
    /// </summary>
    /// <typeparam name="TAggregationRoot"></typeparam>
    /// <param name="event"></param>
    /// <returns></returns>
    public async Task AppendEventStoragePublishEventAsync<TAggregationRoot>(TAggregationRoot @event)
        where TAggregationRoot : IAggregationRoot
    {
        var domainEventList = @event.UncommittedEvents.ToList();
        if (domainEventList.Count == 0)
        {
            throw new Exception("請添加事件!");
        }

        await TryAppendEventStorageAsync(domainEventList).ContinueWith(async e =>
        {
            if (e.Result == (int)EventStorageStatus.Success)
            {
                await TryPublishDomainEventAsync(domainEventList).ConfigureAwait(false);
                @event.ClearEvents();
            }
        });
    }

    /// <summary>
    /// 發布領域事件
    /// </summary>
    /// <returns></returns>
    public async Task PublishDomainEventAsync(List<IDomainEvent> domainEventList)
    {
        using (var connection =
            new SqlConnection(ConnectionStr))
        {
            if (connection.State == ConnectionState.Closed)
            {
                await connection.OpenAsync().ConfigureAwait(false);
            }
            using (var transaction = await connection.BeginTransactionAsync().ConfigureAwait(false))
            {
                try
                {
                    if (domainEventList.Count > 0)
                    {
                        foreach (var domainEvent in domainEventList)
                        {
                            await _capPublisher.PublishAsync(domainEvent.GetRoutingKey(), domainEvent).ConfigureAwait(false);
                        }
                    }
                    await transaction.CommitAsync().ConfigureAwait(false);
                }
                catch (Exception e)
                {
                    await transaction.RollbackAsync().ConfigureAwait(false);
                    throw;
                }
            }
        }
    }

    /// <summary>
    /// 發布領域事件重試
    /// </summary>
    /// <param name="domainEventList"></param>
    /// <returns></returns>
    public async Task TryPublishDomainEventAsync(List<IDomainEvent> domainEventList)
    {
        var policy = Policy.Handle<SocketException>().Or<IOException>().Or<Exception>()
            .RetryForeverAsync(onRetry: exception =>
            {
                Task.Factory.StartNew(() =>
                {
                    //記錄重試的信息
                    _loggerHelper.LogInfo("發布領域事件異常", exception.Message);
                });
            });
        await policy.ExecuteAsync(async () =>
        {
            await PublishDomainEventAsync(domainEventList).ConfigureAwait(false);
        });

    }

    /// <summary>
    /// 存儲聚合根中的事件到EventStorage中
    /// </summary>
    /// <returns></returns>
    public async Task<int> AppendEventStorageAsync(List<IDomainEvent> domainEventList)
    {
        if (domainEventList.Count == 0)
        {
            throw new Exception("請添加事件!");
        }
        var status = (int)EventStorageStatus.Failure;
        using (var connection = new SqlConnection(ConnectionStr))
        {
            try
            {
                if (connection.State == ConnectionState.Closed)
                {
                    await connection.OpenAsync().ConfigureAwait(false);
                }
                using (var transaction = await connection.BeginTransactionAsync().ConfigureAwait(false))
                {
                    try
                    {
                        if (domainEventList.Count > 0)
                        {
                            foreach (var domainEvent in domainEventList)
                            {
                                EventStorage eventStorage = new EventStorage
                                {
                                    Id = Guid.NewGuid(),
                                    AggregateRootId = domainEvent.AggregateRootId,
                                    AggregateRootType = domainEvent.AggregateRootType,
                                    CreateDateTime = domainEvent.CreateDateTime,
                                    Version = domainEvent.Version,
                                    EventData = Events(domainEvent)
                                };
                                var eventStorageSql =
                                    $"INSERT INTO EventStorageInfo(Id,AggregateRootId,AggregateRootType,CreateDateTime,Version,EventData) VALUES (@Id,@AggregateRootId,@AggregateRootType,@CreateDateTime,@Version,@EventData)";
                                await connection.ExecuteAsync(eventStorageSql, eventStorage, transaction).ConfigureAwait(false);
                            }
                        }
                        await transaction.CommitAsync().ConfigureAwait(false);
                        status = (int)EventStorageStatus.Success;
                    }
                    catch (Exception e)
                    {
                        await transaction.RollbackAsync().ConfigureAwait(false);
                        throw;
                    }
                }

            }
            catch (Exception e)
            {
                connection.Close();
                throw;
            }
        }
        return status;
    }

    /// <summary>
    /// AppendEventStorageAsync異常重試
    /// </summary>
    public async Task<int> TryAppendEventStorageAsync(List<IDomainEvent> domainEventList)
    {
        var policy = Policy.Handle<SocketException>().Or<IOException>().Or<Exception>()
            .RetryForeverAsync(onRetry: exception =>
            {
                Task.Factory.StartNew(() =>
                {
                    //記錄重試的信息
                    _loggerHelper.LogInfo("存儲事件異常", exception.Message);
                });
            });
        var result = await policy.ExecuteAsync(async () =>
          {
              var resulted = await AppendEventStorageAsync(domainEventList).ConfigureAwait(false);
              return resulted;
          });
        return result;
    }

    /// <summary>
    /// 根據DomainEvent序列化事件Json
    /// </summary>
    /// <param name="domainEvent"></param>
    /// <returns></returns>
    public string Events(IDomainEvent domainEvent)
    {
        ConcurrentDictionary<string, string> dictionary = new ConcurrentDictionary<string, string>();
        //獲取領域事件的類型(方便解析Json)
        var domainEventTypeName = domainEvent.GetType().Name;
        var domainEventStr = JsonConvert.SerializeObject(domainEvent);
        dictionary.GetOrAdd(domainEventTypeName, domainEventStr);
        var eventData = JsonConvert.SerializeObject(dictionary);
        return eventData;
    }

解析EventStorage中存儲的事件

    public async Task<List<IDomainEvent>> GetAggregateRootEventStorageById(Guid AggregateRootId)
    {
        try
        {
            using (var connection = new SqlConnection(ConnectionStr))
            {
                var eventStorageList = await connection.QueryAsync<EventStorage>($"SELECT * FROM dbo.EventStorageInfo WHERE AggregateRootId='{AggregateRootId}'");
                List<IDomainEvent> domainEventList = new List<IDomainEvent>();
                foreach (var item in eventStorageList)
                {
                    var dictionaryDomainEvent = JsonConvert.DeserializeObject<Dictionary<string, string>>(item.EventData);
                    foreach (var entry in dictionaryDomainEvent)
                    {
                        var domainEventType = TypeNameProvider.GetType(entry.Key);
                        if (domainEventType != null)
                        {
                            var domainEvent = JsonConvert.DeserializeObject(entry.Value, domainEventType) as IDomainEvent;
                            domainEventList.Add(domainEvent);
                        }
                    }
                }
                return domainEventList;
            }
        }
        catch (Exception ex)
        {
            throw;
        }

注意事項

1.事件沒持久化就代表事件還沒發生成功,事件存儲可能失敗,必須先存儲事件,在發布事件,保證存儲事件與發布事件一致性
1.使用事件驅動,必須要做好冥等的處理
2.如果業務場景中有狀態時:通過狀態來控制
3.新建一張表,用來記錄消費的信息,消費端的代碼裏面,根據唯一的標識,判斷是否處理過該事件
4.Q端的任何更新都應該把聚合根ID和事件版本號作為條件,Q端的更新不用遵循聚合的原則,可以使用最簡單的方式處理
5.倉儲是用來重建聚合的,它的行為和集合一樣只有Get ,Add ,Delete
6.DDD不是技術,是思想,核心在戰略模塊,戰術設計是實現的一種選擇,戰略設計,需要面向對象的分析能力,職責分配,深層次的分析業務

感謝

雖然學習DDD的時間不短了,感覺還是在入門階段,在學習的過程中有許多的不解,經常問ENode群裏面的大佬,也經常@湯總,謝謝大家的幫助與解惑。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※為什麼 USB CONNECTOR 是電子產業重要的元件?

網頁設計一頭霧水??該從何著手呢? 找到專業技術的網頁設計公司,幫您輕鬆架站!

※想要讓你的商品成為最夯、最多人討論的話題?網頁設計公司讓你強力曝光

※想知道最厲害的台北網頁設計公司推薦台中網頁設計公司推薦專業設計師”嚨底家”!!

結合RBAC模型講解權限管理系統需求及表結構創建

在本號之前的文章中,已經為大家介紹了很多關於Spring Security的使用方法,也介紹了RBAC的基於角色權限控制模型。但是很多朋友雖然已經理解了RBAC控制模型,但是仍有很多的問題阻礙他們進一步開發。比如:

  • RBAC模型的表結構該如何創建?
  • 具體到某個頁面,某個按鈕權限是如何控制的?
  • 為了配合登錄驗證表,用戶表中應該包含哪些核心字段?
  • 這些字段與登錄驗證或權限分配的需求有什麼關係?

那麼本文就希望將這些問題,與大家進行一下分享。

一、回顧RBAC權限模型

  • 用戶與角色之間是多對多的關係,一個用戶有多個角色,一個角色包含多個用戶
  • 角色與權限之間是多對多關係,一個角色有多種權限,一個權限可以屬於多個角色

上圖中:

  • User是用戶表,存儲用戶基本信息
  • Role是角色表,存儲角色相關信息
  • Menu(菜單)是權限表,存儲系統包含哪些菜單及其屬性
  • UserRole是用戶和角色的關係表
  • RoleMenu是角色和權限的關係表

本文講解只將權限控制到菜單的訪問級別,即控制頁面的訪問權限。如果想控制到頁面中按鈕級別的訪問,可以參考Menu與RoleMenu的模式同樣的實現方式。或者乾脆在menu表裡面加上一個字段區別該條記錄是菜單項還是按鈕。

為了有理有據,我們參考一個比較優秀的開源項目:若依後台管理系統。

二、組織部門管理

2.1.需求分析

之所以先將部門管理提出來講一下,是因為部門管理沒有在我們上面的RBAC權限模型中進行提現。但是部門這樣一個實體仍然是,後端管理系統的一個重要組成部分。通常有如下的需求:

  • 部門要能體現出上下級的結構(如上圖中的紅框)。在關係型數據庫中。這就需要使用到部門id及上級部門id,來組合成一個樹形結構。這個知識是SQL學習中必備的知識,如果您還不知道,請自行學習。
  • 如果組織與用戶之間是一對多的關係,就在用戶表中加上一個org_id標識用戶所屬的組織。原則是:實體關係在多的那一邊維護。比如:是讓老師記住自己的學生容易,還是讓學生記住自己的老師更容易?
  • 如果組織與用戶是多對多關係,這種情況現實需求也有可能存在。比如:某人在某單位既是生產部長,又是技術部長。所以他及歸屬於技術部。也歸屬於生產部。對於這種情況有兩種解決方案,把該人員放到公司級別,而不是放到部門級別。另外一種就是從數據庫結構上創建User與Org組織之間的多對多關係。
  • 組織信息包含一些基本信息,如組織名稱、組織狀態、展現排序、創建時間
  • 另外,要有基本的組織的增刪改查功能

2.2 組織部門表的CreateSQL

以下SQL以MySQL為例:

CREATE TABLE `sys_org` (
    `id` INT(11) NOT NULL AUTO_INCREMENT,
    `org_pid` INT(11) NOT NULL COMMENT '上級組織編碼',
    `org_pids` VARCHAR(64) NOT NULL COMMENT '所有的父節點id',
    `is_leaf` TINYINT(4) NOT NULL COMMENT '0:不是恭弘=叶 恭弘子節點,1:是恭弘=叶 恭弘子節點',
    `org_name` VARCHAR(32) NOT NULL COMMENT '組織名',
    `address` VARCHAR(64) NULL DEFAULT NULL COMMENT '地址',
    `phone` VARCHAR(13) NULL DEFAULT NULL COMMENT '電話',
    `email` VARCHAR(32) NULL DEFAULT NULL COMMENT '郵件',
    `sort` TINYINT(4) NULL DEFAULT NULL COMMENT '排序',
    `level` TINYINT(4) NOT NULL COMMENT '組織層級',
    `status` TINYINT(4) NOT NULL COMMENT '0:啟用,1:禁用',
    PRIMARY KEY (`id`)
)
COMMENT='系統組織結構表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB
;

注意:mysql沒有oracle中的start with connect by的樹形數據匯總SQL。所以通常需要為了方便管理組織之間的上下級樹形關係,需要加上一些特殊字段,如:org_pids:該組織所有上級組織id逗號分隔,即包括上級的上級;is_leaf是否是恭弘=叶 恭弘子結點;level組織所屬的層級(1,2,3)。

三、菜單權限管理

3.1 需求分析

  • 由上圖可以看出,菜單仍然是樹形結構,所以數據庫表必須有id與menu_pid字段
  • 必要字段:菜單跳轉的url、是否啟用、菜單排序、菜單的icon矢量圖標等
  • 最重要的是菜單要有一個權限標誌,具有唯一性。通常可以使用菜單跳轉的url路徑作為權限標誌。此標誌作為權限管理框架識別用戶是否具有某個頁面查看權限的重要標誌
  • 需要具備菜單的增刪改查基本功能
  • 如果希望將菜單權限和按鈕超鏈接相關權限放到同一個表裡面,可以新增一個字段。用戶標誌該權限記錄是菜單訪問權限還是按鈕訪問權限。

3.2 菜單權限表的CreateSQL

CREATE TABLE `sys_menu` (
    `id` INT(11) NOT NULL AUTO_INCREMENT,
    `menu_pid` INT(11) NOT NULL COMMENT '父菜單ID',
    `menu_pids` VARCHAR(64) NOT NULL COMMENT '當前菜單所有父菜單',
    `is_leaf` TINYINT(4) NOT NULL COMMENT '0:不是恭弘=叶 恭弘子節點,1:是恭弘=叶 恭弘子節點',
    `name` VARCHAR(16) NOT NULL COMMENT '菜單名稱',
    `url` VARCHAR(64) NOT NULL COMMENT '跳轉URL',
    `icon` VARCHAR(45) NULL DEFAULT NULL,
    `icon_color` VARCHAR(16) NULL DEFAULT NULL,
    `sort` TINYINT(4) NULL DEFAULT NULL COMMENT '排序',
    `level` TINYINT(4) NOT NULL COMMENT '菜單層級',
    `status` TINYINT(4) NOT NULL COMMENT '0:啟用,1:禁用',
    PRIMARY KEY (`id`)
)
COMMENT='系統菜單表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB
;

四、角色管理

上圖為角色修改及分配權限的頁面

4.1.需求分析

  • 角色本身的管理需要注意的點非常少,就是簡單的增刪改查。重點在於角色分配該如何做。
  • 角色表包含角色id,角色名稱,備註、排序順序這些基本信息就足夠了
  • 為角色分配權限:以角色為基礎勾選菜單權限或者操作權限,然後先刪除sys_role_menu表內該角色的所有記錄,在將新勾選的權限數據逐條插入sys_role_menu表。
  • sys_role_menu的結構很簡單,記錄role_id與menu_id,一個角色擁有某一個權限就是一條記錄。
  • 角色要有一個全局唯一的標識,因為角色本身也是一種權限。可以通過判斷角色來判斷某用戶的操作是否合法。
  • 通常的需求:不會在角色管理界面為角色添加用戶,而是在用戶管理界面為用戶分配角色。

4.2.角色表與角色菜單權限關聯表的的CreateSQL

CREATE TABLE `sys_role` (
    `id` INT(11) NOT NULL AUTO_INCREMENT,
    `role_id` VARCHAR(16) NOT NULL COMMENT '角色ID',
    `role_name` VARCHAR(16) NOT NULL COMMENT '角色名',
    `role_flag` VARCHAR(64) NULL DEFAULT NULL COMMENT '角色標識',
    `sort` INT(11) NULL DEFAULT NULL COMMENT '排序',
    PRIMARY KEY (`id`)
)
COMMENT='系統角色表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB
;
CREATE TABLE `sys_role_menu` (
    `id` INT(11) NOT NULL AUTO_INCREMENT,
    `role_id` VARCHAR(16) NOT NULL COMMENT '角色ID',
    `menu_id` INT(11) NOT NULL COMMENT '菜單ID',
    PRIMARY KEY (`id`)
)
COMMENT='角色菜單多對多關聯表'
COLLATE='utf8_general_ci'
ENGINE=InnoDB
;

五、用戶管理

5.1.需求分析

  • 上圖中點擊左側的組織菜單樹結點,要能显示出該組織下的所有人員(系統用戶)。在組織與用戶是一對多的關係中,需要在用戶表加上org_id字段,用於查詢某個組織下的所有用戶。
  • 用戶表中要保存用戶的用戶名、加密后的密碼。頁面提供密碼修改或重置的功能。
  • 角色分配:實際上為用戶分配角色,與為角色分配權限的設計原則是一樣的。所以可以參考。
  • 實現用戶基本信息的增刪改查功能

5.2.sys_user 用戶信息表及用戶角色關係表的CreateSQL

CREATE TABLE `sys_user` (
        `id` INT(11) NOT NULL AUTO_INCREMENT,
        `org_id` INT(11) NOT NULL,
        `username` VARCHAR(64) NULL DEFAULT NULL COMMENT '用戶名',
        `password` VARCHAR(64) NULL DEFAULT NULL COMMENT '密碼',
        `enabled` INT(11) NULL DEFAULT '1' COMMENT '用戶賬戶是否可用',
        `locked` INT(11) NULL DEFAULT '0' COMMENT '用戶賬戶是否被鎖定',
        `lockrelease_time` TIMESTAMP NULL  '用戶賬戶鎖定到期時間',
        `expired_time` TIMESTAMP NULL  '用戶賬戶過期時間',
        `create_time` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '用戶賬戶創建時間',
    PRIMARY KEY (`id`)
)
COMMENT='用戶信息表'
ENGINE=InnoDB
;
CREATE TABLE `sys_user_role` (
    `id` INT(11) NOT NULL AUTO_INCREMENT,
    `role_id` VARCHAR(16) NULL DEFAULT NULL,
    `user_id` VARCHAR(18) NULL DEFAULT NULL,
    PRIMARY KEY (`id`)
)
COLLATE='utf8_general_ci'
ENGINE=InnoDB
;

在用戶的信息表中,體現了一些隱藏的需求。如:多次登錄鎖定與鎖定到期時間的關係。賬號有效期的設定規則等。

當然用戶表中,根據業務的不同還可能加更多的信息,比如:用戶頭像等等。但是通常在比較大型的業務系統開發中,業務模塊中使用的用戶表和在權限管理模塊使用的用戶表通常不是一個,而是根據某些唯一字段弱關聯,分開存放。這樣做的好處在於:經常發生變化的業務需求,不會去影響不經常變化的權限模型。

期待您的關注

  • 向您推薦博主的系列文檔:
  • 本文轉載註明出處(必須帶連接,不能只轉文字):。

本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理【其他文章推薦】

※帶您來了解什麼是 USB CONNECTOR  ?

平板收購,iphone手機收購,二手筆電回收,二手iphone收購-全台皆可收購

※自行創業 缺乏曝光? 下一步"網站設計"幫您第一時間規劃公司的門面形象

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

※廣告預算用在刀口上,網站設計公司幫您達到更多曝光效益