Generic JsonDeserializer

先日、Gsonデシリアライザ ラムダで。 - Oboe吹きプログラマの黙示録
や、
Gsonデシリアライザ、汎用化? - Oboe吹きプログラマの黙示録
を書きましたが、
再考して、以下に至りました。
JsonDeserializer実装生成で目標のインスタンスSupplier と JsonDeserializerの
deserialize で求めるオブジェクトを返す function を渡すようにすれば、
GsonBuilder registerTypeAdapterをもっと簡潔に書けます。
BiFunction も思い浮かんだのですが、JsonElement 解析実行するラムダの中で
更に、JsonDeserializationContext deserializeメソッドで他のアダプタのデシリアライザに処理させることを
書くために、BiFunction ではダメだと気づきました。
この理由で、以下、3つの引数で目標のインスタンスを取得する関数型インターフェースを定義します。

@FunctionalInterface
public interface JsonDeserializeFunction<S, T, C>{
   T apply(S s, T t, C c);
}

S = Stream<Entry<String, JsonElement>>
T = 対象
C = JsonDeserializationContext
return = T と同じ型

registerTypeAdapter に渡す JsonDeserializer実装、

import java.lang.reflect.Type;
import java.util.Map.Entry;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Stream;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
/**
 * GenericDeserializer
 */
public class GenericDeserializer<T> implements JsonDeserializer<T>{
   private JsonDeserializeFunction<Stream<Entry<String, JsonElement>>,
                            T, JsonDeserializationContext> function;
   private Supplier<T> supplier;

   public GenericDeserializer(Supplier<T> supplier,
                        JsonDeserializeFunction<Stream<Entry<String, JsonElement>>,
                        T, JsonDeserializationContext> function){
      this.supplier = supplier;
      this.function = function;
   }
   @Override
   public T deserialize(JsonElement json, Type typeOfT,
                   JsonDeserializationContext context) throws JsonParseException{
      T t = supplier.get();
      if (!json.isJsonNull()){
         t = function.apply(json.getAsJsonObject().entrySet()
                        .stream()
                        .filter(Predicate.not(e->e.getValue().isJsonNull())),
                       t, context);
      }
      return t;
   }
}

使用例
JSON のサンプル

{
  foo: { name:"X", date: "2019/03/12" },
  alist: [ { name:"A", date: "2019/03/06" },
           { name:"B", date: "2019/03/07" },
           { name:"C", date: "2019/03/08" }
         ]
}

Json 読込みから生成されるクラス
Foo.class

import java.time.LocalDate;

public class Foo{
   public String name;
   public LocalDate date;
   public Foo(){}
   public void setName(String name){
      this.name = name;
   }
   public void setDate(LocalDate date){
      this.date = date;
   }
}

Fdata.class

import java.util.List;

public class Fdata{
   public Foo foo;
   public List<Foo> alist;
}

Gson 生成→ fromJson

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
import org.labo.devjson.GenericDeserializer;
import org.labo.fileio.FileTool;
import org.yipuran.gsonhelper.LocalDateAdapter;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
Gson gson = new GsonBuilder().serializeNulls()
.registerTypeAdapter(new TypeToken<LocalDate>(){}.getType(),
                        LocalDateAdapter.of("yyyy/MM/dd"))
.registerTypeAdapter(new TypeToken<Foo>(){}.getType(),
 new GenericDeserializer<>(()->new Foo(), (s, t, c)->{
   s.forEach(e->{
      if (e.getKey().equals("name")) t.setName(e.getValue().getAsString());
      if (e.getKey().equals("date"))
         t.setDate(c.deserialize(e.getValue(), new TypeToken<LocalDate>(){}.getType()));
   });
   return t;
}))
.create();

Fdata data = gson.fromJson(str, new TypeToken<Fdata>(){}.getType());

System.out.println("data.foo.name = "
+ Optional.ofNullable(data.foo).map(e->e.name).orElse(null)
+ " date = " + Optional.ofNullable(data.foo.date).orElse(null)
);
if (data.alist != null){
   data.alist.stream().forEach(e->{
      System.out.println("alist  name=" + e.name
      + "  date = " + Optional.ofNullable(e.date).orElse(null));
   });
}


LocalDate は、LocalDateAdapter.of("yyyy/MM/dd")
で、デシリアライズするようにしてます。
https://github.com/yipuran/yipuran-gsonhelper/wiki#localdate-adapter


結果

data.foo.name = X date = 2019-03-12
alist  name=A  date = 2019-03-06
alist  name=B  date = 2019-03-07
alist  name=C  date = 2019-03-08

更にもっと簡潔にするなら、
JsonDeserializeFunction<S, T, C> ラムダで書く
Stream<Entry<String, JsonElement>> の forEach ではなく、collect を使って、

.registerTypeAdapter(new TypeToken<Foo>(){}.getType(), new GenericDeserializer<>(()->new Foo()
   , (s, t, c)->s.collect(()->t, (r, u)->{
      if (u.getKey().equals("name")) r.setName(u.getValue().getAsString());
      if (u.getKey().equals("date"))
          r.setDate(c.deserialize(u.getValue(), new TypeToken<LocalDate>(){}.getType()));
   }, (r, u)->{})))

つまり、collect 集計処理でこういうことです。

.registerTypeAdapter(new TypeToken<Foo>(){}.getType(), new GenericDeserializer<>(()->new Foo()
   , (s, t, c)->s.collect(()->t, (r, u)->{
      // TODO u.getKey() で求めるJSONキーに対して、u.getValue()で取得する JsonElement から
      // 値を読み取って、r に格納する。
      // 必要に応じで、JsonDeserializationContext である c の deserialize 結果を
      // r に格納する。
   }, (r, u)->{})))

GenericDeserializer コンストラクタの Supplier の結果を、collect の Supplier に引き継がせるのです。

テスト用に作ったファイル読み込み

Java テスト用に作ったファイル読込み
以下のような static メソッドを書いて使い回してました。

public static String readText(String path) throws IOException{
   try(InputStream in = new FileInputStream(path);
       ByteArrayOutputStream out = new ByteArrayOutputStream()){
      in.transferTo(out);
      return out.toString();
   }
}   
public static byte[] readBinary(String path) throws IOException{
   try(InputStream in = new FileInputStream(path)){
      byte[] data = new byte[in.available()];
      in.read(data);
      in.close();
      return data;
   }
}

そこで、引数に渡すパスが参照できるパスだけでなくて
このメソッドを呼び出すクラスと同じ場所にあるファイルパスでも
読込めるとテスト構成が、判りやすいと思いました。
実行速度は遅くて構わない。
簡単にテスト用のプログラムを書きたい目的で、以下のようにします。
呼び出し側クラスのパスでファイルが見つかればそれが優先です。
見つからなければ、引数で指定するパスで読み込もうとします。

public static String readText(String path) throws IOException{
   try{
      File file = Optional.ofNullable(
         ClassLoader.getSystemClassLoader()
         .getResource(Class.forName(Thread.currentThread()
         .getStackTrace()[2].getClassName())
         .getPackageName().replaceAll("\\.", "/") + "/" + path)
      ).map(u->{
         try{
            return new File(u.toURI());
         }catch(URISyntaxException e){
            return null;
         }
      }).orElse(new File(path));
      try(InputStream in = new FileInputStream(file);
         ByteArrayOutputStream out = new ByteArrayOutputStream()){
         in.transferTo(out);
         return out.toString();
      }
   }catch(ClassNotFoundException ex){
      ex.printStackTrace();
      throw new IOException(ex.getMessage(), ex);
   }
}
public static byte[] readBinary(String path) throws IOException{
   try{
      File file = Optional.ofNullable(
         ClassLoader.getSystemClassLoader()
         .getResource(Class.forName(Thread.currentThread()
         .getStackTrace()[2].getClassName())
         .getPackageName().replaceAll("\\.", "/") + "/" + path)
      ).map(u->{
         try{
            return new File(u.toURI());
         }catch(URISyntaxException e){
            return null;
         }
      }).orElse(new File(path));
      try(InputStream in = new FileInputStream(file)){
         byte[] data = new byte[in.available()];
         in.read(data);
         in.close();
         return data;
      }
   }catch(ClassNotFoundException ex){
      ex.printStackTrace();
      throw new IOException(ex.getMessage(), ex);
   }
}

PBKDF2ではなくSHA256で CryptoJS のAES暗合復号

Java と JavaScript 間の AES暗合 - Oboe吹きプログラマの黙示録
に書いたように、PBKDF2WithHmacSHA1 で実行する AES暗合も一計ですが、
単純に、SHA-256 ハッシュ&初期ベクトルを単純にハッシュ値から持ってくる方法も、
CryptoJS で書こうと思えば書けます。
Java の以下のキーの生成に対応した CryptoJS の方法を考えます。

private SecretKeySpec key;
private IvParameterSpec iv;
byte[] keydata = password.getBytes();
MessageDigest sha = MessageDigest.getInstance("SHA-256");
keydata = sha.digest(keydata);
keydata = Arrays.copyOf(keydata, 32);
key = new SecretKeySpec(keydata, "AES");
iv = new IvParameterSpec(Arrays.copyOf(key.getEncoded(), 16));

これに対応する JavaScript のコードを書けば良いわけです。
code.google.com
からは、以下を使います。
sha256.js
aes.js

テキスト入力パスワード→ SHA256

var passwd = $('#planetext').val();
var key = CryptoJS.SHA256(passwd);

SHA256キー→16bute の Hex表現

var ivhex = CryptoJS.enc.Hex.stringify(key).substring(0, 32);

平文 planetext,→ 暗合化

var encrypted = CryptoJS.AES.encrypt(planetext, key,
  { iv: CryptoJS.enc.Hex.parse(ivhex),
    mode: CryptoJS.mode.CBC,
    keySize: 256 / 32,
    padding: CryptoJS.pad.Pkcs7
});

暗合文 encrypted,→ 復号 message

var decrypted = CryptoJS.AES.decrypt(encrypted, key,
  { iv: CryptoJS.enc.Hex.parse(ivhex),
    mode: CryptoJS.mode.CBC,
    keySize: 256 / 32,
    padding: CryptoJS.pad.Pkcs7 
});
var message_code = hexStrToURICode(decrypted);
var message = decodeURIComponent(message_code);

CryptoJS の機能で、覚えておくと便利そうなメソッド

var key256base64 = CryptoJS.SHA256(key).toString(CryptoJS.enc.Base64);

→ key の SHA256 を求めて Base64エンコードした文字列を取得します。

続けて、CryptoJS.enc.Hex.stringify で、CryptoJS.SHA256の結果を渡せば、
CryptoJS.SHA256 は、byte配列を返すものを 16進数で文字列として読み込むことで同じ結果になります。

var  k = CryptoJS.enc.Hex.stringify( CryptoJS.SHA256(key) );

→ CryptoJS.enc.Hex.stringify で、16進→文字列、
→ CryptoJS.enc.Hex.parseで、16進文字列→byte array

Java と JavaScript 間の AES暗合

JavaJavaScript 言語間の暗合文や鍵の渡し方はいろんな方法あるので
ここでは書きません。
相互での暗合復号の実際の実行を書いてます。
CBCモードで、PBKDF2WithHmacSHA1 です。
Java

JavaScript は、crypto-js を使います。
https://code.google.com/archive/p/crypto-js/

private SecretKeySpec key;
private IvParameterSpec iv;
private byte[] ivary;
byte[] keydata = password.getBytes();
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), keydata, 100, 256);
SecretKey secretKey = factory.generateSecret(spec);
key = new SecretKeySpec(secretKey.getEncoded(), "AES");
ivary = Arrays.copyOf(key.getEncoded(), 16);
iv = new IvParameterSpec(ivary);
/* 暗合化 */
public byte[] encrypt(String message){
   try{
      Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
      cipher.init(Cipher.ENCRYPT_MODE, key, iv);
      return cipher.doFinal(message.getBytes());
   }catch(Exception e){
      throw new RuntimeException(e);
   }
}
/* 復号 */
public byte[] decrypt(byte[] encbytes){
   try{
      Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
      cipher.init(Cipher.DECRYPT_MODE, key, iv);
      return cipher.doFinal(encbytes);
   }catch(Exception e){
      throw new RuntimeException(e);
   }
}

JavaScript

HTML で必要なJSソースの読込みは、jQuery の他に、、
aes.js
md5.js
pbkdf2.js

<script src="../cryptojs3.1.2/rollups/aes.js" type="text/javascript"></script>
<script src="../cryptojs3.1.2/rollups/md5.js" type="text/javascript"></script>
<script src="../cryptojs3.1.2/rollups/pbkdf2.js" type="text/javascript"></script>


CryptoJS復号

準備、共通鍵 password をUTF-8で読込み、Saltをランダム生成する

var secret_passphrase = CryptoJS.enc.Utf8.parse(password);
var salt = CryptoJS.lib.WordArray.random(256/32);

ブロック長と同じ PBKDF2 を生成
初期化ベクトル iv は、Java暗合実行で生成した iv を持ってくる。
この時16進数HEX表現から変換する

var keyBitsIterations = CryptoJS.PBKDF2(secret_passphrase, secret_passphrase, { keySize: 256/32, iterations: 100 });
var iv = CryptoJS.enc.Hex.parse($('#ivkey').val());
// 暗号化オプション(IV:初期化ベクトル, CBCモード, パディングモード:PKCS7
var options = {iv: iv, mode: CryptoJS.mode.CBC, keySize: 256 / 32, padding: CryptoJS.pad.Pkcs7};

暗合文 encedtext → 複合実行

var decrypted = CryptoJS.AES.decrypt(encedtext, keyBitsIterations, options);

decrypt 実行した byte から、
Hex文字列→decodeURI 可能にする。'%' 付与してデコードして復元する

var message_code = hexStrToURICode(decrypted);
var message = decodeURIComponent(message_code);

CryptoJS暗合

準備として、salt 、PBKDF2 を生成、オプション作成までは複合の手順と同じ。

暗号化実行

var encrypted = CryptoJS.AES.encrypt(planetxt, keyBitsIterations, options);

暗合化した結果の encrypted は、Base64 エンコードした文字列になる。

Java と Python の AES暗合復号

実運用を考えて、書き直しました。
AES のモードは、CBC ,パディングは、PKCS5Padding
Java

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
 * AES 256 暗合複合.  mode : CBC     padding : PKCS5Padding
 * iv ← SHA-256 SecretKeySpec byte[] の先頭16byte
 */
public final class AESCipher{
   private SecretKeySpec key;
   private IvParameterSpec iv;
   private String mode;

   private AESCipher(String password){
      try{
         byte[] keydata = password.getBytes();
         MessageDigest sha = MessageDigest.getInstance("SHA-256");
         keydata = sha.digest(keydata);
         keydata = Arrays.copyOf(keydata, 32);
         key = new SecretKeySpec(keydata, "AES");
         mode = "AES/CBC/PKCS5Padding";
         iv = new IvParameterSpec(Arrays.copyOf(key.getEncoded(), 16));
      }catch(NoSuchAlgorithmException e){
         throw new RuntimeException(e.getMessage());
      }
   }
   /**
    * AES インスタンス生成
    * @param keyword 共通鍵
    * @return AESCipher
    */
   public static AESCipher of(String keyword){
      return new AESCipher(keyword);
   }
   /**
    * Initilize Vector
    * @return 16byte byte[]
    */
   public byte[] getIV(){
      return iv.getIV();
   }
   /**
    * 暗合化.
    * @param message 平文
    * @return 暗合文byte[]
    */
   public byte[] encrypt(String message){
      try{
         Cipher cipher = Cipher.getInstance(mode);
         cipher.init(Cipher.ENCRYPT_MODE, key, iv);
         return cipher.doFinal(message.getBytes());
      }catch(Exception e){
         throw new RuntimeException(e);
      }
   }
   /**
    * 複合
    * @param encbytes 暗合文byte[]
    * @return 平文
    */
   public byte[] decrypt(byte[] encbytes){
      try{
         Cipher cipher = Cipher.getInstance(mode);
         cipher.init(Cipher.DECRYPT_MODE, key, iv);
         return cipher.doFinal(encbytes);
      }catch(Exception e){
         throw new RuntimeException(e);
      }
   }
   /**
    * ランダム文字列生成
    * @param len 長さ
    * @return a-zA-Z0-9 の文字列
    */
   public static String randomKey(int len){
      SecureRandom secureRandom = new SecureRandom();
      String CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
      StringBuilder sb = new StringBuilder();
      for(int i=0; i < len; i++){
         sb.append(CHARACTERS.charAt(secureRandom.nextInt(CHARACTERS.length())));
      }
      return sb.toString();
   }
}

iv は、キーから先頭16byte にしています。
Java 暗合化実行

AESCipher cipher = AESCipher.of(keyword);
byte[] encbytes = cipher.encrypt(planetext);
String enctxt = Base64.getEncoder().encodeToString(encbytes);
// enctxt = Base64 エンコードした暗合文

Java 復号実行

byte[] encbytes = Base64.getDecoder().decode(enctxt);
AESCipher cipher = AESCipher.of(keyword);
String dectxt = new String(cipher.decrypt(encbytes), StandardCharsets.UTF_8);
while(B64Util.isBase64(dectxt)){
   dectxt = new String(Base64.getDecoder().decode(dectxt), StandardCharsets.UTF_8);
}
// dectxt = 復号

B64Util.isBase64 は、
Base64 かどうか判定する - Oboe吹きプログラマの黙示録
のメソッドです。Python で暗合化した暗合文を解くときは、
再度、Base64デコードが必要です。
Python
cryptoaes.py

# -*- coding: UTF-8 -*-
# AES 256 暗合複合  CBCモード
#   使い方:
#     from cryptoaes import AESCipher
#     インスタンス
#           aes = AESCipher(password)
#     暗合化 → Base64 エンコード
#            enctxt = b64encode(aes.encrypt(planetxt)).decode('utf-8')
#     CBCモード 複合 ← Base64エンコード済の暗号文
#            dectxt = aes.decrypt(encted_b64_string)
#
import re
import hashlib
from Crypto.Cipher import AES
from Crypto import Random
from base64 import b64encode, b64decode

BLOCK_SIZE = AES.block_size
pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)
unpad = lambda s: s[:-ord(s[len(s) - 1:])]

class AESCipher:
    def __init__(self, password):
        self.key = hashlib.sha256(password.encode("utf-8")).digest()
        self.iv = self.key[:16]
        self.reb64 = re.compile(r'^[a-z0-9A-Z/\+]+={1,2}$')
    # AES 256キー参照
    def getKey(self):
        return self.key
    # ベクトル値取得
    def getIV(self):
        return self.iv
    # password & ベクトル値セット
    def setPasswdAndIV(self, passandiv):
        self.key = hashlib.sha256(passandiv[0].encode("utf-8")).digest()
        self.iv = passandiv[1]

    # 暗号化  plane → byte
    def encryptCBC(self, message):
        if message is None or len(message) == 0:
            raise NameError("No value given to encrypt")
        raw = b64encode(message.encode('utf-8')).decode()
        raw = pad(raw)
        raw = raw.encode('utf-8')
        cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
        return cipher.encrypt(raw)

    # 復号  
    def decryptCBC(self, enctext, type='b64'):
        if enctext is None or len(enctext) == 0:
            raise NameError("No value given to decrypt")
        cipher = AES.new(self.key, AES.MODE_CBC, self.iv)
        #data = re.sub(b"([\x00-\x08\x0b\x0c\x0e-\x1f])*$", b'', cipher.decrypt(b64decode(enctext))).decode()
        data = unpad(cipher.decrypt(b64decode(enctext))).decode()
        if len(data) % 4 == 0 and self.reb64.match(data):
            data =  b64decode(data).decode()
        return data

復号時、unpad を使うか正規表現で末尾のパディングされている制御文字を除去するか
迷いましたが、unpad を使ってます。

Python暗合実行 暗号文→Base64エンコード

from cryptoaes import AESCipher
from base64 import b64encode

aes = AESCipher(key)
edata = aes.encryptCBC(planetxt)
enctxt = b64encode(edata).decode()

Python復号実行

aes = AESCipher(key)
dectxt = aes.decryptCBC(enctext)

Gsonデシリアライザ ラムダで。

Gson のデシリアライザ、以下を用意すれば何でもリストをデシリアライズできる

import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.function.Function;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
/**
 * ListGenericDeserialiser
 */
public class ListGenericDeserialiser<T> implements JsonDeserializer<List<T>>{
   private Function<JsonElement, T> function;

   public ListGenericDeserialiser(Function<JsonElement, T> function){
      this.function = function;
   }
   @Override
   public List<T> deserialize(JsonElement json, Type typeOfT
   , JsonDeserializationContext context) throws JsonParseException{
      List<T> list = new ArrayList<>();
      if (!json.isJsonNull()){
         if (json.isJsonArray()){
            for(JsonElement je:json.getAsJsonArray()){
               list.add(function.apply(je));
            }
         }
      }
      return list;
   }
}

例えば、Foo というクラス、String name 属性を持っているなら、、

import com.google.gson.reflect.TypeToken;

Gson gson = new GsonBuilder().serializeNulls()
.registerTypeAdapter(new TypeToken<List<Foo>>(){}.getType()
, new ListGenericDeserialiser<Foo>(je->{
   Foo f = new Foo();
   je.getAsJsonObject().entrySet().forEach(e->{
      if ( !e.getValue().isJsonNull() &&  e.getKey().equals("name")){
         f.name = e.getValue().getAsString();
      }
   });
   return f;
}))
.setPrettyPrinting()
.create();

JSON

{
  "foolist" : [
               { "name" : "あ" },
               { "name" : "い" }
            ]
}

List<Foo> foolist; というリストに、Gson の fromJson で格納してくる。


Gsonデシリアライザ、汎用化? - Oboe吹きプログラマの黙示録