问题描述
现有打卡流程 用户获取定位地址上传图片,base64编码后传输到后台,后台会对图片打水印,完成后上传oss
有用户打卡上传图片,打完卡发现图片和水印是别人的,这个算是比较严重的线上bug吧
问题解决
第一步 质疑java类库不安全
阅读代码 发现用了 Image.water类库,认为可能是这里出了问题(实际上没有仔细阅读源码)
Water对象每次都会新建,所以是线程安全的
- javax.imageio.ImageIO : 1605
/**
* Writes image to output stream using given image writer.
*/
private static boolean doWrite(RenderedImage im, ImageWriter writer,
ImageOutputStream output) throws IOException {
if (writer == null) {
return false;
}
writer.setOutput(output);
try {
writer.write(im);
} finally {
writer.dispose();
output.flush();
}
return true;
}
- javax.imageio.ImageIO :1564
/**
* Writes an image using an arbitrary <code>ImageWriter</code>
* that supports the given format to an <code>OutputStream</code>.
*
* <p> This method <em>does not</em> close the provided
* <code>OutputStream</code> after the write operation has completed;
* it is the responsibility of the caller to close the stream, if desired.
*
* <p> The current cache settings from <code>getUseCache</code>and
* <code>getCacheDirectory</code> will be used to control caching.
*
* @param im a <code>RenderedImage</code> to be written.
* @param formatName a <code>String</code> containing the informal
* name of the format.
* @param output an <code>OutputStream</code> to be written to.
*
* @return <code>false</code> if no appropriate writer is found.
*
* @exception IllegalArgumentException if any parameter is
* <code>null</code>.
* @exception IOException if an error occurs during writing.
*/
public static boolean write(RenderedImage im,
String formatName,
OutputStream output) throws IOException {
if (output == null) {
throw new IllegalArgumentException("output == null!");
}
ImageOutputStream stream = null;
try {
stream = createImageOutputStream(output);
} catch (IOException e) {
throw new IIOException("Can't create output stream!", e);
}
try {
return doWrite(im, getWriter(im, formatName), stream);
} finally {
stream.close();
}
}
- 当时没有仔细看,先入为主的认为它使用了静态成员变量(实际上并不是)
getWriter 会返回一个writer对象
/**
* Returns <code>ImageWriter</code> instance according to given
* rendered image and image format or <code>null</code> if there
* is no appropriate writer.
*/
private static ImageWriter getWriter(RenderedImage im,
String formatName) {
ImageTypeSpecifier type =
ImageTypeSpecifier.createFromRenderedImage(im);
Iterator<ImageWriter> iter = getImageWriters(type, formatName);
if (iter.hasNext()) {
return iter.next();
} else {
return null;
}
}
- 为了验证这个问题 ,写了个基于CyclicBarrier模拟并发测试,还跑了好几个g的图片,没有出现线程问题,有点怀疑人生
- 本意是想验证它的线程不安全问题的
- 实际上证明了它的线程安全性
测试代码 cyclicBarrier模拟并发
// @Test
void waterPicture() throws InterruptedException, IOException {
//白色
Color color = new Color(255, 255, 255);
//字体
Font font = new Font("微软雅黑", Font.PLAIN, 20);
String path = "/appdata/logs/pic/";
String pa = "aaa.jpeg";
String pb = "bbb.jpeg";
List<String> contA = Arrays.asList("aaa", "aaa");
List<String> contb = Arrays.asList("bbb", "bbb");
File fa = new File(path + pa);
File fb = new File(path + pb);
BufferedImage aIm = ImageIO.read(fa);
BufferedImage bIm = ImageIO.read(fb);
for (int i = 0; i < 5; i++) {
log.info("wheel====={}", i);
CyclicBarrier cyclicBarrier = new CyclicBarrier(30);
for (int i1 = 0; i1 < 15; i1++) {
executor.execute(new UploadPic(fa, contA, "aaa", color, font, path, cyclicBarrier, i * 100 + i1, aIm));
executor.execute(new UploadPic(fb, contb, "bbb", color, font, path, cyclicBarrier, i * 100 + i1, bIm));
}
}
Thread.sleep(300000);
log.info("000000000000");
}
@Data
@AllArgsConstructor
class UploadPic extends Thread {
private File pic;
private List<String> contentList;
private String threadName;
private Color color;
private Font font;
private String path;
private CyclicBarrier barrier;
private Integer index;
Image srcImg;
@Override
public void run() {
//把文件转换成图片
// Image srcImg = null;
try {
// srcImg = ImageIO.read(pic);
//获取图片的宽和高
int srcImgWidth = srcImg.getWidth(null);
int srcImgHeight = srcImg.getHeight(null);
//画水印需要一个画板 创建一个画板
BufferedImage buffImg = new BufferedImage(srcImgWidth, srcImgHeight, BufferedImage.TYPE_INT_RGB);
//创建一个2D的图像
Graphics2D g = buffImg.createGraphics();
//画出来
g.drawImage(srcImg, 0, 0, srcImgWidth, srcImgHeight, null);
//消除文字锯齿
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
//设置水印的颜色
g.setColor(color);
//设置水印的字体
g.setFont(font);
int mark = 1;
for (String s : contentList) {
int x = 0;
//获取内容长度
int fontSize = font.getSize();
String content = s;
while (true) {
int waterMarkLength = commonController.getWaterMarkLength(content, g);
if (waterMarkLength > srcImgWidth) {
int contentLength = 0;
char[] chars = content.toCharArray();
for (int i = 0; i < chars.length; i++) {
char aChar = chars[i];
int charLength = commonController.getWaterMarkLength(aChar + "", g);
contentLength += charLength;
if (contentLength > srcImgWidth) {
String drawContent = content.substring(0, i);
content = content.substring(i);
int y = mark * fontSize;
g.drawString(drawContent, x, y);
mark += 1;
break;
}
}
} else {
int y = mark * fontSize;
g.drawString(content, x, y);
mark += 1;
break;
}
}
}
//释放画板的资源
g.dispose();
// //输出新的图片
FileOutputStream outputStream = new FileOutputStream(path + "outp/" + index + pic.getName());
// log.info("p===={}",path + "outp/" +index+ pic.getName());
// 测试模拟并发写
barrier.await();
/* if (threadName.equals("aaaa")) {
Thread.sleep(1);
}*/
// //创建新的图片
ImageIO.write(buffImg, "jpg", outputStream);
//刷新流
outputStream.flush();
//关闭流
outputStream.close();
// return new File(path);
long al = new File(path + "outp/" + index + pic.getName()).length();
if (threadName.equals("aaa")) {
if (al != 13700) {
throw new Error(index + "aaaa" + "_" + al);
}
} else {
if (al != 116069) {
throw new Error(index + "bbb" + "_" + al);
}
}
} catch (IOException | InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
log.error("err", e);
}
}
}
ps 测试代码写的比较随意,将就看吧
第二步 怀疑临时文件覆盖
既然类库没啥问题,就继续看代码,有个生成临时文件的方法,为当前时间戳加四位随机数加文件后缀
如果生成的文件名称相同的话就会覆盖前一个生成的文件
@Override
public String getOriginalFilename() {
return System.currentTimeMillis() + (int) Math.random() * 10000 + "." + header.split("/")[1];
}
- 复现问题
每秒之生成一个文件,让并发请求更明显
@Override
public String getOriginalFilename() {
return System.currentTimeMillis() /10000 + "." + header.split("/")[1];
}
- mvc test
@Test
public void upT() throws Exception {
String url ="/uploadALiFileBase64";
String ap = readJsonFile("/Users/chen/work/project/sfa-cloud/sfa-assistant/src/test/java/com/sfa/assistant/aaa.json");
String bp = readJsonFile("/Users/chen/work/project/sfa-cloud/sfa-assistant/src/test/java/com/sfa/assistant/bbb.json");
mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(ap))
.andExpect(status().isOk())
.andDo(print());
mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_UTF8)
.content(bp))
.andExpect(status().isOk())
.andDo(print());
}
- 成功复现问题,可以确认是临时文件冲突导致上传了错误的文件
问题修复
- 修改临时文件的生成策略
- 改为uuid+用户code