写给 Java 程序员的 Go Web 框架 Gin 源码入门
yuyutoo 2024-10-27 17:03 2 浏览 0 评论
本文会比较 Gin 与 Netty 以及 Spring 在一些设计上的异同。如果你没有 Java 开发经验,也完全可以略去对比 Java 的部分内容,并不影响理解本文。
1. Gin 启动过程过程概述
下面是使用 Gin 实现监听本机端口 2333,然后返回一个 hello world 字符串作为 HTTP 响应正文,最终返回给前端的最简单的案例:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "Hello World!")
})
r.Run(":2333")
}
如上的代码块所示,最简单的 Gin 启动逻辑分为三步
如果你写过 Netty 后台程序,你会发现这与 Netty 后台应用的启动逻辑非常相似。案例来自于: Netty-Example-http-helloworld
package io.netty.example.http.helloworld;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.util.SelfSignedCertificate;
/**
* An HTTP server that sends back the content of the received HTTP request
* in a pretty plaintext form.
*/
public final class HttpHelloWorldServer {
static final boolean SSL = System.getProperty("ssl") != null;
static final int PORT = Integer.parseInt(System.getProperty("port", SSL? "8443" : "8080"));
public static void main(String[] args) throws Exception {
// Configure SSL.
final SslContext sslCtx;
if (SSL) {
SelfSignedCertificate ssc = new SelfSignedCertificate();
sslCtx = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build();
} else {
sslCtx = null;
}
// Configure the server.
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.option(ChannelOption.SO_BACKLOG, 1024);
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new HttpHelloWorldServerInitializer(sslCtx));
Channel ch = b.bind(PORT).sync().channel();
System.err.println("Open your web browser and navigate to " +
(SSL? "https" : "http") + "://127.0.0.1:" + PORT + '/');
ch.closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
基于 Netty 的 HTTP 后台启动过程如下(过滤掉 SSL 的相关逻辑):
- 配置基于 Reactor 模型的线程池 bossGroup 以及 workerGroup
- 配置管道处理 Handler,包含了路由规则配置
- 监听本地 TCP 8080 端口
得益于 Go runtime 对 NIO 网络通信逻辑的封装,Gin 框架并不需要自己实现基于 Reactor 模型的网络通信底层框架。
2. Gin 中的请求处理-拦截过滤器模式
Intercepting filter pattern 拦截过滤器模式在 Web 后端非常常见,一个请求的处理流程可能包含如下若干步骤:
- 日志
- Session/Cookie 逻辑
- 权限校验
- CRUD
- 异常处理
- 结果返回
同一路由下的大部分请求将经过完全相同的后端处理逻辑,因此使用过 拦截过滤器模式 非常合适。
或许,可能你还听说过 责任链模式 ?
责任链模式与拦截过滤器模式很接近,区别主要在于:一个请求(也被称为消息)在责任链(或者说管道 Pipeline)上从头向后传播,责任链上的每一个处理器只能处理特定的请求。如果某一个处理器不能处理该请求,通常是将该请求向后传播给其他处理器。如果能够处理,那么处理完成后,可以选择将处理后的结果继续向后传播,或者选择不再传播。总结对比来说:
- 责任链模式 :每一个处理器负责处理前序处理传过来的特定类型消息,每一个处理器能够处理的消息类型通常是不同的。
- 拦截过滤器模式 :每一个处理器作为整个处理链的一环,每一个处理器能处理的消息通常是同一个类型,就像流水线作业一般,通常在流水线末端才能够产出一个可用的商品。
拦截过滤器模式的案例如下:
public class FilterChain {
//过滤器链
private List<Filter> filters = new ArrayList<Filter>();
//接收结果的 Target 实例
private Target target;
//添加一个处理器
public void addFilter(Filter filter){
filters.add(filter);
}
//请求在过滤器链上遍历处理(还有一种方式是链式调用)
public void execute(String request){
for (Filter filter : filters) {
filter.execute(request);
}
target.execute(request);
}
//设置结果值
public void setTarget(Target target){
this.target = target;
}
}
在 Gin 框架中利用拦截过滤器模式来处理路由来的请求。
gin.go 源码文件中 Engine.handleHTTPRequest 方法用于处理请求,如下所示:
func (engine *Engine) handleHTTPRequest(c *Context) {
httpMethod := c.Request.Method
rPath := c.Request.URL.Path
unescape := false
if engine.UseRawPath && len(c.Request.URL.RawPath) > 0 {
rPath = c.Request.URL.RawPath
unescape = engine.UnescapePathValues
}
if engine.RemoveExtraSlash {
rPath = cleanPath(rPath)
}
// Find root of the tree for the given HTTP method
t := engine.trees
for i, tl := 0, len(t); i < tl; i++ {
if t[i].method != httpMethod {
continue
}
root := t[i].root
// Find route in tree
value := root.getValue(rPath, c.params, unescape)
if value.params != nil {
c.Params = *value.params
}
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next()
c.writermem.WriteHeaderNow()
return
}
if httpMethod != "CONNECT" && rPath != "/" {
if value.tsr && engine.RedirectTrailingSlash {
redirectTrailingSlash(c)
return
}
if engine.RedirectFixedPath && redirectFixedPath(c, root, engine.RedirectFixedPath) {
return
}
}
break
}
if engine.HandleMethodNotAllowed {
for _, tree := range engine.trees {
if tree.method == httpMethod {
continue
}
if value := tree.root.getValue(rPath, nil, unescape); value.handlers != nil {
c.handlers = engine.allNoMethod
serveError(c, http.StatusMethodNotAllowed, default405Body)
return
}
}
}
c.handlers = engine.allNoRoute
serveError(c, http.StatusNotFound, default404Body)
}
其中最关键的方法定义于 context.go 源码 Context.Next 方法,用于实现拦截器的遍历:
func (c *Context) Next() {
c.index++
for c.index < int8(len(c.handlers)) {
c.handlers[c.index](c)
c.index++
}
}
可见,Context.Next 方法符合典型的拦截过滤器模式。这种设计与 Spring Interceptor 拦截器设计类似,在 HandlerExecutionChain 的 applyPreHandle 、 applyPostHandle 以及 triggerAfterCompletion 方法上都能看到拦截过滤器模式的应用。
其中 handlers 切片的元素类型为 HandlersChain 函数,定义如下:
type HandlerFunc func(*Context)
type HandlersChain []HandlerFunc
可见,Gin 中每一个 HandlersChain 元素相互并不感知彼此的存在,那么如果前序 HandlersChain 认为该请求不应当继续被后续 HandlersChain 处理,该如何实现?
Context.next() 方法中的 index 不是方法内的局部变量,而是 Context 实例内部字段。可见,我们只要控制此字段的值就能够控制 Next() 方法的执行逻辑。
Context.Abort() 方法能够确保将 index 值置为比 handlers 切片长度更大,这就确保了 Context.Next() 将无法满足 c.index < int8(len(c.handlers)) 条件,因而无法继续遍历。
const abortIndex int8 = math.MaxInt8 / 2
func (c *Context) Abort() {
c.index = abortIndex
}
这种设计与责任链略有不同,责任链中的每一个处理器需要自己显式决定是否将继续将请求交给后续处理器,例如 Netty 的 ChannelHandlerContext.fireChannelRead ,如果显式调用,就是希望将消息向后传播;否则就是拒绝向后传播,相当于执行了 Context.Aboirt() 方法。
3. 默认的中间件 Logger 以及 Recovery
(1)Logger 中间件
Logger 中间件是默认 Engine 自带的中间件,其本质是一个 HandlerFunc 类型的函数,其被 logger.go 中的 LoggerWithConfig 方法返回,如下所示:
func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
formatter := conf.Formatter
if formatter == nil {
formatter = defaultLogFormatter
}
out := conf.Output
if out == nil {
out = DefaultWriter
}
notlogged := conf.SkipPaths
isTerm := true
if w, ok := out.(*os.File); !ok || os.Getenv("TERM") == "dumb" ||
(!isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd())) {
isTerm = false
}
var skip map[string]struct{}
if length := len(notlogged); length > 0 {
skip = make(map[string]struct{}, length)
for _, path := range notlogged {
skip[path] = struct{}{}
}
}
//这里返回一个 HandlerFunc 类型的函数
return func(c *Context) {
// Start timer
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
// Process request,日志依赖于此方法实现方法的计时逻辑
c.Next()
// Log only when path is not being skipped
if _, ok := skip[path]; !ok {
param := LogFormatterParams{
Request: c.Request,
isTerm: isTerm,
Keys: c.Keys,
}
// Stop timer
param.TimeStamp = time.Now()
param.Latency = param.TimeStamp.Sub(start)
param.ClientIP = c.ClientIP()
param.Method = c.Request.Method
param.StatusCode = c.Writer.Status()
param.ErrorMessage = c.Errors.ByType(ErrorTypePrivate).String()
param.BodySize = c.Writer.Size()
if raw != "" {
path = path + "?" + raw
}
param.Path = path
fmt.Fprint(out, formatter(param))
}
}
}
可见,Logger 对应的 HandlerFunc 函数执行逻辑分为 3 步:
- 在请求被处理前记录一些参数,例如当前时间
- 利用 Context.Next() 方法进行拦截过滤器的逻辑调用
- 请求处理后记录一些参数,例如请求处理耗时,并打印相关日志
(2)Recovery 中间件
Gin 在其官网上说明其具有 Crash-free 的特点:
这是如何实现的?
在 Go 中一个简单的 panic 处理如下所示:
func badCall() {
panic("bad end")
}
func test() {
defer func() {
if e := recover(); e != nil {
fmt.Printf("Panicing %s\r\n", e)
}
}()
badCall()
fmt.Printf("After bad call\r\n") // <-- wordt niet bereikt
}
Gin 作为一个 HTTP 框架,应当能够处理任何 panic,并进行 recover,因为 panic 进程停止运行是无法接受的。
Gin 提供一个 HandlerFunc 类型的方法用于捕获所有 panic,其注入 Engine 的逻辑可以从 Gin.Default() 函数出发。该 HandlerFunc 函数在 revobery.go 源码中的 CustomRecoveryWithWriter 方法负责提供,代码如下:
func CustomRecoveryWithWriter(out io.Writer, handle RecoveryFunc) HandlerFunc {
var logger *log.Logger if out != nil {
logger = log.New(out, "\n\n\x1b[31m", log.LstdFlags) }
return func(c *Context) {
defer func() {
if err := recover(); err != nil {
// Check for a broken connection, as it is not really a
// condition that warrants a panic stack trace.
var brokenPipe bool
if ne, ok := err.(*net.OpError); ok {
if se, ok := ne.Err.(*os.SyscallError); ok {
if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
brokenPipe = true
}
}
}
if logger != nil {
stack := stack(3)
httpRequest, _ := httputil.DumpRequest(c.Request, false)
headers := strings.Split(string(httpRequest), "\r\n")
for idx, header := range headers {
current := strings.Split(header, ":")
if current[0] == "Authorization" {
headers[idx] = current[0] + ": *"
}
}
headersToStr := strings.Join(headers, "\r\n")
if brokenPipe {
logger.Printf("%s\n%s%s", err, headersToStr, reset)
} else if IsDebugging() {
logger.Printf("[Recovery] %s panic recovered:\n%s\n%s\n%s%s",
timeFormat(time.Now()), headersToStr, err, stack, reset)
} else {
logger.Printf("[Recovery] %s panic recovered:\n%s\n%s%s",
timeFormat(time.Now()), err, stack, reset)
}
}
if brokenPipe {
// If the connection is dead, we can't write a status to it.
c.Error(err.(error)) // nolint: errcheck c.Abort() }
else {
handle(c, err)
}
}
}
() c.Next()
}
}
HandlerFunc 函数的运行逻辑分为两步:
- 调用 defer 注册一个异常处理逻辑,将在 Context.Next() 返回后执行。
- 调用 Context.Next() 方法进行拦截过滤器模式的请求处理。
可见,Gin 框架利用上述逻辑来处理一切从 Context.Next() 方法中抛出的 panic 异常。
(3)链式调用模型与方法栈
在 Gin 中,默认的 Logger 以及 Recovery 中间件由于主动执行了 Context.Next() 函数,因此在方法栈上看,这两个 HandlerFunc 类型的方法会先后压入方法栈。这两个方法与 Context.handlers 切片中其余 HandlerFunc 类型的方法实际上是链式调用的关系。
Context.handlers 切片中的 HandlerFunc 类型的方法如果内部不主动调用 Context.Next() 函数,那么切片内部元素之间并没有形成链式调用关系,这避免了方法栈过深。如下图所示:
拦截过滤器的两种遍历方式:链式调用以及基于数组的遍历,它们的优缺点如下:
- 链式调用 :优势 :可以拿到后序处理器的处理情况,Logger 与 Recover 组件都需要后序处理的结果,因此选择链式调用。缺点 :会增加方法栈深度,增加内存消耗(不考虑方法栈重用优化)。
- 数组遍历 :优势 :不会增加方法栈深度,goroutine 是轻量级协程,方法栈深度的下降有利于整个应用占用内存的下降缺点 :无法得到后续处理器的执行情况,无法实现日志、panic 捕获等机制。
因此,Gin 的中间件如果要得到后续 HandlerFunc 方法的执行情况,可以选择显式调用 Context.Next(),否则不需要调用,Gin 会自动完成过滤器链的遍历。
4. Gin 的 Goroutine 模型
学习 Go 语言最令人痴迷的是其 Goroutine 的使用。出于如下两个目标,现代后台系统通常会选择 NIO 而不是 BIO 作为网络通信框架:
- 避免大量创建线程,线程切换的代价很大
- 能够处理海量的网络请求,避免浪费 CPU 资源
基于 NIO 衍生出了基于事件的 Reactor 网络通信模型,Netty 是一个典型代表。这种通信模型的优势是整个后台框架的运行效率非常高,但缺陷是对程序员要求高,编程难度大。Goroutine 的 runtime 巧妙地解决了这个问题,使我们可以像传统 BIO 那样直接写可以阻塞于网络读写的 I/O 逻辑,只不过传统 BIO 中阻塞的是线程,而 Go 阻塞的是 Goroutine。
Go 的 HTTP 后台框架模型可以表示为如下伪码:
//while 循环
for{
//1.阻塞监听某一个端口,当有新连接到来时,停止阻塞
conn := accept()
//2.新建一个协程,负责该新连接的处理
go ioProcess(conn)
}
下面我们看看 Gin 是如何做到的。
首先我们来看 Gin.Run 方法:
func (engine *Engine) Run(addr ...string) (err error) {
defer func() { debugPrintError(err) }()
trustedCIDRs, err := engine.prepareTrustedCIDRs()
if err != nil {
return err
}
engine.trustedCIDRs = trustedCIDRs
address := resolveAddress(addr)
debugPrint("Listening and serving HTTP on %s\n", address)
err = http.ListenAndServe(address, engine)
return
}
其中最关键的步骤是将 Gin 中的 Engine 结构体实例作为 Go 官方 http 包的 ListenAndServe 方法的入口参数:
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
Engine 结构体实际上实现了 http.Handler 接口:
type Handler interface { ServeHTTP(ResponseWriter, *Request)}
其实现方式如下:
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
//从缓存得到一个 Context 实例 c := engine.pool.Get().(*Context)
//进行相关结构体的重置,并与当前请求建立关系 c.writermem.reset(w) c.Request = req c.reset()
//这个方法负责处理请求 engine.handleHTTPRequest(c)
//归还缓存,由于 Recovery 中间件会负责处理 panic,因此这里总是可以执行到 engine.pool.Put(c)}
我们在本文第二章中已经提到了 Engine.handleHTTPRequest 是基于拦截过滤器调用逻辑实现的,这里就不再复述了。
Spring 一个常见的问题是:Spring 与 Tomcat 的关系是什么?
一个简单的回答是 Spring 的 DispatcherServlet 作为全局路由器被嵌入到 Tomcat 中,DispatcherServlet 的静态逻辑会负责加载 Spring 容器,最终提供 Spring Web 的各种功能。
那么 Gin 框架与 Go 官方提供的 http 包有什么关系?
首先,http 包中 Server 结构体的 ListenAndServe 会负责监听指定端口,当有新连接接入时,就会启动一个 goroutine 来处理此新连接。代码如下所示:
func (srv *Server) Serve(l net.Listener) error {
if fn := testHookServerServe; fn != nil {
fn(srv, l) // call hook with unwrapped listener
}
origListener := l
l = &onceCloseListener{Listener: l}
defer l.Close()
if err := srv.setupHTTP2_Serve(); err != nil {
return err
}
if !srv.trackListener(&l, true) {
return ErrServerClosed
}
defer srv.trackListener(&l, false)
baseCtx := context.Background()
if srv.BaseContext != nil {
baseCtx = srv.BaseContext(origListener)
if baseCtx == nil {
panic("BaseContext returned a nil context")
}
}
var tempDelay time.Duration // how long to sleep on accept failure
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
for {
rw, err := l.Accept()
if err != nil {
select {
case <-srv.getDoneChan():
return ErrServerClosed
default:
}
if ne, ok := err.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
}
return err
}
connCtx := ctx
if cc := srv.ConnContext; cc != nil {
connCtx = cc(connCtx, rw)
if connCtx == nil {
panic("ConnContext returned nil")
}
}
tempDelay = 0
c := srv.newConn(rw)
c.setState(c.rwc, StateNew, runHooks) // before Serve can return
//启动一个 Goroutine 来处理新连接
go c.serve(connCtx)
}
}
conn 结构体实例代表一个新连接,其 serve 方法比较长,因为处理 HTTP 请求本身就涉及很多复杂的处理逻辑(例如 HTTP 版本就很多,还包括 HTTPS 的握手建立)。
不过最关键的是 conn.serve 方法会调用 http.Handler.ServeHTTP 方法,最终就会调用 Gin 框架中 Engine 对此接口的具体实现。源码比较长,不贴了,这里给出链接:https://github.com/golang/go/blob/release-branch.go1.16/src/net/http/server.go#L1952
5. SUMMARY
- Gin 与基于 Netty 的 HTTP 后台启动逻辑类似,可以归纳为三步:初始化后台服务引擎、配置路由规则、监听后台端口
- Gin 依赖于拦截过滤器模式来处理请求,过滤器在 Gin 中被称为中间件,其本质上是实现了 HandlerFunc 接口的任意函数。通过 Context.Next() 方法来实现请求在过滤器上的遍历处理,通过调用 Context.Abort() 方法能够提前结束遍历逻辑
- Gin 拦截过滤器链中的默认的中间件是 Logger 以及 Recovery,它们分别用于实现日志以及 panic 捕获处理。我们还分析了拦截过滤器链两种执行方式的优缺点。
- Gin 通过 Engine 实现 http.Handler 接口的 ServeHTTP 方法,注入到 http 包中。http 包会负责在新连接到来时,启动一个新的 goroutine。此 goroutine 会负责调用 Engine.ServeHTTP 方法,实现 HTTP 请求的逻辑处理。
相关推荐
- 网站制作的流程是什么呢?简单大概的流程
-
关注我!了解更多网站建设的小干货~如今,随着网络时代的全面到来,网站在人们的生活和工作中发挥着极其重要的作用。网站制作的发展使更多的人加入了这个行业。如果你想掌握网站制作的知识,你可以在学校或网上学习...
- 一款谷歌(Google)打造的广告网页设计制作软件
-
GoogleWebDesigner是由谷歌(Google)打造的一款广告网页设计制作软件,它能够帮助从事于广告网页设计工作或是有这方面需求的用户更加有效快速的进行完成相关的行业设计工作,软件可以支...
- 普通网站如何制作一个网站?
-
对行外人来讲,在预备做一个网站项目时,最想了解的无非就是网站制作的悉数流程。网站制作是要有计划的,事先策划好才能更快更好的完成。网站的几个基本组成元素:域名+空间+程序+模板+维护经验+日常管理.网站...
- 用纯Python就能写一个漂亮的网页,再见HTML
-
再见HTML!用纯Python就能写一个漂亮的网页我们在写一个网站或者一个网页界面的时候,需要学习很多东西,对小白来说很困难!比如我要做一个简单的网页交互:天啊,听听头都大呢!其实我就给老板做一个...
- HTML表单4(form的action、method属性)——零基础自学网页制作
-
表单的工作过程表单的信息发送与处理过程可以简单的进行图示,如下图。以注册会员为例,用户在自己的电脑上打开相应的注册表单页面填写信息,完成填写后点击提交按钮,也就是图中1所示过程。这时浏览器会将这些信息...
- 官网网站设计网页制作模板建站前端自适应响应式网站仿站门户
-
案例背景航科慧联无人机搜索雷达能够在多种天气下检测到无人机的入侵、并获得目标的距离、方向和高度等具体信息,是无人机反制作战中的关键设备。航科慧联无人机搜索雷达能够在多种天气下检测到无人机的入侵、并获得...
- 软网推荐:在线制作软件图标
-
在制作PPT演示、软件、网页或其他程序时,我们往往需要用到一些个性化的图标。现在,即便是不安装任何软件,也可以上网在线制作自己需要的图标。首先访问如下制作网址:http://www.rw-design...
- 自定义跳转的h5网页如何制作?
-
文章来源:墨鹊微站...
- 网页如何制作?这几点要知道
-
这是一个个性张扬的时代,也是一个动手能力和动脑能力都比较强的时代,因此很多人对于能够自己动手完成的东西,都不太想假手于人。于是网页制作成了各大搜索引擎里面排名比较靠前的关键词之一。想要知道网页如何制作...
- 手机端网站简单制作教程,怎么快速制作一个移动端的网站
-
想要创建一个手机端的网站,需要有域名、已经完成网站页面的开发设计,零基础朋友不懂代码技术,直接在线套用乔拓云里面的网站模板来开发是比较简单可行的,进入乔拓云网,复制网站模板编辑网站的内容,注册域名后绑...
- 几张动图教你轻松了解Dreamweaver做网页
-
施老师:当今可是互联网时代,人们的生活、社交离不开互联网,那么不管你是网页设计师,还是销售达人,还是个体户,总必不可少的要在网上呈现一些页面给客户看,这个就是让你做网页,而Dreamweaver是做网...
- 用Deepseek制作网页版的汉诺塔游戏保姆级教程
-
在deepseek中输入:“帮我做一个网页版的汉诺塔演示游戏,游戏包含2层、3层、4层、5层的汉诺塔游戏演示,制作自动求解演示按钮,点击按钮就可以生成出步数,同时自动演示最优解动画。”...
- JS制作网页版计算器
-
大家晚上好,我是洁哥,抱歉今天有点晚了,但是洁哥不会缺席哦,今天我们来看一个JS实现网页版计算器的例题,先来看一看出来的效果吧(123+123=246)(123-123=0)(123*123=1512...
- 网页制作流程哪几步
-
在数字化时代,网页制作成为企业和个人展示形象、传递信息的重要方式。但是,许多人对于网页制作的流程仍感到困扰。为了解决这一问题,我们将深入探讨网页制作的关键步骤,助您更好地理解和应用这一过程。第一步:需...
- 这4个设计技巧,教你做好个人网页制作
-
随着互联网发展,个人建站已经不是什么稀奇事,学生、求职者、插画师、摄影师、作家……都可以制作个人网站,用来展示自身形象,或者吸引粉丝。那么如何做好个人网站呢?在不懂设计和技术知识的情况下,个人网页制作...
你 发表评论:
欢迎- 一周热门
-
-
前端面试:iframe 的优缺点? iframe有那些缺点
-
带斜线的表头制作好了,如何填充内容?这几种方法你更喜欢哪个?
-
漫学笔记之PHP.ini常用的配置信息
-
其实模版网站在开发工作中很重要,推荐几个参考站给大家
-
推荐7个模板代码和其他游戏源码下载的网址
-
[干货] JAVA - JVM - 2 内存两分 [干货]+java+-+jvm+-+2+内存两分吗
-
正在学习使用python搭建自动化测试框架?这个系统包你可能会用到
-
织梦(Dedecms)建站教程 织梦建站详细步骤
-
【开源分享】2024PHP在线客服系统源码(搭建教程+终身使用)
-
2024PHP在线客服系统源码+完全开源 带详细搭建教程
-
- 最近发表
- 标签列表
-
- mybatis plus (70)
- scheduledtask (71)
- css滚动条 (60)
- java学生成绩管理系统 (59)
- 结构体数组 (69)
- databasemetadata (64)
- javastatic (68)
- jsp实用教程 (53)
- fontawesome (57)
- widget开发 (57)
- vb net教程 (62)
- hibernate 教程 (63)
- case语句 (57)
- svn连接 (74)
- directoryindex (69)
- session timeout (58)
- textbox换行 (67)
- extension_dir (64)
- linearlayout (58)
- vba高级教程 (75)
- iframe用法 (58)
- sqlparameter (59)
- trim函数 (59)
- flex布局 (63)
- contextloaderlistener (56)