※当サイトの記事には、広告・プロモーションが含まれます。

Javaで複数の要素で並び替えしたエンティティのリストを単一のキーでグルーピングしたい

www.itmedia.co.jp

 英国民保健サービス(NHS)は6月21日(現地時間)、英病理検査機関Synnovisが3日にランサムウェア攻撃を受けた際に流出した患者データが、サイバー犯罪グループによって公開されたと発表した。

英医療機関へのランサムウェア攻撃、交渉決裂で患者データがダークウェブに - ITmedia NEWS

 この攻撃により、NHSは血液検査の実施に必要なシステムを使用できなくなり、病院や一般開業医の予約や手術が3000件以上中断されている。患者データは、このシステム復旧のための人質となっている。

英医療機関へのランサムウェア攻撃、交渉決裂で患者データがダークウェブに - ITmedia NEWS

 英BBCによると、Qilinとして知られるサイバー犯罪グループがダークネットサイトで約400GBの患者データを公開したという。BBCが確認したデータのサンプルには、患者の氏名、生年月日、NHS番号、血液検査の説明などが含まれていた。

英医療機関へのランサムウェア攻撃、交渉決裂で患者データがダークウェブに - ITmedia NEWS

 QilinはIT系メディアのThe Registerに対し、Synnovisに身代金として5000万ドル(約70億円)要求したが、Synnovisが回答を引き伸ばしたため、「交渉を止め、連絡を断った」と語った。

英医療機関へのランサムウェア攻撃、交渉決裂で患者データがダークウェブに - ITmedia NEWS

 Synnovisは身代金を支払ったかどうかを開示していない。

英医療機関へのランサムウェア攻撃、交渉決裂で患者データがダークウェブに - ITmedia NEWS

 NHSによると、Synnovisはシステムの技術的な復旧に注力しており、今後数週間以内にITシステムの一部機能の復旧を開始する計画を立てているという。「今後数カ月は混乱が続く」としている。

英医療機関へのランサムウェア攻撃、交渉決裂で患者データがダークウェブに - ITmedia NEWS

 各国の法執行機関は、ランサムウェア攻撃の被害者に対し、身代金要求に応じないよう要請している。身代金を支払ってもデータが復元される保証はなく、犯罪者をさらに助長するだけになる可能性があるためだ。

英医療機関へのランサムウェア攻撃、交渉決裂で患者データがダークウェブに - ITmedia NEWS

⇧ 数週間以内にITシステムの一部機能の復旧を計画って、そんな短期間で復旧できるものなんですかね?

サイバー犯罪の分析情報がどの程度、信頼できるのか不明ですが、

www.knowbe4.jp

科学雑誌PLOS ONE」に掲載された世界サイバー犯罪指数から、サイバー犯罪を最も多く発生させている主な国家が明らかになりました。これらの国家は、ロシア、ウクライナ、中国、米国、ナイジェリア、ルーマニアであり、世界的に最も重大なサイバー犯罪の発生元になっています。英国もサイバー犯罪指数ランキングのトップ10にランクインしています。

https://www.knowbe4.jp/blog/global-cybercrime-hotspot-countries-revealed-secure-your-defenses

www.itmedia.co.jp

 VPNのプロバイダーであるパナマのNordVPNは、2006年から2021年の15年間で、政府機関などにサイバー攻撃を受けた回数が多い国のランキングを発表した。1位は米国で198回、2位の英国(58回)に3倍以上の差をつけた。日本は16回の攻撃を受けたとして11位にランクインした。

重大なサイバー攻撃を受けた国ランキング ダントツは米国、日本は何位? - ITmedia NEWS

⇧ とありますと。

攻撃を受けた回数が、政府機関だけに限定されている情報のようなので、何とも言えないですが、公表されていない情報は数え切れないほどありそうですね。

Javaで複数の要素で並び替えしたエンティティのリストを単一のキーでグルーピングしたい

例えば、学校とかのデータで考えた場合だと、

  1. 学年
  2. クラス
  3. 年度

毎に並び替えした後で、「学年」と「クラス」と「年度」の組み合わせ毎にデータをグルーピングしたいってことあるあるだと思います。

高校とかだと、3学年として、「クラス」が6つあると仮定して、「年度」を「2024年」に限定したとして、

No. 学年 クラス 年度
1 1 1 2024
2 1 2 2024
3 1 3 2024
4 1 4 2024
5 1 5 2024
6 1 6 2024
7 2 1 2024
8 2 2 2024
9 2 3 2024
10 2 4 2024
11 2 5 2024
12 2 6 2024
13 3 1 2024
14 3 2 2024
15 3 3 2024
16 3 4 2024
17 3 5 2024
18 3 6 2024

のように、18個の組み合わせがあることになりますと。

で、仮に、3学年分の情報が混在したレコードが1000件あったとして(「学年」と「クラス」でソートされているものとする)、「学年」と「クラス」の組み合わせ毎に、レコードを分類したいと。

つまり、「1年1組」「1年2組」「1年3組」...「3年6組」っという18個のグループ毎にデータを分けたいと。

ちなみに、ソートについては、

qiita.com

Java側で行うことも可能なので、参照系のSQL(SELECT文)側でソートするようにするのか、方針は決めておいた方が良さそう。 

Java 8から導入されたJava標準のAPIである、Collectors.groupingByを使う方法もあるようなのですが、

qiita.com

stackoverflow.com

⇧ 複数の項目でグルーピングした後に、単一の項目をキーにしたMapにするってのができないっぽい。

後、Collectors.groupingByは、複数キーのグルーピングができ無さそう...、複数キーのグルーピング自体はできるっぽいのだけど、複数キーでグルーピング後のキーを単一の要素のものにできないみたい...

複数キーを文字列連結した単一のキーのようなことはできるのだけど、それだと意味無いからなぁ…

そして、どちらにしろ、Mapにする部分は自力で頑張るしかないっぽい。

というわけで、試してみた

今回は、「商品マスタ」のようなデータがあるとして、「企業ID」と「商品ID」でソートして、「企業ID」毎にグルーピングしてみる。

Eclipseで、Spring Bootなプロジェクトを作成し、ファイルを追加。

以下のようなソースコード

■/test-grouping/build.gradle

plugins {
	id 'java'
	id 'war'
	id 'org.springframework.boot' version '3.3.1'
	id 'io.spring.dependency-management' version '1.1.5'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'org.postgresql:postgresql'
	annotationProcessor 'org.projectlombok:lombok'
	providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
	useJUnitPlatform()
}
    

■/test-grouping/src/main/resources/application.properties

spring.application.name=test-grouping

spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://[PostgreSQLがインストールされているマシンのホスト]:5432/[データベース名]
spring.datasource.username=[PostgreSQLのデータベースに接続できるPostgreSQLのユーザー]
spring.datasource.password=[PostgreSQLのデータベースに接続できるPostgreSQLのユーザーのパスワード]
#spring.jpa.properties.hibernate.id.db_structure_naming_strategy: legacy    

■/test-grouping/src/main/java/com/example/demo/entity/MstProductEntity.java

package com.example.demo.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
@Table(name="mst_product")
public class MstProductEntity {

  @Id
  @Column(name="id")
  private Long id;
  
  // シリアル番号
  @Column(name="serial_number")
  private String serialNumber;
  
  // 企業ID
  @Column(name="company_id")
  private String companyId;
  
  // 商品ID
  @Column(name="product_id")
  private String productId;
  
  // 商品名
  @Column(name="product_name")  
  private String productName;

}
    

■/test-grouping/src/main/java/com/example/demo/repository/MstProductRepository.java

package com.example.demo.repository;

import org.springframework.data.jpa.repository.JpaRepository;

import com.example.demo.entity.MstProductEntity;

public interface MstProductRepository extends JpaRepository<MstProductEntity, Long> {

}    

■/test-grouping/src/main/java/com/example/demo/service/MstProductService.java

package com.example.demo.service;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import com.example.demo.entity.MstProductEntity;

@Service
public class MstProductService {

	public List<MstProductEntity> sortByCompanyIdAndProductId(List<MstProductEntity> mstProductEntityList) {

		// 企業ID、商品IDでソート
		Comparator<MstProductEntity> comparator = Comparator
				.comparing(MstProductEntity::getCompanyId)
				.thenComparing(MstProductEntity::getProductId);

		// リストのソートを実施
		List<MstProductEntity> sortedMstProductEntityList = mstProductEntityList.stream().sorted(comparator)
				.toList();

		// ソート後のリストを返却
		return sortedMstProductEntityList;

	}

	public Map<String, List<MstProductEntity>> groupByCompanyId(List<MstProductEntity> sortedMstProductEntityList) {

		if (CollectionUtils.isEmpty(sortedMstProductEntityList)) {
			return Map.of();
		}

		Map<String, List<MstProductEntity>> groupByCompanyIdMap = new LinkedHashMap<>();

		String currentCompanyId = null;
		List<MstProductEntity> mstProductEntityList = new ArrayList<>();
		//for (MstProductEntity mstProductEntity: sortedMstProductEntityList) {
		for (int index = 0; index < sortedMstProductEntityList.size(); index++) {

			MstProductEntity mstProductEntity = sortedMstProductEntityList.get(index);

			// 企業IDが変わった場合
			if (Objects.nonNull(currentCompanyId)
					&& !currentCompanyId.equals(mstProductEntity.getCompanyId())) {

				// Mapに追加
				if (!groupByCompanyIdMap.containsKey(currentCompanyId)) {

					groupByCompanyIdMap.put(currentCompanyId, new ArrayList<>(mstProductEntityList));
					mstProductEntityList = new ArrayList<>();
				}
			}

			currentCompanyId = mstProductEntity.getCompanyId();
			mstProductEntityList.add(mstProductEntity);

			if (index == sortedMstProductEntityList.size() - 1) {
				// Mapに追加
				if (!groupByCompanyIdMap.containsKey(currentCompanyId)) {

					groupByCompanyIdMap.put(currentCompanyId, new ArrayList<>(mstProductEntityList));
					mstProductEntityList = null;
				}
			}

		}
		return groupByCompanyIdMap;

	}

	public Map<String, List<MstProductEntity>> groupingByPairIds(List<MstProductEntity> mstProductEntityList) {

		if (CollectionUtils.isEmpty(mstProductEntityList)) {
			return Map.of();
		}

		Map<List<String>, List<MstProductEntity>> groupedMap = mstProductEntityList.stream()
				.collect(Collectors.groupingBy(mstProductEntity -> Arrays.asList(
						mstProductEntity.getCompanyId(),
						mstProductEntity.getProductId())));
		//	    ), TreeMap::new, Collectors.toList()));
		//			    ), LinkedHashMap::new, Collectors.toList()));

		Map<String, List<MstProductEntity>> groupByCompanyIdMap = new LinkedHashMap<>();
		String currentCompanyId = null;
		List<MstProductEntity> workMstProductEntityList = new ArrayList<>();

		int index = 0;
		for (Map.Entry<List<String>, List<MstProductEntity>> entry : groupedMap.entrySet()) {

			// 企業IDが変わった場合
			if (Objects.nonNull(currentCompanyId)
					&& !currentCompanyId.equals(entry.getKey().get(0))) {

				// Mapに追加
				if (!groupByCompanyIdMap.containsKey(currentCompanyId)) {

					groupByCompanyIdMap.put(currentCompanyId, new ArrayList<>(workMstProductEntityList));
					workMstProductEntityList = new ArrayList<>();
				}
			}

			workMstProductEntityList.addAll(entry.getValue());
			currentCompanyId = entry.getKey().get(0); // cmpanyIdはリストの1番目の要素

			if (index == groupedMap.entrySet().size() - 1) {
				// Mapに追加
				if (!groupByCompanyIdMap.containsKey(currentCompanyId)) {

					groupByCompanyIdMap.put(currentCompanyId, new ArrayList<>(workMstProductEntityList));
					workMstProductEntityList = null;
				}
			}
			index++;

		}

		return groupByCompanyIdMap;
	}

}
  

動作確認用。

■/test-grouping/src/test/java/com/example/demo/service/TestMstProductService.java

package com.example.demo.service;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import com.example.demo.entity.MstProductEntity;

@SpringBootTest
public class TestMstProductService {

	@Autowired
	MstProductService mstProductService;
	
	@Test
	public void test01 () {
		
		// インプット
		List<MstProductEntity> mstProductEntityList = new ArrayList<>() ;
		Collections.addAll(mstProductEntityList
				, new MstProductEntity(1L, "serial-number_0001", "company-id_0001", "product-id_0004", "ほげほげプロテイン4")
				, new MstProductEntity(1L, "serial-number_2001", "company-id_0002", "product-id_0001", "ほげほげプロテイン1")
				, new MstProductEntity(1L, "serial-number_0601", "company-id_0001", "product-id_0001", "ほげほげプロテイン1")
				, new MstProductEntity(1L, "serial-number_3001", "company-id_0002", "product-id_0001", "ほげほげプロテイン1")
				, new MstProductEntity(1L, "serial-number_0401", "company-id_0001", "product-id_0003", "ほげほげプロテイン3")
				, new MstProductEntity(1L, "serial-number_5001", "company-id_0001", "product-id_0001", "ほげほげプロテイン")
		);
		
		List<MstProductEntity> sortedMstProductEntityList = mstProductService.sortByCompanyIdAndProductId(mstProductEntityList);
		Map<String, List<MstProductEntity>> map = mstProductService.groupByCompanyId(sortedMstProductEntityList);
		
		System.out.println("■test01の結果");
		System.out.println(map);
		
	}
	
	@Test
	public void test02() {
		// インプット
		List<MstProductEntity> mstProductEntityList = new ArrayList<>() ;
		Collections.addAll(mstProductEntityList
				, new MstProductEntity(1L, "serial-number_0001", "company-id_0001", "product-id_0004", "ほげほげプロテイン4")
				, new MstProductEntity(1L, "serial-number_2001", "company-id_0002", "product-id_0001", "ほげほげプロテイン1")
				, new MstProductEntity(1L, "serial-number_0601", "company-id_0001", "product-id_0001", "ほげほげプロテイン1")
				, new MstProductEntity(1L, "serial-number_3001", "company-id_0002", "product-id_0001", "ほげほげプロテイン1")
				, new MstProductEntity(1L, "serial-number_0401", "company-id_0001", "product-id_0003", "ほげほげプロテイン3")
				, new MstProductEntity(1L, "serial-number_5001", "company-id_0001", "product-id_0001", "ほげほげプロテイン")
		);
		
		Map<String, List<MstProductEntity>> map = mstProductService.groupingByPairIds(mstProductEntityList);

		System.out.println("■test02の結果");
		System.out.println(map);
	}
	
}
  

⇧ で、保存。

SQL を実行していないので、主キーのカラムに該当するフィールドの値が重複していたりと、テスト用のインプットも適当です。

今回、SQLとかを実行していないのでEntityの定義とかは適当です。(本当は、主キーに該当するフィールドには、シーケンスを利用する記述が必要)

そして、Spring Data JPAを依存関係に加えている影響で、Spring Boot起動時に、application.propertiesで指定している接続情報のホストが起動していないと、エラーで起動できないので、起動しておく。

自分は、「WSL 2(Windows Subsystem for Linux 2)」にインストールしているRocky Linux 9にPostgreSQLをインストールしているので、起動しておく。

で、Spring Bootなアプリケーションを実行すると、

■test01の結果
{company-id_0001=[MstProductEntity(id=1, serialNumber=serial-number_0601, companyId=company-id_0001, productId=product-id_0001, productName=ほげほげプロテイン1), MstProductEntity(id=1, serialNumber=serial-number_5001, companyId=company-id_0001, productId=product-id_0001, productName=ほげほげプロテイン), MstProductEntity(id=1, serialNumber=serial-number_0401, companyId=company-id_0001, productId=product-id_0003, productName=ほげほげプロテイン3), MstProductEntity(id=1, serialNumber=serial-number_0001, companyId=company-id_0001, productId=product-id_0004, productName=ほげほげプロテイン4)], company-id_0002=[MstProductEntity(id=1, serialNumber=serial-number_2001, companyId=company-id_0002, productId=product-id_0001, productName=ほげほげプロテイン1), MstProductEntity(id=1, serialNumber=serial-number_3001, companyId=company-id_0002, productId=product-id_0001, productName=ほげほげプロテイン1)]}
■test02の結果
{company-id_0002=[MstProductEntity(id=1, serialNumber=serial-number_2001, companyId=company-id_0002, productId=product-id_0001, productName=ほげほげプロテイン1), MstProductEntity(id=1, serialNumber=serial-number_3001, companyId=company-id_0002, productId=product-id_0001, productName=ほげほげプロテイン1)], company-id_0001=[MstProductEntity(id=1, serialNumber=serial-number_0601, companyId=company-id_0001, productId=product-id_0001, productName=ほげほげプロテイン1), MstProductEntity(id=1, serialNumber=serial-number_5001, companyId=company-id_0001, productId=product-id_0001, productName=ほげほげプロテイン), MstProductEntity(id=1, serialNumber=serial-number_0401, companyId=company-id_0001, productId=product-id_0003, productName=ほげほげプロテイン3), MstProductEntity(id=1, serialNumber=serial-number_0001, companyId=company-id_0001, productId=product-id_0004, productName=ほげほげプロテイン4)]}
    

⇧ 一応、Mapに企業ID毎のリストを振り分けることはできたけど、かなり面倒ではある。

とは言え、

Map<String, List<Mstproductentity>>

⇧ 参照系のSQLの実行結果を、上記のような形で返すってのが難しいような気もするので致し方ないということですかね...

毎度モヤモヤ感が半端ない…

今回はこのへんで。