Javaで非同期処理したいんだけど、フロントエンドには即時レスポンスしたい

f:id:ts0818:20220410181323j:plain

scienceportal.jst.go.jp

 観測史上最も遠い129億光年かなたの星を発見した、と米航空宇宙局(NASA)、欧州宇宙機関(ESA)などの研究グループが発表した。遠すぎて本来は確認が難しいが、地球からみて手前にある重い天体の影響で姿が歪んで見える「重力レンズ効果」のために拡大して見え、ハッブル宇宙望遠鏡が捉えたことで実現した。これまでの90億光年を大幅に更新した。宇宙初期の状況や、当時作られた星の理解につながるという。

宇宙の「レンズ」が教えてくれた 129億光年、観測史上最も遠い星を発見 | Science Portal - 科学技術の最新情報サイト「サイエンスポータル」

⇧ amazing...

非同期処理とは

これと言って、明確な定義がされてはいなさそうではあるのですが、

www.techtarget.com

What does asynchronous mean?

More specifically, asynchronous describes the relationship between two or more events/objects that do interact within the same system but do not occur at predetermined intervals and do not necessarily rely on each other's existence to function. They are not coordinated with each other, meaning they could occur simultaneously or not because they have their own separate agenda.

https://www.techtarget.com/searchnetworking/definition/asynchronous

⇧「非同期処理」は、他の処理を考慮せず、我が道を行くって感じですかね。

「同期処理」と「非同期処理」のイメージについては、

www.researchgate.net

⇧ 上記サイト様の図がイメージしやすいかと。

「同期処理」は、同時に1つの処理しかできないのに対し、「非同期処理」は同時に複数の処理が行えてます。

逆に言うと、「非同期処理」は、各処理がいつ終わるかは分からないので、この処理が終わってから、次の処理をして欲しいんだけどな~、って問題に悩んで眠れぬ夜を過ごしたことがある人は多いと思います。

そんな時に、

async/awaitパターンは、多くのプログラミング言語における構文機能であり、非同期ブロッキング関数を通常の同期関数と同様の方法で構築できる。それは意味的にコルーチンの概念と関連し、多くの場合は類似した技術を使用して実装される。主に実行時間の長い非同期タスクの完了を待っている間に他のコードを実行する機会の提供を目的とし、通常は promise または同様のデータ構造で表される。

Async/await - Wikipedia

⇧「async/awaitパターン」というものが用意されており、おそらく、JavaScriptなんかで良く目にすることが多いかと。

ただ、このパターンのいけてないところは、例えば、TypeScriptなんかで、サーバーからデータ取得するまでは、後続の処理に移って欲しくないってケースの場合に、

private async getData() {
  // サーバーへのリクエスト
  
  // サーバーからのレスポンスを処理
  await setData()
  
  // 何らかの処理
}

private setData() {
  // サーバーからのレスポンスを処理
}

getData((resolve: any) => {
  // 何らかの処理
})
  .then((resolve: any) => {
    // 何らかの処理
  })
  .then((resolve: any) => {
    // 何らかの処理
  })

...以下省略    

⇧ ってな感じで、後続の処理を全部、thenで囲まないといけなくなるという事態に陥るというね...

もしくは、1つのメソッドの中身を盛り込むだけ盛り込んでしまうという、所謂fat methodにするかのどっちかになるんかな?

誰か上手い方法を教えて...

Javaで非同期処理する場合って、どんな選択肢があるのか

で、Javaで「非同期処理」する場合って、どんな選択肢があるのかって話ですよね。

「非同期処理」を実現するには、複数「スレッド」が必要になりますと。

いろいろと調べたところ、

  • Runnable
  • Thread
  • Executor

あたりがJavaの標準APIで用意されており、基本的には、これらを組み合わせて利用して実現すれば、「マルチスレッド」は実現できるらしいのだけど、気を付けたいのは、

ja.stackoverflow.com

非同期で実行するには thread.start() を使います。これにより、別スレッドが立った上で、その別スレッド上で run() が呼び出されます。
一方、 thread.run() は同期で実行されてしまいます。
run() を呼ぶと、Runnable を実装したクラスの run() メソッドが実行されてしまうため、同期処理になります。

java - Thread を start() と run() で実行するときの違い - スタック・オーバーフロー

⇧ 上記サイト様の説明にあるように、「非同期」にしたい場合は、Threadのインスタンスとしてstart()メソッドを呼び出す必要があるらしいですと。

「マルチスレッド」だからといって「非同期」になるとは限らないってのは、なかなかハマりどころですね...

フロントエンドとの連携をどうするか

Javaの処理が非同期で行えるのは、分かったのですが、フロントエンドからのリクエストに対してのレスポンスをどうするか?

qiita.com

非同期実行の処理中にHTTPレスポンスを開始する方式は、ひとつのHTTPレスポンスを複数のイベントに分割して返却(Push)します。これは、"HTTP Streaming"として知られているレスポンス方式です。

Spring MVC(+Spring Boot)上での非同期リクエストを理解する -後編(HTTP Streaming)- - Qiita

⇧ 上記サイト様によりますと、HTTPレスポンスの返却を複数回に分割できるらしい。

試してみる

というわけで、試してはみたのですが、無茶苦茶に泥沼にハマりました(涙)。

休日がすべて潰れることになるというね...

で、イメージとしては、

f:id:ts0818:20220408210452p:plain

⇧ みたいなことをやりたかったんですが、外部システムのところは、連携まで手が回らなかったので、サーバー側でfor文でloop処理をして、外部システムで処理に時間がかかってることを仮定してます。

あと、今回はフロントエンドからは、一回しかリクエストしてないです。

まずは、サーバー側。

f:id:ts0818:20220410165742p:plain

PostgreSQLは、設定してはいるものの今回は使ってませんが、設定では、実際にPostgreSQLに存在するデータベースへの接続情報を設定してます。

今回で言うと、build.gradleで、

の2つの依存関係を読み込んでいるので、application.propertiesにデータベース接続の設定を記載してないとエラーが出るため。

build.gradleから上記の依存関係を除けば、application.propertiesにデータベース接続の設定を記載しなくてもOK。

■test-uml/build.gradle

plugins {
	id 'org.springframework.boot' version '2.6.6'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

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

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	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'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-actuator
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

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

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

# application server port
server.port=8088

# cors
management.endpoints.web.cors.allowed-origins=http://localhost:8080

# database
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5434/test
spring.datasource.username=postgres
spring.datasource.password=postgres

# multipart 
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=1024MB
spring.servlet.multipart.max-request-size=1025MB

■test-uml/src/main/java/com/example/demo/filter/CORSFilter.java

package com.example.demo.filter;

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;

@Component
public class CORSFilter implements Filter {
	@Override
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {

	    HttpServletRequest request = (HttpServletRequest) req;
	    HttpServletResponse response = (HttpServletResponse) res;

	    response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
	    response.setHeader("Access-Control-Allow-Credentials", "true");
	    response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
	    response.setHeader("Access-Control-Max-Age", "3600");
	    response.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With, remember-me");

	    chain.doFilter(req, res);
	}

	@Override
	public void init(FilterConfig filterConfig) {
	}

	@Override
	public void destroy() {
	}
}

■test-uml/src/main/java/com/example/demo/dto/user/UserFormDto.java

package com.example.demo.dto.user;

import java.io.Serializable;

import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import com.fasterxml.jackson.annotation.JsonIgnore;

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

@Data
@NoArgsConstructor
@AllArgsConstructor
@Component
public class UserFormDto implements Serializable {
	//@JsonProperty("id")
	private String id;
	@JsonIgnore
	//@JsonProperty("file")
	private MultipartFile file;
}

■test-uml/src/main/java/com/example/demo/controller/helper/UserAsyncHelper.java

package com.example.demo.controller.helper;

import java.io.IOException;
import java.math.BigInteger;
import java.util.List;

import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import com.example.demo.entity.User;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Log4j2
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserAsyncHelper implements Runnable {
	
	private SseEmitter sseEmitter;
	
	private List<User> userList;

	@Override
	public synchronized void run() {
		// 
		try {
		  BigInteger num = new BigInteger("100000000");
		  BigInteger result = BigInteger.valueOf(0);
		  log.info("■run() for文開始");
		  for (BigInteger index = BigInteger.ONE; index.compareTo(num) <= 0;   index = index.add(BigInteger.ONE)) {
			  //sseEmitter.send("success:" + i);
			  result = result.add(index);
		  }
		    log.info("■run() for文終了");
			sseEmitter.send("complete:" + result);
		} catch (IOException e) {
			// 
		}
		sseEmitter.complete();
	}
}

■test-uml/src/main/java/com/example/demo/controller/UserController.java

package com.example.demo.controller;

import java.io.IOException;

import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import com.example.demo.controller.helper.UserAsyncHelper;
import com.example.demo.dto.user.UserFormDto;

import lombok.extern.log4j.Log4j2;

@Log4j2
@RestController
@RequestMapping("/user")
public class UserController {
	
	@PostMapping("/save")
	public SseEmitter save(@ModelAttribute  @Validated UserFormDto userFormDto) throws IOException {
		log.info("■■■INPUT チェック");
		log.info(userFormDto);
		log.info(userFormDto.getId());
		log.info(userFormDto.getFile());
		
		long timeOutForSseEmitter = 2 * 60 * 60 * 1000L;
		SseEmitter sseEmitter = new SseEmitter(timeOutForSseEmitter);
		UserAsyncHelper userAsyncHelper = UserAsyncHelper.builder()
		.sseEmitter(sseEmitter)
		.userList(null)
		.build();
		Thread th = new Thread(userAsyncHelper);
		log.info("■■■処理開始前");
		sseEmitter.send("処理開始");
		th.start();
		//userAsyncHelper.run();
		log.info("■■■処理開始後");
		return sseEmitter;
	}
}

■test-uml/src/main/java/com/example/demo/TestUmlApplication.java

package com.example.demo;

import java.io.IOException;
import java.io.InputStream;

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

@SpringBootApplication
public class TestUmlApplication {

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

	private static void prosess() {
		
        try {
            ProcessBuilder builder = new ProcessBuilder("ps");
            Process process = builder.start();
            InputStream is = process.getInputStream();
            while (true) {
                int c = is.read();
                if (c == -1) {
                    is.close();
                    break;
                }
                System.out.print((char) c);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
	}
}

⇧ で保存。一番のハマりポイントは、MultipartFileを含んだDTOとかにしてる場合、MultipartFileのフィールドはJSONにされないように、@JsonIgonreアノテーションを付与してあげる必要があるというところですかね、ここの情報がなかなか見つからず、休日がすべて潰れたということを、もう一度強調しておきましょうか...。

続きまして、フロントエンド側

Vue CLIでプロジェクトを作成していて、パッケージなんかは以下のようなものをインストールしています。

f:id:ts0818:20220410201249p:plain

ファイルなんかは以下のような感じです。

f:id:ts0818:20220410170621p:plain

■C:\Users\Toshinobu\Desktop\soft_work\vue_work\vue-router-work\my-project-vue\src\router\index.ts

import Vue from "vue";
import VueRouter, { RouteConfig } from "vue-router";
import HomeView from "../views/HomeView.vue";

Vue.use(VueRouter);

const routes: Array<RouteConfig> = [
  {
    path: "/",
    name: "home",
    component: HomeView,
  },
  {
    path: "/about",
    name: "about",
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () =>
      import(/* webpackChunkName: "about" */ "../views/AboutView.vue"),
  },
  {
    path: "/test",
    name: "test",
    component: () => import("../views/NavigationGuard.vue"),
  },
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes,
});

export default router;
    

■C:\Users\Toshinobu\Desktop\soft_work\vue_work\vue-router-work\my-project-vue\src\main.ts

import Vue from "vue";
import App from "./App.vue";
import router from "./router";

Vue.config.productionTip = false;

new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");    

■C:\Users\Toshinobu\Desktop\soft_work\vue_work\vue-router-work\my-project-vue\src\App.vue

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <router-view />
  </div>
</template>

<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;

  a {
    font-weight: bold;
    color: #2c3e50;

    &.router-link-exact-active {
      color: #42b983;
    }
  }
}
</style>
    

■C:\Users\Toshinobu\Desktop\soft_work\vue_work\vue-router-work\my-project-vue\src\dto\user\UserDto.ts

interface UserDtoType {
  id: string;
  file: Blob;
}

class UserDto implements UserDtoType {
  public id = "";
  public file = new Blob();

  constructor(init?: Partial<UserDto>) {
    Object.assign(this, init);
  }
}
export default new UserDto();    

■C:\Users\Toshinobu\Desktop\soft_work\vue_work\vue-router-work\my-project-vue\src\api\request.ts

import axios, { AxiosInstance } from "axios";

const apiClient: AxiosInstance = axios.create({
  // APIのURI
  baseURL: "http://localhost:8088",
  // リクエストヘッダ
  headers: {
    "Content-type": "application/json",
  },
});

export default apiClient;    

■C:\Users\Toshinobu\Desktop\soft_work\vue_work\vue-router-work\my-project-vue\src\api\userService.ts

/* eslint-disable no-console */
import request from "@/api/request";

class UserService {
  public chunkedArr: any[] = [];

  save(data: FormData) {
    return request({
      method: "post",
      url: "/user/save",
      data: data,
      //headers: {
      //"content-type": "application/json",
      //"content-type": "multipart/form-data",
      //},
      responseType: "json",
      timeout: 60000 * 30,
      onDownloadProgress: (progressEvent) => {
        const dataChunk = progressEvent.currentTarget.response;
        //console.dir(progressEvent);
        //const loadingValue = Math.floor((progressEvent.loaded / progressEvent.total) * 100);
        const loadingValue = Math.floor((progressEvent.loaded / 100) * 100);
        console.log(loadingValue);
        console.log(JSON.stringify(dataChunk));
        this.chunkedArr.push(progressEvent);
      },
    });
  }
}

export default new UserService();
    

■C:\Users\Toshinobu\Desktop\soft_work\vue_work\vue-router-work\my-project-vue\src\views\AboutView.vue

<template>
  <div class="about">
    <h1>This is an about page</h1>
    <label
      >画像を選択
      <input type="file" @change="onFileChange" />
    </label>
    <button @click="removeFile">削除</button>
    <button @click="uploadFile">アップロード</button>
    <div>
      <img v-if="selectedFile" :src="selectedFile" />
    </div>

    <!--<img :src="require('@/assets/images/SVGアイコン.svg')" />-->
  </div>
</template>

<script lang="ts">
/* eslint-disable no-console */
import { Component, Vue, Watch } from "vue-property-decorator";
import UserService from "../api/userService";
import UserDto from "../dto/user/UserDto";

@Component
export default class HomeView extends Vue {
  private temp = "";
  private selectedFile = "";
  private tempFile = {};
  private userDto?: any;

  created() {
    //this.test();
  }

  private onFileChange(event: any) {
    const files = event.target.files || event.dataTransfer.files;
    this.tempFile = files[0];
    this.createDisplayImage(files[0], event);
  }

  private createDisplayImage(file: any, event: any) {
    const fileReader = new FileReader();
    fileReader.onload = (event) => {
      console.log("createDisplayImage");
      console.dir(event);
      if (event && event.target) {
        this.selectedFile = event.target.result as string;
      }
    };
    fileReader.readAsDataURL(file);
  }

  private removeFile() {
    this.selectedFile = "";
  }

  private uploadFile() {
    console.log("■created()");
    console.dir(this.selectedFile);
    console.dir(this.tempFile);
    let data = "テスト";
    this.userDto = Object.entries({
      id: data,
      file: this.tempFile as Blob,
    });
    let formData = new FormData();
    formData.append("id", data);
    formData.append("file", this.tempFile as File);

    //formData.append("userFormDto", JSON.stringify(userDto as any));
    formData.append("userFormDto", this.userDto);
    var postdata = new URLSearchParams();
    postdata.append("userFormDto", this.userDto);
    //postdata.append("id", data);
    //postdata.append("file", JSON.stringify(this.tempFile as File));

    console.log("■送信前");
    UserService.save(formData)
      //UserService.save(postdata)
      .then((result) => {
        console.log("■レスポンス受信");
        console.log("■バックエンドからの返却の値");
        console.dir(result);
      })
      .catch((error) => {
        console.log(error);
      });
    console.log("■処理中...");
    console.dir(UserService.chunkedArr);
    //const numArr = [...Array(100000).keys()].map((i: number) => ++i);
    const numArr = Array.from(Array(1000000).keys()).map((x: number) => x + 1);
    let response = "[";
    for (let val of numArr) {
      let tmp = val === numArr.length ? "" : ",";
      response = response.concat(val.toString(), tmp);
    }
    response += "]";
    console.log("■フロントエンド");
    console.log(response);
  }

  private test() {
    console.log("■created()");
    let data = "テスト";
    let formData = new FormData();
    formData.append("id", data);
    
    console.log("■送信前");
    UserService.save(formData)
      .then((result) => {
        console.log("■レスポンス受信");
        console.log("■バックエンドからの返却の値");
        console.dir(result);
      })
      .catch((error) => {
        console.log(error);
      });
    console.log("■処理中...");
    console.dir(UserService.chunkedArr);
    //const numArr = [...Array(100000).keys()].map((i: number) => ++i);
    const numArr = Array.from(Array(1000000).keys()).map((x: number) => x + 1);
    let response = "[";
    for (let val of numArr) {
      let tmp = val === numArr.length ? "" : ",";
      response = response.concat(val.toString(), tmp);
    }
    response += "]";
    console.log("■フロントエンド");
    console.log(response);
  }

  @Watch("temp")
  private setInfo() {
    console.dir(this.temp);
  }
}
</script>
    

⇧ で、保存して、 サーバーサイドのSpring Bootアプリケーションを起動。

f:id:ts0818:20220410172500p:plain

フロントエンドのVueアプリケーションを起動。

f:id:ts0818:20220410172226p:plain

で、ブラウザ(Google Chrome)からVueアプリケーションのポートにアクセス。

f:id:ts0818:20220410172733p:plain

「ファイルを選択」ボタン押下で、適当な画像ファイルをアップロードし、「アップロード」ボタンを押下。

f:id:ts0818:20220410172837p:plain

ブラウザ(Google Chrome)の「Google Chrome デベロッパー ツール」の「console」タブを確認。

f:id:ts0818:20220410172156p:plain

⇧ 非同期で処理されてるようです。

サーバーサイドについても、

f:id:ts0818:20220410172401p:plain
⇧ 非同期で処理できてるようです。

今回はこのへんで。

2022年4月22日(金)追記:↓ ここから

職場の方に教えていただき、Postmanでリクエストが送れても、プログラミング経由だとリクエストが送れない場合があり、

stackoverflow.com

The problem is that you are setting the Content-Type by yourself, let it be blank. Google Chrome will do it for you. The multipart Content-Type needs to know the file boundary, and when you remove the Content-Type, Postman will do it automagically for you.

https://stackoverflow.com/questions/36005436/the-request-was-rejected-because-no-multipart-boundary-was-found-in-springboot

⇧ Postmanが良しなに調整してくれてるという罠があるということが分かりました。

で、Content-Typeがmultipart/formの場合は、boundaryってものが必要で、

xtech.nikkei.com

Webサーバーはファイルの種別を識別するための情報をContent-Typeヘッダーに指定する。Webブラウザは,Content-Typeヘッダーの値に応じて,ファイルを画面に表示したり,プラグイン・ソフトに引き渡したりする

第2回 HTTPの仕組み(後編) | 日経クロステック(xTECH)

⇧ おそらくサーバー側で必要ということらしい。

なので、もし、Content-Typeがmultipart/formの場合は、リクエスト時にboundaryの値が消えてしまっていないか注意する必要がある模様。

まぁ、何というかエラーが分かり辛いよね...

2022年4月22日(金)追記:↑ ここまで

 

▼参考にさせていただいたサイト

stackoverflow.com

asnokaze.hatenablog.com

stackoverflow.com

sapper-blog-app.vercel.app

kidotaka.hatenablog.com

stackoverflow.com

qiita.com

qiita.com

qiita.com

teratail.com

idesaku.hatenablog.com

r17n.page

www.techscore.com

takeaction.github.io

qiita.com