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目录下。
打开项目根目录的.classpath文件,可以看到项目开发环境的classpath其实是src/main/java与src/main/resources两个目录,项目运行时会被拷到target/classes目录。项目测试环境的classpath其实是src/test/java与src/test/resources两个目录,测试运行时会被拷到target/test-classes目录。这四个才是真正的classpath!(我觉得这个classpath是对于eclipse来说的)
而对于像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项目中资源定位前缀的意义。
通过上面的描述应该稍稍能理解开发环境与发布环境的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系统重启的时候杀掉正在执行的异步任务了。