第 4 篇 : Netty客户端互发图片和音/视频

说明

因为图片和音/视频不能确定其具体大小, 故引入MinIO。客户端之间只发送消息, 通过上传/下载来获取额外信息

1. MinIO搭建(参考前面文章), 并启动

2. 登录MinIO创建3个Bucket: image、voice、video


3. 客户端改造

3.1 修改 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.3.4.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.hahashou.netty</groupId>
    <artifactId>client</artifactId>
    <version>1.0-SNAPSHOT</version>
    <name>client</name>
    <description>Netty Client Project For Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.100.Final</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.58</version>
        </dependency>
        <dependency>
            <groupId>com.hahashou</groupId>
            <artifactId>minio</artifactId>
            <version>2023.11.16</version>
        </dependency>
        <!-- 此包在引入minio时必须添加-->
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>4.9.3</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

3.2 修改 application.yml, 限制单文件大小为128M以内, 总大小为256M以内

server:
  port: 32001

logging:
  level:
    com.hahashou.netty: info

spring:
  servlet:
    multipart:
      max-file-size: 128MB
      max-request-size: 256MB

userCode: Aa

minio:
  endpoint: http://127.0.0.1:9000
  accessKey: root
  secretKey: root123456

3.3 config包下新增 MinIOConfig类

package com.hahashou.netty.client.config;

import io.minio.MinioClient;
import io.minio.http.HttpUtils;
import okhttp3.OkHttpClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @description: MinIO配置
 * @author: 哼唧兽
 * @date: 9999/9/21
 **/
@Configuration
public class MinIOConfig {

    @Value("${minio.endpoint}")
    private String endpoint;

    @Value("${minio.accessKey}")
    private String accessKey;

    @Value("${minio.secretKey}")
    private String secretKey;

    @Bean
    public MinioClient minioClient() {
        OkHttpClient okHttpClient = HttpUtils.newDefaultHttpClient(60 * 1000,
                50 * 1000, 30 * 1000);
        MinioClient minioClient = MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .httpClient(okHttpClient)
                .build();
        return minioClient;
    }
}

3.4 新增utils包, 放入 MinioUtils类

package com.hahashou.netty.client.utils;

import io.minio.*;
import io.minio.errors.MinioException;
import io.minio.http.Method;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import java.io.File;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.TimeUnit;

/**
 * @description:
 * @author: 哼唧兽
 * @date: 9999/9/21
 **/
@Component
@Slf4j
public class MinioUtils {

    @Resource
    private MinioClient minioClient;

    /**
     * 文件上传
     * @param bucketName
     * @param fileName
     * @param multipartFile
     * @return
     */
    public void upload(String bucketName, String multiLevelFolders, String fileName, MultipartFile multipartFile) {
        try {
            boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!found) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            }
            String suffix = fileName.substring(fileName.lastIndexOf("."));
            File file = File.createTempFile(fileName, suffix);
            multipartFile.transferTo(file);
            UploadObjectArgs objectArgs = UploadObjectArgs.builder()
                    .bucket(bucketName)
                    .object(StringUtils.hasText(multiLevelFolders) ? multiLevelFolders + fileName : fileName)
                    .filename(file.getAbsolutePath())
                    .contentType(multipartFile.getContentType())
                    .build();
            minioClient.uploadObject(objectArgs);
        } catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException exception) {
            log.error("MinIO文件上传异常 : {}", exception.getMessage());
        }
    }

    /**
     * 预览地址
     * @param fileName
     * @param bucketName
     * @return
     */
    public String preview(String fileName, String bucketName){
        // 查看文件地址
        GetPresignedObjectUrlArgs build = GetPresignedObjectUrlArgs
                .builder()
                .bucket(bucketName)
                .expiry(30, TimeUnit.MINUTES)
                .object(fileName)
                .method(Method.GET)
                .build();
        try {
            return minioClient.getPresignedObjectUrl(build);
        } catch (Exception exception) {
            log.error("获取MinIO预览地址异常 : {}", exception.getMessage());
        }
        return null;
    }
}

3.5 修改 Message类

package com.hahashou.netty.client.config;

import com.alibaba.fastjson.JSON;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.util.CharsetUtil;
import lombok.Data;
import lombok.Getter;

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

/**
 * @description:
 * @author: 哼唧兽
 * @date: 9999/9/21
 **/
@Data
public class Message {

    /** 发送者用户code */
    private String userCode;

    /** 接收者用户code */
    private String friendUserCode;

    /** 连接时专用 */
    private String channelId;

    /** 消息类型 */
    private Integer type;

    public enum TypeEnum {

        TEXT(0, "文字", "", new ArrayList<>()),
        IMAGE(1, "图片", "image", Arrays.asList("bmp", "gif", "jpeg", "jpg", "png")),
        VOICE(2, "语音", "voice", Arrays.asList("mp3", "amr", "flac", "wma", "aac")),
        VIDEO(3, "视频", "video", Arrays.asList("mp4", "avi", "rmvb", "flv", "3gp", "ts", "mkv")),

        ;

        @Getter
        private Integer key;

        @Getter
        private String describe;

        @Getter
        private String bucketName;

        @Getter
        private List<String> formatList;

        TypeEnum(int key, String describe, String bucketName, List<String> formatList) {
            this.key = key;
            this.describe = describe;
            this.bucketName = bucketName;
            this.formatList = formatList;
        }

        public static TypeEnum select(String format) {
            TypeEnum result = null;
            for (TypeEnum typeEnum : TypeEnum.values()) {
                if (typeEnum.getFormatList().contains(format)) {
                    result = typeEnum;
                    break;
                }
            }
            return result;
        }
    }

    /** 文字或文件的全路径名称 */
    private String text;

    public static ByteBuf transfer(Message message) {
        return Unpooled.copiedBuffer(JSON.toJSONString(message), CharsetUtil.UTF_8);
    }

    /**
     * 生成指定长度的随机字符串
     * @param length
     * @return
     */
    public static String randomString (int length) {
        if (length > 64) {
            length = 64;
        }
        List<String> list = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            list.add(i + "");
        }
        for (char i = 'A'; i <= 'Z'; i++) {
            list.add(String.valueOf(i));
        }
        for (char i = 'a'; i <= 'z'; i++) {
            list.add(String.valueOf(i));
        }
        list.add("α");
        list.add("ω");
        Collections.shuffle(list);
        String string = list.toString();
        return string.replace("[", "")
                .replace("]", "")
                .replace(", ", "")
                .substring(0, length);
    }
}

3.6 新增service包, 放入 ClientService接口

package com.hahashou.netty.client.service;

import com.hahashou.netty.client.config.Message;

import javax.servlet.http.HttpServletRequest;

/**
 * @description:
 * @author: 哼唧兽
 * @date: 9999/9/21
 **/
public interface ClientService {

    /**
     * 上传
     * @param userCode
     * @param httpServletRequest
     * @return
     */
    Message upload(String userCode, HttpServletRequest httpServletRequest);

    /**
     * 下载链接
     * @param fileName
     * @return
     */
    String link(String fileName);
}

3.7 service包下新建impl包, 放入 ClientServiceImpl类

package com.hahashou.netty.client.service.imol;

import com.hahashou.netty.client.config.Message;
import com.hahashou.netty.client.service.ClientService;
import com.hahashou.netty.client.utils.MinioUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.*;

/**
 * @description:
 * @author: 哼唧兽
 * @date: 9999/9/21
 **/
@Service
@Slf4j
public class ClientServiceImpl implements ClientService {

    @Resource
    private MinioUtils minioUtils;

    @Override
    public Message upload(String userCode, HttpServletRequest httpServletRequest) {
        Message result = new Message();
        MultipartHttpServletRequest multipartHttpServletRequest = (MultipartHttpServletRequest) httpServletRequest;
        List<MultipartFile> multipartFileList = multipartHttpServletRequest.getFiles("file");
        if (!CollectionUtils.isEmpty(multipartFileList)) {
            MultipartFile multipartFile = multipartFileList.get(0);
            String originalFilename = multipartFile.getOriginalFilename();
            String suffix = originalFilename.substring(originalFilename.lastIndexOf(".") + 1);
            Message.TypeEnum typeEnum = Message.TypeEnum.select(suffix);
            if (typeEnum != null) {
                String fileName = generateFileName(suffix);
                String multiLevelFolders = userCode + "/"
                        + LocalDate.now().toString().replace("-", "") + "/";
                minioUtils.upload(typeEnum.getBucketName(), multiLevelFolders, fileName, multipartFile);
                result.setType(typeEnum.getKey());
                result.setText(multiLevelFolders + fileName);
            }
        }
        return result;
    }

    public static String generateFileName(String suffix) {
        LocalTime now = LocalTime.now();
        return now.toString().replace(":", "")
                .replace(".", "-") + Message.randomString(4) + "." + suffix;
    }

    @Override
    public String link(String fileName) {
        String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
        Message.TypeEnum typeEnum = Message.TypeEnum.select(suffix);
        if (typeEnum != null) {
            return minioUtils.preview(fileName, typeEnum.getBucketName());
        }
        return null;
    }
}

3.8 修改 ClientController类

package com.hahashou.netty.client.controller;

import com.alibaba.fastjson.JSON;
import com.hahashou.netty.client.config.Message;
import com.hahashou.netty.client.config.NettyClientHandler;
import com.hahashou.netty.client.service.ClientService;
import io.netty.channel.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

/**
 * @description:
 * @author: 哼唧兽
 * @date: 9999/9/21
 **/
@RestController
@RequestMapping("/client")
@Slf4j
public class ClientController {

    @Resource
    private ClientService clientService;

    @PostMapping("/send")
    public String send(@RequestBody Message dto) {
        Channel channel = NettyClientHandler.CHANNEL;
        if (channel == null) {
            return "服务端已下线";
        }
        channel.writeAndFlush(Message.transfer(dto));
        return "success";
    }

    @GetMapping("/upload/{userCode}")
    public String upload(@PathVariable String userCode, final HttpServletRequest httpServletRequest) {
        if (StringUtils.isEmpty(userCode)) {
            return "userCode is null";
        }
        Message upload = clientService.upload(userCode, httpServletRequest);
        return JSON.toJSONString(upload);
    }

    @GetMapping("/link")
    public String link(@RequestParam String fileName) {
        // 如果Bucket包含多级目录, fileName为Bucket下文件的全路径名
        if (StringUtils.isEmpty(fileName)) {
            return "fileName is null";
        }
        return clientService.link(fileName);
    }
}

4. 服务端改造

4.1 复制客户端的 Message类

4.2 修改 NettyServerHandler类

package com.hahashou.netty.server.config;

import com.alibaba.fastjson.JSON;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @description:
 * @author: 哼唧兽
 * @date: 9999/9/21
 **/
@Component
@ChannelHandler.Sharable
@Slf4j
public class NettyServerHandler extends ChannelInboundHandlerAdapter {

    /** key: 用户code; value: channelId */
    public static Map<String, String> USER_CHANNEL = new ConcurrentHashMap<>(32);

    /** key: channelId; value: Channel */
    public static Map<String, Channel> CHANNEL = new ConcurrentHashMap<>(32);

    /** 用户离线消息 */
    public static Map<String, List<Message>> USER_MESSAGE = new ConcurrentHashMap<>(32);

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        Channel channel = ctx.channel();
        String channelId = channel.id().asLongText();
        log.info("有客户端连接, channelId : {}", channelId);
        CHANNEL.put(channelId, channel);
        Message message = new Message();
        message.setChannelId(channelId);
        channel.writeAndFlush(Message.transfer(message));
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        log.info("有客户端断开连接, channelId : {}", ctx.channel().id().asLongText());
        CHANNEL.remove(ctx.channel().id().asLongText());
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg != null) {
            Message message = JSON.parseObject(msg.toString(), Message.class);
            String userCode = message.getUserCode(),
                    channelId = message.getChannelId();
            if (StringUtils.hasText(userCode) && StringUtils.hasText(channelId)) {
                connect(userCode, channelId);
            } else if (StringUtils.hasText(message.getText())) {
                if (StringUtils.hasText(message.getFriendUserCode())) {
                    sendOtherClient(message);
                } else {
                    sendAdmin(ctx.channel(), message);
                }
            }
        }
    }

    /**
     * 建立连接
     * @param userCode
     * @param channelId
     */
    private void connect(String userCode, String channelId) {
        log.info("客户端 {} 连接", userCode);
        USER_CHANNEL.put(userCode, channelId);
    }

    /**
     * 发送给其他客户端
     * @param message
     */
    private void sendOtherClient(Message message) {
        String friendUserCode = message.getFriendUserCode();
        String queryChannelId = USER_CHANNEL.get(friendUserCode);
        if (StringUtils.hasText(queryChannelId)) {
            Channel channel = CHANNEL.get(queryChannelId);
            if (channel == null) {
                offlineMessage(friendUserCode, message);
                return;
            }
            channel.writeAndFlush(Message.transfer(message));
        } else {
            offlineMessage(friendUserCode, message);
        }
    }

    /**
     * 离线消息存储
     * @param friendUserCode
     * @param message
     */
    public void offlineMessage(String friendUserCode, Message message) {
        List<Message> messageList = USER_MESSAGE.get(friendUserCode);
        if (CollectionUtils.isEmpty(messageList)) {
            messageList = new ArrayList<>();
        }
        messageList.add(message);
        USER_MESSAGE.put(friendUserCode, messageList);
    }

    /**
     * 发送给服务端
     * @param channel
     * @param message
     */
    private void sendAdmin(Channel channel, Message message) {
        message.setUserCode("ADMIN");
        message.setText(LocalDateTime.now().toString());
        channel.writeAndFlush(Message.transfer(message));
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        log.info("有客户端发生异常, channelId : {}", ctx.channel().id().asLongText());
    }
}

5. 测试

5.1 上传


5.2 获取下载链接, 之后直接在浏览器中打开链接

5.3 Aa向Bb发送消息, Bb收到后可以通过下载链接获取


5.4 测试音频


5.5 测试视频


5.6 MioIO界面Buckets

相关推荐
无限大.9 分钟前
c语言200例 067
java·c语言·开发语言
余炜yw11 分钟前
【Java序列化器】Java 中常用序列化器的探索与实践
java·开发语言
攸攸太上11 分钟前
JMeter学习
java·后端·学习·jmeter·微服务
Kenny.志14 分钟前
2、Spring Boot 3.x 集成 Feign
java·spring boot·后端
不修×蝙蝠16 分钟前
八大排序--01冒泡排序
java
sky丶Mamba31 分钟前
Spring Boot中获取application.yml中属性的几种方式
java·spring boot·后端
数据龙傲天1 小时前
1688商品API接口:电商数据自动化的新引擎
java·大数据·sql·mysql
带带老表学爬虫1 小时前
java数据类型转换和注释
java·开发语言
千里码aicood2 小时前
【2025】springboot教学评价管理系统(源码+文档+调试+答疑)
java·spring boot·后端·教学管理系统
彭于晏6892 小时前
Android广播
android·java·开发语言