Spring Mobile でスマートフォン・タブレット・パソコンのデバイス種別判定をする

## 概要

- Spring Boot + Spring Mobile を使って、アクセスしてきた端末がスマートフォン・タブレット・パソコンのどれかデバイス判別する

## ソースコード

### ソースコード一覧

DeviceInfoController.java と pom.xml の2つのみ。

```
├── pom.xml
└── src
    └── main
        └── java
            └── com
                └── example
                    └── deviceinfo
                        └── DeviceInfoController.java
```

### Maven のビルド用ファイル pom.xml

- dependencies に Spring Mobile を導入
- repositories に Spring Mobile のマイルストーンバージョンが置いてあるリポジトリを追加 (今回使う Spring Mobile のバージョンが 2.0.0.M3 のため)

```pom.xml
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.7.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
  </parent>

  <groupId>com.example</groupId>
  <artifactId>deviceinfo</artifactId>
  <version>0.0.1</version>
  <name>deviceinfo</name>
  <description>Deviceinfo project for Spring Boot</description>

  <properties>
    <java.version>11</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Mobile を導入 -->
    <dependency>
      <groupId>org.springframework.mobile</groupId>
      <artifactId>spring-mobile-device</artifactId>
      <version>2.0.0.M3</version>
    </dependency>
  </dependencies>

  <repositories>
    <!-- Spring Mobile のマイルストーンバージョンが置いてあるリポジトリを追加 -->
    <repository>
      <id>spring-milestone</id>
      <name>Spring Milestone Repository</name>
      <url>http://repo.spring.io/milestone</url>
    </repository>
  </repositories>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>

</project>
```

### DeviceInfoController.java

```java
package com.example.deviceinfo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.mobile.device.Device;
import org.springframework.mobile.device.DevicePlatform;
import org.springframework.mobile.device.DeviceResolverHandlerInterceptor;
import org.springframework.mobile.device.DeviceUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.HashMap;
import java.util.Map;

@SpringBootApplication
@Configuration
@RestController
public class DeviceInfoController implements WebMvcConfigurer {

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

  // Spring Mobile を使うために必要
  @Bean
  public DeviceResolverHandlerInterceptor deviceResolverHandlerInterceptor() {
    return new DeviceResolverHandlerInterceptor();
  }

  // Spring Mobile を使うために必要
  // WebMvcConfigurer#addInterceptors
  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(deviceResolverHandlerInterceptor());
  }

  @RequestMapping("/")
  public Map index(WebRequest req) {

    Map res = new HashMap<String, String>();

    String userAgent = req.getHeader(HttpHeaders.USER_AGENT);
    res.put("useragent", userAgent);

    Device device = DeviceUtils.getCurrentDevice(req);
    if (device == null) {
      res.put("device", "null");
    } else if (device.isMobile()) {
      res.put("device", "mobile");
    } else if (device.isTablet()) {
      res.put("device", "tablet");
    } else if (device.isNormal()) {
      res.put("device", "normal");
    }

    if (device != null) {
      DevicePlatform dp = device.getDevicePlatform();
      switch (dp) {
        case ANDROID:
          res.put("platform", "android");
          break;
        case IOS:
          res.put("platform", "ios");
          break;
        case UNKNOWN:
          res.put("platform", "unknown");
          break;
      }
    }

    return res;
  }
}
```

## ビルドと起動

mvn package で JAR ファイルを生成。

```
$ mvn package
```

java コマンドで Spring Boot を起動。

```
$ java -jar target/deviceinfo-0.0.1.jar
```

## いろいろなユーザーエージェントでアクセスしてみる

Ruby + curl でいろいろなユーザーエージェントを指定してアクセスしてみる。

```rb
require 'json'

ualist = []

## Smartphone

# iPhone + iOS + Safari
ualist << 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1'

# iPhone + iOS + Chrome
ualist << 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/69.0.3497.91 Mobile/15E148 Safari/605.1'

# Galaxy A30 SCV43 + Android + Browser
ualist << 'Mozilla/5.0 (Linux; Android 9; SCV43 Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.0 Chrome/67.0.3396.87 Mobile Safari/537.36'

# Xperia XZ3 SOV39 + Chrome
ualist << 'Mozilla/5.0 (Linux; Android 9; SOV39 Build/52.0.C.1.119) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.91 Mobile Safari/537.36'

## Tablet

# iPad mini + iOS + Safari
ualist << 'Mozilla/5.0 (iPad; CPU OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1'

# Qua tab QZ10 + Android + Chrome
ualist << 'Mozilla/5.0 (Linux; Android 8.1.0; KYT33 Build/3.020VE.0072.a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.126 Safari/537.36'

## Desktop

# macOS + Safari
ualist << 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Safari/605.1.15'

# Windows 10 + Microsoft Edge
ualist << 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18362'

ualist.each do |ua|
  json = `#{"curl --silent --user-agent '#{ua}' http://localhost:8080/"}`
  puts JSON.pretty_generate(JSON.parse(json))
  puts
end
```

実行結果。
ユーザーエージェントを解析してしっかりと判別できているのがわかる。

```
{
  "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1",
  "device": "mobile",
  "platform": "ios"
}

{
  "useragent": "Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/69.0.3497.91 Mobile/15E148 Safari/605.1",
  "device": "mobile",
  "platform": "ios"
}

{
  "useragent": "Mozilla/5.0 (Linux; Android 9; SCV43 Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.0 Chrome/67.0.3396.87 Mobile Safari/537.36",
  "device": "mobile",
  "platform": "android"
}

{
  "useragent": "Mozilla/5.0 (Linux; Android 9; SOV39 Build/52.0.C.1.119) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.91 Mobile Safari/537.36",
  "device": "mobile",
  "platform": "android"
}

{
  "useragent": "Mozilla/5.0 (iPad; CPU OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1",
  "device": "tablet",
  "platform": "ios"
}

{
  "useragent": "Mozilla/5.0 (Linux; Android 8.1.0; KYT33 Build/3.020VE.0072.a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.126 Safari/537.36",
  "device": "tablet",
  "platform": "android"
}

{
  "useragent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Safari/605.1.15",
  "device": "normal",
  "platform": "unknown"
}

{
  "useragent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18.18362",
  "device": "normal",
  "platform": "unknown"
}
```

## ハンドラ・メソッドの引数に Device を指定できるようにする

ハンドラ・メソッド (@RequestMapping アノテーションが付加されたメソッド) の引数に Device を指定できるように DeviceInfoController.java を修正する。

```java
package com.example.deviceinfo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.mobile.device.Device;
import org.springframework.mobile.device.DeviceHandlerMethodArgumentResolver;
import org.springframework.mobile.device.DevicePlatform;
import org.springframework.mobile.device.DeviceResolverHandlerInterceptor;
import org.springframework.mobile.device.DeviceUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@SpringBootApplication
@Configuration
@RestController
public class DeviceInfoController implements WebMvcConfigurer {

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

  // Spring Mobile を使うために必要
  @Bean
  public DeviceResolverHandlerInterceptor deviceResolverHandlerInterceptor() {
    return new DeviceResolverHandlerInterceptor();
  }

  // Spring Mobile を使うために必要
  // WebMvcConfigurer#addInterceptors
  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(deviceResolverHandlerInterceptor());
  }

  // @RequestMapping が付加されたメソッド (ハンドラ・メソッド) の引数に
  // Spring Mobile の Device を指定できるようにするために必要
  @Bean
  public DeviceHandlerMethodArgumentResolver deviceHandlerMethodArgumentResolver() {
    return new DeviceHandlerMethodArgumentResolver();
  }

  // @RequestMapping が付加されたメソッド (ハンドラ・メソッド) の引数に
  // Spring Mobile の Device を指定できるようにするために必要
  // WebMvcConfigurer#addArgumentResolvers
  @Override
  public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
    argumentResolvers.add(deviceHandlerMethodArgumentResolver());
  }

  @RequestMapping("/")
  public Map index(WebRequest req, Device device) {

    Map res = new HashMap<String, String>();

    String userAgent = req.getHeader(HttpHeaders.USER_AGENT);
    res.put("useragent", userAgent);

    // ハンドラ・メソッドの引数に Device を指定できるようになったのでこの処理は不要
    //Device device = DeviceUtils.getCurrentDevice(req);

    if (device == null) {
      res.put("device", "null");
    } else if (device.isMobile()) {
      res.put("device", "mobile");
    } else if (device.isTablet()) {
      res.put("device", "tablet");
    } else if (device.isNormal()) {
      res.put("device", "normal");
    }

    if (device != null) {
      DevicePlatform dp = device.getDevicePlatform();
      switch (dp) {
        case ANDROID:
          res.put("platform", "android");
          break;
        case IOS:
          res.put("platform", "ios");
          break;
        case UNKNOWN:
          res.put("platform", "unknown");
          break;
      }
    }

    return res;
  }
}
```

## Spring Mobile がデバイス判別する仕組み

次の記事は古いバージョンについて書かれているが、それほど変わっていないのではないかと思われる。

[Spring Mobile 1\.0がリリース](https://www.infoq.com/jp/news/2012/06/spring-mobile-1.0.0-released)

> デバイス解決には、LiteDeviceResolverが既定で使われる。これは WordPress Mobile Packの検知アルゴリズム を基にしている。DeviceResolverHandlerInterceptorのコンストラクタ引数を注入することにより、別のDeviceResolver実装 をプラグインできる。WURFLのようなより洗練されたデバイスのリゾルバは、画面サイズ、製造元、モデル、または優先マークアップなどの特定のデバイスの機能を識別することができる。

LiteDeviceResolver.java のソースコードを見るとユーザーエージェント文字列からデバイスを判別している様子がわかる。

[spring\-mobile/LiteDeviceResolver\.java at v2\.0\.0\.M3 · spring\-projects/spring\-mobile · GitHub](https://github.com/spring-projects/spring-mobile/blob/v2.0.0.M3/spring-mobile-device/src/main/java/org/springframework/mobile/device/LiteDeviceResolver.java)

```java
// Android special case
if (userAgent.contains("android")) {
  return resolveWithPlatform(DeviceType.MOBILE, DevicePlatform.ANDROID);
}
// Apple special case
if (userAgent.contains("iphone") || userAgent.contains("ipod") || userAgent.contains("ipad")) {
  return resolveWithPlatform(DeviceType.MOBILE, DevicePlatform.IOS);
}
```

Spring Mobile を使うためには WebMvcConfigurer#addInterceptors を実装して、そのメソッド内で DeviceResolverHandlerInterceptor オブジェクトを InterceptorRegistry オブジェクトに追加する必要がある。

これは、ハンドラメソッドでの処理の前に DeviceResolverHandlerInterceptor オブジェクトが Device オブジェクトを生成して、HttpServletRequest#setAttribute で属性として追加しているためである。

[spring\-mobile/DeviceResolverHandlerInterceptor\.java at v2\.0\.0\.M3 · spring\-projects/spring\-mobile · GitHub](https://github.com/spring-projects/spring-mobile/blob/v2.0.0.M3/spring-mobile-device/src/main/java/org/springframework/mobile/device/DeviceResolverHandlerInterceptor.java)

```java
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
  Device device = deviceResolver.resolveDevice(request);
  request.setAttribute(DeviceUtils.CURRENT_DEVICE_ATTRIBUTE, device);
  return true;
}
```

[spring\-mobile/DeviceUtils\.java at v2\.0\.0\.M3 · spring\-projects/spring\-mobile · GitHub](https://github.com/spring-projects/spring-mobile/blob/v2.0.0.M3/spring-mobile-device/src/main/java/org/springframework/mobile/device/DeviceUtils.java)

DeviceUtils.getCurrentDevice ではセットされた属性を返しているだけとなる。

```java
public static Device getCurrentDevice(HttpServletRequest request) {
  return (Device) request.getAttribute(CURRENT_DEVICE_ATTRIBUTE);
}
```

## 参考資料

- [Spring Mobile](https://projects.spring.io/spring-mobile/)
- [Spring Mobile Reference Documentation](https://docs.spring.io/spring-mobile/docs/2.0.0.M3/reference/html/)
- [2\. Spring Mobile Device Module](https://docs.spring.io/spring-mobile/docs/2.0.0.M3/reference/html/device.html)
- [Overview \(Spring Mobile 2\.0\.0\.M3 API\)](https://docs.spring.io/spring-mobile/docs/2.0.0.M3/api/)