在spring框架中发送邮件

在spring框架中发送邮件

spring封装了一个邮件发送类:org.springframework.mail.javamail.JavaMailSender,这个JavaMailSender底层实现依赖的是公开的JavaMail API。所以要让spring的JavaMailSender正常工作,就必须先导入JavaMail API的实现类,比如com.sun.mail:javax.mail。

发送的邮件有两种类型,一种是简单文本邮件,不支持html,即使邮件内容中出现html标签,对方邮箱收到邮件后显示的仍是原始文本;一种是富文本邮件,支持html,对方邮箱收到邮件后会解析html标签并渲染。

一、配置JavaMailSender

	<bean id="javaMailSender" class="org.springframework.mail.javamail.JavaMailSenderImpl">
	    <property name="host" value="smtp.exmail.qq.com"/>
	    <property name="port" value="25"/>
	    <property name="username" value="your exmail name" />
	    <property name="password" value="your password"></property>
	    <property name="javaMailProperties">
            <props>
	            <prop key="mail.transport.protocol">smtp</prop>
	            <prop key="mail.smtp.auth">true</prop>
	        </props>
        </property>
	</bean>

PS:建议不要使用个人邮箱,我试过163的个人邮箱做发件地址,会被163拒绝发送。

 

二、新建自己的邮件发送类MailSender

import javax.annotation.PostConstruct;
import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;

@Component
public class MailSender {
    private static final Logger logger = LoggerFactory.getLogger(MailSender.class);

    @Autowired
    private JavaMailSender sender;
    
    private String from = "mail.xml中配置的发件邮箱账号";
    
    private String alias = "发件人别名";
    
    /**
     * 初始化操作,依赖注入完成后会被自动调用
     */
    @PostConstruct
    private void init() {
        try {
            String fromAddress = javax.mail.internet.MimeUtility.encodeText(alias) + "<" + from + ">";
            simpleMailTemplate.setFrom(fromAddress);
        } catch (Exception e) {
            simpleMailTemplate.setFrom(from);
            logger.error("识别alias异常", e);
        }
    }
    
    /**
     * 发送简单文本邮件(不支持html)
     * @param mailTo 目标邮箱
     * @param subject 邮件主题
     * @param text 邮件内容
     */
    public void sendSimpleMail(String mailTo, String subject, String text) {
        SimpleMailMessage message = new SimpleMailMessage(simpleMailTemplate);
        message.setTo(mailTo);
        message.setSubject(subject);
        message.setText(text);
        
        try {
            sender.send(message);
        } catch (Exception e) {
            logger.info("邮件发送异常", e);
        }
    }
    
    /**
     * 发送富文本邮件(支持html)
     * @param mailTo
     * @param subject
     * @param text
     */
    public void sendMimeMail(String mailTo, String subject, String text) {
        MimeMessage mimeMessage = sender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "utf-8");
        
        try {
            helper.setFrom(from, alias);
            helper.setTo(mailTo);
            helper.setSubject(subject);
            helper.setText(text, true);
            
            sender.send(mimeMessage);
        } catch (UnsupportedEncodingException | MessagingException e) {
            logger.error("构造mime邮件异常:", e);
        } catch (Exception e) {
            logger.error("邮件发送异常", e);
        }
    }
}

 

三、使用freemarker模板发送富文本邮件

上面的MailSender.sendMimeMail( )方法发送富文本时候,需要将整个html放在字符串参数text中,这样用起来比较麻烦。因此就想有没办法像前端模板引擎那样,给MailSender一个html格式的邮件模板就能发送。折腾了一晚上,总算搞定JavaMailSender + freemarker模板的组合。

3.1 配置spring项目的freemarker configuer

因为我自己的项目用的是freemarker做模板引擎,所以在我的spring xml配置中已经配好了模板目录

    <beans:bean id="freemarkerConfig"
        class="org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer">
        <beans:property name="templateLoaderPath" value="/WEB-INF/views" />
    </beans:bean>

如果并没有用freemarker做前端模板引擎的话,可以新建一个FreeMarkerConfigurationFactoryBean的配置

   <beans:bean id="freemarkerConfig"
        class="org.springframework.ui.freemarker.FreeMarkerConfigurationFactoryBean">
        <beans:property name="templateLoaderPath" value="/WEB-INF/views" />
    </beans:bean>

FreeMarkerConfigurer与FreeMarkerConfigurationFactoryBean其实都继承自FreeMarkerConfigurationFactory,都能生成后面药用的freemarker.template.Configuration。

3.2 修改MailSender类

然后在第二步的MailSender类中添加代码:

import org.springframework.ui.freemarker.FreeMarkerTemplateUtils;
import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer;

import freemarker.template.Configuration;

public class MailSender {
    @Autowired
    private FreeMarkerConfigurer fc;
    
    /**
     * 中间代码省略,同本文第二部分
     */
    
    public void sendFreemarkerMail(String mailTo, String subject, Map<String, String> model) throws Exception {
        Configuration cfg = fc.createConfiguration();

        // 将freemarker模板转换成String字符串
        String output = FreeMarkerTemplateUtils.processTemplateIntoString(cfg.getTemplate("mailTemplate.ftl"), model);

        // 调用富文本邮件发送方法
        sendMimeMail(mailTo, subject, output);
    }
}

看起来挺简单的,但是因为路径的问题被单元测试折腾了一晚上。我还是不太理解java项目的classpath。。。=。= 到现在还是不太理解。

3.3 TemplateNotFoundException与classpath问题

先说上面代码调用freemarker模板遇到的问题吧,在进行单元测试时候,总是发生错误TemplateNotFoundException,说模板文件找不到。我用的是maven生成spring mvc项目的标准目录结构。3.1节中配置的freemarkerConfiguer路径指向"/WEB-INF/views",所以我将mailTemplate.ftl文件也放在views目录下。

屏幕快照 2016-04-21 下午1.47.12

打开项目根目录的.classpath文件,可以看到项目开发环境的classpath其实是src/main/java与src/main/resources两个目录,项目运行时会被拷到target/classes目录。项目测试环境的classpath其实是src/test/java与src/test/resources两个目录,测试运行时会被拷到target/test-classes目录。这四个才是真正的classpath!(我觉得这个classpath是对于eclipse来说的)

屏幕快照 2016-04-21 下午1.21.07

而对于像spring mvc这样的web项目,那个src/main/webapp/WEB-INF目录又是什么呢?在spring mvc的servlet-context.xml配置为什么要放在这个路径?在servlet-context.xml中配置项的路径比如下面这些又怎么理解?

<resources mapping="/resources/**" location="/resources/" />
...
<beans:property name="templateLoaderPath" value="/WEB-INF/views" />

其实把web项目打成war包发布时候就知道为什么了。war包其实就是/srac/main/webapp的zip压缩包。src/main/webapp才是web项目发布时的根目录。在打包war的时候,会将原classpath下的资源文件与java代码生成的class文件拷贝到WEB-INF/classes目录下,项目依赖的jar包拷贝到WEB-INF/lib目录下。

tomcat运行web项目时会首先找到项目根目录下WEB-INF/web.xml这个配置文件进行载入。这时候,对于tomcat而言,项目的classpath应该是WEB-INF/classes与WEB-INF/lib。

而我们在web.xml,spring的xml配置文件中指定的 location ="/resources/", templateLoaderPath="/WEB-INF/views",这些路径都是相对路径,相对于ApplicationContext的路径。tomcat运行的web项目,其ApplicationContext路径就是web.xml的当前路径。

下面这张表描述了spring项目中资源定位前缀的意义。

屏幕快照 2016-04-21 下午2.39.47

通过上面的描述应该稍稍能理解开发环境与发布环境的classpath的区别了,先懂了这个,再来尝试理解测试环境时为什么说找不到模板文件。

再看回上面的.classpath文件,测试环境的classpath是src/test/java与src/test/resources,目标都是放到target/test-classes。所以,要想让测试环境时能找到比如说location="classpath:log4j.xml"、location="classpath:config/db.properties"这样的文件,那么就应该保证该文件在src/test/resources也有一份,这样测试时才能正确读取。

而通常的项目,src/test路径下是不会包含webapp目录的,eclipse IDE也不会帮我们拷贝src/main/webapp过去。那么测试时候根本找不到stc/main/webapp目录中定义的配置与其他资源。

我用junit进行spring的单元测试时,是用file前缀指定了spring的context.xml文件的绝对路径,所以才能在测试时启动spring容器。

测试环境启动,读取到spring context.xml中配置的freemarkerConfiguer路径为/WEB-INF/views,但此时测试环境的classpath与工作目录都是target/test-classes。所以要想让测试环境能读到target/test-calsses/WEB-INF/views/mailTemplate.ftl文件,要么将该文件直接从src/main/webapp/WEB-INF/views/拷贝过来,要么可以拷贝到src/test/resources/WEB-INF/views/目录中。

为什么junit单元测试运行时路径是target/test-classes。。。 我也搞不懂 =。=# java的路径问题真是叫人伤心💔

 

四、不要在主线程中发送邮件!

发送邮件需要连接远程smtp邮件服务器,由于本地服务器到远程smtp邮件服务器之间存在网络延时,这会导致发送邮件的操作需要耗时几百毫秒甚至几秒都有可能,这取决于本地服务器到smtp服务器之间的网络状况。因此,千万不要在业务主线程中以阻塞方式发送邮件!除非你想让用户在发送邮件期间去喝一杯咖啡☕️。

正确的解决方案应该是使用多线程的方式异步发送邮件。

修改MailSender类:

public class MailSender {
    // 使用Executor线程池来管理后台线程
    private static Executor executor = Executors.newFixedThreadPool(3);
    
    @Autowired
    private JavaMailSender sender;

    /**
     * 中间代码省略,同本文第二部分
     */

    public void sendSimpleMail(final String mailTo, final String subject, final String text) {
        final SimpleMailMessage message = new SimpleMailMessage(simpleMailTemplate);
        message.setTo(mailTo);
        message.setSubject(subject);
        message.setText(text);
        
        Runnable task = new Runnable() {
            public void run() {
                try {
                    sender.send(message);
                    logger.info("邮件发送成功.to: {}, subject: {}", mailTo, subject);
                } catch (Exception e) {
                    logger.error("邮件发送异常.to: {}, subject: {}\n{}", mailTo, subject, e);
                }
            }
        };
        
        // 调用Executor线程池来执行发送邮件的线程
        executor.execute(task);
    }
}

PS:如果你使用junit来测试这个多线程版的sendSimpleMail方法,记得在junit主线程末尾sleep一下,否则会导致发送邮件子线程还没运行,主线程就已经结束。那子线程自然就跟着挂艹了。

又PS:由于上面这个原因,如果系统有很多像发送邮件这样需要多线程异步处理的操作,其实更好的方案应该是启动一个独立的任务处理进程,web系统将发送邮件,或者其他异步任务发给这个任务处理进程(这里可以用消息队列来实现通信),由任务进程来处理异步任务。这样就不用怕web系统重启的时候杀掉正在执行的异步任务了。

Leave a Reply

Your email address will not be published. Required fields are marked *

TOC