0

0

如何测试容器外的JSP页面

巴扎黑

巴扎黑

发布时间:2017-08-10 15:12:49

|

1779人浏览过

|

来源于php中文网

原创

Jsp测试技术

开发web应用程序最恼人的一点就是想要测试的话你就必须向将其部署好。当然,并不是所有部分都这样。如果你是经过了精心的设计的话,你可以在Java程序中测试业务逻辑。你可以在应用服务器不运行的情况下测试数据访问、接口以及存储过程。不过如果是测试GUI的话(由Jsp所产生的HTMl),你就必须向将其部署,然后才可能测试。

很多的团队求助于Sellenium,Mercury或是其他的一些工具通过web server来测试GUI。然而,即使是页面的内容不变但样式变了得情况也会让测试变得脆弱不堪。其他的团队使用Cactus解决这种脆弱性,或是用HtmlUnit、HttpUnit这样原始的工具来监测web应用程序所生成的HTML。对于这些问题,我会在另一系列的blog之中来谈论。

本文之中我会介绍一种简单易行的技术,它使用JUnit或是HtmlUnit来测试Jsp页面,并且完全脱离容器。这项技术的优势也在此。

你不必一定保持容器的运行,甚至存在。你可以在选择特定的webserver之前就测试你的Jsp。

你不必在每次修改后重新部署,因而编辑/编译/测试的过程会更迅速。

你可以使用测试优先开发的方式来持续的构建Jsp。

容器外测试Jsp技术之所以并不盛行是因为Jsp在设计上就运行于容器内的。设计者从未过多的想过容器外运行的可能。因此由Jsp编译器的所生成代码往往依赖于容器所提供的诸多组件。即使是生成Jsp代码的工具也假定了你已经有一个成功部署的web应用程序在运行。因此,为了在容器外运行,你就要开发出相应的这些工具和组件。

依赖管理的抱怨

为什么这么多框架和工具的设计者们总期望你生活在他们提供的狭小世界中?为什么我必须先构建出完整的web应用才能编译Jsp?为什么这些东西一定要运行在容器中?信息隐藏早在10年前就已经是优秀软件设计的基本信条了。我们这个行业何时才能认真对待它?

编译Jsp

测试Jsp的第一步是将其编译为servlet。实现这一步,我们还需要先将Jsp转换成Java格式。Apache提供了一个叫做Jasper的工具,我们调用Jasper为MyPage.jsp创建一个Java格式的源文件MyPage_jsp.java。然后,你就可以使用你最喜欢的IDE编译这个文件成Servlet。

可惜Jasper并非是设计用在命令行中使用的,或者说并不是完全这样设计的。但Jasper确有一个main函数用来处理命令行参数,而且通过调用java org.apache.jasper.JspC就能够轻易调用它了。不过,Jasper期望它所运行的环境与容器环境是保持一致的。你要确保classpath中有了很多apache的Jar文件,而且它要能找到web应用程序的web.xml。它还需要能够找到包含web应用程序Jar以及TLD文件等的WEB-INF目录。简而言之,Jasper需要能找到一个完整的web应用程序。

如果事情更糟的话,除非是与TOMCAT的调用方式保持完全一致,否则某些特定的Jasper版本(我用的是tomcat 5.5.20)存在一些bug,它生成的代码会有一些错误。

第一点要做的虽然繁琐但还算简单,你需要创建好正确的目录以及文件结构,然后在Ant(Classpath更容易控制)中调用Jasper。第二点就需要一定的研究和测试才能让它跑起来。以下就是能成功运行的ant文件。JspC的调用出现在最后一个任务中。

 

 

 

 

 

 

 

 

 

   

     

   

   

   

     

   

   

     

   

   

   

     

   

 

 

   

   

 

 

   

   

     

   

 

 

   

   

 

 

   

     

   

   

     

   

    

   

 

 

   

   

     

     

     

       

         

       

       

         

       

       

         

        

       

         

       

       

     

   

   

         includes="**/jsp/**/*.class"

      />

 

当然,你要让所有标准文件以及目录都在${build.war.home}之下以确保工作。如果你在你的Jsp之中使用了自定义tag的话,还要确保所有相应的TLD文件都在你的TLD目录之中。

要注意的是,在ant文件中调用Jspc的命令行,而不是使用Tomcat所提供的JspC的Ant Task。因为我发现当你有自定义tag的时候它无法正确运行。也许我犯了糊涂,或者JspC中确实有bug。不过我所发现的唯一能让Jasper生成正确代码的方式是从命令行调用它,并明确的传递Jsp文件路径作为命令行的参数!如果你依靠它的Ant Task或是使用命令行来搜索所有web应用中的Jsp进行编译的话,它就会生成错误的代码。(请参阅这篇blog)

现在我们有了Java文件,让我们来分析一下它。首先,请看下面的Jsp文件。

  List loanRecords = (List) request.getAttribute("loanRecords");

  if (loanRecords.size() > 0) {

%>

 

   

   

   

   

 

 

    for (int i = 0; i

      LoanRecord loanRecord = (LoanRecord) loanRecords.get(i);

  %>

 

">

   

   

   

   

 

 

    }

  %>

ID Title Due date Fine

   

   

   

   

  }

%>

下面则是Jasper所生成的代码。

package com.objectmentor.library.jsp.WEB_002dINF.pages.patrons.books;

import javax.servlet.*;

import javax.servlet.http.*;

import javax.servlet.jsp.*;

import com.objectmentor.library.utils.DateUtil;

import com.objectmentor.library.web.controller.patrons.LoanRecord;

import java.util.List;

public final class loanRecords_jsp extends org.apache.jasper.runtime.HttpJspBase

    implements org.apache.jasper.runtime.JspSourceDependent {

  private static java.util.List _jspx_dependants;

  public Object getDependants() {

    return _jspx_dependants;

  }

  public void _jspService(HttpServletRequest request, HttpServletResponse response)

        throws java.io.IOException, ServletException {

    JspFactory _jspxFactory = null;

    PageContext pageContext = null;

    HttpSession session = null;

    ServletContext application = null;

    ServletConfig config = null;

    JspWriter out = null;

    Object page = this;

    JspWriter _jspx_out = null;

    PageContext _jspx_page_context = null;

    try {

      _jspxFactory = JspFactory.getDefaultFactory();

      response.setContentType("text/html");

      pageContext = _jspxFactory.getPageContext(this, request, response,

                  null, true, 8192, true);

      _jspx_page_context = pageContext;

      application = pageContext.getServletContext();

      config = pageContext.getServletConfig();

      session = pageContext.getSession();

      out = pageContext.getOut();

      _jspx_out = out;

      out.write('/n');

      out.write('/n');

      out.write('/n');

  List loanRecords = (List) request.getAttribute("loanRecords");

  if (loanRecords.size() > 0) {

      out.write("/n");

      out.write("

/n");

      out.write(" 

/n");

      out.write("   

/n");

      out.write("    

/n");

      out.write("   

/n");

      out.write("   

/n");

      out.write(" 

/n");

      out.write("  ");

    for (int i = 0; i

      LoanRecord loanRecord = (LoanRecord) loanRecords.get(i);

      out.write("/n");

      out.write(" 

      out.print(i%2==0?"even":"odd");

      out.write("/">/n");

      out.write("   

/n");

      out.write("   

/n");

      out.write("   

/n");

      out.write("   

/n");

      out.write(" 

/n");

      out.write("  ");

    }

      out.write("/n");

      out.write("

ID Title Due date Fine
");

      out.print(loanRecord.id);

      out.write("/n");

      out.write("   

");

      out.print(loanRecord.title);

      out.write("/n");

      out.write("   

");

      out.print(DateUtil.dateToString(loanRecord.dueDate));

      out.write("/n");

      out.write("   

");

      out.print(loanRecord.fine.toString());

      out.write("/n");

      out.write("   

/n");

  }

    } catch (Throwable t) {

      if (!(t instanceof SkipPageException)){

        out = _jspx_out;

        if (out != null && out.getBufferSize() != 0)

          out.clearBuffer();

        if (_jspx_page_context != null) _jspx_page_context.handlePageException(t);

      }

    } finally {

      if (_jspxFactory != null) _jspxFactory.releasePageContext(_jspx_page_context);

    }

  }

}

最后的抱怨

这个类为什么要声明为final呢?如果我想创建一个测试的stub派生类呢?为什么有人会觉得生成类如此不可冒犯以至于我都无法覆写它。

仔细读过这段代码你就会发现,要想使用这个servlet的实例我们需要HttpServletRequest以及HttpServletResponse的实例。

更仔细研读一下我们就会发现servlet将所有的HTML写到JspWriter的实例中,而JspWriter是从PageContext中获得的。如果我们能够创建一个JspWriter的mock up的版本来保存所有的这些HTML,再为PageContext创建一个mock up的版本来派送mock JspWriter,那么我们就能在我们的测试中访问这些HTML了。

幸运的是,Tomcat的设计人员把JspWriter的创建放入到了JspFactory的工厂类中。而这个工厂类是可以覆写的!这就意味着我们可以在servlet之中获得我们自己的JspWriter类而不用改变servlet。需要的就是下面这段代码。

  class MockJspFactory extends JspFactory {

    public PageContext getPageContext(Servlet servlet, ServletRequest servletRequest, ServletResponse servletResponse, String string, boolean b, int i, boolean b1) {

      return new MockPageContext(new MockJspWriter());

    }

    public void releasePageContext(PageContext pageContext) {

    }

    public JspEngineInfo getEngineInfo() {

      return null;

    }

  }

现在,我们需要的是mock Jspwriter。为了便于展示,我用了下面的:

MockJspWriter

package com.objectmentor.library.web.framework.mocks;

import javax.servlet.jsp.JspWriter;

import java.io.IOException;

public class MockJspWriter extends JspWriter {

  private StringBuffer submittedContent;

  public MockJspWriter(int bufferSize, boolean autoFlush) {

    super(bufferSize, autoFlush);

    submittedContent = new StringBuffer();

  }

  public String getContent() {

    return submittedContent.toString();

  }

  public void print(String arg0) throws IOException {

    submittedContent.append(arg0);

  }

  public void write(char[] arg0, int arg1, int arg2) throws IOException {

    for (int i=0; i

      submittedContent.append(String.valueOf(arg0[arg1++]));

  }

  public void write(String content) throws IOException {

    submittedContent.append(content);

  }

  // lots of uninteresting methods elided.  I just gave them

  // degenerate implementations.  (e.g. {})

}

无需关心那些我省略掉的未实现方法,我认为只需要关心那些足够使得我的测试得以运行的方法即可。对于剩下的,我只会使用其退化实现。

我的IDE对于创建这些mock类非常有帮助。它能够自动化的构建方法原型,并为那些接口或是抽象类所需要实现的方法给出退化的实现。

同样的用类似方法创建出MockPageContext,MockHttpServletRequest以及MockHttpServletResponse类。

MockPageContext

package com.objectmentor.library.web.framework.mocks;

import javax.servlet.*;

import javax.servlet.http.*;

import javax.servlet.jsp.*;

import java.io.IOException;

import java.util.Enumeration;

public class MockPageContext extends PageContext {

  private final JspWriter out;

  private HttpServletRequest request;

  public MockPageContext(JspWriter out) {

    this.out = out;

    request = new MockHttpServletRequest();

  }

  public JspWriter getOut() {

    return out;

  }

  public ServletRequest getRequest() {

    return request;

  }

  // lots of degenerate functions elided.

}

MockHttpServletRequest

package com.objectmentor.library.web.framework.mocks;

import javax.servlet.*;

import javax.servlet.http.*;

import java.io.*;

import java.security.Principal;

import java.util.*;

public class MockHttpServletRequest implements HttpServletRequest {

  private String method;

  private String contextPath;

  private String requestURI;

  private HttpSession session = new MockHttpSession();

  private Map parameters = new HashMap();

  private Map attributes = new HashMap();

  public MockHttpServletRequest(String method, String contextPath,

                                String requestURI) {

    super();

    this.method = method;

    this.contextPath = contextPath;

    this.requestURI = requestURI;

  }

  public MockHttpServletRequest() {

    this("GET");

  }

  public MockHttpServletRequest(String method) {

    this(method, "/Library", "/Library/foo/bar.jsp");

  }

  public String getContextPath() {

    return contextPath;

  }

  public String getMethod() {

    return method;

  }

  public String getRequestURI() {

    return requestURI;

  }

  public String getServletPath() {

    return requestURI.substring(getContextPath().length());

  }

  public HttpSession getSession() {

    return session;

  }

  public HttpSession getSession(boolean arg0) {

    return session;

  }

  public Object getAttribute(String arg0) {

    return attributes.get(arg0);

  }

  public String getParameter(String arg0) {

    return (String) parameters.get(arg0);

  }

  public Map getParameterMap() {

    return parameters;

  }

  public Enumeration getParameterNames() {

    return null;

  }

  public void setSession(HttpSession session) {

    this.session = session;

  }

  public void setParameter(String s, String s1) {

    parameters.put(s, s1);

  }

  public void setAttribute(String name, Object value) {

    attributes.put(name, value);

  }

  // Lots of degenerate methods elided.

}

MockHttpServletResponse

package com.objectmentor.library.web.framework.mocks;

import javax.servlet.ServletOutputStream;

import javax.servlet.http.*;

import java.io.*;

import java.util.Locale;

public class MockHttpServletResponse implements HttpServletResponse {

  // all functions are implemented to be degenerate.

}

有了这些mock对象,现在我就可以创建一个loanRecords_jsp的servlet实例并且开始调用它!我的头一个测试用例就像下面这样:


  public void testSimpleTest() throws Exception {
    MockJspWriter jspWriter = new MockJspWriter();
    MockPageContext pageContext = new MockPageContext(jspWriter);
    JspFactory.setDefaultFactory(new MockJspFactory(pageContext));
    HttpJspBase jspPage = new loanRecords_jsp();
    HttpServletRequest request = new MockHttpServletRequest();
    HttpServletResponse response = new MockHttpServletResponse();
 
    jspPage._jspInit();
    jspPage._jspService(request, response);
 
    assertEquals("", jspWriter.getContent());
  }

就像预期的一样,测试失败了。这是因为还有些内容还没补充上,不过所剩无多。如果你仔细的看过Jsp文件,你就会发现它调用了request.getAttribute(“loanRecords”)并且期望返回一个List。但因为目前的测试并未为这样的属性赋值,从而导致了代码抛出了异常。

要想成功让servlet输出HTML,我们还需要加载这个属性。然后,我们就可以使用HtmlUnit来解析此HTML并且编写相应的单元测试。

HtmlUnit非常的容易使用,尤其是在测试所产生的像是本例这样的web pages上。我这里还有篇文章详细的介绍了它。

下面就是最终测试加载属性的测试,它通过htmlunit来检测HTML,并且做出正确的判断:


package com.objectmentor.library.jspTest.books.patrons.books;
 
import com.gargoylesoftware.htmlunit.*;
import com.gargoylesoftware.htmlunit.html.*;
import com.objectmentor.library.jsp.WEB_002dINF.pages.patrons.books.loanRecords_jsp;
import com.objectmentor.library.utils.*;
import com.objectmentor.library.web.controller.patrons.LoanRecord;
import com.objectmentor.library.web.framework.mocks.*;
import junit.framework.TestCase;
import org.apache.jasper.runtime.HttpJspBase;
 
import javax.servlet.*;
import javax.servlet.http.*;
import javax.servlet.jsp.*;
import java.util.*;
 
public class LoanRecordsJspTest extends TestCase {
  private MockPageContext pageContext;
  private MockJspWriter jspWriter;
  private JspFactory mockFactory;
  private MockHttpServletResponse response;
  private MockHttpServletRequest request;
  private WebClient webClient;
  private TopLevelWindow dummyWindow;
 
  protected void setUp() throws Exception {
    jspWriter = new MockJspWriter();
    pageContext = new MockPageContext(jspWriter);
    mockFactory = new MockJspFactory(pageContext);
 
    JspFactory.setDefaultFactory(mockFactory);
    response = new MockHttpServletResponse();
    request = new MockHttpServletRequest();
    webClient = new WebClient();
    webClient.setJavaScriptEnabled(false);
    dummyWindow = new TopLevelWindow("", webClient);
  }
 
  public void testLoanRecordsPageGeneratesAppropriateTableRows() throws Exception {
    HttpJspBase jspPage = new loanRecords_jsp();
    jspPage._jspInit();
 
    List loanRecords = new ArrayList();
    addLoanRecord(loanRecords,
                  "99",
                  "Empire",
                  DateUtil.dateFromString("2/11/2007"),
                  new Money(4200));
    addLoanRecord(loanRecords,
                  "98",
                  "Orbitsville",
                  DateUtil.dateFromString("2/12/2007"),
                  new Money(5200));
 
    request.setAttribute("loanRecords", loanRecords);
 
    jspPage._jspService(request, response);
 
    StringWebResponse stringWebResponse = new StringWebResponse(jspWriter.getContent());
    HtmlPage page = HTMLParser.parse(stringWebResponse, dummyWindow);
    HtmlElement html = page.getDocumentElement();
 
    HtmlTable table = (HtmlTable) html.getHtmlElementById("loanRecords");
    List rows = table.getHtmlElementsByTagName("tr");
    assertEquals(3, rows.size());
 
    assertEquals("even", classOfElement(rows.get(1)));
    assertEquals("odd", classOfElement(rows.get(2)));
 
    List firstRowCells = rows.get(1).getCells();
    assertEquals(4, firstRowCells.size());
 
    List secondRowCells = rows.get(2).getCells();
    assertEquals(4, secondRowCells.size());
 
    assertLoanRecordRowEquals("99", "Empire", "02/11/2007", "$42.00", firstRowCells);
    assertLoanRecordRowEquals("98", "Orbitsville", "02/12/2007", "$52.00", secondRowCells);
  }
 
  private String classOfElement(HtmlTableRow firstDataRow) {return firstDataRow.getAttributeValue("class");}
 
  private void assertLoanRecordRowEquals(String id, String title, String dueDate, String fine, List rowCells) {
    assertEquals(id, rowCells.get(0).asText());
    assertEquals(title, rowCells.get(1).asText());
    assertEquals(dueDate, rowCells.get(2).asText());
    assertEquals(fine, rowCells.get(3).asText());
  }
 
  private void addLoanRecord(List loanRecords, String id, String title, Date dueDate, Money fine) {
    LoanRecord loanRecord = new LoanRecord();
    loanRecord.id = id;
    loanRecord.title = title;
    loanRecord.dueDate = dueDate;
    loanRecord.fine = fine;
 
    loanRecords.add(loanRecord);
  }
 
  private class MockJspFactory extends JspFactory {
    private PageContext pageContext;
    public MockJspFactory(PageContext pageContext) {
      this.pageContext = pageContext;
    }
 
    public PageContext getPageContext(Servlet servlet, ServletRequest servletRequest, ServletResponse servletResponse, String string, boolean b, int i, boolean b1) {
      return pageContext;
    }
 
    public void releasePageContext(PageContext pageContext) {
    }
 
    public JspEngineInfo getEngineInfo() {
      return null;
    }
  }
}

上述的测试确保了所生成的html中表格中的每一行都具有正确的内容。这项测试确实能够测出是否存在这样的表格,并且判断出是否表格的每一行是按照正确的顺序来展现的。同时,它也确保了每一行的相应style。测试忽略了此外的表单以及语法部分。

结论

这篇发表在此的技术能够用来测试几乎所有目前我们所见过的web页面,并且脱离容器,也无需web server的运行。相对来说,它也比较容易去设置,并且非常易于扩展。有了它,你就可以快速的进行编辑、编译、测试的周期性迭代,并且你也能遵循测试驱动开发的原则了。

站长俱乐部购物系统
站长俱乐部购物系统

功能介绍:1、模块化的程序设计,使得前台页面设计与程序设计几乎完全分离。在前台页面采用过程调用方法。在修改页面设计时只需要在相应位置调用设计好的过程就可以了。另外,这些过程还提供了不同的调用参数,以实现不同的效果;2、阅读等级功能,可以加密产品,进行收费管理;3、可以完全可视化编辑文章内容,所见即所得;4、无组件上传文件,服务器无需安装任何上传组件,无需支持FSO,即可上传文件。可限制文件上传的类

下载

(原文链接网址: http://blog.objectmentor.com/articles/category/testing-guis; Robert C. Martin的英文blog网址: http://blog.objectmentor.com/ 

作者简介:Robert C. Martin是Object Mentor公司总裁,面向对象设计、模式、UML、敏捷方法学和极限编程领域内的资深顾问。他不仅是Jolt获奖图书《敏捷软件开发:原则、模式与实践》(中文版)(《敏捷软件开发》(英文影印版))的作者,还是畅销书Designing Object-Oriented C++ Applications Using the Booch Method的作者。Martin是Pattern Languages of Program Design 3和More C++ Gems的主编,并与James Newkirk合著了XP in Practice。他是国际程序员大会上著名的发言人,并在C++ Report杂志担任过4年的编辑。

热门AI工具

更多
DeepSeek
DeepSeek

幻方量化公司旗下的开源大模型平台

豆包大模型
豆包大模型

字节跳动自主研发的一系列大型语言模型

通义千问
通义千问

阿里巴巴推出的全能AI助手

腾讯元宝
腾讯元宝

腾讯混元平台推出的AI助手

文心一言
文心一言

文心一言是百度开发的AI聊天机器人,通过对话可以生成各种形式的内容。

讯飞写作
讯飞写作

基于讯飞星火大模型的AI写作工具,可以快速生成新闻稿件、品宣文案、工作总结、心得体会等各种文文稿

即梦AI
即梦AI

一站式AI创作平台,免费AI图片和视频生成。

ChatGPT
ChatGPT

最最强大的AI聊天机器人程序,ChatGPT不单是聊天机器人,还能进行撰写邮件、视频脚本、文案、翻译、代码等任务。

相关专题

更多
Golang处理数据库错误教程合集
Golang处理数据库错误教程合集

本专题整合了Golang数据库错误处理方法、技巧、管理策略相关内容,阅读专题下面的文章了解更多详细内容。

2

2026.02.06

java多线程方法汇总
java多线程方法汇总

本专题整合了java多线程面试题、实现函数、执行并发相关内容,阅读专题下面的文章了解更多详细内容。

0

2026.02.06

1688阿里巴巴货源平台入口与批发采购指南
1688阿里巴巴货源平台入口与批发采购指南

本专题整理了1688阿里巴巴批发进货平台的最新入口地址与在线采购指南,帮助用户快速找到官方网站入口,了解如何进行批发采购、货源选择以及厂家直销等功能,提升采购效率与平台使用体验。

90

2026.02.06

快手网页版入口与电脑端使用指南 快手官方短视频观看入口
快手网页版入口与电脑端使用指南 快手官方短视频观看入口

本专题汇总了快手网页版的最新入口地址和电脑版使用方法,详细提供快手官网直接访问链接、网页端操作教程,以及如何无需下载安装直接观看短视频的方式,帮助用户轻松浏览和观看快手短视频内容。

15

2026.02.06

C# 多线程与异步编程
C# 多线程与异步编程

本专题深入讲解 C# 中多线程与异步编程的核心概念与实战技巧,包括线程池管理、Task 类的使用、async/await 异步编程模式、并发控制与线程同步、死锁与竞态条件的解决方案。通过实际项目,帮助开发者掌握 如何在 C# 中构建高并发、低延迟的异步系统,提升应用性能和响应速度。

10

2026.02.06

Python 微服务架构与 FastAPI 框架
Python 微服务架构与 FastAPI 框架

本专题系统讲解 Python 微服务架构设计与 FastAPI 框架应用,涵盖 FastAPI 的快速开发、路由与依赖注入、数据模型验证、API 文档自动生成、OAuth2 与 JWT 身份验证、异步支持、部署与扩展等。通过实际案例,帮助学习者掌握 使用 FastAPI 构建高效、可扩展的微服务应用,提高服务响应速度与系统可维护性。

6

2026.02.06

JavaScript 异步编程与事件驱动架构
JavaScript 异步编程与事件驱动架构

本专题深入讲解 JavaScript 异步编程与事件驱动架构,涵盖 Promise、async/await、事件循环机制、回调函数、任务队列与微任务队列、以及如何设计高效的异步应用架构。通过多个实际示例,帮助开发者掌握 如何处理复杂异步操作,并利用事件驱动设计模式构建高效、响应式应用。

7

2026.02.06

java连接字符串方法汇总
java连接字符串方法汇总

本专题整合了java连接字符串教程合集,阅读专题下面的文章了解更多详细操作。

25

2026.02.05

java中fail含义
java中fail含义

本专题整合了java中fail的含义、作用相关内容,阅读专题下面的文章了解更多详细内容。

28

2026.02.05

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
React 教程
React 教程

共58课时 | 4.7万人学习

TypeScript 教程
TypeScript 教程

共19课时 | 2.8万人学习

Bootstrap 5教程
Bootstrap 5教程

共46课时 | 3.2万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2026 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号